Files
zot/pkg/api/authz.go
T
uaggarwa 66e9cfb01f feat(metrics): anonymous access when enabled in accessControl config (#4110)
* feat: add anonymouspolicy support in metrics

Signed-off-by: uaggarwa <uaggarwa@akamai.com>

* test: add unit tests

Signed-off-by: uaggarwa <uaggarwa@akamai.com>

---------

Signed-off-by: uaggarwa <uaggarwa@akamai.com>
2026-06-10 10:19:28 +03:00

778 lines
22 KiB
Go

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"
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
)
const (
BASIC = "Basic"
BEARER = "Bearer"
BEARER_OIDC = "BearerOIDC" // OIDC bearer tokens use accessControl config for authorization
OPENID = "OpenID"
)
func AuthzFilterFunc(userAc *reqCtx.UserAccessControl) storageTypes.FilterRepoFunc {
return func(repo string) (bool, error) {
if userAc == nil {
return true, nil
}
if userAc.Can(constants.ReadPermission, repo) {
return true, nil
}
return false, nil
}
}
// AccessController authorizes users to act on resources.
type AccessController struct {
Config *config.AccessControlConfig
Log log.Logger
}
func NewAccessController(conf *config.Config) *AccessController {
// Get access control config safely
accessControlConfig := conf.CopyAccessControlConfig()
logConfig := conf.CopyLogConfig()
if accessControlConfig == nil {
return &AccessController{
Config: &config.AccessControlConfig{},
Log: log.NewLogger(logConfig.Level, logConfig.Output),
}
}
return &AccessController{
Config: accessControlConfig,
Log: log.NewLogger(logConfig.Level, logConfig.Output),
}
}
// 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(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
if slices.Contains(policyGroup.AnonymousPolicy, action) {
globPatterns[pattern] = true
}
} else {
// check default policy (authenticated user)
if slices.Contains(policyGroup.DefaultPolicy, 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 _, policy := range policyGroup.Policies {
if !slices.Contains(policy.Groups, group) || !slices.Contains(policy.Actions, action) {
continue
}
globPatterns[pattern] = true
}
}
// if not allowed then mark it
if _, ok := globPatterns[pattern]; !ok {
globPatterns[pattern] = false
}
}
return globPatterns
}
// 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 {
matched, err := glob.Match(pattern, repository)
if err == nil {
if matched && len(pattern) > len(longestMatchedPattern) {
longestMatchedPattern = pattern
}
}
}
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
var (
can bool
reason string
)
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) {
// 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, reason
}
// isAdmin .
func (ac *AccessController) isAdmin(username string, userGroups []string) bool {
adminPolicy := ac.Config.GetAdminPolicy()
if slices.Contains(adminPolicy.Users, username) || ac.isAnyGroupInAdminPolicy(userGroups) {
return true
}
return false
}
func (ac *AccessController) isAnyGroupInAdminPolicy(userGroups []string) bool {
adminPolicy := ac.Config.GetAdminPolicy()
return slices.ContainsFunc(userGroups, func(group string) bool {
return slices.Contains(adminPolicy.Groups, group)
})
}
func (ac *AccessController) getUserGroups(username string) []string {
var groupNames []string
groups := ac.Config.GetGroups()
for groupName, group := range groups {
for _, user := range group.Users {
// find if the user is part of any groups
if user == username {
groupNames = append(groupNames, groupName)
}
}
}
return groupNames
}
// getContext updates an UserAccessControl with admin status and specific permissions on repos.
func (ac *AccessController) updateUserAccessControl(httpReq *http.Request, userAc *reqCtx.UserAccessControl) {
isAdmin := ac.isAdmin(userAc.GetUsername(), userAc.GetGroups())
userAc.SetIsAdmin(isAdmin)
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.
func (ac *AccessController) getAuthnMiddlewareContext(authnType string, request *http.Request) context.Context {
amwCtx := reqCtx.AuthnMiddlewareContext{
AuthnType: authnType,
}
amwCtxKey := reqCtx.GetAuthnMiddlewareCtxKey()
ctx := context.WithValue(request.Context(), amwCtxKey, amwCtx)
return ctx
}
// 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 _, 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 _, 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, ""
}
// check anonymousPolicy
if slices.Contains(policyGroup.AnonymousPolicy, action) && username == "" {
return true, ""
}
return false, lastDenyReason
}
func BaseAuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
// Get configs safely
authConfig := ctlr.Config.CopyAuthConfig()
realm := ctlr.Config.GetRealm()
failDelay := authConfig.GetFailDelay()
/* NOTE:
since we only do READ actions in extensions, this middleware is enough for them because
it populates the context with user relevant data to be processed by each individual extension
*/
if request.Method == http.MethodOptions {
next.ServeHTTP(response, request)
return
}
// request comes from bearer authn, bypass it. note: we don't bypass for BEARER_OIDC
// tokens since they use accessControl config for authorization
authnMwCtx, err := reqCtx.GetAuthnMiddlewareContext(request.Context())
if err != nil || (authnMwCtx != nil && authnMwCtx.AuthnType == BEARER) {
next.ServeHTTP(response, request)
return
}
// bypass authz for /v2/ route
if request.RequestURI == "/v2/" {
next.ServeHTTP(response, request)
return
}
aCtlr := NewAccessController(ctlr.Config)
// get access control context made in authn.go
userAc, err := reqCtx.UserAcFromContext(request.Context())
if err != nil { // should never happen
authFail(response, request, realm, failDelay)
return
}
aCtlr.updateUserAccessControl(request, userAc)
userAc.SaveOnRequest(request)
next.ServeHTTP(response, request) //nolint:contextcheck
})
}
}
func DistSpecAuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
// Get configs safely
authConfig := ctlr.Config.CopyAuthConfig()
realm := ctlr.Config.GetRealm()
failDelay := authConfig.GetFailDelay()
if request.Method == http.MethodOptions {
next.ServeHTTP(response, request)
return
}
// request comes from bearer authn, bypass it. note: we don't bypass for BEARER_OIDC
// tokens since they use accessControl config for authorization
authnMwCtx, err := reqCtx.GetAuthnMiddlewareContext(request.Context())
if err != nil || (authnMwCtx != nil && authnMwCtx.AuthnType == BEARER) {
next.ServeHTTP(response, request)
return
}
vars := mux.Vars(request)
resource := vars["name"]
reference, ok := vars["reference"]
acCtrlr := NewAccessController(ctlr.Config)
// get userAc built in authn and previous authz middlewares
userAc, err := reqCtx.UserAcFromContext(request.Context())
if err != nil { // should never happen
authFail(response, request, realm, failDelay)
return
}
var action string
if request.Method == http.MethodGet || request.Method == http.MethodHead {
action = constants.ReadPermission
}
if request.Method == http.MethodPut || request.Method == http.MethodPatch || request.Method == http.MethodPost {
// assume user wants to create
action = constants.CreatePermission
// if we get a reference (tag)
if ok {
is := ctlr.StoreController.GetImageStore(resource)
tags, err := is.GetImageTags(resource)
if err == nil && slices.Contains(tags, reference) {
// if repo exists and request's tag exists then action is UPDATE
action = constants.UpdatePermission
}
}
}
if request.Method == http.MethodDelete {
action = constants.DeletePermission
}
can, denyReason := acCtrlr.can(request, userAc, action, resource, reference) //nolint:contextcheck
if !can {
common.AuthzFailWithReason(response, request, userAc.GetUsername(), realm, failDelay, denyReason)
} else {
next.ServeHTTP(response, request) //nolint:contextcheck
}
})
}
}
func MetricsAuthzHandler(ctlr *Controller) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
// Get configs safely
authConfig := ctlr.Config.CopyAuthConfig()
realm := ctlr.Config.GetRealm()
failDelay := authConfig.GetFailDelay()
accessControlConfig := ctlr.Config.CopyAccessControlConfig()
if accessControlConfig == nil {
// allow access to authenticated user as anonymous policy does not exist
next.ServeHTTP(response, request)
return
}
metricsConfig := accessControlConfig.GetMetrics()
if slices.Contains(metricsConfig.AnonymousPolicy, constants.ReadPermission) {
next.ServeHTTP(response, request)
return
}
if len(metricsConfig.Users) == 0 {
log := ctlr.Log
log.Warn().Msg("auth is enabled but no metrics users in accessControl: /metrics is unaccesible")
common.AuthzFail(response, request, "", realm, failDelay)
return
}
// get access control context made in authn.go
userAc, err := reqCtx.UserAcFromContext(request.Context())
if err != nil { // should never happen
common.AuthzFail(response, request, "", realm, failDelay)
return
}
username := userAc.GetUsername()
if !slices.Contains(metricsConfig.Users, username) {
common.AuthzFail(response, request, username, realm, failDelay)
return
}
next.ServeHTTP(response, request) //nolint:contextcheck
})
}
}