From 8a6674f1985e3b1cfec5bd3c4cfd1fee4e5a867f Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sat, 9 May 2026 20:43:00 +0100 Subject: [PATCH] feat(authz): introduce conditional access control via CEL (#4040) --- errors/errors.go | 1 + examples/README.md | 60 ++ examples/config-policy.json | 16 + examples/kind/kind-oidc-workload-identity.sh | 113 ++- pkg/api/authn.go | 16 +- pkg/api/authz.go | 466 +++++++++-- pkg/api/authz_internal_test.go | 766 +++++++++++++++++++ pkg/api/config/config.go | 103 +++ pkg/api/controller.go | 17 + pkg/cel/claim_processor.go | 4 + pkg/cel/expression.go | 13 + pkg/cli/server/root.go | 6 + pkg/cli/server/root_test.go | 89 +++ pkg/common/http_server.go | 19 +- pkg/requestcontext/user_access_control.go | 32 +- 15 files changed, 1636 insertions(+), 85 deletions(-) create mode 100644 pkg/api/authz_internal_test.go diff --git a/errors/errors.go b/errors/errors.go index 1e2a6ebb..07a4e875 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -211,4 +211,5 @@ var ( ErrCertificateNotLoaded = errors.New("tls certificate not yet loaded") ErrCertificateWatcherAlreadyRunning = errors.New("certificate watcher is already running") ErrInvalidEndSessionEndpoint = errors.New("end_session_endpoint must be an absolute http(s) URL") + ErrPolicyConditionNotCompiled = errors.New("policy condition not compiled") ) diff --git a/examples/README.md b/examples/README.md index df51e2b2..ab51845d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -867,6 +867,66 @@ Behaviour-based action list } ``` +##### Conditional access on policies + +Policy entries can carry an optional list of `conditions`: CEL boolean +expressions that must all evaluate to true for the entry to grant access. +This is the same pattern as conditional access in cloud IAM systems. + +``` +"policies": [{ + "users": ["alice"], + "actions": ["read", "create", "update"], + "conditions": [{ + "expression": "req.time < timestamp(\"2099-12-31T23:59:59Z\")", + "message": "alice's access expires end of 2099" + }, + { + "expression": "req.referenceType == \"digest\"", + "message": "prod pushes must use digest references" + } + ] +}] +``` + +Expressions evaluate against a `req` struct with the following fields: + +| Path | Type | Description | +|---|---|---| +| `req.time` | timestamp | Current time as a CEL timestamp; compare with `timestamp("2099-12-31T23:59:59Z")`. | +| `req.method` | string | Raw HTTP method of the originating request (`"GET"`, `"PUT"`, ...). | +| `req.userAgent` | string | `User-Agent` header. | +| `req.action` | string | Abstract action being authorized: `"read"`, `"create"`, `"update"`, `"delete"`. Use this for action gating; `req.method` is the raw verb escape hatch. | +| `req.repository` | string | The requested repository, when known. | +| `req.reference` | string | Tag or digest, when the route has one. | +| `req.referenceType` | string | `"tag"`, `"digest"`, or `""` when the route has no reference. | +| `req.tag` | string | The tag, when reference is a tag. | +| `req.digest` | string | The digest, when reference is a digest. | +| `req.user.username` | string | Authenticated username. | +| `req.user.groups` | list<string> | Authenticated user's groups. | +| `req.auth.anonymous` | bool | Convenience for `req.user.username == ""`. | +| `req.auth.admin` | bool | True when the user matches the admin policy. | +| `req.client.ip` | string | TCP peer address from `RemoteAddr` (port stripped). Always trustworthy. | +| `req.client.forwardedFor` | list<string> | `X-Forwarded-For` chain, left to right. **Untrusted** — anyone can set the header. | +| `req.tls.enabled` | bool | Whether the request arrived over TLS at zot. | +| `req.tls.version` | string | TLS version: `"1.2"`, `"1.3"`, ... when applicable. | +| `req.claims` | map | Authn-time attribute bag, populated by the active authn flow (today: OIDC bearer fills it with the ID token claim set; other flows can feed this surface as they grow that capability). | + +**Network gates.** `req.client.ip` is the TCP peer (the proxy, behind a +reverse proxy). `req.client.forwardedFor` is the raw header chain — useful +but spoofable, since any client can set it. The idiomatic pattern is to gate +on the chain only after asserting the TCP peer is your trusted proxy: + +``` +req.client.ip == "10.0.0.5" && req.client.forwardedFor[0].startsWith("192.0.2.") +``` + +**Deny messages.** When a condition evaluates to false, its `message` is +surfaced to the client in the 403 response body's error detail under the +`reason` key, and also logged for operator diagnosis. Internal lookup or +evaluation failures are *not* surfaced (the client just gets a generic deny) +to avoid leaking implementation issues. + #### Scheduler Workers The number of workers for the task scheduler has the default value of runtime.NumCPU()*4, and it is configurable with: diff --git a/examples/config-policy.json b/examples/config-policy.json index bd44d394..a8eb61c4 100644 --- a/examples/config-policy.json +++ b/examples/config-policy.json @@ -60,6 +60,22 @@ } ], "defaultPolicy": ["read"] + }, + "prod/**": { + "policies": [{ + "users": ["alice"], + "actions": ["read", "create", "update"], + "conditions": [{ + "expression": "req.time < timestamp(\"2099-12-31T23:59:59Z\")", + "message": "alice's prod access expires end of 2099" + }, + { + "expression": "req.referenceType == \"digest\"", + "message": "prod pushes must use digest references, not mutable tags" + } + ] + } + ] } }, "adminPolicy": { diff --git a/examples/kind/kind-oidc-workload-identity.sh b/examples/kind/kind-oidc-workload-identity.sh index 4ed04520..8f92274a 100755 --- a/examples/kind/kind-oidc-workload-identity.sh +++ b/examples/kind/kind-oidc-workload-identity.sh @@ -18,8 +18,8 @@ # Options: # --skip-setup Skip cluster creation, image building, and initial setup # (assumes resources already exist from a previous run) -# --only-crane Only run crane e2e tests (tests 8-14) -# --only-curl Only run curl-based tests (tests 1-7) +# --only-crane Only run crane e2e tests +# --only-curl Only run curl-based tests (includes conditional access) # --keep-resources Don't clean up resources on exit (useful for debugging) # --help Show this help message @@ -338,6 +338,21 @@ cat < /tmp/zot-oidc-config.json } ], "defaultPolicy": [] + }, + "cond-*/**": { + "policies": [ + { + "users": ["${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:other-sa"], + "actions": ["read", "create", "update", "delete"], + "conditions": [ + { + "expression": "req.repository.startsWith(\"cond-allowed/\")", + "message": "other-sa may only push to cond-allowed/*" + } + ] + } + ], + "defaultPolicy": [] } } } @@ -605,16 +620,24 @@ EOF kubectl wait --for=condition=Ready pod/oidc-test-pod-other-sa -n "${TEST_NAMESPACE}" --timeout=60s fi -# Verify that other-sa can authenticate but sees an EMPTY catalog (no read permissions) +# Verify that other-sa authenticates but cannot see test-repo (which lives +# under the `**` pattern where other-sa has no policy). The catalog may +# still contain repos under `cond-*/**`, since the conditional policy on +# that pattern is *optimistically* included in glob-time filtering — the +# real condition enforcement happens at per-request authz time. Asserting +# "test-repo is absent" expresses the intent without depending on whether +# cond-allowed/test exists from a previous test run (re-runs with +# --skip-setup keep storage around). CATALOG_RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/_catalog"' 2>/dev/null || echo "{}") -if echo "$CATALOG_RESPONSE" | grep -q '"repositories":\[\]'; then - log_info "TEST 6 PASSED: Other ServiceAccount authenticated but has NO permissions (empty catalog)" +if ! echo "$CATALOG_RESPONSE" | jq -e '.repositories | index("test-repo")' >/dev/null 2>&1; then + log_info "TEST 6 PASSED: Other ServiceAccount authenticated but cannot see test-repo" log_info " The username '${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:other-sa' was extracted from the token." log_info " Authorization is enforced via accessControl config." + log_info " Catalog: $CATALOG_RESPONSE" else - log_error "TEST 6 FAILED: Expected empty catalog for other-sa (not in config)" + log_error "TEST 6 FAILED: Expected test-repo to be absent from other-sa's catalog" log_error "Got: $CATALOG_RESPONSE" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 @@ -622,20 +645,86 @@ fi # ============================================================================= # TEST 7: Verify other-sa gets 403 when trying to write (authorization enforced) +# This is a NON-conditional deny: no policy on the matched pattern grants +# other-sa, so the response body must NOT carry a `reason` field — that +# field is reserved for condition-driven denies (see Test 9 for the contrast). # ============================================================================= log_info "TEST 7: Verifying other-sa gets 403 Forbidden when trying to write..." -HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ - sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/unauthorized-repo/blobs/uploads/"' 2>/dev/null || echo "000") +# `-w "\n%{http_code}"` appends the status code on its own line after the body, +# so we can split with shell builtins. +RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/unauthorized-repo/blobs/uploads/"' 2>/dev/null || echo $'\n000') +TEST7_HTTP=$(echo "$RESPONSE" | tail -n1) +TEST7_BODY=$(echo "$RESPONSE" | sed '$d') -if [ "$HTTP_CODE" = "403" ]; then - log_info "TEST 7 PASSED: Other ServiceAccount correctly rejected for write (HTTP 403)" -else - log_error "TEST 7 FAILED: Expected 403 for write operation, got HTTP $HTTP_CODE" +if [ "$TEST7_HTTP" != "403" ]; then + log_error "TEST 7 FAILED: Expected 403 for write operation, got HTTP $TEST7_HTTP" docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 exit 1 fi +# A non-conditional deny must NOT surface a reason. jq -e returns non-zero if +# .errors[0].detail.reason is absent or null, which is what we want here. +if echo "$TEST7_BODY" | jq -e '.errors[0].detail.reason // empty' >/dev/null 2>&1; then + log_error "TEST 7 FAILED: Non-conditional 403 unexpectedly carried a reason: $TEST7_BODY" + exit 1 +fi + +log_info "TEST 7 PASSED: Other ServiceAccount rejected (HTTP 403, no reason in body)" + +# ============================================================================= +# TEST 8: Conditional access GRANTS other-sa write on cond-allowed/* (condition true) +# ============================================================================= +# The accessControl config grants other-sa read/create/update/delete on the +# `cond-*/**` pattern only when `req.repository.startsWith("cond-allowed/")` +# is true. A push to `cond-allowed/test` should be authorized by the +# conditional policy (HTTP 202 Accepted on blob upload start). +log_info "TEST 8: Verifying CEL condition grants other-sa write on cond-allowed/*..." + +HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/cond-allowed/test/blobs/uploads/"' 2>/dev/null || echo "000") + +if [ "$HTTP_CODE" = "202" ]; then + log_info "TEST 8 PASSED: Conditional policy grants write on cond-allowed/* (HTTP 202)" +else + log_error "TEST 8 FAILED: Expected 202 for cond-allowed/test write, got HTTP $HTTP_CODE" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 9: Conditional access DENIES other-sa on cond-denied/* and surfaces the +# operator-authored Message in the 403 response body's error detail +# ============================================================================= +# Same conditional policy, but `cond-denied/*` does not satisfy +# `startsWith("cond-allowed/")`. The policy's `message` should appear in the +# response body's error detail under the `reason` key, so the client knows +# why access was denied. +log_info "TEST 9: Verifying CEL condition denies on cond-denied/* and surfaces reason..." + +RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/cond-denied/test/blobs/uploads/"' 2>/dev/null || echo $'\n000') +TEST9_HTTP=$(echo "$RESPONSE" | tail -n1) +TEST9_BODY=$(echo "$RESPONSE" | sed '$d') + +if [ "$TEST9_HTTP" != "403" ]; then + log_error "TEST 9 FAILED: Expected 403 for cond-denied/* write, got HTTP $TEST9_HTTP" + log_error "Body: $TEST9_BODY" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# Conditional denies must surface the operator-authored Message in +# detail.reason — that's the contrast with Test 7. +if ! echo "$TEST9_BODY" | jq -e '.errors[0].detail.reason | contains("only push to cond-allowed/*")' >/dev/null 2>&1; then + log_error "TEST 9 FAILED: Expected deny reason in response body, got: $TEST9_BODY" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +log_info "TEST 9 PASSED: Conditional deny returned HTTP 403 with reason in body" + fi # End of curl-based tests conditional # ============================================================================= diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 1fd84ca9..e43c337f 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -602,24 +602,20 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { } // Try OIDC authentication first if configured - var identity string - - var groups []string - if oidcAuthorizer != nil { - var err error - - var authenticated bool - - identity, groups, authenticated, err = oidcAuthorizer.AuthenticateRequest(request.Context(), header) - if err == nil && authenticated { + res, err := oidcAuthorizer.Authenticate(request.Context(), header) + if err == nil && res != nil && res.Username != "" { // OIDC authentication succeeded + identity := res.Username + groups := res.Groups + ctlr.Log.Debug().Str("identity", identity).Msg("the OIDC bearer authentication was successful") // Set user context for authorization userAc := reqCtx.NewUserAccessControl() userAc.SetUsername(identity) userAc.AddGroups(groups) + userAc.SetClaims(res.Claims) userAc.SaveOnRequest(request) // Update user groups in MetaDB if available diff --git a/pkg/api/authz.go b/pkg/api/authz.go index d2cfafed..2b204604 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -2,14 +2,21 @@ package api import ( "context" + "crypto/tls" + "fmt" + "net" "net/http" "slices" + "strings" + "time" glob "github.com/bmatcuk/doublestar/v4" "github.com/gorilla/mux" + zerr "zotregistry.dev/zot/v2/errors" "zotregistry.dev/zot/v2/pkg/api/config" "zotregistry.dev/zot/v2/pkg/api/constants" + "zotregistry.dev/zot/v2/pkg/cel" "zotregistry.dev/zot/v2/pkg/common" "zotregistry.dev/zot/v2/pkg/log" reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext" @@ -63,9 +70,13 @@ func NewAccessController(conf *config.Config) *AccessController { // getGlobPatterns gets glob patterns from authz config on which has perms. // used to filter /v2/_catalog repositories based on user rights. -func (ac *AccessController) getGlobPatterns(username string, groups []string, action string) map[string]bool { +func (ac *AccessController) getGlobPatterns(evalReq *evalRequest) map[string]bool { globPatterns := make(map[string]bool) + username := evalReq.username() + groups := evalReq.groups() + action := evalReq.action + for pattern, policyGroup := range ac.Config.Repositories { if username == "" { // check anonymous policy @@ -79,19 +90,32 @@ func (ac *AccessController) getGlobPatterns(username string, groups []string, ac } } - // check user based policy - for _, p := range policyGroup.Policies { - if slices.Contains(p.Users, username) && slices.Contains(p.Actions, action) { - globPatterns[pattern] = true + // check user based policy. Conditions are NOT evaluated at glob-time + // because the concrete repository / reference are unknown; evaluating + // them against empty placeholders would produce false negatives for + // conditions like `req.repository.startsWith("prod/")`. We include + // such policies optimistically — per-request authz (ac.can) does the + // real enforcement once repo + ref are known. The trade-off is that + // the catalog filter may over-list (a repo shows in /v2/_catalog + // even though the eventual GET is denied with 403); under-listing + // (hiding a repo the user can actually access) is the worse failure + // mode and is what we avoid here. + for _, policy := range policyGroup.Policies { + if !slices.Contains(policy.Users, username) || !slices.Contains(policy.Actions, action) { + continue } + + globPatterns[pattern] = true } // check group based policy for _, group := range groups { - for _, p := range policyGroup.Policies { - if slices.Contains(p.Groups, group) && slices.Contains(p.Actions, action) { - globPatterns[pattern] = true + for _, policy := range policyGroup.Policies { + if !slices.Contains(policy.Groups, group) || !slices.Contains(policy.Actions, action) { + continue } + + globPatterns[pattern] = true } } @@ -104,10 +128,12 @@ func (ac *AccessController) getGlobPatterns(username string, groups []string, ac return globPatterns } -// can verifies if a user can do action on repository. -func (ac *AccessController) can(userAc *reqCtx.UserAccessControl, action, repository string) bool { - can := false - +// can verifies if a user can do action on repository. When access is denied +// and a matched policy's condition was the reason, the second return value is +// the operator-authored Message for that condition. +func (ac *AccessController) can(httpReq *http.Request, userAc *reqCtx.UserAccessControl, + action, repository, reference string, +) (bool, string) { var longestMatchedPattern string for pattern := range ac.Config.Repositories { @@ -121,24 +147,47 @@ func (ac *AccessController) can(userAc *reqCtx.UserAccessControl, action, reposi userGroups := userAc.GetGroups() username := userAc.GetUsername() + evalReq := &evalRequest{ + httpReq: httpReq, + userAc: userAc, + isAdmin: userAc.IsAdmin(), + action: action, + repository: repository, + reference: reference, + } // check matched repo based policy - repositories := ac.Config.GetRepositories() - pg, ok := repositories[longestMatchedPattern] + var ( + can bool + reason string + ) - if ok { - can = ac.isPermitted(userGroups, username, action, pg) + repositories := ac.Config.GetRepositories() + if pg, ok := repositories[longestMatchedPattern]; ok { + can, reason = ac.isPermitted(evalReq, pg) } // check admins based policy if !can { adminPolicy := ac.Config.GetAdminPolicy() if ac.isAdmin(username, userGroups) && slices.Contains(adminPolicy.Actions, action) { - can = true + // AdminPolicy conditions are repo-agnostic by design: they + // gate the blanket admin grant on per-request properties + // (e.g. require TLS, restrict to a corp CIDR, time-of-day + // windows). When a condition denies, surface its Message + // instead of any earlier repo-policy denial reason — the + // admin-path denial is the most specific explanation. + ok, denyReason := ac.policyConditionsMet(adminPolicy, evalReq) + if ok { + can = true + reason = "" + } else if denyReason != "" { + reason = denyReason + } } } - return can + return can, reason } // isAdmin . @@ -176,27 +225,30 @@ func (ac *AccessController) getUserGroups(username string) []string { } // getContext updates an UserAccessControl with admin status and specific permissions on repos. -func (ac *AccessController) updateUserAccessControl(userAc *reqCtx.UserAccessControl) { - identity := userAc.GetUsername() - groups := userAc.GetGroups() +func (ac *AccessController) updateUserAccessControl(httpReq *http.Request, userAc *reqCtx.UserAccessControl) { + isAdmin := ac.isAdmin(userAc.GetUsername(), userAc.GetGroups()) + userAc.SetIsAdmin(isAdmin) - readGlobPatterns := ac.getGlobPatterns(identity, groups, constants.ReadPermission) - createGlobPatterns := ac.getGlobPatterns(identity, groups, constants.CreatePermission) - updateGlobPatterns := ac.getGlobPatterns(identity, groups, constants.UpdatePermission) - deleteGlobPatterns := ac.getGlobPatterns(identity, groups, constants.DeletePermission) - dmcGlobPatterns := ac.getGlobPatterns(identity, groups, constants.DetectManifestCollisionPermission) - - userAc.SetGlobPatterns(constants.ReadPermission, readGlobPatterns) - userAc.SetGlobPatterns(constants.CreatePermission, createGlobPatterns) - userAc.SetGlobPatterns(constants.UpdatePermission, updateGlobPatterns) - userAc.SetGlobPatterns(constants.DeletePermission, deleteGlobPatterns) - userAc.SetGlobPatterns(constants.DetectManifestCollisionPermission, dmcGlobPatterns) - - if ac.isAdmin(userAc.GetUsername(), userAc.GetGroups()) { - userAc.SetIsAdmin(true) - } else { - userAc.SetIsAdmin(false) + mkER := func(action string) *evalRequest { + return &evalRequest{ + httpReq: httpReq, + userAc: userAc, + isAdmin: isAdmin, + action: action, + // repository/reference unknown at glob-computation time + } } + + userAc.SetGlobPatterns(constants.ReadPermission, + ac.getGlobPatterns(mkER(constants.ReadPermission))) + userAc.SetGlobPatterns(constants.CreatePermission, + ac.getGlobPatterns(mkER(constants.CreatePermission))) + userAc.SetGlobPatterns(constants.UpdatePermission, + ac.getGlobPatterns(mkER(constants.UpdatePermission))) + userAc.SetGlobPatterns(constants.DeletePermission, + ac.getGlobPatterns(mkER(constants.DeletePermission))) + userAc.SetGlobPatterns(constants.DetectManifestCollisionPermission, + ac.getGlobPatterns(mkER(constants.DetectManifestCollisionPermission))) } // getAuthnMiddlewareContext builds ac context(allowed to read repos and if user is admin) and returns it. @@ -211,40 +263,342 @@ func (ac *AccessController) getAuthnMiddlewareContext(authnType string, request return ctx } -// isPermitted returns true if username can do action on a repository policy. -func (ac *AccessController) isPermitted(userGroups []string, username, action string, - policyGroup config.PolicyGroup, -) bool { +// CompileAccessControl walks every policy condition in cfg, compiles its CEL +// expression, and returns the resulting expression -> program map. Called at +// config validation (LoadConfiguration) to surface syntax errors at load +// time, and again at controller startup / hot reload to refresh the live +// program cache attached to the Controller. +// +// The map values are *cel.Expression; the return type is map[string]any so +// the result can be passed straight to AccessControlConfig.StoreCompiledConditions +// without an extra conversion. See AccessControlConfig.compiledConditions for why +// that field is type-erased. +func CompileAccessControl(cfg *config.AccessControlConfig) (map[string]any, error) { + if cfg == nil { + return nil, nil //nolint:nilnil // empty cfg = no programs to compile + } + + programs := map[string]any{} + + compileAll := func(policy config.Policy, where string) error { + for i, cond := range policy.Conditions { + if _, ok := programs[cond.Expression]; ok { + continue + } + + // We don't pass WithOutputType(BoolType): values pulled out of + // the dyn `req` struct (e.g. `req.tls.enabled`) carry the dyn + // type even when they are concretely booleans. EvaluateBoolean + // enforces the bool result at runtime instead. + program, err := cel.NewExpression(cond.Expression, + cel.WithCompile(), + cel.WithDynMapVariables("req")) + if err != nil { + return fmt.Errorf("%s: condition[%d]: %w", where, i, err) + } + + programs[cond.Expression] = program + } + + return nil + } + + for pattern, pg := range cfg.Repositories { + for i, policy := range pg.Policies { + if err := compileAll(policy, fmt.Sprintf("repositories[%q].policies[%d]", pattern, i)); err != nil { + return nil, err + } + } + } + + if err := compileAll(cfg.AdminPolicy, "adminPolicy"); err != nil { + return nil, err + } + + return programs, nil +} + +// lookupCondition returns the pre-compiled program for expr from the +// access-control config's snapshot. The expression must have been registered +// by CompileAccessControl; otherwise authorization fails closed. +func (ac *AccessController) lookupCondition(expr string) (*cel.Expression, error) { + // The compiled-conditions map is type-erased to keep pkg/cel out of + // pkg/api/config's import graph (and thus out of zli); see the comment + // on AccessControlConfig.compiledConditions. CompileAccessControl is the + // only writer and always stores *cel.Expression, so this assertion is + // safe — a failure means a programmer bug, not bad input. + if v, ok := ac.Config.LoadCompiledConditions()[expr]; ok { + program, ok := v.(*cel.Expression) + if !ok { + return nil, fmt.Errorf("%w: %q (unexpected type %T)", zerr.ErrPolicyConditionNotCompiled, expr, v) + } + + return program, nil + } + + return nil, fmt.Errorf("%w: %q", zerr.ErrPolicyConditionNotCompiled, expr) +} + +// evalRequest is the bundle of per-request inputs fed to CEL policy +// conditions. Any field may be zero-valued: missing inputs surface as empty +// strings / nils on the corresponding `req.*` paths in the expression. +type evalRequest struct { + httpReq *http.Request + userAc *reqCtx.UserAccessControl + isAdmin bool + action string + repository string + reference string +} + +func (evalReq *evalRequest) username() string { + if evalReq == nil || evalReq.userAc == nil { + return "" + } + + return evalReq.userAc.GetUsername() +} + +func (evalReq *evalRequest) groups() []string { + if evalReq == nil || evalReq.userAc == nil { + return nil + } + + return evalReq.userAc.GetGroups() +} + +func tlsVersionString(v uint16) string { + switch v { + case tls.VersionTLS10: + return "1.0" + case tls.VersionTLS11: + return "1.1" + case tls.VersionTLS12: + return "1.2" + case tls.VersionTLS13: + return "1.3" + } + + return "" +} + +// data builds the CEL evaluation input map exposing the `req` struct. +func (evalReq *evalRequest) data() map[string]any { + var ( + username string + groups []string + claims map[string]any + anonymous = true + method string + userAgent string + clientIP string + forwardedFor []string + tlsOn bool + tlsVer string + refType string + tag string + digest string + ) + + if evalReq.userAc != nil { + username = evalReq.userAc.GetUsername() + groups = evalReq.userAc.GetGroups() + claims = evalReq.userAc.GetClaims() + anonymous = username == "" + } + + if evalReq.httpReq != nil { + method = evalReq.httpReq.Method + userAgent = evalReq.httpReq.UserAgent() + if host, _, err := net.SplitHostPort(evalReq.httpReq.RemoteAddr); err == nil { + clientIP = host + } else { + clientIP = evalReq.httpReq.RemoteAddr + } + + // X-Forwarded-For is exposed verbatim (untrusted). Conditions are + // expected to validate the chain themselves — typically by checking + // req.client.ip is a known proxy before reading req.client.forwardedFor. + for _, header := range evalReq.httpReq.Header.Values("X-Forwarded-For") { + for ip := range strings.SplitSeq(header, ",") { + if ip = strings.TrimSpace(ip); ip != "" { + forwardedFor = append(forwardedFor, ip) + } + } + } + + if evalReq.httpReq.TLS != nil { + tlsOn = true + tlsVer = tlsVersionString(evalReq.httpReq.TLS.Version) + } + } + + // A digest reference contains an algorithm separator (e.g. "sha256:..."); + // anything else is a tag. + if evalReq.reference != "" { + if strings.Contains(evalReq.reference, ":") { + refType = "digest" + digest = evalReq.reference + } else { + refType = "tag" + tag = evalReq.reference + } + } + + return map[string]any{ + "req": map[string]any{ + "time": time.Now().UTC(), + "method": method, + "userAgent": userAgent, + "action": evalReq.action, + "repository": evalReq.repository, + "reference": evalReq.reference, + "referenceType": refType, + "tag": tag, + "digest": digest, + "user": map[string]any{ + "username": username, + "groups": groups, + }, + "auth": map[string]any{ + "anonymous": anonymous, + "admin": evalReq.isAdmin, + }, + "client": map[string]any{ + "ip": clientIP, + "forwardedFor": forwardedFor, + }, + "tls": map[string]any{ + "enabled": tlsOn, + "version": tlsVer, + }, + "claims": claims, + }, + } +} + +// policyConditionsMet reports whether every condition on the policy +// evaluates to true. When a condition denies, the second return value is the +// operator-authored Message for that condition (intended to be surfaced to +// the client). When a condition fails to look up or evaluate, the policy is +// treated as not granting and the second return value is empty (we do not +// leak internal failure modes to the client). +func (ac *AccessController) policyConditionsMet(policy config.Policy, evalReq *evalRequest) (bool, string) { + if len(policy.Conditions) == 0 { + return true, "" + } + + data := evalReq.data() + + ctx := context.Background() + if evalReq.httpReq != nil { + ctx = evalReq.httpReq.Context() + } + + for _, cond := range policy.Conditions { + expr, err := ac.lookupCondition(cond.Expression) + if err != nil { + ac.Log.Warn().Err(err). + Str("expression", cond.Expression). + Str("message", cond.Message). + Msg("policy condition lookup failed") + + return false, "" + } + + ok, err := expr.EvaluateBoolean(ctx, data) + if err != nil { + ac.Log.Warn().Err(err). + Str("expression", cond.Expression). + Str("message", cond.Message). + Msg("failed to evaluate policy condition") + + return false, "" + } + + if !ok { + ac.Log.Debug(). + Str("expression", cond.Expression). + Str("message", cond.Message). + Msg("policy condition not met") + + return false, cond.Message + } + } + + return true, "" +} + +// isPermitted returns true if the request, as described by evalReq, is +// allowed by any entry in the policy group. When no entry grants access but +// some matching entry's condition denied, the second return value is the +// operator-authored Message of the most-recent condition denial seen, which +// the caller can surface to the client. +func (ac *AccessController) isPermitted(evalReq *evalRequest, policyGroup config.PolicyGroup) (bool, string) { + username := evalReq.username() + userGroups := evalReq.groups() + action := evalReq.action + + var lastDenyReason string + // check repo/system based policies - for _, p := range policyGroup.Policies { - if slices.Contains(p.Users, username) && slices.Contains(p.Actions, action) { - return true + for _, policy := range policyGroup.Policies { + if !slices.Contains(policy.Users, username) || !slices.Contains(policy.Actions, action) { + continue + } + + ok, reason := ac.policyConditionsMet(policy, evalReq) + if ok { + return true, "" + } + + if reason != "" { + lastDenyReason = reason } } if userGroups != nil { - for _, p := range policyGroup.Policies { - if slices.Contains(p.Actions, action) { - for _, group := range p.Groups { - if slices.Contains(userGroups, group) { - return true - } + for _, policy := range policyGroup.Policies { + if !slices.Contains(policy.Actions, action) { + continue + } + + matchedGroup := false + + for _, group := range policy.Groups { + if slices.Contains(userGroups, group) { + matchedGroup = true + + break } } + + if !matchedGroup { + continue + } + + ok, reason := ac.policyConditionsMet(policy, evalReq) + if ok { + return true, "" + } + + if reason != "" { + lastDenyReason = reason + } } } // check defaultPolicy if slices.Contains(policyGroup.DefaultPolicy, action) && username != "" { - return true + return true, "" } // check anonymousPolicy if slices.Contains(policyGroup.AnonymousPolicy, action) && username == "" { - return true + return true, "" } - return false + return false, lastDenyReason } func BaseAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { @@ -291,7 +645,7 @@ func BaseAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } - aCtlr.updateUserAccessControl(userAc) + aCtlr.updateUserAccessControl(request, userAc) userAc.SaveOnRequest(request) next.ServeHTTP(response, request) //nolint:contextcheck @@ -360,9 +714,9 @@ func DistSpecAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { action = constants.DeletePermission } - can := acCtrlr.can(userAc, action, resource) //nolint:contextcheck + can, denyReason := acCtrlr.can(request, userAc, action, resource, reference) //nolint:contextcheck if !can { - common.AuthzFail(response, request, userAc.GetUsername(), realm, failDelay) + common.AuthzFailWithReason(response, request, userAc.GetUsername(), realm, failDelay, denyReason) } else { next.ServeHTTP(response, request) //nolint:contextcheck } diff --git a/pkg/api/authz_internal_test.go b/pkg/api/authz_internal_test.go new file mode 100644 index 00000000..c9122a87 --- /dev/null +++ b/pkg/api/authz_internal_test.go @@ -0,0 +1,766 @@ +package api + +import ( + "crypto/tls" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/api/constants" + "zotregistry.dev/zot/v2/pkg/log" + reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext" +) + +// permitted returns just the bool from AccessController.isPermitted; tests +// that don't care about the deny reason use this for assert readability. +func permitted(ac *AccessController, evalReq *evalRequest, pg config.PolicyGroup) bool { + ok, _ := ac.isPermitted(evalReq, pg) + return ok +} + +func TestPolicyConditions(t *testing.T) { + t.Parallel() + + pastRFC := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + futureRFC := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + makeAC := func(policies []config.Policy, groups config.Groups) *AccessController { + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: policies}, + }, + Groups: groups, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + return &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + } + + makeER := func(username, repo string, groups []string) *evalRequest { + uac := reqCtx.NewUserAccessControl() + uac.SetUsername(username) + uac.AddGroups(groups) + + return &evalRequest{ + userAc: uac, + action: constants.ReadPermission, + repository: repo, + } + } + + t.Run("user policy without conditions is permitted", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + }}, nil) + + assert.True(t, permitted(ac, makeER("alice", "repo", nil), ac.Config.Repositories["**"])) + }) + + t.Run("user policy with future-time condition is permitted", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.time < timestamp("` + futureRFC + `")`, + Message: "access expired", + }}, + }}, nil) + + assert.True(t, permitted(ac, makeER("alice", "repo", nil), ac.Config.Repositories["**"])) + }) + + t.Run("user policy with past-time condition is denied", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.time < timestamp("` + pastRFC + `")`, + Message: "access expired", + }}, + }}, nil) + + assert.False(t, permitted(ac, makeER("alice", "repo", nil), ac.Config.Repositories["**"])) + }) + + t.Run("group policy with failing condition is denied", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Policy{{ + Groups: []string{"devs"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.time < timestamp("` + pastRFC + `")`, + Message: "access expired", + }}, + }}, config.Groups{"devs": config.Group{Users: []string{"alice"}}}) + + assert.False(t, permitted(ac, makeER("alice", "repo", []string{"devs"}), + ac.Config.Repositories["**"])) + }) + + t.Run("condition can reference repository", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.repository.startsWith("prod/")`, + Message: "only prod/* allowed", + }}, + }}, nil) + + assert.True(t, permitted(ac, makeER("alice", "prod/api", nil), ac.Config.Repositories["**"])) + assert.False(t, permitted(ac, makeER("alice", "staging/api", nil), ac.Config.Repositories["**"])) + }) + + t.Run("invalid expression fails CompileAccessControl", func(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `this is not valid CEL`, + Message: "broken", + }}, + }}}, + }, + } + _, err := CompileAccessControl(cfg) + assert.Error(t, err) + }) + + t.Run("uncompiled expression denies (defense in depth)", func(t *testing.T) { + t.Parallel() + + // Build the AccessController without going through CompileAccessControl. + // At authz time the lookup misses and the policy is treated as not granting. + ac := &AccessController{ + Config: &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + // A unique expression unlikely to be cached by other tests. + Expression: `req.repository == "uncompiled-fixture-only"`, + Message: "no compile", + }}, + }}}, + }, + }, + Log: log.NewLogger("debug", ""), + } + + assert.False(t, permitted(ac, makeER("alice", "uncompiled-fixture-only", nil), + ac.Config.Repositories["**"])) + }) + + t.Run("conditional entry contributes glob patterns optimistically", func(t *testing.T) { + t.Parallel() + + // Glob computation does NOT evaluate conditions because repo and + // reference are unknown at glob-time. A policy with a condition + // that would deny at request-time still contributes its pattern; + // per-request can() does the real enforcement. Over-listing in + // catalog is the chosen tradeoff over under-listing. + ac := makeAC([]config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.time < timestamp("` + pastRFC + `")`, + Message: "access expired", + }}, + }}, nil) + + patterns := ac.getGlobPatterns(makeER("alice", "", nil)) + assert.True(t, patterns["**"]) + }) +} + +// TestPolicyConditionsRequestFields verifies the full set of req.* fields +// exposed to CEL conditions: HTTP context, TLS, reference parsing, auth +// flags, and OIDC claims passthrough. +func TestPolicyConditionsRequestFields(t *testing.T) { + t.Parallel() + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + uac.SetClaims(map[string]any{ + "email": "alice@example.com", + "email_verified": true, + "roles": []any{"prod-pusher", "dev"}, + }) + + httpReq := httptest.NewRequest("PUT", "/v2/prod/api/manifests/v1.2.3", nil) + httpReq.RemoteAddr = "10.0.0.5:54321" + httpReq.Header.Set("User-Agent", "docker/24.0.7") + httpReq.TLS = &tls.ConnectionState{Version: tls.VersionTLS13} + + makeER := func() *evalRequest { + return &evalRequest{ + httpReq: httpReq, + userAc: uac, + action: constants.ReadPermission, + repository: "prod/api", + reference: "v1.2.3", + } + } + + cases := []struct { + name string + expr string + }{ + {"method", `req.method == "PUT"`}, + {"userAgent", `req.userAgent.startsWith("docker/")`}, + {"client.ip", `req.client.ip == "10.0.0.5"`}, + {"tls.enabled", `req.tls.enabled`}, + {"tls.version", `req.tls.version == "1.3"`}, + {"reference (tag)", `req.reference == "v1.2.3"`}, + {"referenceType is tag", `req.referenceType == "tag"`}, + {"tag set", `req.tag == "v1.2.3"`}, + {"digest empty for tag", `req.digest == ""`}, + {"auth.anonymous false", `!req.auth.anonymous`}, + {"auth.admin false", `!req.auth.admin`}, + {"claims passthrough", `req.claims.email_verified == true`}, + {"claims list", `"prod-pusher" in req.claims.roles`}, + {"action", `req.action == "read"`}, + {"repository", `req.repository == "prod/api"`}, + {"user.username", `req.user.username == "alice"`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: tc.expr, + Message: "denied", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + + assert.True(t, permitted(ac, makeER(), ac.Config.Repositories["**"]), tc.expr) + }) + } +} + +func TestPolicyConditionsDigestReference(t *testing.T) { + t.Parallel() + + digest := "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.referenceType == "digest" && req.digest.startsWith("sha256:")`, + Message: "expected digest", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + evalReq := &evalRequest{ + userAc: uac, + action: constants.ReadPermission, + repository: "prod/api", + reference: digest, + } + + assert.True(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) +} + +func TestTLSVersionString(t *testing.T) { + t.Parallel() + + cases := map[uint16]string{ + tls.VersionTLS10: "1.0", + tls.VersionTLS11: "1.1", + tls.VersionTLS12: "1.2", + tls.VersionTLS13: "1.3", + 0xffff: "", + } + for v, want := range cases { + if got := tlsVersionString(v); got != want { + t.Errorf("tlsVersionString(%#x) = %q, want %q", v, got, want) + } + } +} + +func TestEvalRequestNilSafety(t *testing.T) { + t.Parallel() + + var nilER *evalRequest + assert.Equal(t, "", nilER.username()) + assert.Nil(t, nilER.groups()) + + emptyER := &evalRequest{} + assert.Equal(t, "", emptyER.username()) + assert.Nil(t, emptyER.groups()) +} + +func TestCompileAccessControlNilAndAdminPolicy(t *testing.T) { + t.Parallel() + + programs, err := CompileAccessControl(nil) + assert.NoError(t, err) + assert.Nil(t, programs) + + cfg := &config.AccessControlConfig{ + AdminPolicy: config.Policy{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `definitely not valid`, + Message: "broken admin", + }}, + }, + } + _, err = CompileAccessControl(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "adminPolicy") +} + +func TestEvalRequestRemoteAddrWithoutPort(t *testing.T) { + t.Parallel() + + httpReq := httptest.NewRequest("GET", "/v2/", nil) + httpReq.RemoteAddr = "no-port-here" + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.client.ip == "no-port-here"`, + Message: "bad ip", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + evalReq := &evalRequest{httpReq: httpReq, userAc: uac, action: constants.ReadPermission, repository: "r"} + + assert.True(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) +} + +func TestPolicyConditionRuntimeTypeError(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.repository`, + Message: "wrong type", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + evalReq := &evalRequest{userAc: uac, action: constants.ReadPermission, repository: "r"} + + assert.False(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) +} + +func TestPolicyGroupNonMatchingNoActionOrGroup(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{ + {Groups: []string{"devs"}, Actions: []string{constants.CreatePermission}}, + {Groups: []string{"others"}, Actions: []string{constants.ReadPermission}}, + }}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + uac.AddGroups([]string{"devs"}) + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + evalReq := &evalRequest{userAc: uac, action: constants.ReadPermission, repository: "r"} + + assert.False(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) +} + +func TestCompileAccessControlDedupesIdenticalExpressions(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{ + { + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{ + {Expression: `req.repository == "x"`, Message: "first"}, + {Expression: `req.repository == "x"`, Message: "duplicate"}, + }, + }, + }}, + }, + } + programs, err := CompileAccessControl(cfg) + assert.NoError(t, err) + assert.Len(t, programs, 1) +} + +func TestNewAccessControllerNilAccessControl(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + HTTP: config.HTTPConfig{}, // AccessControl is nil + Log: &config.LogConfig{Level: "debug"}, + } + + ac := NewAccessController(cfg) + assert.NotNil(t, ac) + assert.NotNil(t, ac.Config) + assert.Empty(t, ac.Config.Repositories) +} + +func TestControllerLoadNewConfigRecompilesConditions(t *testing.T) { + t.Parallel() + + htp := NewHTPasswd(log.NewLogger("debug", "")) + htw, err := NewHTPasswdWatcher(htp, "") + assert.NoError(t, err) + + original := config.New() + original.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{ + {Expression: `req.repository == "old"`, Message: "old"}, + }, + }}}, + }, + } + originalPrograms, err := CompileAccessControl(original.HTTP.AccessControl) + assert.NoError(t, err) + original.HTTP.AccessControl.StoreCompiledConditions(originalPrograms) + + ctlr := &Controller{ + Config: original, + Log: log.NewLogger("debug", ""), + HTPasswd: htp, + HTPasswdWatcher: htw, + } + + newConfig := config.New() + newConfig.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{ + {Expression: `req.repository == "new"`, Message: "new"}, + }, + }}}, + }, + } + + ctlr.LoadNewConfig(newConfig) + + updated := ctlr.Config.HTTP.AccessControl.LoadCompiledConditions() + _, hasNew := updated[`req.repository == "new"`] + assert.True(t, hasNew, "new expression should be compiled after reload") + _, hasOld := updated[`req.repository == "old"`] + assert.False(t, hasOld, "old expression should be evicted after reload") +} + +// TestPolicyConditionForwardedFor verifies that req.client.forwardedFor +// exposes the X-Forwarded-For chain split into a list, and that conditions +// can express "trust XFF only when the TCP peer is the configured proxy". +func TestPolicyConditionForwardedFor(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.client.ip == "10.0.0.5" && size(req.client.forwardedFor) > 0 && req.client.forwardedFor[0] == "192.0.2.7"`, + Message: "must arrive via trusted proxy from 192.0.2.7", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + assert.NoError(t, err) + cfg.StoreCompiledConditions(programs) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + mk := func(remoteAddr, xff string) *evalRequest { + req := httptest.NewRequest("GET", "/v2/", nil) + req.RemoteAddr = remoteAddr + + if xff != "" { + req.Header.Set("X-Forwarded-For", xff) + } + + return &evalRequest{ + httpReq: req, + userAc: uac, + action: constants.ReadPermission, + repository: "r", + } + } + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + + // Through the trusted proxy with the expected client IP at the head of XFF: granted. + assert.True(t, permitted(ac, mk("10.0.0.5:1234", "192.0.2.7, 10.0.0.5"), + ac.Config.Repositories["**"])) + + // Same XFF claim but TCP peer is NOT the trusted proxy: denied (XFF is spoofable). + assert.False(t, permitted(ac, mk("203.0.113.9:1234", "192.0.2.7"), + ac.Config.Repositories["**"])) + + // Trusted proxy but no XFF header: denied. + assert.False(t, permitted(ac, mk("10.0.0.5:1234", ""), + ac.Config.Repositories["**"])) +} + +// TestPolicyConditionDenyReasonIsSurfaced verifies that when a condition +// denies, the operator-authored Message bubbles up through isPermitted/can +// so the handler can put it in the 403 response detail. +func TestPolicyConditionDenyReasonIsSurfaced(t *testing.T) { + t.Parallel() + + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{ + Expression: `req.repository.startsWith("prod/")`, + Message: "alice may only read prod/*", + }}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + assert.NoError(t, err) + cfg.StoreCompiledConditions(programs) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + ac := &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + evalReq := &evalRequest{userAc: uac, action: constants.ReadPermission, repository: "staging/api"} + + ok, reason := ac.isPermitted(evalReq, ac.Config.Repositories["**"]) + assert.False(t, ok) + assert.Equal(t, "alice may only read prod/*", reason) +} + +// TestSetGlobPatternsOrderIndependence verifies that SetGlobPatterns can be +// called before SetIsAdmin without panicking on a nil internal map. This is a +// regression guard for an earlier ordering hazard between the two setters. +func TestSetGlobPatternsOrderIndependence(t *testing.T) { + t.Parallel() + + uac := reqCtx.NewUserAccessControl() + uac.SetGlobPatterns(constants.ReadPermission, map[string]bool{"**": true}) + uac.SetIsAdmin(true) + assert.True(t, uac.IsAdmin()) + + uac2 := reqCtx.NewUserAccessControl() + uac2.SetIsAdmin(true) + uac2.SetGlobPatterns(constants.ReadPermission, map[string]bool{"**": true}) + assert.True(t, uac2.IsAdmin()) +} + +func TestPolicyConditionsAnonymousAndAdminFlags(t *testing.T) { + t.Parallel() + + mkAC := func(expr string) *AccessController { + cfg := &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{Policies: []config.Policy{{ + Users: []string{"alice", ""}, + Actions: []string{constants.ReadPermission}, + Conditions: []config.Condition{{Expression: expr, Message: "denied"}}, + }}}, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + return &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + } + + t.Run("anonymous true when username empty", func(t *testing.T) { + t.Parallel() + + ac := mkAC(`req.auth.anonymous`) + uac := reqCtx.NewUserAccessControl() + evalReq := &evalRequest{userAc: uac, action: constants.ReadPermission, repository: "r"} + assert.True(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) + }) + + t.Run("admin reflects evalRequest.isAdmin", func(t *testing.T) { + t.Parallel() + + ac := mkAC(`req.auth.admin`) + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + evalReq := &evalRequest{userAc: uac, action: constants.ReadPermission, repository: "r", isAdmin: true} + assert.True(t, permitted(ac, evalReq, ac.Config.Repositories["**"])) + }) +} + +func TestAdminPolicyConditions(t *testing.T) { + t.Parallel() + + makeAC := func(adminConditions []config.Condition) *AccessController { + cfg := &config.AccessControlConfig{ + AdminPolicy: config.Policy{ + Users: []string{"alice"}, + Actions: []string{constants.ReadPermission}, + Conditions: adminConditions, + }, + } + programs, err := CompileAccessControl(cfg) + if err != nil { + t.Fatalf("CompileAccessControl: %v", err) + } + cfg.StoreCompiledConditions(programs) + + return &AccessController{Config: cfg, Log: log.NewLogger("debug", "")} + } + + httpReqTLS := httptest.NewRequest("GET", "/v2/", nil) + httpReqTLS.TLS = &tls.ConnectionState{Version: tls.VersionTLS13} + + httpReqPlain := httptest.NewRequest("GET", "/v2/", nil) + + uac := reqCtx.NewUserAccessControl() + uac.SetUsername("alice") + + t.Run("admin without conditions is permitted (existing behavior)", func(t *testing.T) { + t.Parallel() + + ac := makeAC(nil) + can, reason := ac.can(httpReqPlain, uac, constants.ReadPermission, "any/repo", "ref") + assert.True(t, can) + assert.Equal(t, "", reason) + }) + + t.Run("admin condition met grants access", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Condition{{ + Expression: `req.tls.enabled`, + Message: "admin actions require TLS", + }}) + can, reason := ac.can(httpReqTLS, uac, constants.ReadPermission, "any/repo", "ref") + assert.True(t, can) + assert.Equal(t, "", reason) + }) + + t.Run("admin condition denied surfaces operator message", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Condition{{ + Expression: `req.tls.enabled`, + Message: "admin actions require TLS", + }}) + can, reason := ac.can(httpReqPlain, uac, constants.ReadPermission, "any/repo", "ref") + assert.False(t, can) + assert.Equal(t, "admin actions require TLS", reason) + }) + + t.Run("non-admin user is unaffected by admin conditions", func(t *testing.T) { + t.Parallel() + + ac := makeAC([]config.Condition{{ + Expression: `req.tls.enabled`, + Message: "admin actions require TLS", + }}) + bob := reqCtx.NewUserAccessControl() + bob.SetUsername("bob") + can, _ := ac.can(httpReqTLS, bob, constants.ReadPermission, "any/repo", "ref") + assert.False(t, can) + }) +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 1799ac27..43614748 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -6,6 +6,7 @@ import ( "os" "slices" "sync" + "sync/atomic" "time" distspec "github.com/opencontainers/distribution-spec/specs-go" @@ -503,6 +504,47 @@ type AccessControlConfig struct { AdminPolicy Policy Groups Groups Metrics Metrics + + // compiledConditions caches CEL programs for all policy condition + // expressions present in this access-control config, keyed by expression + // string. Populated at config validation and refreshed on hot reload. + // Reads are atomic; writes are infrequent (startup + SIGHUP). + // + // Type-erased to map[string]any (rather than map[string]*cel.Expression) + // to keep this package free of any reference to pkg/cel. pkg/common (and + // thus zli, transitively via pkg/cli/client) imports pkg/api/config; a + // typed cel.Expression field here would pull cel-go, ANTLR, and the + // protobuf reflection runtime into the zli binary (~8MB of dead code, + // since zli never evaluates CEL). Callers in pkg/api cast back to + // *cel.Expression at use. + compiledConditions atomic.Pointer[map[string]any] +} + +// LoadCompiledConditions returns the current compiled-conditions snapshot, or +// nil if none have been registered. Safe for concurrent use. Values are +// *cel.Expression; see compiledConditions for why the type is erased. +func (config *AccessControlConfig) LoadCompiledConditions() map[string]any { + if config == nil { + return nil + } + + if p := config.compiledConditions.Load(); p != nil { + return *p + } + + return nil +} + +// StoreCompiledConditions atomically replaces the compiled-conditions +// snapshot. Called by the authz layer after compiling an access-control +// config (initial startup and hot reload). Values must be *cel.Expression; +// see compiledConditions for why the type is erased. +func (config *AccessControlConfig) StoreCompiledConditions(programs map[string]any) { + if config == nil { + return + } + + config.compiledConditions.Store(&programs) } // IsAuthzEnabled checks if authorization is enabled (access control is configured). @@ -629,6 +671,60 @@ type Policy struct { Users []string Actions []string Groups []string + + // Conditions is an optional list of CEL expressions that must all evaluate + // to true for this policy entry to grant access. When any condition is + // false (or fails to evaluate) the policy is ignored. + Conditions []Condition +} + +// Condition is a CEL boolean expression gating a Policy entry, modeled after +// conditional access in cloud IAM systems. The expression is evaluated against +// a `req` struct containing: +// +// - req.time current time as a CEL timestamp (compare with timestamp("...")) +// - req.method raw HTTP method of the originating request (e.g. "GET", "PUT") +// - req.userAgent User-Agent header +// - req.action abstract action being authorized ("read", "create", "update", "delete") +// - req.repository the requested repository, when known +// - req.reference tag or digest, when the route has one +// - req.referenceType "tag", "digest", or "" when the route has no reference +// - req.tag the tag, when reference is a tag +// - req.digest the digest, when reference is a digest +// - req.user.username authenticated username +// - req.user.groups authenticated user's groups (list) +// - req.auth.anonymous convenience for `req.user.username == ""` +// - req.auth.admin true when the user matches the admin policy +// - req.client.ip TCP peer address from RemoteAddr (port stripped); always trustworthy +// - req.client.forwardedFor X-Forwarded-For chain as list, left to right; untrusted +// - req.tls.enabled whether the request arrived over TLS at zot +// - req.tls.version TLS version string ("1.2", "1.3", ...) when applicable +// - req.claims authn-time attribute bag (map), populated by the active authn flow +// +// Use `req.action` for action gating (it incorporates create-vs-update logic); +// `req.method` is the raw verb escape hatch. +// +// `req.claims` is a generic surface, not tied to OIDC: today the OIDC bearer +// flow feeds the ID token's claim set into it, and other flows (browser +// OpenID, mTLS cert attributes, ...) can feed this surface as they grow that +// capability. +// +// Network gates: `req.client.ip` is always the TCP peer (the proxy, behind a +// reverse proxy). `req.client.forwardedFor` is the raw X-Forwarded-For header +// chain — useful but untrusted, since any client can set that header. The +// idiomatic pattern is to gate on the chain only after asserting the TCP +// peer is your trusted proxy: +// +// req.client.ip == "10.0.0.5" && req.client.forwardedFor[0].startsWith("192.0.2.") +// +// When the expression evaluates to false, Message is surfaced to the client +// in the 403 response body's error detail under the "reason" key (so the +// client knows why the policy did not apply) and is also logged for operator +// diagnosis. Internal lookup or evaluation failures are *not* surfaced — the +// client just gets a generic deny — so as not to leak implementation issues. +type Condition struct { + Expression string + Message string } type Metrics struct { @@ -924,6 +1020,13 @@ func (c *Config) CopyAccessControlConfig() *AccessControlConfig { accessControlCopy := &AccessControlConfig{} _ = deepcopy.Copy(accessControlCopy, c.HTTP.AccessControl) + // deepcopy skips unexported fields, so the compiled-conditions atomic + // pointer would be empty in the copy. Carry it through by sharing the + // pointer — compiled programs are immutable and concurrency-safe. + if p := c.HTTP.AccessControl.compiledConditions.Load(); p != nil { + accessControlCopy.compiledConditions.Store(p) + } + return accessControlCopy } diff --git a/pkg/api/controller.go b/pkg/api/controller.go index ed776ebb..05d86e96 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -123,6 +123,15 @@ func NewController(appConfig *config.Config) *Controller { controller.Audit = audit } + // Pre-compile policy conditions. Errors were already surfaced by config + // validation; if anything still fails here it's a programmer bug. + programs, err := CompileAccessControl(appConfig.HTTP.AccessControl) + if err != nil { + logger.Panic().Err(err).Msg("failed to compile access control policy conditions") + } + + appConfig.HTTP.AccessControl.StoreCompiledConditions(programs) + return &controller } @@ -459,6 +468,14 @@ func (c *Controller) LoadNewConfig(newConfig *config.Config) { // Update only reloadable config fields atomically c.Config.UpdateReloadableConfig(newConfig) + // Refresh compiled policy conditions to reflect the new access-control + // config. Errors were caught during validation in LoadConfiguration. + if programs, err := CompileAccessControl(newConfig.HTTP.AccessControl); err != nil { + c.Log.Error().Err(err).Msg("failed to recompile access control policy conditions") + } else { + c.Config.HTTP.AccessControl.StoreCompiledConditions(programs) + } + // Operations that need to happen after config update authConfig := c.Config.CopyAuthConfig() if authConfig.IsHtpasswdAuthEnabled() { diff --git a/pkg/cel/claim_processor.go b/pkg/cel/claim_processor.go index 1c13741d..259946bf 100644 --- a/pkg/cel/claim_processor.go +++ b/pkg/cel/claim_processor.go @@ -19,6 +19,9 @@ const defaultUsernameExpr = "claims.iss + '/' + claims.sub" type ClaimResult struct { Username string Groups []string + // Claims is the raw OIDC claim set. Carried through so authorization-time + // CEL expressions can reference token claims directly via `req.claims`. + Claims map[string]any } // ClaimProcessor processes OIDC claims using CEL expressions. @@ -206,6 +209,7 @@ func (c *ClaimProcessor) Process(ctx context.Context, claims map[string]any) (*C return &ClaimResult{ Username: username, Groups: groups, + Claims: claims, }, nil } diff --git a/pkg/cel/expression.go b/pkg/cel/expression.go index 05ecf6e2..4b8a9f74 100644 --- a/pkg/cel/expression.go +++ b/pkg/cel/expression.go @@ -55,6 +55,19 @@ func WithStructVariables(vars ...string) Option { } } +// WithDynMapVariables declares variables of type map. Unlike +// google.protobuf.Struct (JSON-shape only), values in a dyn-valued map carry +// their natural CEL type, so a Go time.Time at evaluation time surfaces as a +// CEL timestamp and can be compared with timestamp(...) directly. +func WithDynMapVariables(vars ...string) Option { + return func(o *options) { + for _, v := range vars { + d := cel.Variable(v, cel.MapType(cel.StringType, cel.DynType)) + o.variables = append(o.variables, d) + } + } +} + // WithCompile specifies that the expression should be compiled, // which provides stricter checks at parse time, before evaluation. func WithCompile() Option { diff --git a/pkg/cli/server/root.go b/pkg/cli/server/root.go index d16f9bcb..61f4e4e2 100644 --- a/pkg/cli/server/root.go +++ b/pkg/cli/server/root.go @@ -792,6 +792,12 @@ func validateAuthzPolicies(config *config.Config, logger zlog.Logger) error { return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) } + if _, err := api.CompileAccessControl(accessControlConfig); err != nil { + logger.Error().Err(err).Msg("failed to compile access control policy conditions") + + return fmt.Errorf("%w: %w", zerr.ErrBadConfig, err) + } + return nil } diff --git a/pkg/cli/server/root_test.go b/pkg/cli/server/root_test.go index 98e511f7..a4d4439d 100644 --- a/pkg/cli/server/root_test.go +++ b/pkg/cli/server/root_test.go @@ -87,6 +87,95 @@ func TestServerUsage(t *testing.T) { }) } +func TestLoadConfigurationDecodesPolicyConditions(t *testing.T) { + Convey("conditions on accessControl policy decode into []Condition", t, func() { + htpasswdPath := MakeHtpasswdFileFromString(t, "alice:$2y$05$ajq8Q7fbtFRQvPndnct8OuRu7n6BDpRYHvz7dNH0G9z2j5XbB7yIm") + content := fmt.Sprintf(`{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", + "port": "8080", + "auth": {"htpasswd": {"path": %q}}, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["alice"], + "actions": ["read"], + "conditions": [ + { + "expression": "req.time < timestamp(\"2099-12-31T23:59:59Z\")", + "message": "access expired" + }, + { + "expression": "req.repository.startsWith(\"prod/\")", + "message": "only prod/* allowed" + } + ] + }, + { + "users": ["bob"], + "actions": ["read"] + } + ] + } + } + } + } + }`, htpasswdPath) + + tmpfile := MakeTempFileWithContent(t, "zot-policy-conditions.json", content) + cfg := config.New() + + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldBeNil) + + policies := cfg.HTTP.AccessControl.Repositories["**"].Policies + So(policies, ShouldHaveLength, 2) + So(policies[0].Conditions, ShouldHaveLength, 2) + So(policies[0].Conditions[0].Expression, ShouldEqual, + `req.time < timestamp("2099-12-31T23:59:59Z")`) + So(policies[0].Conditions[0].Message, ShouldEqual, "access expired") + So(policies[0].Conditions[1].Expression, ShouldEqual, `req.repository.startsWith("prod/")`) + So(policies[0].Conditions[1].Message, ShouldEqual, "only prod/* allowed") + So(policies[1].Conditions, ShouldBeEmpty) + }) + + Convey("malformed condition expression fails config load", t, func() { + htpasswdPath := MakeHtpasswdFileFromString(t, "alice:$2y$05$ajq8Q7fbtFRQvPndnct8OuRu7n6BDpRYHvz7dNH0G9z2j5XbB7yIm") + content := fmt.Sprintf(`{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", + "port": "8080", + "auth": {"htpasswd": {"path": %q}}, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["alice"], + "actions": ["read"], + "conditions": [ + {"expression": "this is not valid CEL", "message": "broken"} + ] + } + ] + } + } + } + } + }`, htpasswdPath) + + tmpfile := MakeTempFileWithContent(t, "zot-policy-conditions-bad.json", content) + cfg := config.New() + + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldNotBeNil) + }) +} + func TestLoadConfigurationInjectsHTTPTimeoutDefaults(t *testing.T) { Convey("load config sets HTTP read/write timeout defaults when not explicitly configured", t, func() { content := `{ diff --git a/pkg/common/http_server.go b/pkg/common/http_server.go index f2965077..4429cd45 100644 --- a/pkg/common/http_server.go +++ b/pkg/common/http_server.go @@ -110,6 +110,14 @@ func AuthzOnlyAdminsMiddleware(conf *config.Config) mux.MiddlewareFunc { } func AuthzFail(w http.ResponseWriter, r *http.Request, identity, realm string, delay int) { + AuthzFailWithReason(w, r, identity, realm, delay, "") +} + +// AuthzFailWithReason behaves like AuthzFail but, when reason is non-empty, +// embeds it in the response body's error detail under the "reason" key. This +// lets policy conditions surface the operator-authored Message to the client +// alongside the standard DENIED error code. +func AuthzFailWithReason(w http.ResponseWriter, r *http.Request, identity, realm string, delay int, reason string) { time.Sleep(time.Duration(delay) * time.Second) // don't send auth headers if request is coming from UI @@ -127,9 +135,16 @@ func AuthzFail(w http.ResponseWriter, r *http.Request, identity, realm string, d if identity == "" { WriteJSON(w, http.StatusUnauthorized, apiErr.NewErrorList(apiErr.NewError(apiErr.UNAUTHORIZED))) - } else { - WriteJSON(w, http.StatusForbidden, apiErr.NewErrorList(apiErr.NewError(apiErr.DENIED))) + + return } + + denied := apiErr.NewError(apiErr.DENIED) + if reason != "" { + denied.AddDetail(map[string]string{"reason": reason}) + } + + WriteJSON(w, http.StatusForbidden, apiErr.NewErrorList(denied)) } func WriteJSON(response http.ResponseWriter, status int, data any) { diff --git a/pkg/requestcontext/user_access_control.go b/pkg/requestcontext/user_access_control.go index 6c38a6c6..dbac46ca 100644 --- a/pkg/requestcontext/user_access_control.go +++ b/pkg/requestcontext/user_access_control.go @@ -22,8 +22,14 @@ func GetContextKey() *Key { } type UserAccessControl struct { - authzInfo *UserAuthzInfo - authnInfo *UserAuthnInfo + authzInfo *UserAuthzInfo + authnInfo *UserAuthnInfo + // claims is a free-form bag of authentication-time attributes surfaced to + // authorization-time CEL conditions as `req.claims`. It is populated by + // whichever authn flow has structured attributes to expose: OIDC bearer + // today (the ID token's claim set), and optionally other flows (browser + // OpenID, mTLS cert attributes, ...) as they grow that capability. + claims map[string]any methodActions []string behaviourActions []string } @@ -92,6 +98,20 @@ func (uac *UserAccessControl) GetGroups() []string { return uac.authnInfo.groups } +// SetClaims stores authentication-time attributes (OIDC token claims, mTLS +// cert attributes, etc.) that should be exposed to authz-time CEL conditions +// as `req.claims`. Authn flows are free to populate whichever subset they +// have available; everything else is left nil. +func (uac *UserAccessControl) SetClaims(claims map[string]any) { + uac.claims = claims +} + +// GetClaims returns the authentication-time attribute bag, or nil if the +// active authn flow did not populate one. +func (uac *UserAccessControl) GetClaims() map[string]any { + return uac.claims +} + func (uac *UserAccessControl) IsAnonymous() bool { if uac.authnInfo == nil { return true @@ -137,9 +157,11 @@ func UserAcFromContext(ctx context.Context) (*UserAccessControl, error) { func (uac *UserAccessControl) SetGlobPatterns(action string, patterns map[string]bool) { if uac.authzInfo == nil { - uac.authzInfo = &UserAuthzInfo{ - globPatterns: make(map[string]map[string]bool), - } + uac.authzInfo = &UserAuthzInfo{} + } + + if uac.authzInfo.globPatterns == nil { + uac.authzInfo.globPatterns = make(map[string]map[string]bool) } uac.authzInfo.globPatterns[action] = patterns