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