From 26e3a03d12d71e6e97bc7c40542cb7519051dd73 Mon Sep 17 00:00:00 2001 From: Anish Ramasekar Date: Wed, 20 Sep 2023 23:11:37 +0000 Subject: [PATCH] Implement CEL and wire it with OIDC authenticator Signed-off-by: Anish Ramasekar --- .../apis/apiserver/validation/validation.go | 263 +++++- .../apiserver/validation/validation_test.go | 564 +++++++++++- .../pkg/authentication/cel/compile.go | 154 ++++ .../pkg/authentication/cel/compile_test.go | 263 ++++++ .../pkg/authentication/cel/interface.go | 147 ++++ .../pkg/authentication/cel/mapper.go | 97 +++ .../pkg/authenticator/token/oidc/oidc.go | 353 +++++++- .../pkg/authenticator/token/oidc/oidc_test.go | 819 +++++++++++++++++- vendor/modules.txt | 1 + 9 files changed, 2559 insertions(+), 102 deletions(-) create mode 100644 staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go create mode 100644 staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go create mode 100644 staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go index ddf0b4b100d..7b22a200f96 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go @@ -31,6 +31,7 @@ import ( utilvalidation "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" api "k8s.io/apiserver/pkg/apis/apiserver" + authenticationcel "k8s.io/apiserver/pkg/authentication/cel" authorizationcel "k8s.io/apiserver/pkg/authorization/cel" "k8s.io/apiserver/pkg/cel" "k8s.io/apiserver/pkg/cel/environment" @@ -75,26 +76,32 @@ func ValidateAuthenticationConfiguration(c *api.AuthenticationConfiguration) fie // check and add validation for duplicate issuers. for i, a := range c.JWT { fldPath := root.Index(i) - allErrs = append(allErrs, validateJWTAuthenticator(a, fldPath)...) + _, errs := validateJWTAuthenticator(a, fldPath, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration)) + allErrs = append(allErrs, errs...) } return allErrs } -// ValidateJWTAuthenticator validates a given JWTAuthenticator. +// CompileAndValidateJWTAuthenticator validates a given JWTAuthenticator and returns a CELMapper with the compiled +// CEL expressions for claim mappings and validation rules. // This is exported for use in oidc package. -func ValidateJWTAuthenticator(authenticator api.JWTAuthenticator) field.ErrorList { - return validateJWTAuthenticator(authenticator, nil) +func CompileAndValidateJWTAuthenticator(authenticator api.JWTAuthenticator) (authenticationcel.CELMapper, field.ErrorList) { + return validateJWTAuthenticator(authenticator, nil, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration)) } -func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path) field.ErrorList { +func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path, structuredAuthnFeatureEnabled bool) (authenticationcel.CELMapper, field.ErrorList) { var allErrs field.ErrorList - allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...) - allErrs = append(allErrs, validateClaimValidationRules(authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"))...) - allErrs = append(allErrs, validateClaimMappings(authenticator.ClaimMappings, fldPath.Child("claimMappings"))...) + compiler := authenticationcel.NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) + mapper := &authenticationcel.CELMapper{} - return allErrs + allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...) + allErrs = append(allErrs, validateClaimValidationRules(compiler, mapper, authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"), structuredAuthnFeatureEnabled)...) + allErrs = append(allErrs, validateClaimMappings(compiler, mapper, authenticator.ClaimMappings, fldPath.Child("claimMappings"), structuredAuthnFeatureEnabled)...) + allErrs = append(allErrs, validateUserValidationRules(compiler, mapper, authenticator.UserValidationRules, fldPath.Child("userValidationRules"), structuredAuthnFeatureEnabled)...) + + return *mapper, allErrs } func validateIssuer(issuer api.Issuer, fldPath *field.Path) field.ErrorList { @@ -174,48 +181,250 @@ func validateCertificateAuthority(certificateAuthority string, fldPath *field.Pa return allErrs } -func validateClaimValidationRules(rules []api.ClaimValidationRule, fldPath *field.Path) field.ErrorList { +func validateClaimValidationRules(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, rules []api.ClaimValidationRule, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList { var allErrs field.ErrorList seenClaims := sets.NewString() + seenExpressions := sets.NewString() + var compilationResults []authenticationcel.CompilationResult + for i, rule := range rules { fldPath := fldPath.Index(i) - if len(rule.Claim) == 0 { - allErrs = append(allErrs, field.Required(fldPath.Child("claim"), "claim name is required")) - continue + if len(rule.Expression) > 0 && !structuredAuthnFeatureEnabled { + allErrs = append(allErrs, field.Invalid(fldPath.Child("expression"), rule.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled")) } - if seenClaims.Has(rule.Claim) { - allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim)) - continue + switch { + case len(rule.Claim) > 0 && len(rule.Expression) > 0: + allErrs = append(allErrs, field.Invalid(fldPath, rule.Claim, "claim and expression can't both be set")) + case len(rule.Claim) == 0 && len(rule.Expression) == 0: + allErrs = append(allErrs, field.Required(fldPath, "claim or expression is required")) + case len(rule.Claim) > 0: + if len(rule.Message) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("message"), rule.Message, "message can't be set when claim is set")) + } + if seenClaims.Has(rule.Claim) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim)) + } + seenClaims.Insert(rule.Claim) + case len(rule.Expression) > 0: + if len(rule.RequiredValue) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("requiredValue"), rule.RequiredValue, "requiredValue can't be set when expression is set")) + } + if seenExpressions.Has(rule.Expression) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("expression"), rule.Expression)) + continue + } + seenExpressions.Insert(rule.Expression) + + compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ClaimValidationCondition{ + Expression: rule.Expression, + }, fldPath.Child("expression")) + + if err != nil { + allErrs = append(allErrs, err) + continue + } + if compilationResult != nil { + compilationResults = append(compilationResults, *compilationResult) + } } - seenClaims.Insert(rule.Claim) + } + + if structuredAuthnFeatureEnabled && len(compilationResults) > 0 { + celMapper.ClaimValidationRules = authenticationcel.NewClaimsMapper(compilationResults) } return allErrs } -func validateClaimMappings(m api.ClaimMappings, fldPath *field.Path) field.ErrorList { +func validateClaimMappings(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, m api.ClaimMappings, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList { var allErrs field.ErrorList - if len(m.Username.Claim) == 0 { - allErrs = append(allErrs, field.Required(fldPath.Child("username", "claim"), "claim name is required")) + if !structuredAuthnFeatureEnabled { + if len(m.Username.Expression) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("username").Child("expression"), m.Username.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled")) + } + if len(m.Groups.Expression) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("groups").Child("expression"), m.Groups.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled")) + } + if len(m.UID.Claim) > 0 || len(m.UID.Expression) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("uid"), "", "uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled")) + } + if len(m.Extra) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("extra"), "", "extra claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled")) + } } - // TODO(aramase): when Expression is added to PrefixedClaimOrExpression, check prefix and expression are not both set. - if m.Username.Prefix == nil { - allErrs = append(allErrs, field.Required(fldPath.Child("username", "prefix"), "prefix is required")) + + compilationResult, err := validatePrefixClaimOrExpression(compiler, m.Username, fldPath.Child("username"), true, structuredAuthnFeatureEnabled) + if err != nil { + allErrs = append(allErrs, err...) + } else if compilationResult != nil && structuredAuthnFeatureEnabled { + celMapper.Username = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult}) } - if len(m.Groups.Claim) > 0 && m.Groups.Prefix == nil { - allErrs = append(allErrs, field.Required(fldPath.Child("groups", "prefix"), "prefix is required when claim is set")) + + compilationResult, err = validatePrefixClaimOrExpression(compiler, m.Groups, fldPath.Child("groups"), false, structuredAuthnFeatureEnabled) + if err != nil { + allErrs = append(allErrs, err...) + } else if compilationResult != nil && structuredAuthnFeatureEnabled { + celMapper.Groups = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult}) } - if m.Groups.Prefix != nil && len(m.Groups.Claim) == 0 { - allErrs = append(allErrs, field.Required(fldPath.Child("groups", "claim"), "non-empty claim name is required when prefix is set")) + + switch { + case len(m.UID.Claim) > 0 && len(m.UID.Expression) > 0: + allErrs = append(allErrs, field.Invalid(fldPath.Child("uid"), "", "claim and expression can't both be set")) + case len(m.UID.Expression) > 0: + compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ClaimMappingExpression{ + Expression: m.UID.Expression, + }, fldPath.Child("uid").Child("expression")) + + if err != nil { + allErrs = append(allErrs, err) + } else if structuredAuthnFeatureEnabled && compilationResult != nil { + celMapper.UID = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult}) + } + } + + var extraCompilationResults []authenticationcel.CompilationResult + seenExtraKeys := sets.NewString() + + for i, mapping := range m.Extra { + fldPath := fldPath.Child("extra").Index(i) + // Key should be namespaced to the authenticator or authenticator/authorizer pair making use of them. + // For instance: "example.org/foo" instead of "foo". + // xref: https://github.com/kubernetes/kubernetes/blob/3825e206cb162a7ad7431a5bdf6a065ae8422cf7/staging/src/k8s.io/apiserver/pkg/authentication/user/user.go#L31-L41 + // IsDomainPrefixedPath checks for non-empty key and that the key is prefixed with a domain name. + allErrs = append(allErrs, utilvalidation.IsDomainPrefixedPath(fldPath.Child("key"), mapping.Key)...) + if mapping.Key != strings.ToLower(mapping.Key) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("key"), mapping.Key, "key must be lowercase")) + } + if seenExtraKeys.Has(mapping.Key) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("key"), mapping.Key)) + continue + } + seenExtraKeys.Insert(mapping.Key) + + if len(mapping.ValueExpression) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("valueExpression"), "valueExpression is required")) + continue + } + + compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ExtraMappingExpression{ + Key: mapping.Key, + Expression: mapping.ValueExpression, + }, fldPath.Child("valueExpression")) + + if err != nil { + allErrs = append(allErrs, err) + continue + } + + if compilationResult != nil { + extraCompilationResults = append(extraCompilationResults, *compilationResult) + } + } + + if structuredAuthnFeatureEnabled && len(extraCompilationResults) > 0 { + celMapper.Extra = authenticationcel.NewClaimsMapper(extraCompilationResults) } return allErrs } +func validatePrefixClaimOrExpression(compiler authenticationcel.Compiler, mapping api.PrefixedClaimOrExpression, fldPath *field.Path, claimOrExpressionRequired, structuredAuthnFeatureEnabled bool) (*authenticationcel.CompilationResult, field.ErrorList) { + var allErrs field.ErrorList + + var compilationResult *authenticationcel.CompilationResult + switch { + case len(mapping.Expression) > 0 && len(mapping.Claim) > 0: + allErrs = append(allErrs, field.Invalid(fldPath, "", "claim and expression can't both be set")) + case len(mapping.Expression) == 0 && len(mapping.Claim) == 0 && claimOrExpressionRequired: + allErrs = append(allErrs, field.Required(fldPath, "claim or expression is required")) + case len(mapping.Expression) > 0: + var err *field.Error + + if mapping.Prefix != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("prefix"), *mapping.Prefix, "prefix can't be set when expression is set")) + } + compilationResult, err = compileClaimsCELExpression(compiler, &authenticationcel.ClaimMappingExpression{ + Expression: mapping.Expression, + }, fldPath.Child("expression")) + + if err != nil { + allErrs = append(allErrs, err) + } + + case len(mapping.Claim) > 0: + if mapping.Prefix == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("prefix"), "prefix is required when claim is set. It can be set to an empty string to disable prefixing")) + } + } + + return compilationResult, allErrs +} + +func validateUserValidationRules(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, rules []api.UserValidationRule, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList { + var allErrs field.ErrorList + var compilationResults []authenticationcel.CompilationResult + + if len(rules) > 0 && !structuredAuthnFeatureEnabled { + allErrs = append(allErrs, field.Invalid(fldPath, "", "user validation rules are not supported when StructuredAuthenticationConfiguration feature gate is disabled")) + } + + seenExpressions := sets.NewString() + for i, rule := range rules { + fldPath := fldPath.Index(i) + + if len(rule.Expression) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("expression"), "expression is required")) + continue + } + + if seenExpressions.Has(rule.Expression) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("expression"), rule.Expression)) + continue + } + seenExpressions.Insert(rule.Expression) + + compilationResult, err := compileUserCELExpression(compiler, &authenticationcel.UserValidationCondition{ + Expression: rule.Expression, + Message: rule.Message, + }, fldPath.Child("expression")) + + if err != nil { + allErrs = append(allErrs, err) + continue + } + + if compilationResult != nil { + compilationResults = append(compilationResults, *compilationResult) + } + } + + if structuredAuthnFeatureEnabled && len(compilationResults) > 0 { + celMapper.UserValidationRules = authenticationcel.NewUserMapper(compilationResults) + } + + return allErrs +} + +func compileClaimsCELExpression(compiler authenticationcel.Compiler, expression authenticationcel.ExpressionAccessor, fldPath *field.Path) (*authenticationcel.CompilationResult, *field.Error) { + compilationResult, err := compiler.CompileClaimsExpression(expression) + if err != nil { + return nil, convertCELErrorToValidationError(fldPath, expression, err) + } + return &compilationResult, nil +} + +func compileUserCELExpression(compiler authenticationcel.Compiler, expression authenticationcel.ExpressionAccessor, fldPath *field.Path) (*authenticationcel.CompilationResult, *field.Error) { + compilationResult, err := compiler.CompileUserExpression(expression) + if err != nil { + return nil, convertCELErrorToValidationError(fldPath, expression, err) + } + return &compilationResult, nil +} + // ValidateAuthorizationConfiguration validates a given AuthorizationConfiguration. func ValidateAuthorizationConfiguration(fldPath *field.Path, c *api.AuthorizationConfiguration, knownTypes sets.String, repeatableTypes sets.String) field.ErrorList { allErrs := field.ErrorList{} diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go index 22630309d28..ca688a0bee5 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go @@ -21,6 +21,7 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/pem" + "fmt" "os" "testing" "time" @@ -32,6 +33,8 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation/field" api "k8s.io/apiserver/pkg/apis/apiserver" + authenticationcel "k8s.io/apiserver/pkg/authentication/cel" + "k8s.io/apiserver/pkg/cel/environment" "k8s.io/apiserver/pkg/features" utilfeature "k8s.io/apiserver/pkg/util/feature" certutil "k8s.io/client-go/util/cert" @@ -39,7 +42,13 @@ import ( "k8s.io/utils/pointer" ) +var ( + compiler = authenticationcel.NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) +) + func TestValidateAuthenticationConfiguration(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)() + testCases := []struct { name string in *api.AuthenticationConfiguration @@ -133,7 +142,37 @@ func TestValidateAuthenticationConfiguration(t *testing.T) { }, }, }, - want: "jwt[0].claimMappings.username.claim: Required value: claim name is required", + want: "jwt[0].claimMappings.username: Required value: claim or expression is required", + }, + { + name: "failed userValidationRule validation", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + { + Issuer: api.Issuer{ + URL: "https://issuer-url", + Audiences: []string{"audience"}, + }, + ClaimValidationRules: []api.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + ClaimMappings: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String("prefix"), + }, + }, + UserValidationRules: []api.UserValidationRule{ + {Expression: "user.username == 'foo'"}, + {Expression: "user.username == 'foo'"}, + }, + }, + }, + }, + want: `jwt[0].userValidationRules[1].expression: Duplicate value: "user.username == 'foo'"`, }, { name: "valid authentication configuration", @@ -313,31 +352,98 @@ func TestValidateCertificateAuthority(t *testing.T) { } } -func TestClaimValidationRules(t *testing.T) { +func TestValidateClaimValidationRules(t *testing.T) { fldPath := field.NewPath("issuer", "claimValidationRules") testCases := []struct { - name string - in []api.ClaimValidationRule - want string + name string + in []api.ClaimValidationRule + structuredAuthnFeatureEnabled bool + want string + wantCELMapper bool }{ { - name: "claim validation rule claim is empty", - in: []api.ClaimValidationRule{{Claim: ""}}, - want: "issuer.claimValidationRules[0].claim: Required value: claim name is required", + name: "claim and expression are empty, structured authn feature enabled", + in: []api.ClaimValidationRule{{}}, + structuredAuthnFeatureEnabled: true, + want: "issuer.claimValidationRules[0]: Required value: claim or expression is required", + }, + { + name: "claim and expression are set", + in: []api.ClaimValidationRule{ + {Claim: "claim", Expression: "expression"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[0]: Invalid value: "claim": claim and expression can't both be set`, + }, + { + name: "message set when claim is set", + in: []api.ClaimValidationRule{ + {Claim: "claim", Message: "message"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[0].message: Invalid value: "message": message can't be set when claim is set`, + }, + { + name: "requiredValue set when expression is set", + in: []api.ClaimValidationRule{ + {Expression: "claims.foo == 'bar'", RequiredValue: "value"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[0].requiredValue: Invalid value: "value": requiredValue can't be set when expression is set`, }, { name: "duplicate claim", - in: []api.ClaimValidationRule{{ - Claim: "claim", RequiredValue: "value1"}, - {Claim: "claim", RequiredValue: "value2"}, + in: []api.ClaimValidationRule{ + {Claim: "claim"}, + {Claim: "claim"}, }, - want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`, }, { - name: "valid claim validation rule", - in: []api.ClaimValidationRule{{Claim: "claim", RequiredValue: "value"}}, - want: "", + name: "duplicate expression", + in: []api.ClaimValidationRule{ + {Expression: "claims.foo == 'bar'"}, + {Expression: "claims.foo == 'bar'"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[1].expression: Duplicate value: "claims.foo == 'bar'"`, + }, + { + name: "expression set when structured authn feature is disabled", + in: []api.ClaimValidationRule{ + {Expression: "claims.foo == 'bar'"}, + }, + structuredAuthnFeatureEnabled: false, + want: `issuer.claimValidationRules[0].expression: Invalid value: "claims.foo == 'bar'": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled`, + }, + { + name: "CEL expression compilation error", + in: []api.ClaimValidationRule{ + {Expression: "foo.bar"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[0].expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "expression does not evaluate to bool", + in: []api.ClaimValidationRule{ + {Expression: "claims.foo"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimValidationRules[0].expression: Invalid value: "claims.foo": must evaluate to bool`, + }, + { + name: "valid claim validation rule with expression", + in: []api.ClaimValidationRule{ + {Expression: "claims.foo == 'bar'"}, + }, + structuredAuthnFeatureEnabled: true, + want: "", + wantCELMapper: true, }, { name: "valid claim validation rule with multiple rules", @@ -345,16 +451,21 @@ func TestClaimValidationRules(t *testing.T) { {Claim: "claim1", RequiredValue: "value1"}, {Claim: "claim2", RequiredValue: "value2"}, }, - want: "", + structuredAuthnFeatureEnabled: true, + want: "", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - got := validateClaimValidationRules(tt.in, fldPath).ToAggregate() + celMapper := &authenticationcel.CELMapper{} + got := validateClaimValidationRules(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate() if d := cmp.Diff(tt.want, errString(got)); d != "" { t.Fatalf("ClaimValidationRules validation mismatch (-want +got):\n%s", d) } + if tt.wantCELMapper && celMapper.ClaimValidationRules == nil { + t.Fatalf("ClaimValidationRules validation mismatch: CELMapper.ClaimValidationRules is nil") + } }) } } @@ -363,52 +474,421 @@ func TestValidateClaimMappings(t *testing.T) { fldPath := field.NewPath("issuer", "claimMappings") testCases := []struct { - name string - in api.ClaimMappings - want string + name string + in api.ClaimMappings + structuredAuthnFeatureEnabled bool + want string + wantCELMapper bool }{ { - name: "username claim is empty", - in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "", Prefix: pointer.String("prefix")}}, - want: "issuer.claimMappings.username.claim: Required value: claim name is required", - }, - { - name: "username prefix is empty", - in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "claim"}}, - want: "issuer.claimMappings.username.prefix: Required value: prefix is required", - }, - { - name: "groups prefix is empty", + name: "username expression and claim are set", in: api.ClaimMappings{ - Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, - Groups: api.PrefixedClaimOrExpression{Claim: "claim"}, + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Expression: "claims.username", + }, }, - want: "issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set", + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.username: Invalid value: "": claim and expression can't both be set`, }, { - name: "groups prefix set but claim is empty", + name: "username expression and claim are empty", + in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{}}, + structuredAuthnFeatureEnabled: true, + want: "issuer.claimMappings.username: Required value: claim or expression is required", + }, + { + name: "username prefix set when expression is set", in: api.ClaimMappings{ - Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, - Groups: api.PrefixedClaimOrExpression{Prefix: pointer.String("prefix")}, + Username: api.PrefixedClaimOrExpression{ + Expression: "claims.username", + Prefix: pointer.String("prefix"), + }, }, - want: "issuer.claimMappings.groups.claim: Required value: non-empty claim name is required when prefix is set", + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.username.prefix: Invalid value: "prefix": prefix can't be set when expression is set`, + }, + { + name: "username prefix is nil when claim is set", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.username.prefix: Required value: prefix is required when claim is set. It can be set to an empty string to disable prefixing`, + }, + { + name: "username expression is invalid", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.username.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "groups expression and claim are set", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Groups: api.PrefixedClaimOrExpression{ + Claim: "claim", + Expression: "claims.groups", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.groups: Invalid value: "": claim and expression can't both be set`, + }, + { + name: "groups prefix set when expression is set", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Groups: api.PrefixedClaimOrExpression{ + Expression: "claims.groups", + Prefix: pointer.String("prefix"), + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.groups.prefix: Invalid value: "prefix": prefix can't be set when expression is set`, + }, + { + name: "groups prefix is nil when claim is set", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Groups: api.PrefixedClaimOrExpression{ + Claim: "claim", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set. It can be set to an empty string to disable prefixing`, + }, + { + name: "groups expression is invalid", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Groups: api.PrefixedClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.groups.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "uid claim and expression are set", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + UID: api.ClaimOrExpression{ + Claim: "claim", + Expression: "claims.uid", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.uid: Invalid value: "": claim and expression can't both be set`, + }, + { + name: "uid expression is invalid", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + UID: api.ClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.uid.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "extra mapping key is empty", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Extra: []api.ExtraMapping{ + {Key: "", ValueExpression: "claims.extra"}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[0].key: Required value`, + }, + { + name: "extra mapping value expression is empty", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Extra: []api.ExtraMapping{ + {Key: "example.org/foo", ValueExpression: ""}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[0].valueExpression: Required value: valueExpression is required`, + }, + { + name: "extra mapping value expression is invalid", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Extra: []api.ExtraMapping{ + {Key: "example.org/foo", ValueExpression: "foo.bar"}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[0].valueExpression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "username expression is invalid when structured authn feature is disabled", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: false, + want: `[issuer.claimMappings.username.expression: Invalid value: "foo.bar": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.username.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^]`, + }, + { + name: "groups expression is invalid when structured authn feature is disabled", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Groups: api.PrefixedClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: false, + want: `[issuer.claimMappings.groups.expression: Invalid value: "foo.bar": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.groups.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^]`, + }, + { + name: "uid expression is invalid when structured authn feature is disabled", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + UID: api.ClaimOrExpression{ + Expression: "foo.bar", + }, + }, + structuredAuthnFeatureEnabled: false, + want: `[issuer.claimMappings.uid: Invalid value: "": uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.uid.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^]`, + }, + { + name: "uid claim is invalid when structured authn feature is disabled", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + UID: api.ClaimOrExpression{ + Claim: "claim", + }, + }, + structuredAuthnFeatureEnabled: false, + want: `issuer.claimMappings.uid: Invalid value: "": uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled`, + }, + { + name: "extra mapping is invalid when structured authn feature is disabled", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + Extra: []api.ExtraMapping{ + {Key: "example.org/foo", ValueExpression: "claims.extra"}, + }, + }, + structuredAuthnFeatureEnabled: false, + want: `issuer.claimMappings.extra: Invalid value: "": extra claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled`, + }, + { + name: "duplicate extra mapping key", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Expression: "claims.username"}, + Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"}, + Extra: []api.ExtraMapping{ + {Key: "example.org/foo", ValueExpression: "claims.extra"}, + {Key: "example.org/foo", ValueExpression: "claims.extras"}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[1].key: Duplicate value: "example.org/foo"`, + }, + { + name: "extra mapping key is not domain prefix path", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Expression: "claims.username"}, + Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"}, + Extra: []api.ExtraMapping{ + {Key: "foo", ValueExpression: "claims.extra"}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[0].key: Invalid value: "foo": must be a domain-prefixed path (such as "acme.io/foo")`, + }, + { + name: "extra mapping key is not lower case", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Expression: "claims.username"}, + Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"}, + Extra: []api.ExtraMapping{ + {Key: "example.org/Foo", ValueExpression: "claims.extra"}, + }, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.claimMappings.extra[0].key: Invalid value: "example.org/Foo": key must be lowercase`, }, { name: "valid claim mappings", in: api.ClaimMappings{ - Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, - Groups: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, + Username: api.PrefixedClaimOrExpression{Expression: "claims.username"}, + Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"}, + UID: api.ClaimOrExpression{Expression: "claims.uid"}, + Extra: []api.ExtraMapping{ + {Key: "example.org/foo", ValueExpression: "claims.extra"}, + }, }, - want: "", + structuredAuthnFeatureEnabled: true, + wantCELMapper: true, + want: "", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - got := validateClaimMappings(tt.in, fldPath).ToAggregate() + celMapper := &authenticationcel.CELMapper{} + got := validateClaimMappings(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate() if d := cmp.Diff(tt.want, errString(got)); d != "" { + fmt.Println(errString(got)) t.Fatalf("ClaimMappings validation mismatch (-want +got):\n%s", d) } + if tt.wantCELMapper { + if len(tt.in.Username.Expression) > 0 && celMapper.Username == nil { + t.Fatalf("ClaimMappings validation mismatch: CELMapper.Username is nil") + } + if len(tt.in.Groups.Expression) > 0 && celMapper.Groups == nil { + t.Fatalf("ClaimMappings validation mismatch: CELMapper.Groups is nil") + } + if len(tt.in.UID.Expression) > 0 && celMapper.UID == nil { + t.Fatalf("ClaimMappings validation mismatch: CELMapper.UID is nil") + } + if len(tt.in.Extra) > 0 && celMapper.Extra == nil { + t.Fatalf("ClaimMappings validation mismatch: CELMapper.Extra is nil") + } + } + }) + } +} + +func TestValidateUserValidationRules(t *testing.T) { + fldPath := field.NewPath("issuer", "userValidationRules") + + testCases := []struct { + name string + in []api.UserValidationRule + structuredAuthnFeatureEnabled bool + want string + wantCELMapper bool + }{ + { + name: "user info validation rule, expression is empty", + in: []api.UserValidationRule{{}}, + structuredAuthnFeatureEnabled: true, + want: "issuer.userValidationRules[0].expression: Required value: expression is required", + }, + { + name: "duplicate expression", + in: []api.UserValidationRule{ + {Expression: "user.username == 'foo'"}, + {Expression: "user.username == 'foo'"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.userValidationRules[1].expression: Duplicate value: "user.username == 'foo'"`, + }, + { + name: "user validation rule is invalid when structured authn feature is disabled", + in: []api.UserValidationRule{ + {Expression: "user.username == 'foo'"}, + }, + structuredAuthnFeatureEnabled: false, + want: `issuer.userValidationRules: Invalid value: "": user validation rules are not supported when StructuredAuthenticationConfiguration feature gate is disabled`, + }, + { + name: "expression is invalid", + in: []api.UserValidationRule{ + {Expression: "foo.bar"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.userValidationRules[0].expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '') + | foo.bar + | ^`, + }, + { + name: "expression does not return bool", + in: []api.UserValidationRule{ + {Expression: "user.username"}, + }, + structuredAuthnFeatureEnabled: true, + want: `issuer.userValidationRules[0].expression: Invalid value: "user.username": must evaluate to bool`, + }, + { + name: "valid user info validation rule", + in: []api.UserValidationRule{ + {Expression: "user.username == 'foo'"}, + {Expression: "!user.username.startsWith('system:')", Message: "username cannot used reserved system: prefix"}, + }, + structuredAuthnFeatureEnabled: true, + want: "", + wantCELMapper: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + celMapper := &authenticationcel.CELMapper{} + got := validateUserValidationRules(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("UserValidationRules validation mismatch (-want +got):\n%s", d) + } + if tt.wantCELMapper && celMapper.UserValidationRules == nil { + t.Fatalf("UserValidationRules validation mismatch: CELMapper.UserValidationRules is nil") + } }) } } diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go new file mode 100644 index 00000000000..3bcff5e9051 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go @@ -0,0 +1,154 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 + + http://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 cel + +import ( + "fmt" + + "github.com/google/cel-go/cel" + + "k8s.io/apimachinery/pkg/util/version" + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/apiserver/pkg/cel/environment" +) + +const ( + claimsVarName = "claims" + userVarName = "user" +) + +// compiler implements the Compiler interface. +type compiler struct { + // varEnvs is a map of CEL environments, keyed by the name of the CEL variable. + // The CEL variable is available to the expression. + // We have 2 environments, one for claims and one for user. + varEnvs map[string]*environment.EnvSet +} + +// NewCompiler returns a new Compiler. +func NewCompiler(env *environment.EnvSet) Compiler { + return &compiler{ + varEnvs: mustBuildEnvs(env), + } +} + +// CompileClaimsExpression compiles the given expressionAccessor into a CEL program that can be evaluated. +// The claims CEL variable is available to the expression. +func (c compiler) CompileClaimsExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) { + return c.compile(expressionAccessor, claimsVarName) +} + +// CompileUserExpression compiles the given expressionAccessor into a CEL program that can be evaluated. +// The user CEL variable is available to the expression. +func (c compiler) CompileUserExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) { + return c.compile(expressionAccessor, userVarName) +} + +func (c compiler) compile(expressionAccessor ExpressionAccessor, envVarName string) (CompilationResult, error) { + resultError := func(errorString string, errType apiservercel.ErrorType) (CompilationResult, error) { + return CompilationResult{}, &apiservercel.Error{ + Type: errType, + Detail: errorString, + } + } + + env, err := c.varEnvs[envVarName].Env(environment.StoredExpressions) + if err != nil { + return resultError(fmt.Sprintf("unexpected error loading CEL environment: %v", err), apiservercel.ErrorTypeInternal) + } + + ast, issues := env.Compile(expressionAccessor.GetExpression()) + if issues != nil { + return resultError("compilation failed: "+issues.String(), apiservercel.ErrorTypeInvalid) + } + + found := false + returnTypes := expressionAccessor.ReturnTypes() + for _, returnType := range returnTypes { + if ast.OutputType() == returnType || cel.AnyType == returnType { + found = true + break + } + } + if !found { + var reason string + if len(returnTypes) == 1 { + reason = fmt.Sprintf("must evaluate to %v", returnTypes[0].String()) + } else { + reason = fmt.Sprintf("must evaluate to one of %v", returnTypes) + } + + return resultError(reason, apiservercel.ErrorTypeInvalid) + } + + if _, err = cel.AstToCheckedExpr(ast); err != nil { + // should be impossible since env.Compile returned no issues + return resultError("unexpected compilation error: "+err.Error(), apiservercel.ErrorTypeInternal) + } + prog, err := env.Program(ast) + if err != nil { + return resultError("program instantiation failed: "+err.Error(), apiservercel.ErrorTypeInternal) + } + + return CompilationResult{ + Program: prog, + ExpressionAccessor: expressionAccessor, + }, nil +} + +func buildUserType() *apiservercel.DeclType { + field := func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField { + return apiservercel.NewDeclField(name, declType, required, nil, nil) + } + fields := func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField { + result := make(map[string]*apiservercel.DeclField, len(fields)) + for _, f := range fields { + result[f.Name] = f + } + return result + } + + return apiservercel.NewObjectType("kubernetes.UserInfo", fields( + field("username", apiservercel.StringType, false), + field("uid", apiservercel.StringType, false), + field("groups", apiservercel.NewListType(apiservercel.StringType, -1), false), + field("extra", apiservercel.NewMapType(apiservercel.StringType, apiservercel.NewListType(apiservercel.StringType, -1), -1), false), + )) +} + +func mustBuildEnvs(baseEnv *environment.EnvSet) map[string]*environment.EnvSet { + buildEnvSet := func(envOpts []cel.EnvOption, declTypes []*apiservercel.DeclType) *environment.EnvSet { + env, err := baseEnv.Extend(environment.VersionedOptions{ + IntroducedVersion: version.MajorMinor(1, 0), + EnvOptions: envOpts, + DeclTypes: declTypes, + }) + if err != nil { + panic(fmt.Sprintf("environment misconfigured: %v", err)) + } + return env + } + + userType := buildUserType() + claimsType := apiservercel.NewMapType(apiservercel.StringType, apiservercel.AnyType, -1) + + envs := make(map[string]*environment.EnvSet, 2) // build two environments, one for claims and one for user + envs[claimsVarName] = buildEnvSet([]cel.EnvOption{cel.Variable(claimsVarName, claimsType.CelType())}, []*apiservercel.DeclType{claimsType}) + envs[userVarName] = buildEnvSet([]cel.EnvOption{cel.Variable(userVarName, userType.CelType())}, []*apiservercel.DeclType{userType}) + + return envs +} diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go new file mode 100644 index 00000000000..c8659aa830b --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go @@ -0,0 +1,263 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 + + http://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 cel + +import ( + "reflect" + "strings" + "testing" + + authenticationv1 "k8s.io/api/authentication/v1" + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/apiserver/pkg/cel/environment" +) + +func TestCompileClaimsExpression(t *testing.T) { + testCases := []struct { + name string + expressionAccessors []ExpressionAccessor + }{ + { + name: "valid ClaimMappingCondition", + expressionAccessors: []ExpressionAccessor{ + &ClaimMappingExpression{ + Expression: "claims.foo", + }, + }, + }, + { + name: "valid ClaimValidationCondition", + expressionAccessors: []ExpressionAccessor{ + &ClaimValidationCondition{ + Expression: "claims.foo == 'bar'", + }, + }, + }, + { + name: "valid ExtraMapppingCondition", + expressionAccessors: []ExpressionAccessor{ + &ExtraMappingExpression{ + Expression: "claims.foo", + }, + }, + }, + } + + compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, expressionAccessor := range tc.expressionAccessors { + _, err := compiler.CompileClaimsExpression(expressionAccessor) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestCompileUserExpression(t *testing.T) { + testCases := []struct { + name string + expressionAccessors []ExpressionAccessor + }{ + { + name: "valid UserValidationCondition", + expressionAccessors: []ExpressionAccessor{ + &ExtraMappingExpression{ + Expression: "user.username == 'bar'", + }, + }, + }, + } + + compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, expressionAccessor := range tc.expressionAccessors { + _, err := compiler.CompileUserExpression(expressionAccessor) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestCompileClaimsExpressionError(t *testing.T) { + testCases := []struct { + name string + expressionAccessors []ExpressionAccessor + wantErr string + }{ + { + name: "invalid ClaimValidationCondition", + expressionAccessors: []ExpressionAccessor{ + &ClaimValidationCondition{ + Expression: "claims.foo", + }, + }, + wantErr: "must evaluate to bool", + }, + { + name: "UserValidationCondition with wrong env", + expressionAccessors: []ExpressionAccessor{ + &UserValidationCondition{ + Expression: "user.username == 'foo'", + }, + }, + wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'user' (in container '')`, + }, + { + name: "invalid ClaimMappingCondition", + expressionAccessors: []ExpressionAccessor{ + &ClaimMappingExpression{ + Expression: "claims + 1", + }, + }, + wantErr: `compilation failed: ERROR: :1:8: found no matching overload for '_+_' applied to '(map(string, any), int)'`, + }, + } + + compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, expressionAccessor := range tc.expressionAccessors { + _, err := compiler.CompileClaimsExpression(expressionAccessor) + if err == nil { + t.Errorf("expected error but got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error to contain %q but got %q", tc.wantErr, err.Error()) + } + } + }) + } +} + +func TestCompileUserExpressionError(t *testing.T) { + testCases := []struct { + name string + expressionAccessors []ExpressionAccessor + wantErr string + }{ + { + name: "invalid UserValidationCondition", + expressionAccessors: []ExpressionAccessor{ + &UserValidationCondition{ + Expression: "user.username", + }, + }, + wantErr: "must evaluate to bool", + }, + { + name: "ClamMappingCondition with wrong env", + expressionAccessors: []ExpressionAccessor{ + &ClaimMappingExpression{ + Expression: "claims.foo", + }, + }, + wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`, + }, + { + name: "ExtraMappingCondition with wrong env", + expressionAccessors: []ExpressionAccessor{ + &ExtraMappingExpression{ + Expression: "claims.foo", + }, + }, + wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`, + }, + { + name: "ClaimValidationCondition with wrong env", + expressionAccessors: []ExpressionAccessor{ + &ClaimValidationCondition{ + Expression: "claims.foo == 'bar'", + }, + }, + wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`, + }, + { + name: "UserValidationCondition expression with unknown field", + expressionAccessors: []ExpressionAccessor{ + &UserValidationCondition{ + Expression: "user.unknown == 'foo'", + }, + }, + wantErr: `compilation failed: ERROR: :1:5: undefined field 'unknown'`, + }, + } + + compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, expressionAccessor := range tc.expressionAccessors { + _, err := compiler.CompileUserExpression(expressionAccessor) + if err == nil { + t.Errorf("expected error but got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error to contain %q but got %q", tc.wantErr, err.Error()) + } + } + }) + } +} + +func TestBuildUserType(t *testing.T) { + userDeclType := buildUserType() + userType := reflect.TypeOf(authenticationv1.UserInfo{}) + + if len(userDeclType.Fields) != userType.NumField() { + t.Errorf("expected %d fields, got %d", userType.NumField(), len(userDeclType.Fields)) + } + + for i := 0; i < userType.NumField(); i++ { + field := userType.Field(i) + jsonTagParts := strings.Split(field.Tag.Get("json"), ",") + if len(jsonTagParts) < 1 { + t.Fatal("expected json tag to be present") + } + fieldName := jsonTagParts[0] + + declField, ok := userDeclType.Fields[fieldName] + if !ok { + t.Errorf("expected field %q to be present", field.Name) + } + if nativeTypeToCELType(t, field.Type).CelType().Equal(declField.Type.CelType()).Value() != true { + t.Errorf("expected field %q to have type %v, got %v", field.Name, field.Type, declField.Type) + } + } +} + +func nativeTypeToCELType(t *testing.T, nativeType reflect.Type) *apiservercel.DeclType { + switch nativeType { + case reflect.TypeOf(""): + return apiservercel.StringType + case reflect.TypeOf([]string{}): + return apiservercel.NewListType(apiservercel.StringType, -1) + case reflect.TypeOf(map[string]authenticationv1.ExtraValue{}): + return apiservercel.NewMapType(apiservercel.StringType, apiservercel.AnyType, -1) + default: + t.Fatalf("unsupported type %v", nativeType) + } + return nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go new file mode 100644 index 00000000000..7ec0c9af6af --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go @@ -0,0 +1,147 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 + + http://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 cel contains the CEL related interfaces and structs for authentication. +package cel + +import ( + "context" + + celgo "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// ExpressionAccessor is an interface that provides access to a CEL expression. +type ExpressionAccessor interface { + GetExpression() string + ReturnTypes() []*celgo.Type +} + +// CompilationResult represents a compiled validations expression. +type CompilationResult struct { + Program celgo.Program + ExpressionAccessor ExpressionAccessor +} + +// EvaluationResult contains the minimal required fields and metadata of a cel evaluation +type EvaluationResult struct { + EvalResult ref.Val + ExpressionAccessor ExpressionAccessor +} + +// Compiler provides a CEL expression compiler configured with the desired authentication related CEL variables. +type Compiler interface { + CompileClaimsExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) + CompileUserExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) +} + +// ClaimsMapper provides a CEL expression mapper configured with the claims CEL variable. +type ClaimsMapper interface { + // EvalClaimMapping evaluates the given claim mapping expression and returns a EvaluationResult. + // This is used for username, groups and uid claim mapping that contains a single expression. + EvalClaimMapping(ctx context.Context, claims *unstructured.Unstructured) (EvaluationResult, error) + // EvalClaimMappings evaluates the given expressions and returns a list of EvaluationResult. + // This is used for extra claim mapping and claim validation that contains a list of expressions. + EvalClaimMappings(ctx context.Context, claims *unstructured.Unstructured) ([]EvaluationResult, error) +} + +// UserMapper provides a CEL expression mapper configured with the user CEL variable. +type UserMapper interface { + // EvalUser evaluates the given user expressions and returns a list of EvaluationResult. + // This is used for user validation that contains a list of expressions. + EvalUser(ctx context.Context, userInfo *unstructured.Unstructured) ([]EvaluationResult, error) +} + +var _ ExpressionAccessor = &ClaimMappingExpression{} + +// ClaimMappingExpression is a CEL expression that maps a claim. +type ClaimMappingExpression struct { + Expression string +} + +// GetExpression returns the CEL expression. +func (v *ClaimMappingExpression) GetExpression() string { + return v.Expression +} + +// ReturnTypes returns the CEL expression return types. +func (v *ClaimMappingExpression) ReturnTypes() []*celgo.Type { + // return types is only used for validation. The claims variable that's available + // to the claim mapping expressions is a map[string]interface{}, so we can't + // really know what the return type is during compilation. Strict type checking + // is done during evaluation. + return []*celgo.Type{celgo.AnyType} +} + +var _ ExpressionAccessor = &ClaimValidationCondition{} + +// ClaimValidationCondition is a CEL expression that validates a claim. +type ClaimValidationCondition struct { + Expression string + Message string +} + +// GetExpression returns the CEL expression. +func (v *ClaimValidationCondition) GetExpression() string { + return v.Expression +} + +// ReturnTypes returns the CEL expression return types. +func (v *ClaimValidationCondition) ReturnTypes() []*celgo.Type { + return []*celgo.Type{celgo.BoolType} +} + +var _ ExpressionAccessor = &ExtraMappingExpression{} + +// ExtraMappingExpression is a CEL expression that maps an extra to a list of values. +type ExtraMappingExpression struct { + Key string + Expression string +} + +// GetExpression returns the CEL expression. +func (v *ExtraMappingExpression) GetExpression() string { + return v.Expression +} + +// ReturnTypes returns the CEL expression return types. +func (v *ExtraMappingExpression) ReturnTypes() []*celgo.Type { + // return types is only used for validation. The claims variable that's available + // to the claim mapping expressions is a map[string]interface{}, so we can't + // really know what the return type is during compilation. Strict type checking + // is done during evaluation. + return []*celgo.Type{celgo.AnyType} +} + +var _ ExpressionAccessor = &UserValidationCondition{} + +// UserValidationCondition is a CEL expression that validates a User. +type UserValidationCondition struct { + Expression string + Message string +} + +// GetExpression returns the CEL expression. +func (v *UserValidationCondition) GetExpression() string { + return v.Expression +} + +// ReturnTypes returns the CEL expression return types. +func (v *UserValidationCondition) ReturnTypes() []*celgo.Type { + return []*celgo.Type{celgo.BoolType} +} diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go new file mode 100644 index 00000000000..ab308bb7f0f --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go @@ -0,0 +1,97 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 + + http://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 cel + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var _ ClaimsMapper = &mapper{} +var _ UserMapper = &mapper{} + +// mapper implements the ClaimsMapper and UserMapper interface. +type mapper struct { + compilationResults []CompilationResult +} + +// CELMapper is a struct that holds the compiled expressions for +// username, groups, uid, extra, claimValidation and userValidation +type CELMapper struct { + Username ClaimsMapper + Groups ClaimsMapper + UID ClaimsMapper + Extra ClaimsMapper + ClaimValidationRules ClaimsMapper + UserValidationRules UserMapper +} + +// NewClaimsMapper returns a new ClaimsMapper. +func NewClaimsMapper(compilationResults []CompilationResult) ClaimsMapper { + return &mapper{ + compilationResults: compilationResults, + } +} + +// NewUserMapper returns a new UserMapper. +func NewUserMapper(compilationResults []CompilationResult) UserMapper { + return &mapper{ + compilationResults: compilationResults, + } +} + +// EvalClaimMapping evaluates the given claim mapping expression and returns a EvaluationResult. +func (m *mapper) EvalClaimMapping(ctx context.Context, claims *unstructured.Unstructured) (EvaluationResult, error) { + results, err := m.eval(ctx, map[string]interface{}{claimsVarName: claims.Object}) + if err != nil { + return EvaluationResult{}, err + } + if len(results) != 1 { + return EvaluationResult{}, fmt.Errorf("expected 1 evaluation result, got %d", len(results)) + } + return results[0], nil +} + +// EvalClaimMappings evaluates the given expressions and returns a list of EvaluationResult. +func (m *mapper) EvalClaimMappings(ctx context.Context, claims *unstructured.Unstructured) ([]EvaluationResult, error) { + return m.eval(ctx, map[string]interface{}{claimsVarName: claims.Object}) +} + +// EvalUser evaluates the given user expressions and returns a list of EvaluationResult. +func (m *mapper) EvalUser(ctx context.Context, userInfo *unstructured.Unstructured) ([]EvaluationResult, error) { + return m.eval(ctx, map[string]interface{}{userVarName: userInfo.Object}) +} + +func (m *mapper) eval(ctx context.Context, input map[string]interface{}) ([]EvaluationResult, error) { + evaluations := make([]EvaluationResult, len(m.compilationResults)) + + for i, compilationResult := range m.compilationResults { + var evaluation = &evaluations[i] + evaluation.ExpressionAccessor = compilationResult.ExpressionAccessor + + evalResult, _, err := compilationResult.Program.ContextEval(ctx, input) + if err != nil { + return nil, fmt.Errorf("expression '%s' resulted in error: %w", compilationResult.ExpressionAccessor.GetExpression(), err) + } + + evaluation.EvalResult = evalResult + } + + return evaluations, nil +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go index 148ae79dfc6..76fc3cdd592 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go @@ -35,18 +35,25 @@ import ( "fmt" "io/ioutil" "net/http" + "reflect" "strings" "sync" "sync/atomic" "time" "github.com/coreos/go-oidc" + celgo "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/apis/apiserver" apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation" "k8s.io/apiserver/pkg/authentication/authenticator" + authenticationcel "k8s.io/apiserver/pkg/authentication/cel" "k8s.io/apiserver/pkg/authentication/user" certutil "k8s.io/client-go/util/cert" "k8s.io/klog/v2" @@ -165,6 +172,13 @@ type Authenticator struct { // resolver is used to resolve distributed claims. resolver *claimResolver + + // celMapper contains the compiled CEL expressions for + // username, groups, uid, extra, claimMapping and claimValidation + celMapper authenticationcel.CELMapper + + // requiredClaims contains the list of claims that must be present in the token. + requiredClaims map[string]string } func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) { @@ -197,7 +211,8 @@ var allowedSigningAlgs = map[string]bool{ } func New(opts Options) (*Authenticator, error) { - if err := apiservervalidation.ValidateJWTAuthenticator(opts.JWTAuthenticator).ToAggregate(); err != nil { + celMapper, fieldErr := apiservervalidation.CompileAndValidateJWTAuthenticator(opts.JWTAuthenticator) + if err := fieldErr.ToAggregate(); err != nil { return nil, err } @@ -262,10 +277,19 @@ func New(opts Options) (*Authenticator, error) { resolver = newClaimResolver(groupsClaim, client, verifierConfig) } + requiredClaims := make(map[string]string) + for _, claimValidationRule := range opts.JWTAuthenticator.ClaimValidationRules { + if len(claimValidationRule.Claim) > 0 { + requiredClaims[claimValidationRule.Claim] = claimValidationRule.RequiredValue + } + } + authenticator := &Authenticator{ jwtAuthenticator: opts.JWTAuthenticator, cancel: cancel, resolver: resolver, + celMapper: celMapper, + requiredClaims: requiredClaims, } if opts.KeySet != nil { @@ -521,10 +545,130 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a } } + var claimsUnstructured *unstructured.Unstructured + // Convert the claims to unstructured so that we can evaluate the CEL expressions + // against the claims. This is done once here so that we don't have to convert + // the claims to unstructured multiple times in the CEL mapper for each mapping. + // Only perform this conversion if any of the mapping or validation rules contain + // CEL expressions. + // TODO(aramase): In the future when we look into making distributed claims work, + // we should see if we can skip this function and use a dynamic type resolver for + // both json.RawMessage and the distributed claim fetching. + if a.celMapper.Username != nil || a.celMapper.Groups != nil || a.celMapper.UID != nil || a.celMapper.Extra != nil || a.celMapper.ClaimValidationRules != nil { + if claimsUnstructured, err = convertObjectToUnstructured(&c); err != nil { + return nil, false, fmt.Errorf("oidc: could not convert claims to unstructured: %w", err) + } + } + + var username string + if username, err = a.getUsername(ctx, c, claimsUnstructured); err != nil { + return nil, false, err + } + + info := &user.DefaultInfo{Name: username} + if info.Groups, err = a.getGroups(ctx, c, claimsUnstructured); err != nil { + return nil, false, err + } + + if info.UID, err = a.getUID(ctx, c, claimsUnstructured); err != nil { + return nil, false, err + } + + extra, err := a.getExtra(ctx, claimsUnstructured) + if err != nil { + return nil, false, err + } + if len(extra) > 0 { + info.Extra = extra + } + + // check to ensure all required claims are present in the ID token and have matching values. + for claim, value := range a.requiredClaims { + if !c.hasClaim(claim) { + return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim) + } + + // NOTE: Only string values are supported as valid required claim values. + var claimValue string + if err := c.unmarshalClaim(claim, &claimValue); err != nil { + return nil, false, fmt.Errorf("oidc: parse claim %s: %w", claim, err) + } + if claimValue != value { + return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value) + } + } + + if a.celMapper.ClaimValidationRules != nil { + evalResult, err := a.celMapper.ClaimValidationRules.EvalClaimMappings(ctx, claimsUnstructured) + if err != nil { + return nil, false, fmt.Errorf("oidc: error evaluating claim validation expression: %w", err) + } + if err := checkValidationRulesEvaluation(evalResult, func(a authenticationcel.ExpressionAccessor) (string, error) { + claimValidationCondition, ok := a.(*authenticationcel.ClaimValidationCondition) + if !ok { + return "", fmt.Errorf("invalid type conversion, expected ClaimValidationCondition") + } + return claimValidationCondition.Message, nil + }); err != nil { + return nil, false, fmt.Errorf("oidc: error evaluating claim validation expression: %w", err) + } + } + + if a.celMapper.UserValidationRules != nil { + userInfo := &authenticationv1.UserInfo{ + Extra: make(map[string]authenticationv1.ExtraValue), + Groups: info.GetGroups(), + UID: info.GetUID(), + Username: info.GetName(), + } + // Convert the extra information in the user object + for key, val := range info.GetExtra() { + userInfo.Extra[key] = authenticationv1.ExtraValue(val) + } + + // Convert the user info to unstructured so that we can evaluate the CEL expressions + // against the user info. This is done once here so that we don't have to convert + // the user info to unstructured multiple times in the CEL mapper for each mapping. + userInfoUnstructured, err := convertObjectToUnstructured(userInfo) + if err != nil { + return nil, false, fmt.Errorf("oidc: could not convert user info to unstructured: %v", err) + } + + evalResult, err := a.celMapper.UserValidationRules.EvalUser(ctx, userInfoUnstructured) + if err != nil { + return nil, false, fmt.Errorf("oidc: error evaluating user info validation rule: %w", err) + } + if err := checkValidationRulesEvaluation(evalResult, func(a authenticationcel.ExpressionAccessor) (string, error) { + userValidationCondition, ok := a.(*authenticationcel.UserValidationCondition) + if !ok { + return "", fmt.Errorf("invalid type conversion, expected UserValidationCondition") + } + return userValidationCondition.Message, nil + }); err != nil { + return nil, false, fmt.Errorf("oidc: error evaluating user info validation rule: %w", err) + } + } + + return &authenticator.Response{User: info}, true, nil +} + +func (a *Authenticator) getUsername(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) (string, error) { + if a.celMapper.Username != nil { + evalResult, err := a.celMapper.Username.EvalClaimMapping(ctx, claimsUnstructured) + if err != nil { + return "", fmt.Errorf("oidc: error evaluating username claim expression: %w", err) + } + if evalResult.EvalResult.Type() != celgo.StringType { + return "", fmt.Errorf("oidc: error evaluating username claim expression: %w", fmt.Errorf("username claim expression must return a string")) + } + + return evalResult.EvalResult.Value().(string), nil + } + var username string usernameClaim := a.jwtAuthenticator.ClaimMappings.Username.Claim if err := c.unmarshalClaim(usernameClaim, &username); err != nil { - return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", usernameClaim, err) + return "", fmt.Errorf("oidc: parse username claims %q: %v", usernameClaim, err) } if usernameClaim == "email" { @@ -533,24 +677,26 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified { var emailVerified bool if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil { - return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err) + return "", fmt.Errorf("oidc: parse 'email_verified' claim: %v", err) } // If the email_verified claim is present we have to verify it is set to `true`. if !emailVerified { - return nil, false, fmt.Errorf("oidc: email not verified") + return "", fmt.Errorf("oidc: email not verified") } } } userNamePrefix := a.jwtAuthenticator.ClaimMappings.Username.Prefix if userNamePrefix != nil && *userNamePrefix != "" { - username = *userNamePrefix + username + return *userNamePrefix + username, nil } + return username, nil +} - info := &user.DefaultInfo{Name: username} +func (a *Authenticator) getGroups(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) ([]string, error) { groupsClaim := a.jwtAuthenticator.ClaimMappings.Groups.Claim - if groupsClaim != "" { + if len(groupsClaim) > 0 { if _, ok := c[groupsClaim]; ok { // Some admins want to use string claims like "role" as the group value. // Allow the group claim to be a single string instead of an array. @@ -558,39 +704,91 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a // See: https://github.com/kubernetes/kubernetes/issues/33290 var groups stringOrArray if err := c.unmarshalClaim(groupsClaim, &groups); err != nil { - return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", groupsClaim, err) + return nil, fmt.Errorf("oidc: parse groups claim %q: %w", groupsClaim, err) } - info.Groups = []string(groups) + + prefix := a.jwtAuthenticator.ClaimMappings.Groups.Prefix + if prefix != nil && *prefix != "" { + for i, group := range groups { + groups[i] = *prefix + group + } + } + + return []string(groups), nil } } - groupsPrefix := a.jwtAuthenticator.ClaimMappings.Groups.Prefix - if groupsPrefix != nil && *groupsPrefix != "" { - for i, group := range info.Groups { - info.Groups[i] = *groupsPrefix + group - } + if a.celMapper.Groups == nil { + return nil, nil } - // check to ensure all required claims are present in the ID token and have matching values. - for _, claimValidationRule := range a.jwtAuthenticator.ClaimValidationRules { - claim := claimValidationRule.Claim - value := claimValidationRule.RequiredValue - - if !c.hasClaim(claim) { - return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim) - } - - // NOTE: Only string values are supported as valid required claim values. - var claimValue string - if err := c.unmarshalClaim(claim, &claimValue); err != nil { - return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err) - } - if claimValue != value { - return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value) - } + evalResult, err := a.celMapper.Groups.EvalClaimMapping(ctx, claimsUnstructured) + if err != nil { + return nil, fmt.Errorf("oidc: error evaluating group claim expression: %w", err) } - return &authenticator.Response{User: info}, true, nil + groups, err := convertCELValueToStringList(evalResult.EvalResult) + if err != nil { + return nil, fmt.Errorf("oidc: error evaluating group claim expression: %w", err) + } + return groups, nil +} + +func (a *Authenticator) getUID(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) (string, error) { + uidClaim := a.jwtAuthenticator.ClaimMappings.UID.Claim + if len(uidClaim) > 0 { + var uid string + if err := c.unmarshalClaim(uidClaim, &uid); err != nil { + return "", fmt.Errorf("oidc: parse uid claim %q: %w", uidClaim, err) + } + return uid, nil + } + + if a.celMapper.UID == nil { + return "", nil + } + + evalResult, err := a.celMapper.UID.EvalClaimMapping(ctx, claimsUnstructured) + if err != nil { + return "", fmt.Errorf("oidc: error evaluating uid claim expression: %w", err) + } + if evalResult.EvalResult.Type() != celgo.StringType { + return "", fmt.Errorf("oidc: error evaluating uid claim expression: %w", fmt.Errorf("uid claim expression must return a string")) + } + + return evalResult.EvalResult.Value().(string), nil +} + +func (a *Authenticator) getExtra(ctx context.Context, claimsUnstructured *unstructured.Unstructured) (map[string][]string, error) { + if a.celMapper.Extra == nil { + return nil, nil + } + + evalResult, err := a.celMapper.Extra.EvalClaimMappings(ctx, claimsUnstructured) + if err != nil { + return nil, err + } + + extra := make(map[string][]string, len(evalResult)) + for _, result := range evalResult { + extraMapping, ok := result.ExpressionAccessor.(*authenticationcel.ExtraMappingExpression) + if !ok { + return nil, fmt.Errorf("oidc: error evaluating extra claim expression: %w", fmt.Errorf("invalid type conversion, expected ExtraMappingCondition")) + } + + extraValues, err := convertCELValueToStringList(result.EvalResult) + if err != nil { + return nil, fmt.Errorf("oidc: error evaluating extra claim expression: %s: %w", extraMapping.Expression, err) + } + + if len(extraValues) == 0 { + continue + } + + extra[extraMapping.Key] = extraValues + } + + return extra, nil } // getClaimJWT gets a distributed claim JWT from url, using the supplied access @@ -655,3 +853,94 @@ func (c claims) hasClaim(name string) bool { } return true } + +// convertCELValueToStringList converts the CEL value to a string list. +// The CEL value needs to be either a string or a list of strings. +// "", [] are treated as not being present and will return nil. +// Empty string in a list of strings is treated as not being present and will be filtered out. +func convertCELValueToStringList(val ref.Val) ([]string, error) { + switch val.Type().TypeName() { + case celgo.StringType.TypeName(): + out := val.Value().(string) + if len(out) == 0 { + return nil, nil + } + return []string{out}, nil + + case celgo.ListType(nil).TypeName(): + var result []string + switch val.Value().(type) { + case []interface{}: + for _, v := range val.Value().([]interface{}) { + out, ok := v.(string) + if !ok { + return nil, fmt.Errorf("expression must return a string or a list of strings") + } + if len(out) == 0 { + continue + } + result = append(result, out) + } + case []ref.Val: + for _, v := range val.Value().([]ref.Val) { + out, ok := v.Value().(string) + if !ok { + return nil, fmt.Errorf("expression must return a string or a list of strings") + } + if len(out) == 0 { + continue + } + result = append(result, out) + } + default: + return nil, fmt.Errorf("expression must return a string or a list of strings") + } + + if len(result) == 0 { + return nil, nil + } + + return result, nil + case celgo.NullType.TypeName(): + return nil, nil + default: + return nil, fmt.Errorf("expression must return a string or a list of strings") + } +} + +// messageFunc is a function that returns a message for a validation rule. +type messageFunc func(authenticationcel.ExpressionAccessor) (string, error) + +// checkValidationRulesEvaluation checks if the validation rules evaluation results +// are valid. If the validation rules evaluation results are not valid, it returns +// an error with an optional message that was set in the validation rule. +func checkValidationRulesEvaluation(results []authenticationcel.EvaluationResult, messageFn messageFunc) error { + for _, result := range results { + if result.EvalResult.Type() != celgo.BoolType { + return fmt.Errorf("validation expression must return a boolean") + } + if !result.EvalResult.Value().(bool) { + expression := result.ExpressionAccessor.GetExpression() + + message, err := messageFn(result.ExpressionAccessor) + if err != nil { + return err + } + + return fmt.Errorf("validation expression '%s' failed: %s", expression, message) + } + } + + return nil +} + +func convertObjectToUnstructured(obj interface{}) (*unstructured.Unstructured, error) { + if obj == nil || reflect.ValueOf(obj).IsNil() { + return &unstructured.Unstructured{Object: nil}, nil + } + ret, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: ret}, nil +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go index 692b5cea1a4..4826ced7c5b 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go @@ -38,7 +38,10 @@ import ( "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/server/dynamiccertificates" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/klog/v2" "k8s.io/utils/pointer" ) @@ -288,6 +291,11 @@ func (c *claimsTest) run(t *testing.T) { t.Fatalf("wanted initialization error %q but got none", c.wantInitErr) } + claims := struct{}{} + if err := json.Unmarshal([]byte(c.claims), &claims); err != nil { + t.Fatalf("failed to unmarshal claims: %v", err) + } + // Sign and serialize the claims in a JWT. jws, err := signer.Sign([]byte(c.claims)) if err != nil { @@ -333,6 +341,8 @@ func (c *claimsTest) run(t *testing.T) { } func TestToken(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)() + synchronizeTokenIDVerifierForTest = true tests := []claimsTest{ { @@ -1801,7 +1811,7 @@ func TestToken(t *testing.T) { pubKeys: []*jose.JSONWebKey{ loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), }, - wantInitErr: `claimMappings.username.claim: Required value: claim name is required`, + wantInitErr: `claimMappings.username: Required value: claim or expression is required`, }, { name: "invalid-sig-alg", @@ -1910,6 +1920,813 @@ func TestToken(t *testing.T) { }`, valid.Unix()), wantErr: `oidc: verify token: oidc: expected audience "my-client" got ["my-wrong-client"]`, }, + { + name: "user validation rule fails for user.username", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("system:"), + }, + }, + UserValidationRules: []apiserver.UserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot used reserved system: prefix", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantErr: `oidc: error evaluating user info validation rule: validation expression '!user.username.startsWith('system:')' failed: username cannot used reserved system: prefix`, + }, + { + name: "user validation rule fails for user.groups", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String("system:"), + }, + }, + UserValidationRules: []apiserver.UserValidationRule{ + { + Expression: "user.groups.all(group, !group.startsWith('system:'))", + Message: "groups cannot used reserved system: prefix", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "groups": ["team1", "team2"] + }`, valid.Unix()), + wantErr: `oidc: error evaluating user info validation rule: validation expression 'user.groups.all(group, !group.startsWith('system:'))' failed: groups cannot used reserved system: prefix`, + }, + { + name: "claim validation rule with expression fails", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Expression: `claims.hd == "example.com"`, + Message: "hd claim must be example.com", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantErr: `oidc: error evaluating claim validation expression: expression 'claims.hd == "example.com"' resulted in error: no such key: hd`, + }, + { + name: "claim validation rule with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Expression: `claims.hd == "example.com"`, + Message: "hd claim must be example.com", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "hd": "example.com" + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "claim validation rule with expression and nested claims", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Expression: `claims.foo.bar == "baz"`, + Message: "foo.bar claim must be baz", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "hd": "example.com", + "foo": { + "bar": "baz" + } + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "claim validation rule with mix of expression and claim", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Expression: `claims.foo.bar == "baz"`, + Message: "foo.bar claim must be baz", + }, + { + Claim: "hd", + RequiredValue: "example.com", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "hd": "example.com", + "foo": { + "bar": "baz" + } + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "username claim mapping with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "username claim mapping with expression and nested claim", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.foo.username", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "foo": { + "username": "jane" + } + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "groups claim mapping with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + }, + }, + { + name: "groups claim with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("oidc:"), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: `(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "groups:" + role)`, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "roles": "foo,bar", + "other_roles": "baz,qux", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "oidc:jane", + Groups: []string{"groups:foo", "groups:bar", "groups:baz", "groups:qux"}, + }, + }, + { + name: "uid claim mapping with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Expression: "claims.uid", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234" + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + UID: "1234", + }, + }, + { + name: "uid claim mapping with claim", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Claim: "uid", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234" + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + UID: "1234", + }, + }, + { + name: "extra claim mapping with expression", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Expression: "claims.uid", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/foo", + ValueExpression: "claims.foo", + }, + { + Key: "example.org/bar", + ValueExpression: "claims.bar", + }, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234", + "foo": "bar", + "bar": [ + "baz", + "qux" + ] + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + UID: "1234", + Extra: map[string][]string{ + "example.org/foo": {"bar"}, + "example.org/bar": {"baz", "qux"}, + }, + }, + }, + { + name: "extra claim mapping, value derived from claim value", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/admin", + ValueExpression: `(has(claims.is_admin) && claims.is_admin) ? "true":""`, + }, + { + Key: "example.org/admin_1", + ValueExpression: `claims.?is_admin.orValue(false) == true ? "true":""`, + }, + { + Key: "example.org/non_existent", + ValueExpression: `claims.?non_existent.orValue("default") == "default" ? "true":""`, + }, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "is_admin": true + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Extra: map[string][]string{ + "example.org/admin": {"true"}, + "example.org/admin_1": {"true"}, + "example.org/non_existent": {"true"}, + }, + }, + }, + { + name: "hardcoded extra claim mapping", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/admin", + ValueExpression: `"true"`, + }, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d, + "is_admin": true + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Extra: map[string][]string{ + "example.org/admin": {"true"}, + }, + }, + }, + { + name: "extra claim mapping, multiple expressions for same key", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Expression: "claims.uid", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/foo", + ValueExpression: "claims.foo", + }, + { + Key: "example.org/bar", + ValueExpression: "claims.bar", + }, + { + Key: "example.org/foo", + ValueExpression: "claims.bar", + }, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234", + "foo": "bar", + "bar": [ + "baz", + "qux" + ] + }`, valid.Unix()), + wantInitErr: `claimMappings.extra[2].key: Duplicate value: "example.org/foo"`, + }, + { + name: "extra claim mapping, empty string value for key", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Expression: "claims.uid", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/foo", + ValueExpression: "claims.foo", + }, + { + Key: "example.org/bar", + ValueExpression: "claims.bar", + }, + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234", + "foo": "", + "bar": [ + "baz", + "qux" + ] + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + UID: "1234", + Extra: map[string][]string{ + "example.org/bar": {"baz", "qux"}, + }, + }, + }, + { + name: "extra claim mapping with user validation rule succeeds", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + UID: apiserver.ClaimOrExpression{ + Expression: "claims.uid", + }, + Extra: []apiserver.ExtraMapping{ + { + Key: "example.org/foo", + ValueExpression: "'bar'", + }, + { + Key: "example.org/baz", + ValueExpression: "claims.baz", + }, + }, + }, + UserValidationRules: []apiserver.UserValidationRule{ + { + Expression: "'bar' in user.extra['example.org/foo'] && 'qux' in user.extra['example.org/baz']", + Message: "example.org/foo must be bar and example.org/baz must be qux", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d, + "uid": "1234", + "baz": "qux" + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + UID: "1234", + Extra: map[string][]string{ + "example.org/foo": {"bar"}, + "example.org/baz": {"qux"}, + }, + }, + }, + { + name: "groups expression returns null", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.username", + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Expression: "claims.groups", + }, + }, + }, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": null, + "exp": %d, + "uid": "1234", + "baz": "qux" + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, } for _, test := range tests { t.Run(test.name, test.run) diff --git a/vendor/modules.txt b/vendor/modules.txt index 043b2e5bf00..0e823a2d34b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1501,6 +1501,7 @@ k8s.io/apiserver/pkg/audit k8s.io/apiserver/pkg/audit/policy k8s.io/apiserver/pkg/authentication/authenticator k8s.io/apiserver/pkg/authentication/authenticatorfactory +k8s.io/apiserver/pkg/authentication/cel k8s.io/apiserver/pkg/authentication/group k8s.io/apiserver/pkg/authentication/request/anonymous k8s.io/apiserver/pkg/authentication/request/bearertoken