feat(authz): introduce conditional access control via CEL (#4040)

This commit is contained in:
Matheus Pimenta
2026-05-09 20:43:00 +01:00
committed by GitHub
parent ddb6279a25
commit 8a6674f198
15 changed files with 1636 additions and 85 deletions
+103
View File
@@ -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<string>)
// - 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<string>, 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
}