mirror of
https://github.com/project-zot/zot.git
synced 2026-06-15 11:37:56 +08:00
66e9cfb01f
* 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>
778 lines
22 KiB
Go
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
|
|
})
|
|
}
|
|
}
|