mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
feat(authz): introduce conditional access control via CEL (#4040)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user