mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
bf619c570e
* 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>
645 lines
18 KiB
Go
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())
|
|
})
|
|
}
|
|
}
|