diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go index 1b2b4fd5e49..f3b4ae321ef 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go @@ -172,6 +172,7 @@ type JWTAuthenticator struct { Issuer Issuer ClaimValidationRules []ClaimValidationRule ClaimMappings ClaimMappings + UserValidationRules []UserValidationRule } // Issuer provides the configuration for a external provider specific settings. @@ -185,18 +186,43 @@ type Issuer struct { type ClaimValidationRule struct { Claim string RequiredValue string + + Expression string + Message string } // ClaimMappings provides the configuration for claim mapping type ClaimMappings struct { Username PrefixedClaimOrExpression Groups PrefixedClaimOrExpression + UID ClaimOrExpression + Extra []ExtraMapping } // PrefixedClaimOrExpression provides the configuration for a single prefixed claim or expression. type PrefixedClaimOrExpression struct { Claim string Prefix *string + + Expression string +} + +// ClaimOrExpression provides the configuration for a single claim or expression. +type ClaimOrExpression struct { + Claim string + Expression string +} + +// ExtraMapping provides the configuration for a single extra mapping. +type ExtraMapping struct { + Key string + ValueExpression string +} + +// UserValidationRule provides the configuration for a single user validation rule. +type UserValidationRule struct { + Expression string + Message string } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go index 22ac6eee340..9394ba6f70a 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go @@ -192,6 +192,13 @@ type JWTAuthenticator struct { // claimMappings points claims of a token to be treated as user attributes. // +required ClaimMappings ClaimMappings `json:"claimMappings"` + + // userValidationRules are rules that are applied to final user before completing authentication. + // These allow invariants to be applied to incoming identities such as preventing the + // use of the system: prefix that is commonly used by Kubernetes components. + // The validation rules are logically ANDed together and must all return true for the validation to pass. + // +optional + UserValidationRules []UserValidationRule `json:"userValidationRules,omitempty"` } // Issuer provides the configuration for a external provider specific settings. @@ -225,14 +232,36 @@ type ClaimValidationRule struct { // claim is the name of a required claim. // Same as --oidc-required-claim flag. // Only string claim keys are supported. - // +required - Claim string `json:"claim"` + // Mutually exclusive with expression and message. + // +optional + Claim string `json:"claim,omitempty"` // requiredValue is the value of a required claim. // Same as --oidc-required-claim flag. // Only string claim values are supported. // If claim is set and requiredValue is not set, the claim must be present with a value set to the empty string. + // Mutually exclusive with expression and message. // +optional - RequiredValue string `json:"requiredValue"` + RequiredValue string `json:"requiredValue,omitempty"` + + // expression represents the expression which will be evaluated by CEL. + // Must produce a boolean. + // + // CEL expressions have access to the contents of the token claims, organized into CEL variable: + // - 'claims' is a map of claim names to claim values. + // For example, a variable named 'sub' can be accessed as 'claims.sub'. + // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'. + // Must return true for the validation to pass. + // + // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/ + // + // Mutually exclusive with claim and requiredValue. + // +optional + Expression string `json:"expression,omitempty"` + // message customizes the returned error message when expression returns false. + // message is a literal string. + // Mutually exclusive with claim and requiredValue. + // +optional + Message string `json:"message,omitempty"` } // ClaimMappings provides the configuration for claim mapping @@ -240,6 +269,7 @@ type ClaimMappings struct { // username represents an option for the username attribute. // The claim's value must be a singular string. // Same as the --oidc-username-claim and --oidc-username-prefix flags. + // If username.expression is set, the expression must produce a string value. // // In the flag based approach, the --oidc-username-claim and --oidc-username-prefix are optional. If --oidc-username-claim is not set, // the default value is "sub". For the authentication config, there is no defaulting for claim or prefix. The claim and prefix must be set explicitly. @@ -254,19 +284,136 @@ type ClaimMappings struct { Username PrefixedClaimOrExpression `json:"username"` // groups represents an option for the groups attribute. // The claim's value must be a string or string array claim. - // // If groups.claim is set, the prefix must be specified (and can be the empty string). + // If groups.claim is set, the prefix must be specified (and can be the empty string). + // If groups.expression is set, the expression must produce a string or string array value. + // "", [], and null values are treated as the group mapping not being present. // +optional Groups PrefixedClaimOrExpression `json:"groups,omitempty"` + + // uid represents an option for the uid attribute. + // Claim must be a singular string claim. + // If uid.expression is set, the expression must produce a string value. + // +optional + UID ClaimOrExpression `json:"uid"` + + // extra represents an option for the extra attribute. + // expression must produce a string or string array value. + // If the value is empty, the extra mapping will not be present. + // + // hard-coded extra key/value + // - key: "foo" + // valueExpression: "'bar'" + // This will result in an extra attribute - foo: ["bar"] + // + // hard-coded key, value copying claim value + // - key: "foo" + // valueExpression: "claims.some_claim" + // This will result in an extra attribute - foo: [value of some_claim] + // + // hard-coded key, value derived from claim value + // - key: "admin" + // valueExpression: '(has(claims.is_admin) && claims.is_admin) ? "true":""' + // This will result in: + // - if is_admin claim is present and true, extra attribute - admin: ["true"] + // - if is_admin claim is present and false or is_admin claim is not present, no extra attribute will be added + // + // +optional + Extra []ExtraMapping `json:"extra,omitempty"` } // PrefixedClaimOrExpression provides the configuration for a single prefixed claim or expression. type PrefixedClaimOrExpression struct { // claim is the JWT claim to use. + // Mutually exclusive with expression. // +optional - Claim string `json:"claim"` + Claim string `json:"claim,omitempty"` // prefix is prepended to claim's value to prevent clashes with existing names. + // prefix needs to be set if claim is set and can be the empty string. + // Mutually exclusive with expression. + // +optional + Prefix *string `json:"prefix,omitempty"` + + // expression represents the expression which will be evaluated by CEL. + // + // CEL expressions have access to the contents of the token claims, organized into CEL variable: + // - 'claims' is a map of claim names to claim values. + // For example, a variable named 'sub' can be accessed as 'claims.sub'. + // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'. + // + // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/ + // + // Mutually exclusive with claim and prefix. + // +optional + Expression string `json:"expression,omitempty"` +} + +// ClaimOrExpression provides the configuration for a single claim or expression. +type ClaimOrExpression struct { + // claim is the JWT claim to use. + // Either claim or expression must be set. + // Mutually exclusive with expression. + // +optional + Claim string `json:"claim,omitempty"` + + // expression represents the expression which will be evaluated by CEL. + // + // CEL expressions have access to the contents of the token claims, organized into CEL variable: + // - 'claims' is a map of claim names to claim values. + // For example, a variable named 'sub' can be accessed as 'claims.sub'. + // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'. + // + // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/ + // + // Mutually exclusive with claim. + // +optional + Expression string `json:"expression,omitempty"` +} + +// ExtraMapping provides the configuration for a single extra mapping. +type ExtraMapping struct { + // key is a string to use as the extra attribute key. + // key must be a domain-prefix path (e.g. example.org/foo). All characters before the first "/" must be a valid + // subdomain as defined by RFC 1123. All characters trailing the first "/" must + // be valid HTTP Path characters as defined by RFC 3986. + // key must be lowercase. // +required - Prefix *string `json:"prefix"` + Key string `json:"key"` + + // valueExpression is a CEL expression to extract extra attribute value. + // valueExpression must produce a string or string array value. + // "", [], and null values are treated as the extra mapping not being present. + // Empty string values contained within a string array are filtered out. + // + // CEL expressions have access to the contents of the token claims, organized into CEL variable: + // - 'claims' is a map of claim names to claim values. + // For example, a variable named 'sub' can be accessed as 'claims.sub'. + // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'. + // + // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/ + // + // +required + ValueExpression string `json:"valueExpression"` +} + +// UserValidationRule provides the configuration for a single user info validation rule. +type UserValidationRule struct { + // expression represents the expression which will be evaluated by CEL. + // Must return true for the validation to pass. + // + // CEL expressions have access to the contents of UserInfo, organized into CEL variable: + // - 'user' - authentication.k8s.io/v1, Kind=UserInfo object + // Refer to https://github.com/kubernetes/api/blob/release-1.28/authentication/v1/types.go#L105-L122 for the definition. + // API documentation: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#userinfo-v1-authentication-k8s-io + // + // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/ + // + // +required + Expression string `json:"expression"` + + // message customizes the returned error message when rule returns false. + // message is a literal string. + // +optional + Message string `json:"message,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go index a7a09ad0eed..92060206840 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go @@ -96,6 +96,16 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddGeneratedConversionFunc((*ClaimOrExpression)(nil), (*apiserver.ClaimOrExpression)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(a.(*ClaimOrExpression), b.(*apiserver.ClaimOrExpression), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*apiserver.ClaimOrExpression)(nil), (*ClaimOrExpression)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(a.(*apiserver.ClaimOrExpression), b.(*ClaimOrExpression), scope) + }); err != nil { + return err + } if err := s.AddGeneratedConversionFunc((*ClaimValidationRule)(nil), (*apiserver.ClaimValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(a.(*ClaimValidationRule), b.(*apiserver.ClaimValidationRule), scope) }); err != nil { @@ -131,6 +141,16 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddGeneratedConversionFunc((*ExtraMapping)(nil), (*apiserver.ExtraMapping)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(a.(*ExtraMapping), b.(*apiserver.ExtraMapping), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*apiserver.ExtraMapping)(nil), (*ExtraMapping)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(a.(*apiserver.ExtraMapping), b.(*ExtraMapping), scope) + }); err != nil { + return err + } if err := s.AddGeneratedConversionFunc((*Issuer)(nil), (*apiserver.Issuer)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1alpha1_Issuer_To_apiserver_Issuer(a.(*Issuer), b.(*apiserver.Issuer), scope) }); err != nil { @@ -211,6 +231,16 @@ func RegisterConversions(s *runtime.Scheme) error { }); err != nil { return err } + if err := s.AddGeneratedConversionFunc((*UserValidationRule)(nil), (*apiserver.UserValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(a.(*UserValidationRule), b.(*apiserver.UserValidationRule), scope) + }); err != nil { + return err + } + if err := s.AddGeneratedConversionFunc((*apiserver.UserValidationRule)(nil), (*UserValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error { + return Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(a.(*apiserver.UserValidationRule), b.(*UserValidationRule), scope) + }); err != nil { + return err + } if err := s.AddGeneratedConversionFunc((*WebhookConfiguration)(nil), (*apiserver.WebhookConfiguration)(nil), func(a, b interface{}, scope conversion.Scope) error { return Convert_v1alpha1_WebhookConfiguration_To_apiserver_WebhookConfiguration(a.(*WebhookConfiguration), b.(*apiserver.WebhookConfiguration), scope) }); err != nil { @@ -364,6 +394,10 @@ func autoConvert_v1alpha1_ClaimMappings_To_apiserver_ClaimMappings(in *ClaimMapp if err := Convert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpression(&in.Groups, &out.Groups, s); err != nil { return err } + if err := Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(&in.UID, &out.UID, s); err != nil { + return err + } + out.Extra = *(*[]apiserver.ExtraMapping)(unsafe.Pointer(&in.Extra)) return nil } @@ -379,6 +413,10 @@ func autoConvert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in *apiserver if err := Convert_apiserver_PrefixedClaimOrExpression_To_v1alpha1_PrefixedClaimOrExpression(&in.Groups, &out.Groups, s); err != nil { return err } + if err := Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(&in.UID, &out.UID, s); err != nil { + return err + } + out.Extra = *(*[]ExtraMapping)(unsafe.Pointer(&in.Extra)) return nil } @@ -387,9 +425,33 @@ func Convert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in *apiserver.Cla return autoConvert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in, out, s) } +func autoConvert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in *ClaimOrExpression, out *apiserver.ClaimOrExpression, s conversion.Scope) error { + out.Claim = in.Claim + out.Expression = in.Expression + return nil +} + +// Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression is an autogenerated conversion function. +func Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in *ClaimOrExpression, out *apiserver.ClaimOrExpression, s conversion.Scope) error { + return autoConvert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in, out, s) +} + +func autoConvert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in *apiserver.ClaimOrExpression, out *ClaimOrExpression, s conversion.Scope) error { + out.Claim = in.Claim + out.Expression = in.Expression + return nil +} + +// Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression is an autogenerated conversion function. +func Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in *apiserver.ClaimOrExpression, out *ClaimOrExpression, s conversion.Scope) error { + return autoConvert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in, out, s) +} + func autoConvert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(in *ClaimValidationRule, out *apiserver.ClaimValidationRule, s conversion.Scope) error { out.Claim = in.Claim out.RequiredValue = in.RequiredValue + out.Expression = in.Expression + out.Message = in.Message return nil } @@ -401,6 +463,8 @@ func Convert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(in *C func autoConvert_apiserver_ClaimValidationRule_To_v1alpha1_ClaimValidationRule(in *apiserver.ClaimValidationRule, out *ClaimValidationRule, s conversion.Scope) error { out.Claim = in.Claim out.RequiredValue = in.RequiredValue + out.Expression = in.Expression + out.Message = in.Message return nil } @@ -492,6 +556,28 @@ func Convert_apiserver_EgressSelectorConfiguration_To_v1alpha1_EgressSelectorCon return autoConvert_apiserver_EgressSelectorConfiguration_To_v1alpha1_EgressSelectorConfiguration(in, out, s) } +func autoConvert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in *ExtraMapping, out *apiserver.ExtraMapping, s conversion.Scope) error { + out.Key = in.Key + out.ValueExpression = in.ValueExpression + return nil +} + +// Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping is an autogenerated conversion function. +func Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in *ExtraMapping, out *apiserver.ExtraMapping, s conversion.Scope) error { + return autoConvert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in, out, s) +} + +func autoConvert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in *apiserver.ExtraMapping, out *ExtraMapping, s conversion.Scope) error { + out.Key = in.Key + out.ValueExpression = in.ValueExpression + return nil +} + +// Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping is an autogenerated conversion function. +func Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in *apiserver.ExtraMapping, out *ExtraMapping, s conversion.Scope) error { + return autoConvert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in, out, s) +} + func autoConvert_v1alpha1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.Issuer, s conversion.Scope) error { out.URL = in.URL out.CertificateAuthority = in.CertificateAuthority @@ -524,6 +610,7 @@ func autoConvert_v1alpha1_JWTAuthenticator_To_apiserver_JWTAuthenticator(in *JWT if err := Convert_v1alpha1_ClaimMappings_To_apiserver_ClaimMappings(&in.ClaimMappings, &out.ClaimMappings, s); err != nil { return err } + out.UserValidationRules = *(*[]apiserver.UserValidationRule)(unsafe.Pointer(&in.UserValidationRules)) return nil } @@ -540,6 +627,7 @@ func autoConvert_apiserver_JWTAuthenticator_To_v1alpha1_JWTAuthenticator(in *api if err := Convert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(&in.ClaimMappings, &out.ClaimMappings, s); err != nil { return err } + out.UserValidationRules = *(*[]UserValidationRule)(unsafe.Pointer(&in.UserValidationRules)) return nil } @@ -551,6 +639,7 @@ func Convert_apiserver_JWTAuthenticator_To_v1alpha1_JWTAuthenticator(in *apiserv func autoConvert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpression(in *PrefixedClaimOrExpression, out *apiserver.PrefixedClaimOrExpression, s conversion.Scope) error { out.Claim = in.Claim out.Prefix = (*string)(unsafe.Pointer(in.Prefix)) + out.Expression = in.Expression return nil } @@ -562,6 +651,7 @@ func Convert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpr func autoConvert_apiserver_PrefixedClaimOrExpression_To_v1alpha1_PrefixedClaimOrExpression(in *apiserver.PrefixedClaimOrExpression, out *PrefixedClaimOrExpression, s conversion.Scope) error { out.Claim = in.Claim out.Prefix = (*string)(unsafe.Pointer(in.Prefix)) + out.Expression = in.Expression return nil } @@ -678,6 +768,28 @@ func Convert_apiserver_UDSTransport_To_v1alpha1_UDSTransport(in *apiserver.UDSTr return autoConvert_apiserver_UDSTransport_To_v1alpha1_UDSTransport(in, out, s) } +func autoConvert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in *UserValidationRule, out *apiserver.UserValidationRule, s conversion.Scope) error { + out.Expression = in.Expression + out.Message = in.Message + return nil +} + +// Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule is an autogenerated conversion function. +func Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in *UserValidationRule, out *apiserver.UserValidationRule, s conversion.Scope) error { + return autoConvert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in, out, s) +} + +func autoConvert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in *apiserver.UserValidationRule, out *UserValidationRule, s conversion.Scope) error { + out.Expression = in.Expression + out.Message = in.Message + return nil +} + +// Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule is an autogenerated conversion function. +func Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in *apiserver.UserValidationRule, out *UserValidationRule, s conversion.Scope) error { + return autoConvert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in, out, s) +} + func autoConvert_v1alpha1_WebhookConfiguration_To_apiserver_WebhookConfiguration(in *WebhookConfiguration, out *apiserver.WebhookConfiguration, s conversion.Scope) error { out.AuthorizedTTL = in.AuthorizedTTL out.UnauthorizedTTL = in.UnauthorizedTTL diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go index 5121d05e7d3..932af612707 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go @@ -168,6 +168,12 @@ func (in *ClaimMappings) DeepCopyInto(out *ClaimMappings) { *out = *in in.Username.DeepCopyInto(&out.Username) in.Groups.DeepCopyInto(&out.Groups) + out.UID = in.UID + if in.Extra != nil { + in, out := &in.Extra, &out.Extra + *out = make([]ExtraMapping, len(*in)) + copy(*out, *in) + } return } @@ -181,6 +187,22 @@ func (in *ClaimMappings) DeepCopy() *ClaimMappings { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClaimOrExpression) DeepCopyInto(out *ClaimOrExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimOrExpression. +func (in *ClaimOrExpression) DeepCopy() *ClaimOrExpression { + if in == nil { + return nil + } + out := new(ClaimOrExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClaimValidationRule) DeepCopyInto(out *ClaimValidationRule) { *out = *in @@ -267,6 +289,22 @@ func (in *EgressSelectorConfiguration) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraMapping) DeepCopyInto(out *ExtraMapping) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraMapping. +func (in *ExtraMapping) DeepCopy() *ExtraMapping { + if in == nil { + return nil + } + out := new(ExtraMapping) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Issuer) DeepCopyInto(out *Issuer) { *out = *in @@ -298,6 +336,11 @@ func (in *JWTAuthenticator) DeepCopyInto(out *JWTAuthenticator) { copy(*out, *in) } in.ClaimMappings.DeepCopyInto(&out.ClaimMappings) + if in.UserValidationRules != nil { + in, out := &in.UserValidationRules, &out.UserValidationRules + *out = make([]UserValidationRule, len(*in)) + copy(*out, *in) + } return } @@ -437,6 +480,22 @@ func (in *UDSTransport) DeepCopy() *UDSTransport { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserValidationRule) DeepCopyInto(out *UserValidationRule) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserValidationRule. +func (in *UserValidationRule) DeepCopy() *UserValidationRule { + if in == nil { + return nil + } + out := new(UserValidationRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WebhookConfiguration) DeepCopyInto(out *WebhookConfiguration) { *out = *in 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/apis/apiserver/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go index 87b41f7ef6b..77e5c314219 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go @@ -168,6 +168,12 @@ func (in *ClaimMappings) DeepCopyInto(out *ClaimMappings) { *out = *in in.Username.DeepCopyInto(&out.Username) in.Groups.DeepCopyInto(&out.Groups) + out.UID = in.UID + if in.Extra != nil { + in, out := &in.Extra, &out.Extra + *out = make([]ExtraMapping, len(*in)) + copy(*out, *in) + } return } @@ -181,6 +187,22 @@ func (in *ClaimMappings) DeepCopy() *ClaimMappings { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClaimOrExpression) DeepCopyInto(out *ClaimOrExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimOrExpression. +func (in *ClaimOrExpression) DeepCopy() *ClaimOrExpression { + if in == nil { + return nil + } + out := new(ClaimOrExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClaimValidationRule) DeepCopyInto(out *ClaimValidationRule) { *out = *in @@ -267,6 +289,22 @@ func (in *EgressSelectorConfiguration) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtraMapping) DeepCopyInto(out *ExtraMapping) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraMapping. +func (in *ExtraMapping) DeepCopy() *ExtraMapping { + if in == nil { + return nil + } + out := new(ExtraMapping) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Issuer) DeepCopyInto(out *Issuer) { *out = *in @@ -298,6 +336,11 @@ func (in *JWTAuthenticator) DeepCopyInto(out *JWTAuthenticator) { copy(*out, *in) } in.ClaimMappings.DeepCopyInto(&out.ClaimMappings) + if in.UserValidationRules != nil { + in, out := &in.UserValidationRules, &out.UserValidationRules + *out = make([]UserValidationRule, len(*in)) + copy(*out, *in) + } return } @@ -437,6 +480,22 @@ func (in *UDSTransport) DeepCopy() *UDSTransport { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserValidationRule) DeepCopyInto(out *UserValidationRule) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserValidationRule. +func (in *UserValidationRule) DeepCopy() *UserValidationRule { + if in == nil { + return nil + } + out := new(UserValidationRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WebhookConfiguration) DeepCopyInto(out *WebhookConfiguration) { *out = *in 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/test/integration/apiserver/oidc/oidc_test.go b/test/integration/apiserver/oidc/oidc_test.go index b8df34bdfc1..9f7ec3c6a36 100644 --- a/test/integration/apiserver/oidc/oidc_test.go +++ b/test/integration/apiserver/oidc/oidc_test.go @@ -36,6 +36,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + authenticationv1 "k8s.io/api/authentication/v1" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -99,6 +100,9 @@ var ( } ) +// authenticationConfigFunc is a function that returns a string representation of an authentication config. +type authenticationConfigFunc func(t *testing.T, issuerURL, caCert string) string + func TestOIDC(t *testing.T) { t.Log("Testing OIDC authenticator with --oidc-* flags") runTests(t, false) @@ -114,7 +118,7 @@ func TestStructuredAuthenticationConfig(t *testing.T) { func runTests(t *testing.T, useAuthenticationConfig bool) { var tests = []struct { name string - configureInfrastructure func(t *testing.T, useAuthenticationConfig bool) ( + configureInfrastructure func(t *testing.T, fn authenticationConfigFunc) ( oidcServer *utilsoidc.TestServer, apiServer *kubeapiserverapptesting.TestServer, signingPrivateKey *rsa.PrivateKey, @@ -129,27 +133,29 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { certPath, oidcServerURL, oidcServerTokenURL string, - ) *kubernetes.Clientset - asserErrFn func(t *testing.T, errorToCheck error) + ) kubernetes.Interface + assertErrFn func(t *testing.T, errorToCheck error) }{ { name: "ID token is ok", configureInfrastructure: configureTestInfrastructure, configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { idTokenLifetime := time.Second * 1200 - oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT( + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( t, signingPrivateKey, - oidcServer.URL(), - defaultOIDCClientID, - defaultOIDCClaimedUsername, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + }, defaultStubAccessToken, defaultStubRefreshToken, - time.Now().Add(idTokenLifetime).Unix(), )) }, configureClient: configureClientFetchingOIDCCredentials, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { assert.NoError(t, errorToCheck) }, }, @@ -160,7 +166,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { configureOIDCServerToReturnExpiredIDToken(t, 2, oidcServer, signingPrivateKey) }, configureClient: configureClientFetchingOIDCCredentials, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck) }, }, @@ -171,7 +177,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { oidcServer.TokenHandler().EXPECT().Token().Times(2).Return(utilsoidc.Token{}, utilsoidc.ErrBadClientID) }, configureClient: configureClientWithEmptyIDToken, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { urlError, ok := errorToCheck.(*url.Error) require.True(t, ok) assert.Equal( @@ -185,7 +191,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { name: "client has wrong CA", configureInfrastructure: configureTestInfrastructure, configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, _ *rsa.PrivateKey) {}, - configureClient: func(t *testing.T, restCfg *rest.Config, caCert []byte, _, oidcServerURL, oidcServerTokenURL string) *kubernetes.Clientset { + configureClient: func(t *testing.T, restCfg *rest.Config, caCert []byte, _, oidcServerURL, oidcServerTokenURL string) kubernetes.Interface { tempDir := t.TempDir() certFilePath := filepath.Join(tempDir, "localhost_127.0.0.1_.crt") @@ -194,7 +200,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { return configureClientWithEmptyIDToken(t, restCfg, caCert, certFilePath, oidcServerURL, oidcServerTokenURL) }, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { expectedErr := new(x509.UnknownAuthorityError) assert.ErrorAs(t, errorToCheck, expectedErr) }, @@ -212,7 +218,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { }, nil) }, configureClient: configureClientFetchingOIDCCredentials, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { expectedError := new(apierrors.StatusError) assert.ErrorAs(t, errorToCheck, &expectedError) assert.Equal( @@ -224,7 +230,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { }, { name: "ID token signature can not be verified due to wrong JWKs", - configureInfrastructure: func(t *testing.T, useAuthenticationConfig bool) ( + configureInfrastructure: func(t *testing.T, fn authenticationConfigFunc) ( oidcServer *utilsoidc.TestServer, apiServer *kubeapiserverapptesting.TestServer, signingPrivateKey *rsa.PrivateKey, @@ -239,7 +245,21 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath) if useAuthenticationConfig { - authenticationConfig := generateAuthenticationConfig(t, oidcServer.URL(), defaultOIDCClientID, string(caCertContent), defaultOIDCUsernamePrefix) + authenticationConfig := fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + claim: sub + prefix: %s +`, oidcServer.URL(), defaultOIDCClientID, indentCertificateAuthority(string(caCertContent)), defaultOIDCUsernamePrefix) apiServer = startTestAPIServerForOIDC(t, "", "", "", authenticationConfig) } else { apiServer = startTestAPIServerForOIDC(t, oidcServer.URL(), defaultOIDCClientID, caFilePath, "") @@ -250,24 +270,26 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { anotherSigningPrivateKey, wantErr := rsa.GenerateKey(rand.Reader, rsaKeyBitSize) require.NoError(t, wantErr) - oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehaviour(t, &anotherSigningPrivateKey.PublicKey)) + oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehavior(t, &anotherSigningPrivateKey.PublicKey)) return oidcServer, apiServer, signingPrivateKey, caCertContent, caFilePath }, configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { - oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT( + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( t, signingPrivateKey, - oidcServer.URL(), - defaultOIDCClientID, - defaultOIDCClaimedUsername, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(time.Second * 1200).Unix(), + }, defaultStubAccessToken, defaultStubRefreshToken, - time.Now().Add(time.Second*1200).Unix(), )) }, configureClient: configureClientFetchingOIDCCredentials, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck) }, }, @@ -275,7 +297,27 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, useAuthenticationConfig) + fn := func(t *testing.T, issuerURL, caCert string) string { return "" } + if useAuthenticationConfig { + fn = func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + claim: sub + prefix: %s +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert), defaultOIDCUsernamePrefix) + } + } + oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, fn) tt.configureOIDCServerBehaviour(t, oidcServer, signingPrivateKey) @@ -284,12 +326,10 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { client := tt.configureClient(t, apiServer.ClientConfig, caCert, certPath, oidcServer.URL(), tokenURL) - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - + ctx := testContext(t) _, err = client.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{}) - tt.asserErrFn(t, err) + tt.assertErrFn(t, err) }) } } @@ -298,24 +338,26 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) { var tests = []struct { name string configureUpdatingTokenBehaviour func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) - asserErrFn func(t *testing.T, errorToCheck error) + assertErrFn func(t *testing.T, errorToCheck error) }{ { name: "cache returns stale client if refresh token is not updated in config", configureUpdatingTokenBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { - oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT( + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( t, signingPrivateKey, - oidcServer.URL(), - defaultOIDCClientID, - defaultOIDCClaimedUsername, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(time.Second * 1200).Unix(), + }, defaultStubAccessToken, defaultStubRefreshToken, - time.Now().Add(time.Second*1200).Unix(), )) configureOIDCServerToReturnExpiredRefreshTokenErrorOnTryingToUpdateIDToken(oidcServer) }, - asserErrFn: func(t *testing.T, errorToCheck error) { + assertErrFn: func(t *testing.T, errorToCheck error) { urlError, ok := errorToCheck.(*url.Error) require.True(t, ok) assert.Equal( @@ -327,7 +369,7 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) { }, } - oidcServer, apiServer, signingPrivateKey, caCert, certPath := configureTestInfrastructure(t, false) + oidcServer, apiServer, signingPrivateKey, caCert, certPath := configureTestInfrastructure(t, func(t *testing.T, _, _ string) string { return "" }) tokenURL, err := oidcServer.TokenURL() require.NoError(t, err) @@ -339,9 +381,7 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) { expiredClient := kubernetes.NewForConfigOrDie(clientConfig) configureOIDCServerToReturnExpiredRefreshTokenErrorOnTryingToUpdateIDToken(oidcServer) - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - + ctx := testContext(t) _, err = expiredClient.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{}) assert.Error(t, err) @@ -351,12 +391,347 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) { expectedOkClient := kubernetes.NewForConfigOrDie(clientConfig) _, err = expectedOkClient.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{}) - tt.asserErrFn(t, err) + tt.assertErrFn(t, err) }) } } -func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) ( +func TestStructuredAuthenticationConfigCEL(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)() + + tests := []struct { + name string + authConfigFn authenticationConfigFunc + configureInfrastructure func(t *testing.T, fn authenticationConfigFunc) ( + oidcServer *utilsoidc.TestServer, + apiServer *kubeapiserverapptesting.TestServer, + signingPrivateKey *rsa.PrivateKey, + caCertContent []byte, + caFilePath string, + ) + configureOIDCServerBehaviour func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) + configureClient func( + t *testing.T, + restCfg *rest.Config, + caCert []byte, + certPath, + oidcServerURL, + oidcServerTokenURL string, + ) kubernetes.Interface + assertErrFn func(t *testing.T, errorToCheck error) + wantUser *authenticationv1.UserInfo + }{ + { + name: "username CEL expression is ok", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.NoError(t, errorToCheck) + }, + wantUser: &authenticationv1.UserInfo{ + Username: "k8s-john_doe", + Groups: []string{"system:authenticated"}, + }, + }, + { + name: "groups CEL expression is ok", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + groups: + expression: '(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "prefix:" + role)' +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + "roles": "foo,bar", + "other_roles": "baz,qux", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.NoError(t, errorToCheck) + }, + wantUser: &authenticationv1.UserInfo{ + Username: "k8s-john_doe", + Groups: []string{"prefix:foo", "prefix:bar", "prefix:baz", "prefix:qux", "system:authenticated"}, + }, + }, + { + name: "claim validation rule fails", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + claimValidationRules: + - expression: 'claims.hd == "example.com"' + message: "the hd claim must be set to example.com" +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + "hd": "notexample.com", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck) + }, + }, + { + name: "extra mapping CEL expressions are ok", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + extra: + - key: "example.org/foo" + valueExpression: "'bar'" + - key: "example.org/baz" + valueExpression: "claims.baz" + userValidationRules: + - 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" +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + "baz": "qux", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.NoError(t, errorToCheck) + }, + wantUser: &authenticationv1.UserInfo{ + Username: "k8s-john_doe", + Groups: []string{"system:authenticated"}, + Extra: map[string]authenticationv1.ExtraValue{ + "example.org/foo": {"bar"}, + "example.org/baz": {"qux"}, + }, + }, + }, + { + name: "uid CEL expression is ok", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + uid: + expression: "claims.uid" +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + "uid": "1234", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.NoError(t, errorToCheck) + }, + wantUser: &authenticationv1.UserInfo{ + Username: "k8s-john_doe", + Groups: []string{"system:authenticated"}, + UID: "1234", + }, + }, + { + name: "user validation rule fails", + authConfigFn: func(t *testing.T, issuerURL, caCert string) string { + return fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + audiences: + - %s + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + groups: + expression: '(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "system:" + role)' + userValidationRules: + - expression: "user.groups.all(group, !group.startsWith('system:'))" + message: "groups cannot used reserved system: prefix" +`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert)) + }, + configureInfrastructure: configureTestInfrastructure, + configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) { + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(idTokenLifetime).Unix(), + "roles": "foo,bar", + "other_roles": "baz,qux", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + }, + configureClient: configureClientFetchingOIDCCredentials, + assertErrFn: func(t *testing.T, errorToCheck error) { + assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck) + }, + wantUser: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, tt.authConfigFn) + + tt.configureOIDCServerBehaviour(t, oidcServer, signingPrivateKey) + + tokenURL, err := oidcServer.TokenURL() + require.NoError(t, err) + + client := tt.configureClient(t, apiServer.ClientConfig, caCert, certPath, oidcServer.URL(), tokenURL) + + ctx := testContext(t) + + if tt.wantUser != nil { + res, err := client.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authenticationv1.SelfSubjectReview{}, metav1.CreateOptions{}) + require.NoError(t, err) + assert.Equal(t, *tt.wantUser, res.Status.UserInfo) + } + + _, err = client.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{}) + tt.assertErrFn(t, err) + }) + } +} + +func configureTestInfrastructure(t *testing.T, fn authenticationConfigFunc) ( oidcServer *utilsoidc.TestServer, apiServer *kubeapiserverapptesting.TestServer, signingPrivateKey *rsa.PrivateKey, @@ -372,14 +747,14 @@ func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) ( oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath) - if useAuthenticationConfig { - authenticationConfig := generateAuthenticationConfig(t, oidcServer.URL(), defaultOIDCClientID, string(caCertContent), defaultOIDCUsernamePrefix) + authenticationConfig := fn(t, oidcServer.URL(), string(caCertContent)) + if len(authenticationConfig) > 0 { apiServer = startTestAPIServerForOIDC(t, "", "", "", authenticationConfig) } else { apiServer = startTestAPIServerForOIDC(t, oidcServer.URL(), defaultOIDCClientID, caFilePath, "") } - oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehaviour(t, &signingPrivateKey.PublicKey)) + oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehavior(t, &signingPrivateKey.PublicKey)) adminClient := kubernetes.NewForConfigOrDie(apiServer.ClientConfig) configureRBAC(t, adminClient, defaultRole, defaultRoleBinding) @@ -387,19 +762,19 @@ func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) ( return oidcServer, apiServer, signingPrivateKey, caCertContent, caFilePath } -func configureClientFetchingOIDCCredentials(t *testing.T, restCfg *rest.Config, caCert []byte, certPath, oidcServerURL, oidcServerTokenURL string) *kubernetes.Clientset { +func configureClientFetchingOIDCCredentials(t *testing.T, restCfg *rest.Config, caCert []byte, certPath, oidcServerURL, oidcServerTokenURL string) kubernetes.Interface { idToken, stubRefreshToken := fetchOIDCCredentials(t, oidcServerTokenURL, caCert) clientConfig := configureClientConfigForOIDC(t, restCfg, defaultOIDCClientID, certPath, idToken, stubRefreshToken, oidcServerURL) return kubernetes.NewForConfigOrDie(clientConfig) } -func configureClientWithEmptyIDToken(t *testing.T, restCfg *rest.Config, _ []byte, certPath, oidcServerURL, _ string) *kubernetes.Clientset { +func configureClientWithEmptyIDToken(t *testing.T, restCfg *rest.Config, _ []byte, certPath, oidcServerURL, _ string) kubernetes.Interface { emptyIDToken, stubRefreshToken := "", defaultStubRefreshToken clientConfig := configureClientConfigForOIDC(t, restCfg, defaultOIDCClientID, certPath, emptyIDToken, stubRefreshToken, oidcServerURL) return kubernetes.NewForConfigOrDie(clientConfig) } -func configureRBAC(t *testing.T, clientset *kubernetes.Clientset, role *rbacv1.Role, binding *rbacv1.RoleBinding) { +func configureRBAC(t *testing.T, clientset kubernetes.Interface, role *rbacv1.Role, binding *rbacv1.RoleBinding) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) @@ -500,15 +875,17 @@ func configureOIDCServerToReturnExpiredIDToken(t *testing.T, returningExpiredTok t.Helper() oidcServer.TokenHandler().EXPECT().Token().Times(returningExpiredTokenTimes).DoAndReturn(func() (utilsoidc.Token, error) { - token, err := utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT( + token, err := utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( t, signingPrivateKey, - oidcServer.URL(), - defaultOIDCClientID, - defaultOIDCClaimedUsername, + map[string]interface{}{ + "iss": oidcServer.URL(), + "sub": defaultOIDCClaimedUsername, + "aud": defaultOIDCClientID, + "exp": time.Now().Add(-time.Millisecond).Unix(), + }, defaultStubAccessToken, defaultStubRefreshToken, - time.Now().Add(-time.Millisecond).Unix(), )() return token, err }) @@ -548,26 +925,14 @@ func writeTempFile(t *testing.T, content string) string { return file.Name() } -func generateAuthenticationConfig(t *testing.T, issuerURL, clientID, caCert, usernamePrefix string) string { - t.Helper() - - // Indent the certificate authority to match the format of the generated - // authentication config. - caCert = strings.ReplaceAll(caCert, "\n", "\n ") - - return fmt.Sprintf(` -apiVersion: apiserver.config.k8s.io/v1alpha1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: %s - audiences: - - %s - certificateAuthority: | - %s - claimMappings: - username: - claim: sub - prefix: %s -`, issuerURL, clientID, string(caCert), usernamePrefix) +// indentCertificateAuthority indents the certificate authority to match +// the format of the generated authentication config. +func indentCertificateAuthority(caCert string) string { + return strings.ReplaceAll(caCert, "\n", "\n ") +} + +func testContext(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + return ctx } diff --git a/test/utils/oidc/testserver.go b/test/utils/oidc/testserver.go index aa957dbd95f..8eb1167accb 100644 --- a/test/utils/oidc/testserver.go +++ b/test/utils/oidc/testserver.go @@ -170,17 +170,12 @@ func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath string) *TestServer { return oidcServer } -// TokenHandlerBehaviourReturningPredefinedJWT describes the scenario when signed JWT token is being created. -// This behaviour should being applied to the MockTokenHandler. -func TokenHandlerBehaviourReturningPredefinedJWT( +// TokenHandlerBehaviorReturningPredefinedJWT describes the scenario when signed JWT token is being created. +// This behavior should being applied to the MockTokenHandler. +func TokenHandlerBehaviorReturningPredefinedJWT( t *testing.T, rsaPrivateKey *rsa.PrivateKey, - issClaim, - audClaim, - subClaim, - accessToken, - refreshToken string, - expClaim int64, + claims map[string]interface{}, accessToken, refreshToken string, ) func() (Token, error) { t.Helper() @@ -188,18 +183,7 @@ func TokenHandlerBehaviourReturningPredefinedJWT( signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: rsaPrivateKey}, nil) require.NoError(t, err) - payload := struct { - Iss string `json:"iss"` - Aud string `json:"aud"` - Sub string `json:"sub"` - Exp int64 `json:"exp"` - }{ - Iss: issClaim, - Aud: audClaim, - Sub: subClaim, - Exp: expClaim, - } - payloadJSON, err := json.Marshal(payload) + payloadJSON, err := json.Marshal(claims) require.NoError(t, err) idTokenSignature, err := signer.Sign(payloadJSON) @@ -215,9 +199,9 @@ func TokenHandlerBehaviourReturningPredefinedJWT( } } -// DefaultJwksHandlerBehaviour describes the scenario when JSON Web Key Set token is being returned. -// This behaviour should being applied to the MockJWKsHandler. -func DefaultJwksHandlerBehaviour(t *testing.T, verificationPublicKey *rsa.PublicKey) func() jose.JSONWebKeySet { +// DefaultJwksHandlerBehavior describes the scenario when JSON Web Key Set token is being returned. +// This behavior should being applied to the MockJWKsHandler. +func DefaultJwksHandlerBehavior(t *testing.T, verificationPublicKey *rsa.PublicKey) func() jose.JSONWebKeySet { t.Helper() return func() jose.JSONWebKeySet { diff --git a/vendor/modules.txt b/vendor/modules.txt index 58b36562b77..0a303d68e10 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