Files
zot/pkg/cel/claim_processor_test.go
T
Matheus Pimenta bf619c570e Introduce support for OIDC workload identity federation (#3711)
* feat(oidc): introduce support for OIDC workload identity federation

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): add e2e test for bearer OIDC and a kind cluster

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): make OIDC workload identity federation its own feature

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): move errors to the errors package

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): fix race in cel package

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

* feat(oidc): compile cel expressions

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>

---------

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2026-01-24 21:03:53 -08:00

645 lines
18 KiB
Go

package cel_test
import (
"context"
"testing"
. "github.com/onsi/gomega"
"zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/cel"
)
func TestNewClaimProcessor(t *testing.T) {
t.Parallel()
for _, testCase := range []struct {
name string
audiences []string
conf *config.CELClaimValidationAndMapping
err string
}{
{
name: "nil config uses defaults",
audiences: []string{"my-audience"},
conf: nil,
},
{
name: "empty config uses defaults",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{},
},
{
name: "custom username expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Username: "claims.email",
},
},
{
name: "custom groups expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Groups: "claims.groups",
},
},
{
name: "multiple audiences",
audiences: []string{"aud1", "aud2", "aud3"},
conf: nil,
},
{
name: "with variables",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "org", Expression: "claims.org"},
{Name: "team", Expression: "claims.team"},
},
},
},
{
name: "with validations",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.email_verified == true", Message: "email must be verified"},
},
},
},
{
name: "empty audiences",
audiences: []string{},
conf: nil,
err: "at least one audience must be specified",
},
{
name: "nil audiences",
audiences: nil,
conf: nil,
err: "at least one audience must be specified",
},
{
name: "empty audience in list",
audiences: []string{"valid", ""},
conf: nil,
err: "audience[1]:",
},
{
name: "variable with empty name",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "", Expression: "claims.org"},
},
},
err: "variable[0]:",
},
{
name: "variable with invalid expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "org", Expression: "claims."},
},
},
err: "failed to parse CEL expression for variable[0] (name: org)",
},
{
name: "validation with empty message",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "true", Message: ""},
},
},
err: "validation[0]:",
},
{
name: "validation with invalid expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.", Message: "some error"},
},
},
err: "failed to parse CEL expression for validation[0]",
},
{
name: "invalid username expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Username: "claims.",
},
err: "failed to parse CEL expression for username",
},
{
name: "invalid groups expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Groups: "claims.",
},
err: "failed to parse CEL expression for groups",
},
} {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
gomega := NewWithT(t)
processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf)
if testCase.err != "" {
gomega.Expect(err).To(HaveOccurred())
gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
gomega.Expect(processor).To(BeNil())
} else {
gomega.Expect(err).NotTo(HaveOccurred())
gomega.Expect(processor).NotTo(BeNil())
}
})
}
}
func TestClaimProcessor_Process(t *testing.T) {
t.Parallel()
for _, testCase := range []struct {
name string
audiences []string
conf *config.CELClaimValidationAndMapping
claims map[string]any
username string
groups []string
err string
}{
{
name: "default config extracts iss/sub as username",
audiences: []string{"my-audience"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
groups: nil,
},
{
name: "custom username from email claim",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Username: "claims.email",
},
claims: map[string]any{
"sub": "user123",
"email": "user@example.com",
"aud": []any{"my-audience"},
},
username: "user@example.com",
groups: nil,
},
{
name: "extract groups from claims",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Groups: "claims.groups",
},
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"groups": []string{"admin", "developers"},
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
groups: []string{"admin", "developers"},
},
{
name: "extract groups from any slice",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Groups: "claims.groups",
},
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"groups": []any{"admin", "developers"},
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
groups: []string{"admin", "developers"},
},
{
name: "audience validation - single audience match",
audiences: []string{"my-audience"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
},
{
name: "audience validation - multiple audiences, one matches",
audiences: []string{"aud1", "aud2"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": []any{"aud2", "other"},
},
username: "https://issuer.example.com/user123",
},
{
name: "audience validation - token has multiple, config has one",
audiences: []string{"aud2"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": []any{"aud1", "aud2", "aud3"},
},
username: "https://issuer.example.com/user123",
},
{
name: "audience validation - single string audience matches",
audiences: []string{"my-audience"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": "my-audience",
},
username: "https://issuer.example.com/user123",
},
{
name: "audience validation - []string type audience matches",
audiences: []string{"my-audience"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": []string{"my-audience", "other-audience"},
},
username: "https://issuer.example.com/user123",
},
{
name: "audience validation fails - single string audience no match",
audiences: []string{"expected-aud"},
conf: nil,
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"aud": "other-aud",
},
err: "does not match any of the expected audiences",
},
{
name: "audience validation fails - no match",
audiences: []string{"expected-aud"},
conf: nil,
claims: map[string]any{
"sub": "user123",
"aud": []any{"other-aud"},
},
err: "token audience does not match any of the expected audiences",
},
{
name: "audience validation fails - empty token audience",
audiences: []string{"expected-aud"},
conf: nil,
claims: map[string]any{
"sub": "user123",
"aud": []any{},
},
err: "does not match any of the expected audiences",
},
{
name: "variables can be used in username expression",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "prefix", Expression: "'user-'"},
},
Username: "vars.prefix + claims.sub",
},
claims: map[string]any{
"sub": "123",
"aud": []any{"my-audience"},
},
username: "user-123",
},
{
name: "variables can reference claims",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "domain", Expression: "claims.email.split('@')[1]"},
},
Username: "vars.domain + '/' + claims.sub",
},
claims: map[string]any{
"sub": "user123",
"email": "user@example.com",
"aud": []any{"my-audience"},
},
username: "example.com/user123",
},
{
name: "variables can reference other variables",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "org", Expression: "claims.org"},
{Name: "fullOrg", Expression: "'org-' + vars.org"},
},
Username: "vars.fullOrg + '/' + claims.sub",
},
claims: map[string]any{
"sub": "user123",
"org": "myorg",
"aud": []any{"my-audience"},
},
username: "org-myorg/user123",
},
{
name: "validation passes",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.email_verified == true", Message: "email must be verified"},
},
},
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"email_verified": true,
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
},
{
name: "validation fails",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.email_verified == true", Message: "email must be verified"},
},
},
claims: map[string]any{
"sub": "user123",
"email_verified": false,
"aud": []any{"my-audience"},
},
err: "OIDC claim validation failed: email must be verified",
},
{
name: "multiple validations all pass",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.email_verified == true", Message: "email must be verified"},
{Expression: "claims.org == 'myorg'", Message: "must be in myorg"},
},
},
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"email_verified": true,
"org": "myorg",
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
},
{
name: "multiple validations - second fails",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Validations: []config.CELValidation{
{Expression: "claims.email_verified == true", Message: "email must be verified"},
{Expression: "claims.org == 'myorg'", Message: "must be in myorg"},
},
},
claims: map[string]any{
"sub": "user123",
"email_verified": true,
"org": "otherorg",
"aud": []any{"my-audience"},
},
err: "OIDC claim validation failed: must be in myorg",
},
{
name: "validation can use variables",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"},
},
Validations: []config.CELValidation{
{Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"},
},
},
claims: map[string]any{
"iss": "https://issuer.example.com",
"sub": "user123",
"org": "org2",
"aud": []any{"my-audience"},
},
username: "https://issuer.example.com/user123",
},
{
name: "validation using variables fails",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"},
},
Validations: []config.CELValidation{
{Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"},
},
},
claims: map[string]any{
"sub": "user123",
"org": "org4",
"aud": []any{"my-audience"},
},
err: "OIDC claim validation failed: organization not allowed",
},
{
name: "username expression evaluation error",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Username: "claims.nonexistent",
},
claims: map[string]any{
"sub": "user123",
"aud": []any{"my-audience"},
},
err: "failed to evaluate username expression",
},
{
name: "groups expression evaluation error",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Username: "claims.sub",
Groups: "claims.nonexistent",
},
claims: map[string]any{
"sub": "user123",
"aud": []any{"my-audience"},
},
err: "failed to evaluate groups expression",
},
{
name: "variable expression evaluation error",
audiences: []string{"my-audience"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "bad", Expression: "claims.nonexistent"},
},
},
claims: map[string]any{
"sub": "user123",
"aud": []any{"my-audience"},
},
err: "failed to evaluate variable 'bad'",
},
{
name: "complex real-world scenario - GitHub Actions OIDC",
audiences: []string{"zot-registry"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "repo", Expression: "claims.repository"},
{Name: "owner", Expression: "claims.repository_owner"},
},
Validations: []config.CELValidation{
{Expression: "vars.owner == 'myorg'", Message: "only myorg repositories allowed"},
{Expression: "claims.ref.startsWith('refs/heads/')", Message: "must be a branch ref"},
},
Username: "vars.repo",
Groups: "['github-actions', 'ci']",
},
claims: map[string]any{
"sub": "repo:myorg/myrepo:ref:refs/heads/main",
"repository": "myorg/myrepo",
"repository_owner": "myorg",
"ref": "refs/heads/main",
"aud": []any{"zot-registry"},
},
username: "myorg/myrepo",
groups: []string{"github-actions", "ci"},
},
{
name: "complex real-world scenario - Kubernetes service account",
audiences: []string{"zot"},
conf: &config.CELClaimValidationAndMapping{
Variables: []config.CELVariable{
{Name: "ns", Expression: "claims['kubernetes.io/serviceaccount/namespace']"},
{Name: "sa", Expression: "claims['kubernetes.io/serviceaccount/service-account.name']"},
},
Validations: []config.CELValidation{
{Expression: "vars.ns in ['production', 'staging']", Message: "namespace not allowed"},
},
Username: "vars.ns + ':' + vars.sa",
Groups: "['k8s-workloads']",
},
claims: map[string]any{
"sub": "system:serviceaccount:production:my-app",
"kubernetes.io/serviceaccount/namespace": "production",
"kubernetes.io/serviceaccount/service-account.name": "my-app",
"aud": []any{"zot"},
},
username: "production:my-app",
groups: []string{"k8s-workloads"},
},
} {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
gomega := NewWithT(t)
processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf)
gomega.Expect(err).NotTo(HaveOccurred())
result, err := processor.Process(context.Background(), testCase.claims)
if testCase.err != "" {
gomega.Expect(err).To(HaveOccurred())
gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
gomega.Expect(result).To(BeNil())
} else {
gomega.Expect(err).NotTo(HaveOccurred())
gomega.Expect(result).NotTo(BeNil())
gomega.Expect(result.Username).To(Equal(testCase.username))
gomega.Expect(result.Groups).To(Equal(testCase.groups))
}
})
}
}
func TestClaimProcessor_Process_AudienceEdgeCases(t *testing.T) {
t.Parallel()
for _, testCase := range []struct {
name string
audiences []string
claims map[string]any
err string
}{
{
name: "missing aud claim",
audiences: []string{"my-audience"},
claims: map[string]any{
"sub": "user123",
},
err: "missing 'aud' claim",
},
{
name: "aud claim with wrong type (integer)",
audiences: []string{"my-audience"},
claims: map[string]any{
"sub": "user123",
"iss": "test-issuer",
"aud": 12345,
},
err: "does not match any of the expected audiences",
},
{
name: "aud claim with wrong type (map)",
audiences: []string{"my-audience"},
claims: map[string]any{
"sub": "user123",
"iss": "test-issuer",
"aud": map[string]any{"key": "value"},
},
err: "does not match any of the expected audiences",
},
{
name: "aud array contains non-string value",
audiences: []string{"my-audience"},
claims: map[string]any{
"sub": "user123",
"iss": "test-issuer",
"aud": []any{"valid-aud", 123},
},
err: "'aud' claim contains non-string value",
},
} {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
gomega := NewWithT(t)
processor, err := cel.NewClaimProcessor(testCase.audiences, nil)
gomega.Expect(err).NotTo(HaveOccurred())
result, err := processor.Process(context.Background(), testCase.claims)
gomega.Expect(err).To(HaveOccurred())
gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
gomega.Expect(result).To(BeNil())
})
}
}