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
+410 -56
View File
@@ -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 <username> has <action> 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
}