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
+6 -10
View File
@@ -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
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
}
+766
View File
@@ -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)
})
}
+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
}
+17
View File
@@ -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() {