mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 00:35:41 +00:00
9470c1f126
* fixes #3952 reject invalid externalIdClaim and stop enrollment panic - moves the error check before the locator assignment in Ca.GetExternalId so an unsupported matcher/parser no longer nil-derefs and returns HTTP 500 - guards the claim index against negative and out-of-range values and errors when a matched claim resolves to an empty string, so an empty externalId is never silently accepted nor mapped to an identity - adds validateExternalIdClaim, rejecting unsupported locations, unsupported matcher/parser combos, missing matcher/parser criteria, and negative indexes with HTTP 400 at CA create and update time - applies the validation to both CA create and update, which previously had no externalIdClaim validation on the update path - tests GetExternalId across the matcher/parser matrix for no-panic and correct error/value behavior - tests CA create and update reject invalid externalIdClaim configurations - tests that an externalIdClaim resolving to empty fails enrollment cleanly * address pr review - treats an externalIdClaim with no location as no claim in GetExternalId so an empty stored claim (e.g. the empty bucket older CLIs leave behind) falls back to fingerprint enrollment instead of erroring - validates the merged result on CA update rather than the raw request: a partial patch overlays only its supplied subfields onto the stored claim, an empty {} object preserves the stored claim, and a full replace validates as-is - skips externalIdClaim validation on update when no claim is supplied, fixing a spurious rejection of updates to CAs that have no claim - reports the offending location/matcher/parser in the externalIdClaim validation error instead of the raw struct - adds a --clear-external-id-claim flag to update ca and only sends the claim object when a claim flag changed, so existing updates preserve the claim and clearing is explicit - tests externalIdClaim patch merge, preserve, and clear behavior, and that an unconfigured claim reads as no claim
449 lines
15 KiB
Go
449 lines
15 KiB
Go
/*
|
|
Copyright NetFoundry Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package model
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"github.com/openziti/foundation/v2/errorz"
|
|
"github.com/openziti/x509-claims/x509claims"
|
|
"github.com/openziti/ziti/v2/common/cert"
|
|
"github.com/openziti/ziti/v2/common/eid"
|
|
"github.com/openziti/ziti/v2/controller/apierror"
|
|
"github.com/openziti/ziti/v2/controller/db"
|
|
"github.com/openziti/ziti/v2/controller/models"
|
|
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
|
"github.com/pkg/errors"
|
|
"go.etcd.io/bbolt"
|
|
)
|
|
|
|
type Ca struct {
|
|
models.BaseEntity
|
|
Name string
|
|
Fingerprint string
|
|
CertPem string
|
|
IsVerified bool
|
|
VerificationToken string
|
|
IsAutoCaEnrollmentEnabled bool
|
|
IsOttCaEnrollmentEnabled bool
|
|
IsAuthEnabled bool
|
|
IdentityRoles []string
|
|
IdentityNameFormat string
|
|
ExternalIdClaim *ExternalIdClaim
|
|
}
|
|
|
|
type ExternalIdClaim struct {
|
|
Location string
|
|
Matcher string
|
|
MatcherCriteria string
|
|
Parser string
|
|
ParserCriteria string
|
|
Index int64
|
|
}
|
|
|
|
type ExternalIdFieldType string
|
|
|
|
func (entity *Ca) fillFrom(_ Env, _ *bbolt.Tx, boltCa *db.Ca) error {
|
|
entity.FillCommon(boltCa)
|
|
entity.Name = boltCa.Name
|
|
entity.Fingerprint = boltCa.Fingerprint
|
|
entity.CertPem = boltCa.CertPem
|
|
entity.IsVerified = boltCa.IsVerified
|
|
entity.VerificationToken = boltCa.VerificationToken
|
|
entity.IsAutoCaEnrollmentEnabled = boltCa.IsAutoCaEnrollmentEnabled
|
|
entity.IsOttCaEnrollmentEnabled = boltCa.IsOttCaEnrollmentEnabled
|
|
entity.IsAuthEnabled = boltCa.IsAuthEnabled
|
|
entity.IdentityRoles = boltCa.IdentityRoles
|
|
entity.IdentityNameFormat = boltCa.IdentityNameFormat
|
|
|
|
if boltCa.ExternalIdClaim != nil {
|
|
entity.ExternalIdClaim = &ExternalIdClaim{}
|
|
entity.ExternalIdClaim.Location = boltCa.ExternalIdClaim.Location
|
|
entity.ExternalIdClaim.Index = boltCa.ExternalIdClaim.Index
|
|
entity.ExternalIdClaim.Matcher = boltCa.ExternalIdClaim.Matcher
|
|
entity.ExternalIdClaim.MatcherCriteria = boltCa.ExternalIdClaim.MatcherCriteria
|
|
entity.ExternalIdClaim.Parser = boltCa.ExternalIdClaim.Parser
|
|
entity.ExternalIdClaim.ParserCriteria = boltCa.ExternalIdClaim.ParserCriteria
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (entity *Ca) toBoltEntityForCreate(tx *bbolt.Tx, env Env) (*db.Ca, error) {
|
|
if entity.IdentityNameFormat == "" {
|
|
entity.IdentityNameFormat = DefaultCaIdentityNameFormat
|
|
}
|
|
|
|
if err := validateExternalIdClaim(entity.ExternalIdClaim); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var fp string
|
|
|
|
if entity.CertPem != "" {
|
|
blocks, err := cert.PemChain2Blocks(entity.CertPem)
|
|
|
|
if err != nil {
|
|
return nil, errorz.NewFieldError(err.Error(), "certPem", entity.CertPem)
|
|
}
|
|
|
|
if len(blocks) == 0 {
|
|
return nil, errorz.NewFieldError("at least one leaf certificate must be supplied", "certPem", entity.CertPem)
|
|
}
|
|
|
|
certs, err := cert.Blocks2Certs(blocks)
|
|
|
|
if err != nil {
|
|
return nil, errorz.NewFieldError(err.Error(), "certPem", entity.CertPem)
|
|
}
|
|
|
|
leaf := certs[0]
|
|
|
|
if !leaf.IsCA {
|
|
//return nil, &response.ApiError{
|
|
// Code: response.CertificateIsNotCaCode,
|
|
// Message: response.CertificateIsNotCaMessage,
|
|
// HttpStatusCode: http.StatusBadRequest,
|
|
//}
|
|
return nil, errors.New("certificate is not a CA")
|
|
}
|
|
fp = cert.NewFingerprintGenerator().FromCert(certs[0])
|
|
}
|
|
|
|
if fp == "" {
|
|
return nil, fmt.Errorf("invalid certificate, could not parse PEM body")
|
|
}
|
|
|
|
query := fmt.Sprintf(`fingerprint = "%v"`, fp)
|
|
queryResults, _, err := env.GetStores().Ca.QueryIds(tx, query)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(queryResults) > 0 {
|
|
return nil, errorz.NewFieldError(fmt.Sprintf("certificate already used as CA %s", queryResults[0]), "certPem", entity.CertPem)
|
|
}
|
|
|
|
boltEntity := &db.Ca{
|
|
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
|
|
Name: entity.Name,
|
|
CertPem: entity.CertPem,
|
|
Fingerprint: fp,
|
|
IsVerified: false,
|
|
VerificationToken: eid.New(),
|
|
IsAuthEnabled: entity.IsAuthEnabled,
|
|
IsAutoCaEnrollmentEnabled: entity.IsAutoCaEnrollmentEnabled,
|
|
IsOttCaEnrollmentEnabled: entity.IsOttCaEnrollmentEnabled,
|
|
IdentityRoles: entity.IdentityRoles,
|
|
IdentityNameFormat: entity.IdentityNameFormat,
|
|
}
|
|
|
|
if entity.ExternalIdClaim != nil {
|
|
boltEntity.ExternalIdClaim = &db.ExternalIdClaim{
|
|
Location: entity.ExternalIdClaim.Location,
|
|
Matcher: entity.ExternalIdClaim.Matcher,
|
|
MatcherCriteria: entity.ExternalIdClaim.MatcherCriteria,
|
|
Parser: entity.ExternalIdClaim.Parser,
|
|
ParserCriteria: entity.ExternalIdClaim.ParserCriteria,
|
|
Index: entity.ExternalIdClaim.Index,
|
|
}
|
|
}
|
|
|
|
return boltEntity, nil
|
|
}
|
|
|
|
func (entity *Ca) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, checker boltz.FieldChecker) (*db.Ca, error) {
|
|
if entity.IdentityNameFormat == "" {
|
|
entity.IdentityNameFormat = DefaultCaIdentityNameFormat
|
|
}
|
|
|
|
claim, err := entity.effectiveExternalIdClaim(tx, env, checker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := validateExternalIdClaim(claim); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
boltEntity := &db.Ca{
|
|
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
|
|
Name: entity.Name,
|
|
IsAuthEnabled: entity.IsAuthEnabled,
|
|
IsAutoCaEnrollmentEnabled: entity.IsAutoCaEnrollmentEnabled,
|
|
IsOttCaEnrollmentEnabled: entity.IsOttCaEnrollmentEnabled,
|
|
IsVerified: entity.IsVerified,
|
|
IdentityRoles: entity.IdentityRoles,
|
|
IdentityNameFormat: entity.IdentityNameFormat,
|
|
}
|
|
|
|
if entity.ExternalIdClaim != nil {
|
|
boltEntity.ExternalIdClaim = &db.ExternalIdClaim{
|
|
Location: entity.ExternalIdClaim.Location,
|
|
Matcher: entity.ExternalIdClaim.Matcher,
|
|
MatcherCriteria: entity.ExternalIdClaim.MatcherCriteria,
|
|
Parser: entity.ExternalIdClaim.Parser,
|
|
ParserCriteria: entity.ExternalIdClaim.ParserCriteria,
|
|
Index: entity.ExternalIdClaim.Index,
|
|
}
|
|
}
|
|
|
|
return boltEntity, nil
|
|
}
|
|
|
|
// caExternalIdClaimFields are the persisted externalIdClaim subfields, used to detect which
|
|
// subfields a partial update touches.
|
|
var caExternalIdClaimFields = []string{
|
|
db.FieldCaExternalIdClaimLocation,
|
|
db.FieldCaExternalIdClaimMatcher,
|
|
db.FieldCaExternalIdClaimMatcherCriteria,
|
|
db.FieldCaExternalIdClaimParser,
|
|
db.FieldCaExternalIdClaimParserCriteria,
|
|
db.FieldCaExternalIdClaimIndex,
|
|
}
|
|
|
|
// effectiveExternalIdClaim returns the externalIdClaim to validate for an update so validation
|
|
// matches what will be stored. A nil checker is a full replace, so the request claim stands as-is.
|
|
// For a patch only the subfields named in the checker change: if none touch the claim it is left
|
|
// unchanged (nil, validation skipped), otherwise the supplied subfields are overlaid onto the stored
|
|
// claim and the merged result is validated. Subfield gating mirrors PersistEntity exactly.
|
|
func (entity *Ca) effectiveExternalIdClaim(tx *bbolt.Tx, env Env, checker boltz.FieldChecker) (*ExternalIdClaim, error) {
|
|
// No claim supplied: the update either clears the claim or leaves it untouched. Nothing to validate.
|
|
if entity.ExternalIdClaim == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Full replace (nil checker): validate the supplied claim as-is.
|
|
if checker == nil {
|
|
return entity.ExternalIdClaim, nil
|
|
}
|
|
|
|
// Partial update: if no claim subfield is being set, the stored claim is unchanged (the empty {}
|
|
// object older CLIs send on every update). Skip validation rather than treating it as incomplete.
|
|
touched := false
|
|
for _, field := range caExternalIdClaimFields {
|
|
if checker.IsUpdated(field) {
|
|
touched = true
|
|
break
|
|
}
|
|
}
|
|
if !touched {
|
|
return nil, nil
|
|
}
|
|
|
|
// Overlay the supplied subfields onto the stored claim so the merged result is what gets validated.
|
|
merged := &ExternalIdClaim{}
|
|
if existing, err := env.GetStores().Ca.LoadById(tx, entity.Id); err != nil {
|
|
return nil, err
|
|
} else if existing != nil && existing.ExternalIdClaim != nil {
|
|
merged.Location = existing.ExternalIdClaim.Location
|
|
merged.Matcher = existing.ExternalIdClaim.Matcher
|
|
merged.MatcherCriteria = existing.ExternalIdClaim.MatcherCriteria
|
|
merged.Parser = existing.ExternalIdClaim.Parser
|
|
merged.ParserCriteria = existing.ExternalIdClaim.ParserCriteria
|
|
merged.Index = existing.ExternalIdClaim.Index
|
|
}
|
|
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimLocation) {
|
|
merged.Location = entity.ExternalIdClaim.Location
|
|
}
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimMatcher) {
|
|
merged.Matcher = entity.ExternalIdClaim.Matcher
|
|
}
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimMatcherCriteria) {
|
|
merged.MatcherCriteria = entity.ExternalIdClaim.MatcherCriteria
|
|
}
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimParser) {
|
|
merged.Parser = entity.ExternalIdClaim.Parser
|
|
}
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimParserCriteria) {
|
|
merged.ParserCriteria = entity.ExternalIdClaim.ParserCriteria
|
|
}
|
|
if checker.IsUpdated(db.FieldCaExternalIdClaimIndex) {
|
|
merged.Index = entity.ExternalIdClaim.Index
|
|
}
|
|
|
|
return merged, nil
|
|
}
|
|
|
|
// validateExternalIdClaim rejects unsupported or incomplete externalIdClaim configurations
|
|
// with a bad request error so they cannot be stored and later fail enrollment/authentication.
|
|
// It exercises the same location/matcher/parser rules used to extract the claim, so any
|
|
// configuration that would error at extraction time is rejected up front at CA create/update.
|
|
func validateExternalIdClaim(claim *ExternalIdClaim) error {
|
|
if claim == nil {
|
|
return nil
|
|
}
|
|
|
|
if claim.Index < 0 {
|
|
return apierror.NewBadRequestFieldError(*errorz.NewFieldError("index must be greater than or equal to zero", "externalIdClaim.index", claim.Index))
|
|
}
|
|
|
|
var err error
|
|
switch claim.Location {
|
|
case db.ExternalIdClaimLocCommonName, db.ExternalIdClaimLocSanEmail:
|
|
_, err = getStringDefinition(claim)
|
|
case db.ExternalIdClaimLocSanUri:
|
|
_, err = getUriDefinition(claim)
|
|
default:
|
|
return apierror.NewBadRequestFieldError(*errorz.NewFieldError(fmt.Sprintf("unsupported location [%s]", claim.Location), "externalIdClaim.location", claim.Location))
|
|
}
|
|
|
|
if err != nil {
|
|
value := fmt.Sprintf("location=%s matcher=%s parser=%s", claim.Location, claim.Matcher, claim.Parser)
|
|
return apierror.NewBadRequestFieldError(*errorz.NewFieldError(err.Error(), "externalIdClaim", value))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetExternalId will attempt to retrieve a string claim from a x509 Certificate based on
|
|
// location, matching, and parsing of various x509 Certificate fields.
|
|
func (entity *Ca) GetExternalId(cert *x509.Certificate) (string, error) {
|
|
// A claim with no location is unconfigured (e.g. the empty bucket an older CLI's empty {} patch
|
|
// leaves behind). Treat it as no claim so enrollment falls back to a certificate fingerprint.
|
|
if entity.ExternalIdClaim == nil || entity.ExternalIdClaim.Location == "" {
|
|
return "", nil
|
|
}
|
|
|
|
provider := &x509claims.ProviderBasic{
|
|
Definitions: []x509claims.Definition{},
|
|
}
|
|
|
|
switch entity.ExternalIdClaim.Location {
|
|
case db.ExternalIdClaimLocCommonName:
|
|
definition, err := getStringDefinition(entity.ExternalIdClaim)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
definition.Locator = &x509claims.LocatorCommonName{}
|
|
|
|
provider.Definitions = append(provider.Definitions, definition)
|
|
|
|
case db.ExternalIdClaimLocSanUri:
|
|
definition, err := getUriDefinition(entity.ExternalIdClaim)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
definition.Locator = &x509claims.LocatorSanUri{}
|
|
|
|
provider.Definitions = append(provider.Definitions, definition)
|
|
case db.ExternalIdClaimLocSanEmail:
|
|
definition, err := getStringDefinition(entity.ExternalIdClaim)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
definition.Locator = &x509claims.LocatorSanEmail{}
|
|
|
|
provider.Definitions = append(provider.Definitions, definition)
|
|
default:
|
|
return "", fmt.Errorf("unsupported location [%s]", entity.ExternalIdClaim.Location)
|
|
}
|
|
|
|
claims := provider.Claims(cert)
|
|
|
|
if entity.ExternalIdClaim.Index < 0 || entity.ExternalIdClaim.Index >= int64(len(claims)) {
|
|
return "", errors.New("no claim found")
|
|
}
|
|
|
|
externalId := claims[entity.ExternalIdClaim.Index]
|
|
|
|
if externalId == "" {
|
|
return "", errors.New("claim resolved to an empty value")
|
|
}
|
|
|
|
return externalId, nil
|
|
}
|
|
|
|
// getUriDefinition returns an x509Claims.DefinitionLMP that will locate, match, and parse url.URL properties.
|
|
func getUriDefinition(externalIdClaim *ExternalIdClaim) (*x509claims.DefinitionLMP[*url.URL], error) {
|
|
definition := &x509claims.DefinitionLMP[*url.URL]{}
|
|
|
|
switch externalIdClaim.Matcher {
|
|
case db.ExternalIdClaimMatcherAll:
|
|
definition.Matcher = &x509claims.MatcherAll[*url.URL]{}
|
|
case db.ExternalIdClaimMatcherScheme:
|
|
if externalIdClaim.MatcherCriteria == "" {
|
|
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
|
|
}
|
|
|
|
definition.Matcher = &x509claims.MatcherScheme{Scheme: externalIdClaim.MatcherCriteria}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported matcher [%s] for location [%s]", externalIdClaim.Matcher, externalIdClaim.Location)
|
|
}
|
|
|
|
var err error
|
|
definition.Parser, err = getStringParser(externalIdClaim)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return definition, nil
|
|
}
|
|
|
|
// getStringDefinition returns an x509Claims.DefinitionLMP that will locate, match, and parse string properties.
|
|
func getStringDefinition(externalIdClaim *ExternalIdClaim) (*x509claims.DefinitionLMP[string], error) {
|
|
definition := &x509claims.DefinitionLMP[string]{}
|
|
|
|
switch externalIdClaim.Matcher {
|
|
case db.ExternalIdClaimMatcherAll:
|
|
definition.Matcher = &x509claims.MatcherAll[string]{}
|
|
case db.ExternalIdClaimMatcherPrefix:
|
|
if externalIdClaim.MatcherCriteria == "" {
|
|
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
|
|
}
|
|
|
|
definition.Matcher = &x509claims.MatcherPrefix{Prefix: externalIdClaim.MatcherCriteria}
|
|
case db.ExternalIdClaimMatcherSuffix:
|
|
if externalIdClaim.MatcherCriteria == "" {
|
|
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
|
|
}
|
|
|
|
definition.Matcher = &x509claims.MatcherSuffix{Suffix: externalIdClaim.MatcherCriteria}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported matcher [%s] for location [%s]", externalIdClaim.Matcher, externalIdClaim.Location)
|
|
}
|
|
|
|
var err error
|
|
definition.Parser, err = getStringParser(externalIdClaim)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return definition, nil
|
|
}
|
|
|
|
// getStringParser returns a x509claims.Parser that parses string values into claims
|
|
func getStringParser(externalIdClaim *ExternalIdClaim) (x509claims.Parser, error) {
|
|
switch externalIdClaim.Parser {
|
|
case db.ExternalIdClaimParserNone:
|
|
return &x509claims.ParserNoOp{}, nil
|
|
case db.ExternalIdClaimParserSplit:
|
|
if externalIdClaim.ParserCriteria == "" {
|
|
return nil, fmt.Errorf("invalid criteria [%s] for parser [%s]", externalIdClaim.ParserCriteria, externalIdClaim.Parser)
|
|
}
|
|
|
|
return &x509claims.ParserSplit{Separator: externalIdClaim.ParserCriteria}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported parser [%s] for location [%s]", externalIdClaim.Parser, externalIdClaim.Location)
|
|
}
|