From 0d804fbd39ed656511b671d237ad14fc8c21786d Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:57:28 +0200 Subject: [PATCH] feat(oidc): add fine grained access control (#91) * feat(access-control): fine grain tokens * fix(docs): clean wording * test(access-control): add tests for team extraction from access tokens and bucket info permissions * test(vocabulary): add test for ExpandGlob function to reject non-glob patterns * feat(helm): add multi-user access control documentation and schema support --- .gitignore | 1 + README.md | 1 + backend/internal/auth/auth.go | 55 +++- backend/internal/auth/auth_teams_test.go | 75 ++++++ backend/internal/auth/jwt.go | 20 +- backend/internal/authz/authorizer.go | 129 ++++++++++ backend/internal/authz/authorizer_test.go | 117 +++++++++ backend/internal/authz/middleware.go | 235 ++++++++++++++++++ backend/internal/authz/middleware_test.go | 235 ++++++++++++++++++ backend/internal/authz/policy.go | 212 ++++++++++++++++ backend/internal/authz/policy_test.go | 201 +++++++++++++++ backend/internal/authz/resolver.go | 55 ++++ backend/internal/authz/resolver_test.go | 93 +++++++ backend/internal/authz/vocabulary.go | 136 ++++++++++ backend/internal/authz/vocabulary_test.go | 122 +++++++++ backend/internal/config/config.go | 66 ++++- backend/internal/config/config_test.go | 138 ++++++++++ backend/internal/handlers/auth.go | 6 +- backend/internal/handlers/buckets.go | 24 ++ .../internal/handlers/buckets_authz_test.go | 46 ++++ .../internal/handlers/buckets_getinfo_test.go | 45 ++++ backend/internal/handlers/capabilities.go | 57 ++++- .../internal/handlers/capabilities_test.go | 91 ++++++- backend/internal/models/admin.go | 4 + backend/internal/models/responses.go | 4 + backend/internal/routes/routes.go | 92 ++++--- backend/internal/routes/routes_authz_test.go | 231 +++++++++++++++++ backend/internal/routes/routes_teams_test.go | 103 ++++++++ backend/internal/routes/routes_test.go | 10 +- backend/main.go | 17 +- config.example.yaml | 25 ++ docs/access-control.md | 170 +++++++++++++ .../src/components/buckets/BucketListView.tsx | 60 +++-- .../components/buckets/ObjectBrowserView.tsx | 57 +++-- .../components/buckets/ObjectDetailsView.tsx | 15 +- .../src/components/buckets/ObjectsTable.tsx | 83 ++++--- .../components/layout/bucket-detail-shell.tsx | 22 +- frontend/src/components/layout/layout.tsx | 5 +- frontend/src/components/layout/sidebar.tsx | 79 +++--- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/usePermissions.ts | 76 ++++++ frontend/src/pages/BucketObjects.tsx | 22 +- frontend/src/pages/BucketSettings.tsx | 38 +-- frontend/src/pages/Buckets.tsx | 10 +- frontend/src/pages/NoAccess.tsx | 16 ++ frontend/src/types/index.ts | 15 ++ helm/garage-ui/README.md | 39 +++ helm/garage-ui/values.schema.json | 84 +++++++ helm/garage-ui/values.yaml | 20 ++ 49 files changed, 3224 insertions(+), 234 deletions(-) create mode 100644 backend/internal/auth/auth_teams_test.go create mode 100644 backend/internal/authz/authorizer.go create mode 100644 backend/internal/authz/authorizer_test.go create mode 100644 backend/internal/authz/middleware.go create mode 100644 backend/internal/authz/middleware_test.go create mode 100644 backend/internal/authz/policy.go create mode 100644 backend/internal/authz/policy_test.go create mode 100644 backend/internal/authz/resolver.go create mode 100644 backend/internal/authz/resolver_test.go create mode 100644 backend/internal/authz/vocabulary.go create mode 100644 backend/internal/authz/vocabulary_test.go create mode 100644 backend/internal/handlers/buckets_authz_test.go create mode 100644 backend/internal/handlers/buckets_getinfo_test.go create mode 100644 backend/internal/routes/routes_authz_test.go create mode 100644 backend/internal/routes/routes_teams_test.go create mode 100644 docs/access-control.md create mode 100644 frontend/src/hooks/usePermissions.ts create mode 100644 frontend/src/pages/NoAccess.tsx diff --git a/.gitignore b/.gitignore index 520c98a..02c3824 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,6 @@ backend/docs/ config.yaml docs/**/*.md !docs/garage-setup.md +!docs/access-control.md **/worktrees \ No newline at end of file diff --git a/README.md b/README.md index ced5765..fa1ba98 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ A modern web interface to manage Garag - **Access key management** - create keys, assign per-bucket permissions - **Cluster overview** - monitor node status, layout configuration, and storage usage - **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.) +- **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md) - **Easy deployment** - single Docker image or Helm chart, configure with one YAML file ## Quick Start diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index c5bc6b9..fbe920e 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -30,10 +30,12 @@ type Service struct { // UserInfo represents authenticated user information type UserInfo struct { - Username string - Email string - Name string - Roles []string + Username string + Email string + Name string + Roles []string + Teams []string // raw team claim values (team_attribute_path), OIDC only + AuthMethod string // "oidc" | "admin" | "token"; "" on legacy sessions } // NewAuthService creates a new authentication service @@ -179,6 +181,10 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) } + if a.authConfig.OIDC.TeamAttributePath != "" { + userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath) + } + return userInfo, nil } @@ -219,6 +225,10 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) } + if a.authConfig.OIDC.TeamAttributePath != "" { + userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath) + } + return userInfo, nil } @@ -252,6 +262,33 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string { return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) } +// ExtractTeamsFromAccessToken parses the access token JWT payload and extracts +// team claim values using the configured team_attribute_path. Same rationale +// as ExtractRolesFromAccessToken: Keycloak-style IdPs often emit group claims +// only in the access token, which came from a verified code exchange. +func (a *Service) ExtractTeamsFromAccessToken(accessToken string) []string { + if accessToken == "" || a.authConfig.OIDC.TeamAttributePath == "" { + return nil + } + + parts := strings.Split(accessToken, ".") + if len(parts) < 2 { + return nil + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil + } + + return extractRoles(claims, a.authConfig.OIDC.TeamAttributePath) +} + // IsAdmin checks if the user has any of the configured admin roles. func (a *Service) IsAdmin(userInfo *UserInfo) bool { adminRoles := a.authConfig.OIDC.EffectiveAdminRoles() @@ -388,9 +425,11 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) { } return &UserInfo{ - Username: claims.Username, - Email: claims.Email, - Name: claims.Name, - Roles: claims.Roles, + Username: claims.Username, + Email: claims.Email, + Name: claims.Name, + Roles: claims.Roles, + Teams: claims.Teams, + AuthMethod: claims.AuthMethod, }, nil } diff --git a/backend/internal/auth/auth_teams_test.go b/backend/internal/auth/auth_teams_test.go new file mode 100644 index 0000000..faab68f --- /dev/null +++ b/backend/internal/auth/auth_teams_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "testing" + + "Noooste/garage-ui/internal/config" +) + +func TestSessionTokenRoundTripsTeamsAndMethod(t *testing.T) { + svc, err := NewAuthService(&config.AuthConfig{}, &config.ServerConfig{}) + if err != nil { + t.Fatal(err) + } + in := &UserInfo{ + Username: "alice", + Email: "alice@example.com", + Teams: []string{"garage-team-backend", "garage-team-data"}, + AuthMethod: "oidc", + } + token, err := svc.GenerateSessionToken(in) + if err != nil { + t.Fatal(err) + } + out, err := svc.ValidateSessionToken(token) + if err != nil { + t.Fatal(err) + } + if len(out.Teams) != 2 || out.Teams[0] != "garage-team-backend" { + t.Errorf("Teams = %v, want round-trip", out.Teams) + } + if out.AuthMethod != "oidc" { + t.Errorf("AuthMethod = %q, want oidc", out.AuthMethod) + } +} + +func TestExtractTeamsFromAccessToken(t *testing.T) { + svc, err := NewAuthService(&config.AuthConfig{ + OIDC: config.OIDCConfig{TeamAttributePath: "groups"}, + }, &config.ServerConfig{}) + if err != nil { + t.Fatal(err) + } + // Unsigned JWT with {"groups":["team-a","team-b"]} payload. Extraction + // parses claims without verifying (token came from a verified exchange). + // header {"alg":"none"} / payload base64url of {"groups":["team-a","team-b"]} + tok := "eyJhbGciOiJub25lIn0.eyJncm91cHMiOlsidGVhbS1hIiwidGVhbS1iIl19.x" + got := svc.ExtractTeamsFromAccessToken(tok) + if len(got) != 2 || got[0] != "team-a" || got[1] != "team-b" { + t.Errorf("ExtractTeamsFromAccessToken = %v, want [team-a team-b]", got) + } + if got := svc.ExtractTeamsFromAccessToken(""); got != nil { + t.Errorf("empty token should return nil, got %v", got) + } +} + +func TestExtractTeamsFromAccessToken_Malformed(t *testing.T) { + svc, err := NewAuthService(&config.AuthConfig{ + OIDC: config.OIDCConfig{TeamAttributePath: "groups"}, + }, &config.ServerConfig{}) + if err != nil { + t.Fatal(err) + } + // Fewer than two dot-separated segments. + if got := svc.ExtractTeamsFromAccessToken("single-segment"); got != nil { + t.Errorf("one-segment token = %v, want nil", got) + } + // Correct shape but the payload segment is not valid base64url. + if got := svc.ExtractTeamsFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil { + t.Errorf("bad base64 payload = %v, want nil", got) + } + // Valid base64url ("bm90anNvbg" -> "notjson") but not JSON. + if got := svc.ExtractTeamsFromAccessToken("hdr.bm90anNvbg.sig"); got != nil { + t.Errorf("non-JSON payload = %v, want nil", got) + } +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index c19ccee..904affe 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -31,10 +31,12 @@ type StateData struct { } type SessionClaims struct { - Username string `json:"username"` - Email string `json:"email"` - Name string `json:"name"` - Roles []string `json:"roles"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + Teams []string `json:"teams,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` jwt.RegisteredClaims } @@ -160,10 +162,12 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second) claims := SessionClaims{ - Username: userInfo.Username, - Email: userInfo.Email, - Name: userInfo.Name, - Roles: userInfo.Roles, + Username: userInfo.Username, + Email: userInfo.Email, + Name: userInfo.Name, + Roles: userInfo.Roles, + Teams: userInfo.Teams, + AuthMethod: userInfo.AuthMethod, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt), diff --git a/backend/internal/authz/authorizer.go b/backend/internal/authz/authorizer.go new file mode 100644 index 0000000..55def3f --- /dev/null +++ b/backend/internal/authz/authorizer.go @@ -0,0 +1,129 @@ +package authz + +import ( + "sort" + "strings" +) + +// Subject is who is asking: resolved once per request by the TeamResolver. +type Subject struct { + ID string + IsAdmin bool + Bindings []Binding + ClusterPerms PermSet +} + +// Resource is what is being acted on. Empty Bucket means the action is +// global or unscoped (e.g. the ListBuckets endpoint itself). +type Resource struct { + Bucket string +} + +// Decision is the outcome of an authorization check. +type Decision struct { + Allow bool + Reason string +} + +// Authorizer decides whether a subject may perform an action on a resource. +// It is an interface so enforcement can later move to an external PDP or +// Garage-side scoped tokens without touching handlers. +type Authorizer interface { + Decide(subj Subject, action string, res Resource) Decision +} + +type policyAuthorizer struct{} + +// NewAuthorizer returns the built-in policy evaluator. +func NewAuthorizer() Authorizer { return policyAuthorizer{} } + +func (policyAuthorizer) Decide(subj Subject, action string, res Resource) Decision { + return Decide(subj, action, res) +} + +// Decide is the pure decision function. The synthetic admin subject flows +// through the same logic as any team, with no IsAdmin shortcut. +func Decide(subj Subject, action string, res Resource) Decision { + spec, ok := Vocabulary[action] + if !ok { + return Decision{Allow: false, Reason: "unknown_permission"} + } + + if spec.Scope == ScopeGlobal { + if _, ok := subj.ClusterPerms[action]; ok { + return Decision{Allow: true, Reason: "cluster_permission"} + } + return Decision{Allow: false, Reason: "no_cluster_permission"} + } + + // Prefix-scoped. + for _, b := range subj.Bindings { + if _, ok := b.Permissions[action]; !ok { + continue + } + // Unscoped call (list endpoint): any binding holding the permission + // suffices; per-bucket filtering happens on the response. + if res.Bucket == "" { + return Decision{Allow: true, Reason: "any_binding"} + } + if prefixesMatch(b.BucketPrefixes, res.Bucket) { + return Decision{Allow: true, Reason: "binding_match"} + } + } + return Decision{Allow: false, Reason: "no_matching_binding"} +} + +func prefixesMatch(prefixes []string, bucket string) bool { + for _, p := range prefixes { + if p == "*" || strings.HasPrefix(bucket, p) { + return true + } + } + return false +} + +// AdminSubject builds the synthetic admin team: one wildcard binding holding +// every prefix-scoped permission (admin-only included) plus every global +// permission. Same code path as any team. +func AdminSubject(id string) Subject { + prefixPerms := PermSet{} + clusterPerms := PermSet{} + for name, spec := range Vocabulary { + if spec.Scope == ScopePrefix { + prefixPerms[name] = struct{}{} + } else { + clusterPerms[name] = struct{}{} + } + } + return Subject{ + ID: id, + IsAdmin: true, + Bindings: []Binding{{BucketPrefixes: []string{"*"}, Permissions: prefixPerms}}, + ClusterPerms: clusterPerms, + } +} + +// EffectivePermissions returns the sorted union of prefix-scoped permissions +// the subject holds on the named bucket. This is the value served in API responses +// so the frontend never does prefix matching. Returns nil when nothing +// matches. +func EffectivePermissions(subj Subject, bucket string) []string { + set := PermSet{} + for _, b := range subj.Bindings { + if !prefixesMatch(b.BucketPrefixes, bucket) { + continue + } + for perm := range b.Permissions { + set[perm] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for perm := range set { + out = append(out, perm) + } + sort.Strings(out) + return out +} diff --git a/backend/internal/authz/authorizer_test.go b/backend/internal/authz/authorizer_test.go new file mode 100644 index 0000000..29262e8 --- /dev/null +++ b/backend/internal/authz/authorizer_test.go @@ -0,0 +1,117 @@ +package authz + +import ( + "sort" + "testing" +) + +func testSubject() Subject { + return Subject{ + ID: "alice@example.com", + Bindings: []Binding{ + {BucketPrefixes: []string{"backend-"}, Permissions: PermSet{ + "bucket.list": {}, "bucket.read": {}, "bucket.create": {}, "object.read": {}, "object.write": {}, + }}, + {BucketPrefixes: []string{"shared-"}, Permissions: PermSet{ + "bucket.list": {}, "bucket.read": {}, "object.read": {}, + }}, + }, + ClusterPerms: PermSet{"cluster.status": {}}, + } +} + +func TestDecidePrefixScoped(t *testing.T) { + s := testSubject() + cases := []struct { + action, bucket string + allow bool + }{ + {"bucket.read", "backend-api", true}, + {"bucket.read", "shared-docs", true}, + {"bucket.read", "data-warehouse", false}, // no binding matches + {"object.write", "backend-api", true}, + {"object.write", "shared-docs", false}, // readonly binding + {"bucket.create", "backend-new", true}, // prefix guard: new name matches + {"bucket.create", "frontend-new", false}, // prefix guard: no match + {"bucket.delete", "backend-api", false}, // permission not granted at all + } + for _, tc := range cases { + d := Decide(s, tc.action, Resource{Bucket: tc.bucket}) + if d.Allow != tc.allow { + t.Errorf("Decide(%s, %s) = %v (%s), want %v", tc.action, tc.bucket, d.Allow, d.Reason, tc.allow) + } + } +} + +func TestDecideUnscopedListEndpoint(t *testing.T) { + // GET /buckets carries no bucket name: allowed if ANY binding grants + // bucket.list (the response is filtered per bucket afterwards). + s := testSubject() + if d := Decide(s, "bucket.list", Resource{}); !d.Allow { + t.Errorf("bucket.list with empty resource should be allowed for a subject holding it in any binding: %s", d.Reason) + } + noList := Subject{ID: "bob", Bindings: []Binding{{BucketPrefixes: []string{"x-"}, Permissions: PermSet{"object.read": {}}}}} + if d := Decide(noList, "bucket.list", Resource{}); d.Allow { + t.Error("bucket.list should be denied when no binding grants it") + } +} + +func TestDecideGlobal(t *testing.T) { + s := testSubject() + if d := Decide(s, "cluster.status", Resource{}); !d.Allow { + t.Errorf("cluster.status should be allowed: %s", d.Reason) + } + if d := Decide(s, "cluster.statistics", Resource{}); d.Allow { + t.Error("cluster.statistics should be denied") + } + if d := Decide(s, "key.create", Resource{}); d.Allow { + t.Error("admin-only key.create should be denied for a team subject") + } +} + +func TestDecideUnknownPermission(t *testing.T) { + if d := Decide(testSubject(), "bucket.explode", Resource{}); d.Allow { + t.Error("unknown permission must be denied") + } +} + +func TestAdminSubject(t *testing.T) { + a := AdminSubject("root") + if !a.IsAdmin { + t.Error("AdminSubject must set IsAdmin") + } + // Admin goes through the exact same Decide path, no shortcut. + for _, action := range []string{"bucket.delete", "object.write", "key.create", "key.read_secret", "cluster.layout.apply", "node.repair"} { + if d := Decide(a, action, Resource{Bucket: "any-bucket-at-all"}); !d.Allow { + t.Errorf("admin denied %s: %s", action, d.Reason) + } + } +} + +func TestEffectivePermissions(t *testing.T) { + s := testSubject() + got := EffectivePermissions(s, "backend-api") + want := []string{"bucket.create", "bucket.list", "bucket.read", "object.read", "object.write"} + if !equalStrings(got, want) { + t.Errorf("EffectivePermissions(backend-api) = %v, want %v", got, want) + } + if got := EffectivePermissions(s, "data-x"); got != nil { + t.Errorf("EffectivePermissions(data-x) = %v, want nil", got) + } + admin := EffectivePermissions(AdminSubject("root"), "anything") + if !sort.StringsAreSorted(admin) || len(admin) == 0 { + t.Errorf("admin effective permissions should be all prefix-scoped perms, sorted; got %v", admin) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/authz/middleware.go b/backend/internal/authz/middleware.go new file mode 100644 index 0000000..aa39521 --- /dev/null +++ b/backend/internal/authz/middleware.go @@ -0,0 +1,235 @@ +package authz + +import ( + "fmt" + "reflect" + "runtime" + "strconv" + "strings" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/models" + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/gofiber/fiber/v3" +) + +// SubjectLocalsKey is the fiber.Ctx.Locals key carrying the resolved Subject. +const SubjectLocalsKey = "authzSubject" + +// Middleware wires the policy into Fiber. When the policy is disabled +// (access_control absent) every handler is a passthrough no-op. +type Middleware struct { + enabled bool + resolver TeamResolver + authorizer Authorizer +} + +func NewMiddleware(policy *Policy, resolver TeamResolver, authorizer Authorizer) *Middleware { + return &Middleware{enabled: policy.Enabled, resolver: resolver, authorizer: authorizer} +} + +// Enabled reports whether access control is active. +func (m *Middleware) Enabled() bool { return m.enabled } + +// ResolveSubject computes the request's Subject once, right after +// authentication. Handlers and the capabilities endpoint read the same +// struct, so enforcement and UI can never disagree. +func (m *Middleware) ResolveSubject() fiber.Handler { + return func(c fiber.Ctx) error { + if !m.enabled { + return c.Next() + } + userInfo, ok := c.Locals("userInfo").(*auth.UserInfo) + if !ok || userInfo == nil { + return c.Next() // no identity (auth disabled); Require will deny + } + c.Locals(SubjectLocalsKey, m.resolver.Resolve(userInfo)) + return c.Next() + } +} + +// SubjectFrom returns the Subject resolved for this request, if any. +func SubjectFrom(c fiber.Ctx) (Subject, bool) { + subj, ok := c.Locals(SubjectLocalsKey).(Subject) + return subj, ok +} + +// ScopeResolver extracts the target Resource from the request. +type ScopeResolver func(c fiber.Ctx) Resource + +// ScopeNone is for global permissions and unscoped list endpoints. +func ScopeNone(fiber.Ctx) Resource { return Resource{} } + +// BucketFromParam reads the bucket name from a URL parameter. +func BucketFromParam(param string) ScopeResolver { + return func(c fiber.Ctx) Resource { + return Resource{Bucket: c.Params(param)} + } +} + +// BucketFromBody reads the bucket name from a JSON body {"name": "..."}. +// Fiber buffers the body, so the handler can bind it again afterwards. +func BucketFromBody() ScopeResolver { + return func(c fiber.Ctx) Resource { + var req struct { + Name string `json:"name"` + } + if err := c.Bind().JSON(&req); err != nil { + return Resource{} + } + return Resource{Bucket: req.Name} + } +} + +// Require gates a route on the caller holding ALL of perms for the resolved +// resource. One structured decision log line is emitted per check: denies at +// warn, allows at debug. +// +// The closure's function name is the marker VerifyRouteCoverage looks for. +// Do not wrap it in another anonymous function. +func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler { + if len(perms) == 0 { + // A no-perms Require would silently no-op (allow everything) yet still + // satisfy VerifyRouteCoverage, defeating the fail-closed guarantee. + panic("authz.Require: at least one permission required") + } + for _, p := range perms { + if !IsValidPermission(p) { + panic(fmt.Sprintf("authz.Require: unknown permission %q", p)) // programmer error, fail at wiring time + } + } + return func(c fiber.Ctx) error { + if !m.enabled { + return c.Next() + } + subj, ok := SubjectFrom(c) + if !ok { + logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject") + return forbidden(c, perms[0]) + } + res := scope(c) + for _, perm := range perms { + d := m.authorizer.Decide(subj, perm, res) + logDecision(c, subj.ID, perm, res.Bucket, d.Allow, d.Reason) + if !d.Allow { + return forbidden(c, perm) + } + } + return c.Next() + } +} + +func forbidden(c fiber.Ctx, perm string) error { + return c.Status(fiber.StatusForbidden).JSON( + models.ErrorResponse(models.ErrCodeForbidden, "Missing permission: "+perm), + ) +} + +func logDecision(c fiber.Ctx, subject, action, resource string, allow bool, reason string) { + l := logpkg.FromCtx(c.Context()) + evt := l.Debug() + if !allow { + evt = l.Warn() + } + decision := "allow" + if !allow { + decision = "deny" + } + evt.Str("subject", subject). + Str("action", action). + Str("resource", resource). + Str("decision", decision). + Str("reason", reason). + Msg("authz_decision") +} + +// coverageExemptPaths are /api/v1 routes that intentionally carry no Require: +// health is unauthenticated, capabilities is the frontend's fail-closed +// source and returns only the caller's own permissions. +var coverageExemptPaths = map[string]struct{}{ + "/api/v1/health": {}, + "/api/v1/capabilities": {}, +} + +// VerifyRouteCoverage walks the app's route table and errors if any /api/v1 +// route lacks a Require handler. Called at startup (and from tests): a new +// endpoint registered without declaring its permission prevents boot instead +// of silently failing open. +// +// .Use()-registered routes need special handling. Group-level middleware +// (api.Use(handler) on the "/api/v1" group) produces synthetic per-method +// bookkeeping entries at exactly the bare prefix. Those aren't endpoints and +// are exempt. But a use-route at any deeper path (api.Use("/sneaky", +// terminalHandler)) IS a reachable endpoint and gets the same fail-closed +// treatment as normal routes. +func VerifyRouteCoverage(app *fiber.App) error { + // fiber.Route doesn't export whether a route was .Use()-registered, so + // classify by diffing GetRoutes() (everything) against GetRoutes(true) + // (use-routes filtered out): entries unmatched in the filtered multiset + // are use-registered. + nonUse := map[string]int{} + for _, r := range app.GetRoutes(true) { + nonUse[routeKey(r)]++ + } + + var naked []string + for _, route := range app.GetRoutes() { + // Consume the multiset for every route, before any skip, so + // classification stays consistent across the whole table. + isUse := true + if k := routeKey(route); nonUse[k] > 0 { + nonUse[k]-- + isUse = false + } + if !strings.HasPrefix(route.Path, "/api/v1") { + continue + } + if isUse && route.Path == "/api/v1" { + continue // group-middleware bookkeeping at the bare prefix + } + if _, exempt := coverageExemptPaths[route.Path]; exempt { + continue + } + if route.Method == fiber.MethodHead && hasRequireForPath(app, fiber.MethodGet, route.Path) { + continue // Fiber auto-registers HEAD mirroring GET + } + if !routeHasRequire(route.Handlers) { + naked = append(naked, route.Method+" "+route.Path) + } + } + if len(naked) > 0 { + return fmt.Errorf("authz: routes without Require permission declaration: %s", strings.Join(naked, ", ")) + } + return nil +} + +// routeKey identifies a route for the use-route diff. Method+Path+handler +// count is robust enough: two routes sharing all three are interchangeable +// for coverage purposes, and the multiset keeps counts honest. +func routeKey(r fiber.Route) string { + return r.Method + " " + r.Path + " " + strconv.Itoa(len(r.Handlers)) +} + +func hasRequireForPath(app *fiber.App, method, path string) bool { + for _, route := range app.GetRoutes(true) { + if route.Method == method && route.Path == path { + return routeHasRequire(route.Handlers) + } + } + return false +} + +func routeHasRequire(handlers []fiber.Handler) bool { + for _, h := range handlers { + fn := runtime.FuncForPC(fiberHandlerPC(h)) + if fn != nil && strings.Contains(fn.Name(), "authz.(*Middleware).Require") { + return true + } + } + return false +} + +func fiberHandlerPC(h fiber.Handler) uintptr { + return reflect.ValueOf(h).Pointer() +} diff --git a/backend/internal/authz/middleware_test.go b/backend/internal/authz/middleware_test.go new file mode 100644 index 0000000..a9fb46e --- /dev/null +++ b/backend/internal/authz/middleware_test.go @@ -0,0 +1,235 @@ +package authz + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + + "github.com/gofiber/fiber/v3" +) + +func middlewareFixture(t *testing.T) *Middleware { + t.Helper() + policy, err := CompilePolicy(&config.AccessControlConfig{ + Teams: []config.TeamConfig{{ + Name: "backend", + ClaimValues: []string{"g-backend"}, + Bindings: []config.BindingConfig{{ + BucketPrefixes: []string{"backend-"}, + Permissions: []string{"bucket.read", "bucket.create", "object.read"}, + }}, + ClusterPermissions: []string{"cluster.status"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + return NewMiddleware(policy, NewTeamResolver(policy, []string{"garage-admin"}), NewAuthorizer()) +} + +func newTestApp(m *Middleware, userInfo *auth.UserInfo) *fiber.App { + app := fiber.New() + app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware + if userInfo != nil { + c.Locals("userInfo", userInfo) + } + return c.Next() + }) + app.Use(m.ResolveSubject()) + app.Get("/api/v1/buckets/:name", m.Require(BucketFromParam("name"), PermBucketRead), func(c fiber.Ctx) error { + return c.SendString("ok") + }) + app.Post("/api/v1/buckets", m.Require(BucketFromBody(), PermBucketCreate), func(c fiber.Ctx) error { + return c.SendString("created") + }) + app.Get("/api/v1/cluster/status", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { + return c.SendString("ok") + }) + return app +} + +func doReq(t *testing.T, app *fiber.App, method, path, body string) int { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := app.Test(req) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode +} + +func TestRequireAllowsMatchingTeam(t *testing.T) { + m := middlewareFixture(t) + app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}}) + if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 200 { + t.Errorf("matching bucket: status %d, want 200", code) + } + if code := doReq(t, app, "GET", "/api/v1/cluster/status", ""); code != 200 { + t.Errorf("cluster.status: status %d, want 200", code) + } +} + +func TestRequireDeniesOutOfScope(t *testing.T) { + m := middlewareFixture(t) + app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}}) + if code := doReq(t, app, "GET", "/api/v1/buckets/data-warehouse", ""); code != 403 { + t.Errorf("non-matching bucket: status %d, want 403", code) + } +} + +func TestRequireBucketFromBody(t *testing.T) { + m := middlewareFixture(t) + app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}}) + if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"backend-new"}`); code != 200 { + t.Errorf("create with matching prefix: status %d, want 200", code) + } + if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"other-new"}`); code != 403 { + t.Errorf("create with foreign prefix: status %d, want 403", code) + } +} + +func TestRequireDefaultDenyZeroTeamUser(t *testing.T) { + m := middlewareFixture(t) + app := newTestApp(m, &auth.UserInfo{Email: "z@x", AuthMethod: "oidc"}) + if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 { + t.Errorf("zero-team user: status %d, want 403", code) + } +} + +func TestRequirePassthroughWhenDisabled(t *testing.T) { + policy, _ := CompilePolicy(nil) + m := NewMiddleware(policy, NewTeamResolver(policy, nil), NewAuthorizer()) + // No userInfo at all. Disabled access control must not require a subject. + app := newTestApp(m, nil) + if code := doReq(t, app, "GET", "/api/v1/buckets/anything", ""); code != 200 { + t.Errorf("disabled: status %d, want 200", code) + } +} + +func TestMiddlewareEnabledReflectsPolicy(t *testing.T) { + if !middlewareFixture(t).Enabled() { + t.Error("Enabled() = false for a configured policy, want true") + } + disabled, _ := CompilePolicy(nil) + m := NewMiddleware(disabled, NewTeamResolver(disabled, nil), NewAuthorizer()) + if m.Enabled() { + t.Error("Enabled() = true for a nil (disabled) policy, want false") + } +} + +func TestResolveSubjectWithoutUserInfoDenies(t *testing.T) { + // Enabled middleware, but auth set no userInfo local: ResolveSubject leaves + // no subject, and Require then denies for want of one. + m := middlewareFixture(t) + app := newTestApp(m, nil) + if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 { + t.Errorf("enabled + no userInfo: status %d, want 403", code) + } +} + +func TestRequireBucketFromBodyMalformedBody(t *testing.T) { + m := middlewareFixture(t) + app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}}) + // Malformed JSON: BucketFromBody's bind fails and returns an empty resource. + // An empty bucket is an unscoped check, so a team holding bucket.create in + // any binding is allowed (this is what proves the resource came back empty: + // a non-empty foreign bucket name would be denied instead). + if code := doReq(t, app, "POST", "/api/v1/buckets", "{not-json"); code != 200 { + t.Errorf("malformed body: status %d, want 200 (empty resource, any_binding)", code) + } +} + +func TestRequireUnknownPermissionPanics(t *testing.T) { + m := middlewareFixture(t) + defer func() { + r := recover() + if r == nil { + t.Fatal("Require with an unknown permission: want panic, got none") + } + if msg, ok := r.(string); !ok || !strings.Contains(msg, "unknown permission") { + t.Errorf("panic value = %v, want message containing %q", r, "unknown permission") + } + }() + m.Require(ScopeNone, "bogus.permission") +} + +func TestVerifyRouteCoverage(t *testing.T) { + m := middlewareFixture(t) + + covered := fiber.New() + covered.Get("/api/v1/capabilities", func(c fiber.Ctx) error { return nil }) // exempt + covered.Get("/api/v1/health", func(c fiber.Ctx) error { return nil }) // exempt + covered.Get("/api/v1/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil }) + covered.Get("/other", func(c fiber.Ctx) error { return nil }) // outside /api/v1 + if err := VerifyRouteCoverage(covered); err != nil { + t.Errorf("covered app: %v, want nil", err) + } + + uncovered := fiber.New() + uncovered.Get("/api/v1/naked", func(c fiber.Ctx) error { return nil }) + err := VerifyRouteCoverage(uncovered) + if err == nil { + t.Fatal("uncovered app: want error, got nil") + } + if !strings.Contains(err.Error(), "/api/v1/naked") { + t.Errorf("error should name the naked route: %v", err) + } +} + +func TestVerifyRouteCoverage_GroupUseBookkeepingExempt(t *testing.T) { + // Group-level .Use() middleware produces synthetic per-method entries at + // exactly the bare group prefix; those aren't endpoints and must not trip + // the coverage check as long as the real routes carry Require. + m := middlewareFixture(t) + app := fiber.New() + api := app.Group("/api/v1") + api.Use(func(c fiber.Ctx) error { return c.Next() }) // stand-in for AuthMiddleware + api.Use(m.ResolveSubject()) + api.Get("/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil }) + if err := VerifyRouteCoverage(app); err != nil { + t.Errorf("group-level Use at bare prefix should be exempt: %v", err) + } +} + +func TestRequireZeroPermissionsPanics(t *testing.T) { + m := middlewareFixture(t) + defer func() { + r := recover() + if r == nil { + t.Fatal("Require with zero perms: want panic, got none") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "at least one permission required") { + t.Errorf("panic value = %v, want message containing %q", r, "at least one permission required") + } + }() + m.Require(ScopeNone) +} + +func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) { + // A .Use()-registered route at a DEEPER path under /api/v1 is a reachable + // endpoint (Fiber runs it for every method with that prefix); it must get + // the same fail-closed treatment as a normal route. + app := fiber.New() + api := app.Group("/api/v1") + api.Use(func(c fiber.Ctx) error { return c.Next() }) // bare-prefix middleware stays exempt + api.Use("/sneaky", func(c fiber.Ctx) error { return c.SendString("terminal") }) + err := VerifyRouteCoverage(app) + if err == nil { + t.Fatal("use-registered endpoint under /api/v1: want error, got nil") + } + if !strings.Contains(err.Error(), "/api/v1/sneaky") { + t.Errorf("error should name /api/v1/sneaky: %v", err) + } +} diff --git a/backend/internal/authz/policy.go b/backend/internal/authz/policy.go new file mode 100644 index 0000000..c98b7bd --- /dev/null +++ b/backend/internal/authz/policy.go @@ -0,0 +1,212 @@ +package authz + +import ( + "fmt" + "strings" + + "Noooste/garage-ui/internal/config" +) + +// PermSet is a set of concrete permission names. +type PermSet map[string]struct{} + +// Binding pairs bucket-name prefixes with the prefix-scoped permissions that +// apply to buckets matching them. +type Binding struct { + BucketPrefixes []string + Permissions PermSet +} + +// TeamPolicy is a compiled team: presets resolved, globs expanded, validated. +type TeamPolicy struct { + Name string + ClaimValues []string + Bindings []Binding + ClusterPerms PermSet +} + +// Policy is the compiled access-control policy. Enabled=false (access_control +// absent) means "behave exactly as before this feature existed". +type Policy struct { + Enabled bool + Teams []TeamPolicy + byClaim map[string][]int // claim value -> indexes into Teams +} + +const presetPrefix = "preset:" + +// CompilePolicy validates and compiles the access_control config section. +// Any error here must abort startup; a half-valid policy is worse than none. +func CompilePolicy(cfg *config.AccessControlConfig) (*Policy, error) { + if cfg == nil { + return &Policy{Enabled: false}, nil + } + + resolvedPresets := make(map[string]PermSet, len(cfg.Presets)) + for name := range cfg.Presets { + perms, err := resolvePreset(cfg.Presets, name, nil) + if err != nil { + return nil, err + } + resolvedPresets[name] = perms + } + + p := &Policy{Enabled: true, byClaim: map[string][]int{}} + seenNames := map[string]struct{}{} + for ti, team := range cfg.Teams { + if team.Name == "" { + return nil, fmt.Errorf("access_control: team %d has no name", ti) + } + if _, dup := seenNames[team.Name]; dup { + return nil, fmt.Errorf("access_control: duplicate team name %q", team.Name) + } + seenNames[team.Name] = struct{}{} + if len(team.ClaimValues) == 0 { + return nil, fmt.Errorf("access_control: team %q has empty claim_values", team.Name) + } + if len(team.Bindings) == 0 && len(team.ClusterPermissions) == 0 { + return nil, fmt.Errorf("access_control: team %q must have at least one binding or cluster permission", team.Name) + } + + tp := TeamPolicy{Name: team.Name, ClaimValues: team.ClaimValues, ClusterPerms: PermSet{}} + + for bi, b := range team.Bindings { + if len(b.BucketPrefixes) == 0 { + return nil, fmt.Errorf("access_control: team %q binding %d has no bucket_prefixes", team.Name, bi) + } + perms, err := resolvePermList(b.Permissions, resolvedPresets, team.Name) + if err != nil { + return nil, err + } + if len(perms) == 0 { + return nil, fmt.Errorf("access_control: team %q binding %d has no permissions", team.Name, bi) + } + for perm := range perms { + if Vocabulary[perm].Scope != ScopePrefix { + return nil, fmt.Errorf("access_control: team %q binding %d: %q is a global permission, put it under cluster_permissions", team.Name, bi, perm) + } + } + tp.Bindings = append(tp.Bindings, Binding{BucketPrefixes: b.BucketPrefixes, Permissions: perms}) + } + + clusterPerms, err := resolvePermList(team.ClusterPermissions, resolvedPresets, team.Name) + if err != nil { + return nil, err + } + for perm := range clusterPerms { + if Vocabulary[perm].Scope != ScopeGlobal { + return nil, fmt.Errorf("access_control: team %q cluster_permissions: %q is prefix-scoped, put it in a binding", team.Name, perm) + } + } + tp.ClusterPerms = clusterPerms + + p.Teams = append(p.Teams, tp) + idx := len(p.Teams) - 1 + for _, cv := range team.ClaimValues { + p.byClaim[cv] = append(p.byClaim[cv], idx) + } + } + return p, nil +} + +// resolvePermList turns a raw permission list (concrete names, preset refs, +// globs) into a validated PermSet. +func resolvePermList(raw []string, presets map[string]PermSet, teamName string) (PermSet, error) { + out := PermSet{} + for _, entry := range raw { + switch { + case strings.HasPrefix(entry, presetPrefix): + name := strings.TrimPrefix(entry, presetPrefix) + perms, ok := presets[name] + if !ok { + return nil, fmt.Errorf("access_control: team %q references unknown preset %q", teamName, name) + } + for perm := range perms { + out[perm] = struct{}{} + } + case strings.HasSuffix(entry, "*"): + expanded := ExpandGlob(entry) + if expanded == nil { + return nil, fmt.Errorf("access_control: team %q: glob %q matches no permission (unknown permission pattern)", teamName, entry) + } + for _, perm := range expanded { + out[perm] = struct{}{} + } + default: + spec, ok := Vocabulary[entry] + if !ok { + return nil, fmt.Errorf("access_control: team %q: unknown permission %q", teamName, entry) + } + if spec.AdminOnly { + return nil, fmt.Errorf("access_control: team %q: %q is admin-only in v1 and cannot be granted to a team", teamName, entry) + } + out[entry] = struct{}{} + } + } + return out, nil +} + +// resolvePreset resolves one preset, following preset:… references with cycle +// detection. path carries the current resolution chain. +func resolvePreset(presets map[string][]string, name string, path []string) (PermSet, error) { + for _, seen := range path { + if seen == name { + return nil, fmt.Errorf("access_control: preset cycle detected: %s -> %s", strings.Join(path, " -> "), name) + } + } + entries, ok := presets[name] + if !ok { + return nil, fmt.Errorf("access_control: unknown preset %q", name) + } + out := PermSet{} + for _, entry := range entries { + switch { + case strings.HasPrefix(entry, presetPrefix): + sub, err := resolvePreset(presets, strings.TrimPrefix(entry, presetPrefix), append(path, name)) + if err != nil { + return nil, err + } + for perm := range sub { + out[perm] = struct{}{} + } + case strings.HasSuffix(entry, "*"): + expanded := ExpandGlob(entry) + if expanded == nil { + return nil, fmt.Errorf("access_control: preset %q: glob %q matches no permission", name, entry) + } + for _, perm := range expanded { + out[perm] = struct{}{} + } + default: + spec, ok := Vocabulary[entry] + if !ok { + return nil, fmt.Errorf("access_control: preset %q: unknown permission %q", name, entry) + } + if spec.AdminOnly { + return nil, fmt.Errorf("access_control: preset %q: %q is admin-only in v1 and cannot be granted to a team", name, entry) + } + out[entry] = struct{}{} + } + } + return out, nil +} + +// TeamsForClaims returns every team whose claim_values intersect claims. +// Multiple teams sharing a claim value are all returned (union semantics). +func (p *Policy) TeamsForClaims(claims []string) []*TeamPolicy { + if !p.Enabled { + return nil + } + seen := map[int]struct{}{} + var out []*TeamPolicy + for _, c := range claims { + for _, idx := range p.byClaim[c] { + if _, dup := seen[idx]; dup { + continue + } + seen[idx] = struct{}{} + out = append(out, &p.Teams[idx]) + } + } + return out +} diff --git a/backend/internal/authz/policy_test.go b/backend/internal/authz/policy_test.go new file mode 100644 index 0000000..e4ab805 --- /dev/null +++ b/backend/internal/authz/policy_test.go @@ -0,0 +1,201 @@ +package authz + +import ( + "strings" + "testing" + + "Noooste/garage-ui/internal/config" +) + +func validAC() *config.AccessControlConfig { + return &config.AccessControlConfig{ + Presets: map[string][]string{ + "bucket_readonly": {"bucket.list", "bucket.read", "object.list", "object.read"}, + "bucket_owner": {"preset:bucket_readonly", "bucket.create", "bucket.update", "bucket.delete", "object.write", "object.delete"}, + }, + Teams: []config.TeamConfig{ + { + Name: "backend", + ClaimValues: []string{"garage-team-backend"}, + Bindings: []config.BindingConfig{ + {BucketPrefixes: []string{"backend-"}, Permissions: []string{"preset:bucket_owner"}}, + {BucketPrefixes: []string{"shared-"}, Permissions: []string{"preset:bucket_readonly"}}, + }, + ClusterPermissions: []string{"cluster.status", "cluster.health"}, + }, + }, + } +} + +func TestCompilePolicyNilConfig(t *testing.T) { + p, err := CompilePolicy(nil) + if err != nil { + t.Fatalf("CompilePolicy(nil): %v", err) + } + if p.Enabled { + t.Error("nil config must compile to disabled policy") + } +} + +func TestCompilePolicyResolvesPresetsAndGlobs(t *testing.T) { + cfg := validAC() + cfg.Teams[0].Bindings[0].Permissions = append(cfg.Teams[0].Bindings[0].Permissions, "bucket_alias.*") + p, err := CompilePolicy(cfg) + if err != nil { + t.Fatalf("CompilePolicy: %v", err) + } + if !p.Enabled { + t.Fatal("policy should be enabled") + } + b0 := p.Teams[0].Bindings[0].Permissions + for _, want := range []string{"bucket.list", "bucket.read", "bucket.create", "object.delete", "bucket_alias.add", "bucket_alias.remove"} { + if _, ok := b0[want]; !ok { + t.Errorf("binding 0 missing %q after preset/glob resolution: %v", want, b0) + } + } + if _, ok := p.Teams[0].Bindings[1].Permissions["bucket.create"]; ok { + t.Error("readonly binding must not gain owner permissions") + } + if _, ok := p.Teams[0].ClusterPerms["cluster.status"]; !ok { + t.Error("cluster_permissions not compiled") + } +} + +func TestCompilePolicyValidationErrors(t *testing.T) { + cases := []struct { + name string + mutate func(*config.AccessControlConfig) + errPart string + }{ + {"unknown permission", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].Permissions = []string{"bucket.explode"} + }, "unknown permission"}, + {"unknown preset", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].Permissions = []string{"preset:nope"} + }, "unknown preset"}, + {"preset cycle", func(c *config.AccessControlConfig) { + c.Presets["a"] = []string{"preset:b"} + c.Presets["b"] = []string{"preset:a"} + c.Teams[0].Bindings[0].Permissions = []string{"preset:a"} + }, "cycle"}, + {"admin-only to team", func(c *config.AccessControlConfig) { + c.Teams[0].ClusterPermissions = []string{"key.create"} + }, "admin-only"}, + {"duplicate team name", func(c *config.AccessControlConfig) { + c.Teams = append(c.Teams, c.Teams[0]) + }, "duplicate team"}, + {"empty claim_values", func(c *config.AccessControlConfig) { + c.Teams[0].ClaimValues = nil + }, "claim_values"}, + {"no bindings or cluster perms", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings = nil + c.Teams[0].ClusterPermissions = nil + }, "at least one"}, + {"prefix-scoped perm in cluster_permissions", func(c *config.AccessControlConfig) { + c.Teams[0].ClusterPermissions = []string{"bucket.read"} + }, "prefix-scoped"}, + {"global perm in binding", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].Permissions = []string{"cluster.status"} + }, "global"}, + {"binding without prefixes", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].BucketPrefixes = nil + }, "bucket_prefixes"}, + {"binding with empty permissions", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].Permissions = nil + }, "has no permissions"}, + {"empty team name", func(c *config.AccessControlConfig) { + c.Teams[0].Name = "" + }, "has no name"}, + {"glob matches nothing in binding", func(c *config.AccessControlConfig) { + c.Teams[0].Bindings[0].Permissions = []string{"nonexistent.*"} + }, "matches no permission"}, + {"preset references unknown preset", func(c *config.AccessControlConfig) { + c.Presets["broken"] = []string{"preset:ghost"} + }, "unknown preset"}, + {"glob matches nothing in preset", func(c *config.AccessControlConfig) { + c.Presets["globby"] = []string{"nonexistent.*"} + }, "matches no permission"}, + {"unknown permission in preset", func(c *config.AccessControlConfig) { + c.Presets["badperm"] = []string{"bucket.explode"} + }, "unknown permission"}, + {"admin-only permission in preset", func(c *config.AccessControlConfig) { + c.Presets["adminy"] = []string{"key.create"} + }, "admin-only"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := validAC() + tc.mutate(cfg) + _, err := CompilePolicy(cfg) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.errPart) + } + if !strings.Contains(err.Error(), tc.errPart) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.errPart) + } + }) + } +} + +func TestCompilePolicyPresetWithGlob(t *testing.T) { + // A preset may itself contain a trailing-star glob; it must expand at + // compile time just like a glob written directly in a binding. + cfg := validAC() + cfg.Presets["aliasops"] = []string{"bucket_alias.*"} + cfg.Teams[0].Bindings[0].Permissions = []string{"preset:aliasops"} + p, err := CompilePolicy(cfg) + if err != nil { + t.Fatalf("CompilePolicy: %v", err) + } + b0 := p.Teams[0].Bindings[0].Permissions + for _, want := range []string{"bucket_alias.add", "bucket_alias.remove"} { + if _, ok := b0[want]; !ok { + t.Errorf("preset glob did not expand %q: %v", want, b0) + } + } +} + +func TestTeamsForClaimsDisabledReturnsNil(t *testing.T) { + p, err := CompilePolicy(nil) + if err != nil { + t.Fatal(err) + } + if got := p.TeamsForClaims([]string{"anything"}); got != nil { + t.Errorf("disabled policy matched %v, want nil", got) + } +} + +func TestTeamsForClaimsDeduplicatesSameTeam(t *testing.T) { + // One team reachable via two claim values: presenting both must not + // return the team twice. + cfg := validAC() + cfg.Teams[0].ClaimValues = []string{"g-a", "g-b"} + p, err := CompilePolicy(cfg) + if err != nil { + t.Fatal(err) + } + if got := p.TeamsForClaims([]string{"g-a", "g-b"}); len(got) != 1 { + t.Fatalf("TeamsForClaims returned %d teams, want 1 (deduped)", len(got)) + } +} + +func TestTeamsForClaims(t *testing.T) { + cfg := validAC() + // Second team sharing a claim value with the first: union case from the issue. + cfg.Teams = append(cfg.Teams, config.TeamConfig{ + Name: "backend-observers", + ClaimValues: []string{"garage-team-backend"}, + ClusterPermissions: []string{"cluster.statistics"}, + }) + p, err := CompilePolicy(cfg) + if err != nil { + t.Fatalf("CompilePolicy: %v", err) + } + got := p.TeamsForClaims([]string{"garage-team-backend"}) + if len(got) != 2 { + t.Fatalf("TeamsForClaims matched %d teams, want 2 (shared claim value)", len(got)) + } + if got := p.TeamsForClaims([]string{"unrelated"}); len(got) != 0 { + t.Fatalf("unrelated claim matched %d teams, want 0", len(got)) + } +} diff --git a/backend/internal/authz/resolver.go b/backend/internal/authz/resolver.go new file mode 100644 index 0000000..ef5d5b3 --- /dev/null +++ b/backend/internal/authz/resolver.go @@ -0,0 +1,55 @@ +package authz + +import ( + "Noooste/garage-ui/internal/auth" +) + +// TeamResolver maps an authenticated identity to an authorization Subject. +// It is an interface so non-OIDC identity sources (deferred in v1) can plug +// in later without touching middleware or handlers. +type TeamResolver interface { + Resolve(userInfo *auth.UserInfo) Subject +} + +type configTeamResolver struct { + policy *Policy + adminRoles []string +} + +// NewTeamResolver builds the v1 resolver: OIDC identities resolve through the +// compiled policy; admin/token logins resolve to the synthetic admin subject +// (non-OIDC team mapping is deferred). +func NewTeamResolver(policy *Policy, adminRoles []string) TeamResolver { + return &configTeamResolver{policy: policy, adminRoles: adminRoles} +} + +func (r *configTeamResolver) Resolve(userInfo *auth.UserInfo) Subject { + id := userInfo.Email + if id == "" { + id = userInfo.Username + } + + // Trust only signed claims, never the transport channel: the auth method + // is a JWT claim stamped at login. Legacy sessions ("") resolve like OIDC + // so a replayed cookie can never escalate. + if userInfo.AuthMethod == "admin" || userInfo.AuthMethod == "token" { + return AdminSubject(id) + } + + for _, role := range userInfo.Roles { + for _, adminRole := range r.adminRoles { + if role == adminRole { + return AdminSubject(id) + } + } + } + + subj := Subject{ID: id, ClusterPerms: PermSet{}} + for _, team := range r.policy.TeamsForClaims(userInfo.Teams) { + subj.Bindings = append(subj.Bindings, team.Bindings...) + for perm := range team.ClusterPerms { + subj.ClusterPerms[perm] = struct{}{} + } + } + return subj +} diff --git a/backend/internal/authz/resolver_test.go b/backend/internal/authz/resolver_test.go new file mode 100644 index 0000000..20df778 --- /dev/null +++ b/backend/internal/authz/resolver_test.go @@ -0,0 +1,93 @@ +package authz + +import ( + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" +) + +func resolverFixture(t *testing.T) TeamResolver { + t.Helper() + policy, err := CompilePolicy(&config.AccessControlConfig{ + Teams: []config.TeamConfig{ + { + Name: "backend", + ClaimValues: []string{"g-backend"}, + Bindings: []config.BindingConfig{ + {BucketPrefixes: []string{"backend-"}, Permissions: []string{"bucket.list", "bucket.read"}}, + }, + ClusterPermissions: []string{"cluster.status"}, + }, + { + Name: "observers", + ClaimValues: []string{"g-backend", "g-obs"}, + ClusterPermissions: []string{"cluster.statistics"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + return NewTeamResolver(policy, []string{"garage-admin"}) +} + +func TestResolveOIDCTeamUser(t *testing.T) { + r := resolverFixture(t) + s := r.Resolve(&auth.UserInfo{Email: "a@x.com", AuthMethod: "oidc", Teams: []string{"g-backend"}}) + if s.IsAdmin { + t.Error("team user must not be admin") + } + if s.ID != "a@x.com" { + t.Errorf("ID = %q", s.ID) + } + if len(s.Bindings) != 1 { + t.Fatalf("bindings = %d, want 1", len(s.Bindings)) + } + // Union across the two teams matched by g-backend. + if _, ok := s.ClusterPerms["cluster.status"]; !ok { + t.Error("missing cluster.status") + } + if _, ok := s.ClusterPerms["cluster.statistics"]; !ok { + t.Error("missing cluster.statistics from second team sharing the claim") + } +} + +func TestResolveOIDCAdminRole(t *testing.T) { + r := resolverFixture(t) + s := r.Resolve(&auth.UserInfo{Username: "root", AuthMethod: "oidc", Roles: []string{"garage-admin"}}) + if !s.IsAdmin { + t.Error("admin-role user must resolve to admin subject") + } +} + +func TestResolveNonOIDCIsAdmin(t *testing.T) { + r := resolverFixture(t) + for _, method := range []string{"admin", "token"} { + s := r.Resolve(&auth.UserInfo{Username: "op", AuthMethod: method}) + if !s.IsAdmin { + t.Errorf("method %q must resolve to admin (deferred: non-OIDC team mapping)", method) + } + } +} + +func TestResolveLegacySessionFailsClosed(t *testing.T) { + // Pre-upgrade JWTs have no auth_method claim. Resolve them like OIDC: + // roles can still grant admin, but there is no channel-based trust. + r := resolverFixture(t) + s := r.Resolve(&auth.UserInfo{Username: "old", AuthMethod: ""}) + if s.IsAdmin { + t.Error("legacy session without admin role must not be admin") + } + if len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 { + t.Error("legacy session with no teams must have zero permissions") + } +} + +func TestResolveZeroTeamUser(t *testing.T) { + r := resolverFixture(t) + s := r.Resolve(&auth.UserInfo{Email: "b@x.com", AuthMethod: "oidc", Teams: []string{"unmatched"}}) + if s.IsAdmin || len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 { + t.Errorf("zero-team subject must have nothing: %+v", s) + } +} diff --git a/backend/internal/authz/vocabulary.go b/backend/internal/authz/vocabulary.go new file mode 100644 index 0000000..b5a5bb1 --- /dev/null +++ b/backend/internal/authz/vocabulary.go @@ -0,0 +1,136 @@ +// Package authz implements the UI-layer access-control policy for garage-ui. +// +// IMPORTANT: this is UI-layer policy, not a security boundary. garage-ui talks +// to Garage with a single admin token and a single S3 credential set; anyone +// holding those bypasses everything here. +package authz + +import "strings" + +// ScopeKind says what a permission applies to. +type ScopeKind int + +const ( + // ScopeGlobal permissions are all-or-nothing per team (cluster_permissions). + ScopeGlobal ScopeKind = iota + // ScopePrefix permissions are gated by a binding's bucket_prefixes. + ScopePrefix +) + +// PermSpec describes one abstract permission. Endpoints lists the Garage admin +// API endpoint names (or S3 data-plane operations) the permission maps to. +// This registry is the only place those names appear in authz. +type PermSpec struct { + Scope ScopeKind + AdminOnly bool // not grantable to teams in v1; only the synthetic admin subject holds it + Endpoints []string +} + +// Permission constants for every permission referenced from route wiring. +const ( + PermBucketList = "bucket.list" + PermBucketRead = "bucket.read" + PermBucketCreate = "bucket.create" + PermBucketUpdate = "bucket.update" + PermBucketDelete = "bucket.delete" + PermObjectList = "object.list" + PermObjectRead = "object.read" + PermObjectWrite = "object.write" + PermObjectDelete = "object.delete" + PermAllowBucketKey = "permission.allow_bucket_key" + PermDenyBucketKey = "permission.deny_bucket_key" + PermKeyList = "key.list" + PermKeyRead = "key.read" + PermKeyReadSecret = "key.read_secret" + PermKeyCreate = "key.create" + PermKeyUpdate = "key.update" + PermKeyDelete = "key.delete" + PermClusterStatus = "cluster.status" + PermClusterHealth = "cluster.health" + PermClusterStatistics = "cluster.statistics" + PermNodeInfo = "node.info" + PermNodeStatistics = "node.statistics" +) + +// Vocabulary is the full v1 permission registry, ratified in issue #33. +// admin_token.* is deliberately absent (admin-only implicitly, not modeled). +var Vocabulary = map[string]PermSpec{ + "bucket.list": {Scope: ScopePrefix, Endpoints: []string{"ListBuckets"}}, + "bucket.read": {Scope: ScopePrefix, Endpoints: []string{"GetBucketInfo"}}, + "bucket.create": {Scope: ScopePrefix, Endpoints: []string{"CreateBucket"}}, + "bucket.update": {Scope: ScopePrefix, Endpoints: []string{"UpdateBucket"}}, + "bucket.delete": {Scope: ScopePrefix, Endpoints: []string{"DeleteBucket"}}, + "bucket.cleanup_uploads": {Scope: ScopePrefix, Endpoints: []string{"CleanupIncompleteUploads"}}, + "bucket.inspect_object": {Scope: ScopePrefix, Endpoints: []string{"InspectObject"}}, + + "bucket_alias.add": {Scope: ScopePrefix, Endpoints: []string{"AddBucketAlias"}}, + "bucket_alias.remove": {Scope: ScopePrefix, Endpoints: []string{"RemoveBucketAlias"}}, + + // S3 data plane (object browser), no Garage admin endpoint. + "object.list": {Scope: ScopePrefix, Endpoints: []string{"S3:ListObjectsV2"}}, + "object.read": {Scope: ScopePrefix, Endpoints: []string{"S3:GetObject", "S3:HeadObject", "S3:PresignGet"}}, + "object.write": {Scope: ScopePrefix, Endpoints: []string{"S3:PutObject"}}, + "object.delete": {Scope: ScopePrefix, Endpoints: []string{"S3:DeleteObject", "S3:DeleteObjects"}}, + + "permission.allow_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"AllowBucketKey"}}, + "permission.deny_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"DenyBucketKey"}}, + + "key.list": {Scope: ScopeGlobal, Endpoints: []string{"ListKeys"}}, + "key.read": {Scope: ScopeGlobal, Endpoints: []string{"GetKeyInfo"}}, + "key.read_secret": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"GetKeyInfo(showSecretKey)"}}, + "key.create": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"CreateKey"}}, + "key.import": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"ImportKey"}}, + "key.update": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"UpdateKey"}}, + "key.delete": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"DeleteKey"}}, + + "cluster.status": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatus"}}, + "cluster.health": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterHealth"}}, + "cluster.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatistics"}}, + "cluster.connect_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ConnectClusterNodes"}}, + + "cluster.layout.read": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayout"}}, + "cluster.layout.history": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayoutHistory"}}, + "cluster.layout.apply": {Scope: ScopeGlobal, Endpoints: []string{"ApplyClusterLayout"}}, + "cluster.layout.skip_dead_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ClusterLayoutSkipDeadNodes"}}, + + "node.info": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeInfo"}}, + "node.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeStatistics"}}, + "node.snapshot": {Scope: ScopeGlobal, Endpoints: []string{"CreateMetadataSnapshot"}}, + "node.repair": {Scope: ScopeGlobal, Endpoints: []string{"LaunchRepairOperation"}}, + + "worker.list": {Scope: ScopeGlobal, Endpoints: []string{"ListWorkers"}}, + "worker.info": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerInfo"}}, + "worker.get_variable": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerVariable"}}, + "worker.set_variable": {Scope: ScopeGlobal, Endpoints: []string{"SetWorkerVariable"}}, + + "block.list_errors": {Scope: ScopeGlobal, Endpoints: []string{"ListBlockErrors"}}, + "block.info": {Scope: ScopeGlobal, Endpoints: []string{"GetBlockInfo"}}, +} + +// IsValidPermission reports whether p names a concrete vocabulary entry. +// Globs are patterns, not permissions, and return false. +func IsValidPermission(p string) bool { + _, ok := Vocabulary[p] + return ok +} + +// ExpandGlob expands a trailing-star pattern ("bucket.*", "cluster.layout.*", +// bare "*") against the vocabulary. Admin-only permissions are never matched +// by globs; they must be held via the synthetic admin subject. Returns nil +// when the pattern matches nothing or has no trailing star. +func ExpandGlob(pattern string) []string { + if !strings.HasSuffix(pattern, "*") { + return nil + } + prefix := strings.TrimSuffix(pattern, "*") + var out []string + for name, spec := range Vocabulary { + if spec.AdminOnly { + continue + } + if strings.HasPrefix(name, prefix) { + out = append(out, name) + } + } + return out +} diff --git a/backend/internal/authz/vocabulary_test.go b/backend/internal/authz/vocabulary_test.go new file mode 100644 index 0000000..4f1d76e --- /dev/null +++ b/backend/internal/authz/vocabulary_test.go @@ -0,0 +1,122 @@ +package authz + +import ( + "sort" + "testing" +) + +func TestExpandGlobRejectsNonGlob(t *testing.T) { + // A pattern without a trailing star is not a glob and expands to nothing. + if got := ExpandGlob("bucket.read"); got != nil { + t.Errorf("ExpandGlob(%q) = %v, want nil", "bucket.read", got) + } +} + +func TestVocabularyContainsRatifiedPermissions(t *testing.T) { + // Spot-check one permission per family plus scope/admin flags. + cases := []struct { + perm string + scope ScopeKind + adminOnly bool + }{ + {"bucket.list", ScopePrefix, false}, + {"bucket.read", ScopePrefix, false}, + {"bucket.create", ScopePrefix, false}, + {"bucket_alias.add", ScopePrefix, false}, + {"object.list", ScopePrefix, false}, + {"object.read", ScopePrefix, false}, + {"object.write", ScopePrefix, false}, + {"object.delete", ScopePrefix, false}, + {"permission.allow_bucket_key", ScopePrefix, false}, + {"key.list", ScopeGlobal, false}, + {"key.read", ScopeGlobal, false}, + {"key.read_secret", ScopeGlobal, true}, + {"key.create", ScopeGlobal, true}, + {"key.import", ScopeGlobal, true}, + {"key.update", ScopeGlobal, true}, + {"key.delete", ScopeGlobal, true}, + {"cluster.status", ScopeGlobal, false}, + {"cluster.layout.apply", ScopeGlobal, false}, + {"node.repair", ScopeGlobal, false}, + {"worker.set_variable", ScopeGlobal, false}, + {"block.info", ScopeGlobal, false}, + } + for _, tc := range cases { + spec, ok := Vocabulary[tc.perm] + if !ok { + t.Errorf("missing permission %q", tc.perm) + continue + } + if spec.Scope != tc.scope { + t.Errorf("%s: scope = %v, want %v", tc.perm, spec.Scope, tc.scope) + } + if spec.AdminOnly != tc.adminOnly { + t.Errorf("%s: adminOnly = %v, want %v", tc.perm, spec.AdminOnly, tc.adminOnly) + } + } + if _, ok := Vocabulary["admin_token.list"]; ok { + t.Error("admin_token.* must not be in the v1 vocabulary") + } + if len(Vocabulary) != 40 { + t.Errorf("vocabulary size = %d, want 40", len(Vocabulary)) + } +} + +func TestIsValidPermission(t *testing.T) { + if !IsValidPermission("bucket.read") { + t.Error("bucket.read should be valid") + } + if IsValidPermission("bucket.explode") { + t.Error("bucket.explode should be invalid") + } + if IsValidPermission("bucket.*") { + t.Error("globs are not permissions; IsValidPermission must reject them") + } +} + +func TestExpandGlob(t *testing.T) { + got := ExpandGlob("object.*") + sort.Strings(got) + want := []string{"object.delete", "object.list", "object.read", "object.write"} + if len(got) != len(want) { + t.Fatalf("object.* expanded to %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("object.* expanded to %v, want %v", got, want) + } + } + + // key.* must NOT include admin-only permissions. + for _, p := range ExpandGlob("key.*") { + if Vocabulary[p].AdminOnly { + t.Errorf("glob expansion included admin-only permission %q", p) + } + } + + // cluster.* includes cluster.layout.* (prefix match on the dotted name). + found := false + for _, p := range ExpandGlob("cluster.*") { + if p == "cluster.layout.apply" { + found = true + } + } + if !found { + t.Error("cluster.* should include cluster.layout.apply") + } + + // Bare * expands to every non-admin-only permission. + star := ExpandGlob("*") + if len(star) == 0 { + t.Fatal("* expanded to nothing") + } + for _, p := range star { + if Vocabulary[p].AdminOnly { + t.Errorf("* expansion included admin-only permission %q", p) + } + } + + if got := ExpandGlob("nonexistent.*"); got != nil { + t.Errorf("nonexistent.* should expand to nil, got %v", got) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c1ec0f..4e96acb 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,11 +14,12 @@ import ( // Config represents the application configuration type Config struct { - Server ServerConfig `mapstructure:"server"` - Garage GarageConfig `mapstructure:"garage"` - Auth AuthConfig `mapstructure:"auth"` - CORS CORSConfig `mapstructure:"cors"` - Logging LoggingConfig `mapstructure:"logging"` + Server ServerConfig `mapstructure:"server"` + Garage GarageConfig `mapstructure:"garage"` + Auth AuthConfig `mapstructure:"auth"` + CORS CORSConfig `mapstructure:"cors"` + Logging LoggingConfig `mapstructure:"logging"` + AccessControl *AccessControlConfig `mapstructure:"access_control"` } // ServerConfig contains server-related configuration @@ -81,6 +82,7 @@ type OIDCConfig struct { UsernameAttribute string `mapstructure:"username_attribute"` NameAttribute string `mapstructure:"name_attribute"` RoleAttributePath string `mapstructure:"role_attribute_path"` + TeamAttributePath string `mapstructure:"team_attribute_path"` AdminRole string `mapstructure:"admin_role"` AdminRoles []string `mapstructure:"admin_roles"` TLSSkipVerify bool `mapstructure:"tls_skip_verify"` @@ -130,6 +132,33 @@ type LoggingConfig struct { Format string `mapstructure:"format"` } +// AccessControlConfig is the optional access_control section. nil (section +// absent) preserves historical behavior: every authenticated user is admin. +// When present, authorization is default-deny and detailed policy validation +// happens in internal/authz.CompilePolicy at startup. +// This section is config-file only (no env-var binding: nested lists don't +// map to flat env vars). +type AccessControlConfig struct { + Presets map[string][]string `mapstructure:"presets"` + Teams []TeamConfig `mapstructure:"teams"` +} + +// TeamConfig binds a set of IdP claim values to bucket-prefix bindings and +// cluster-level permissions. +type TeamConfig struct { + Name string `mapstructure:"name"` + ClaimValues []string `mapstructure:"claim_values"` + Bindings []BindingConfig `mapstructure:"bindings"` + ClusterPermissions []string `mapstructure:"cluster_permissions"` +} + +// BindingConfig grants a set of permissions (or presets) over buckets whose +// names match one of the given prefixes. +type BindingConfig struct { + BucketPrefixes []string `mapstructure:"bucket_prefixes"` + Permissions []string `mapstructure:"permissions"` +} + // LoadOption configures optional behaviour of Load. type LoadOption func(*loadOptions) @@ -216,6 +245,16 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) { return nil, fmt.Errorf("error unmarshaling config: %w", err) } + // mapstructure leaves AccessControl nil when the section is present but + // decodes to an empty map (e.g. "access_control: {}"), even though + // viper.IsSet still reports it present. AccessControlConfig's documented + // semantics are presence-based, not content-based ("nil = absent = + // historical behavior"; "present, even empty = enabled default-deny"), + // so force allocation here rather than silently falling back to nil. + if cfg.AccessControl == nil && viper.IsSet("access_control") { + cfg.AccessControl = &AccessControlConfig{} + } + // Validate the configuration if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) @@ -269,6 +308,7 @@ func bindEnvVars() { viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE") viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE") viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH") + viper.BindEnv("auth.oidc.team_attribute_path", "GARAGE_UI_AUTH_OIDC_TEAM_ATTRIBUTE_PATH") viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE") viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES") viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY") @@ -374,13 +414,15 @@ func (c *Config) Validate() error { if len(c.Auth.OIDC.Scopes) == 0 { return fmt.Errorf("oidc scopes are required when oidc is enabled") } - // Every authenticated route on this service grants full admin - // access — there is no separate authorization layer. Empty - // admin role configuration would therefore promote every user - // in the IdP realm to cluster admin. Require operators to opt - // in explicitly via admin_role or admin_roles. - if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 { - return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user") + // With access_control configured, default-deny protects unmatched + // users, so admin roles become optional. Without it, every + // authenticated route grants full admin access, so an empty admin + // role list would promote every IdP user to cluster admin. + if c.AccessControl == nil && len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 { + return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled without access_control: leaving them empty would grant cluster-admin access to any authenticated IdP user") + } + if c.AccessControl != nil && len(c.AccessControl.Teams) > 0 && c.Auth.OIDC.TeamAttributePath == "" { + return fmt.Errorf("auth.oidc.team_attribute_path is required when access_control.teams is set: teams cannot be resolved without it") } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index e6e7ae3..394d8d5 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -329,6 +329,17 @@ func TestValidate(t *testing.T) { mutate: applyValidOIDC, wantErrContains: "", }, + { + name: "access_control teams without team_attribute_path rejected", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Auth.OIDC.TeamAttributePath = "" + c.AccessControl = &AccessControlConfig{ + Teams: []TeamConfig{{Name: "t", ClaimValues: []string{"g"}}}, + } + }, + wantErrContains: "team_attribute_path is required", + }, { name: "oidc disabled ignores missing client_id", mutate: func(c *Config) { @@ -736,6 +747,133 @@ func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) { } } +func TestAccessControlConfigParsing(t *testing.T) { + resetViper(t) + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + yaml := ` +server: + port: 8080 +garage: + endpoint: "http://localhost:3900" + admin_endpoint: "http://localhost:3903" + admin_token: "test-token" +auth: + oidc: + enabled: false + team_attribute_path: "groups" +access_control: + presets: + bucket_readonly: [bucket.list, bucket.read] + teams: + - name: backend + claim_values: ["garage-team-backend"] + bindings: + - bucket_prefixes: ["backend-"] + permissions: ["preset:bucket_readonly", "bucket.create"] + cluster_permissions: [cluster.status] +` + if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AccessControl == nil { + t.Fatal("AccessControl is nil, want parsed section") + } + if got := cfg.Auth.OIDC.TeamAttributePath; got != "groups" { + t.Errorf("TeamAttributePath = %q, want groups", got) + } + if len(cfg.AccessControl.Teams) != 1 { + t.Fatalf("teams = %d, want 1", len(cfg.AccessControl.Teams)) + } + team := cfg.AccessControl.Teams[0] + if team.Name != "backend" || len(team.Bindings) != 1 { + t.Errorf("unexpected team: %+v", team) + } + if team.Bindings[0].BucketPrefixes[0] != "backend-" { + t.Errorf("prefix = %q", team.Bindings[0].BucketPrefixes[0]) + } + if cfg.AccessControl.Presets["bucket_readonly"][0] != "bucket.list" { + t.Errorf("preset parse failed: %+v", cfg.AccessControl.Presets) + } +} + +func TestAccessControlAbsentIsNil(t *testing.T) { + resetViper(t) + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + yaml := ` +garage: + endpoint: "http://localhost:3900" + admin_endpoint: "http://localhost:3903" + admin_token: "test-token" +` + if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AccessControl != nil { + t.Fatalf("AccessControl = %+v, want nil when section absent", cfg.AccessControl) + } +} + +func TestAccessControlPresentButEmptyIsNonNil(t *testing.T) { + // A present-but-empty access_control section pins the enablement + // semantics documented on AccessControlConfig: presence, not content, + // turns on default-deny. An operator who writes "access_control: {}" + // (e.g. while staging a config) must get a non-nil, enabled policy, not + // silently fall back to "every authenticated user is admin". + resetViper(t) + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + yaml := ` +garage: + endpoint: "http://localhost:3900" + admin_endpoint: "http://localhost:3903" + admin_token: "test-token" +access_control: {} +` + if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AccessControl == nil { + t.Fatal("AccessControl = nil, want non-nil when section is present but empty") + } + if len(cfg.AccessControl.Teams) != 0 { + t.Errorf("Teams = %+v, want empty", cfg.AccessControl.Teams) + } +} + +func TestOIDCAdminRolesOptionalWithAccessControl(t *testing.T) { + // With access_control present, OIDC no longer requires admin_role: + // default-deny protects unmatched users. + cfg := &Config{ + Server: ServerConfig{Port: 8080, RootURL: "https://ui.example.com"}, + Garage: GarageConfig{Endpoint: "e", AdminEndpoint: "a", AdminToken: "t"}, + Auth: AuthConfig{OIDC: OIDCConfig{ + Enabled: true, ClientID: "id", IssuerURL: "https://idp", Scopes: []string{"openid"}, + }}, + AccessControl: &AccessControlConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate with access_control and no admin_role: %v, want nil", err) + } + cfg.AccessControl = nil + if err := cfg.Validate(); err == nil { + t.Error("Validate without access_control and no admin_role should fail") + } +} + func TestIsProduction(t *testing.T) { tests := []struct { env string diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index b67d0b6..856fbe5 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -93,7 +93,8 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error { // Create user info object userInfo := &auth.UserInfo{ - Username: req.Username, + Username: req.Username, + AuthMethod: "admin", } // Generate JWT session token @@ -135,7 +136,8 @@ func (h *AuthHandler) LoginToken(c fiber.Ctx) error { } userInfo := &auth.UserInfo{ - Username: "admin-token", + Username: "admin-token", + AuthMethod: "token", } sessionToken, err := h.authService.GenerateSessionToken(userInfo) diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index 9ec87b4..e1a1a29 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -1,6 +1,7 @@ package handlers import ( + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/services" @@ -80,6 +81,10 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { buckets = append(buckets, bucketInfo) } + if subj, ok := authz.SubjectFrom(c); ok { + buckets = filterBucketsForSubject(buckets, subj) + } + response := models.BucketListResponse{ Buckets: buckets, Count: len(buckets), @@ -231,6 +236,10 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error { ) } + if subj, ok := authz.SubjectFrom(c); ok { + bucketInfo.EffectivePermissions = authz.EffectivePermissions(subj, bucketName) + } + return c.JSON(models.SuccessResponse(bucketInfo)) } @@ -491,3 +500,18 @@ func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(result)) } + +// filterBucketsForSubject applies the access-control view of a bucket list: +// a bucket is visible iff the subject holds bucket.list for it, and each +// visible bucket carries the subject's effective permissions. +func filterBucketsForSubject(buckets []models.BucketInfo, subj authz.Subject) []models.BucketInfo { + out := make([]models.BucketInfo, 0, len(buckets)) + for _, b := range buckets { + if !authz.Decide(subj, authz.PermBucketList, authz.Resource{Bucket: b.Name}).Allow { + continue + } + b.EffectivePermissions = authz.EffectivePermissions(subj, b.Name) + out = append(out, b) + } + return out +} diff --git a/backend/internal/handlers/buckets_authz_test.go b/backend/internal/handlers/buckets_authz_test.go new file mode 100644 index 0000000..1648f7b --- /dev/null +++ b/backend/internal/handlers/buckets_authz_test.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "testing" + + "Noooste/garage-ui/internal/authz" + "Noooste/garage-ui/internal/models" +) + +func teamSubject() authz.Subject { + return authz.Subject{ + ID: "alice", + Bindings: []authz.Binding{{ + BucketPrefixes: []string{"backend-"}, + Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}, "object.read": {}}, + }}, + } +} + +func TestFilterBucketsForSubject(t *testing.T) { + buckets := []models.BucketInfo{ + {Name: "backend-api"}, + {Name: "backend-assets"}, + {Name: "data-warehouse"}, + } + got := filterBucketsForSubject(buckets, teamSubject()) + if len(got) != 2 { + t.Fatalf("filtered to %d buckets, want 2", len(got)) + } + for _, b := range got { + if b.Name == "data-warehouse" { + t.Error("data-warehouse must be filtered out") + } + if len(b.EffectivePermissions) == 0 { + t.Errorf("%s: effective_permissions must be populated", b.Name) + } + } +} + +func TestFilterBucketsAdminSeesAll(t *testing.T) { + buckets := []models.BucketInfo{{Name: "a"}, {Name: "b"}} + got := filterBucketsForSubject(buckets, authz.AdminSubject("root")) + if len(got) != 2 { + t.Fatalf("admin sees %d buckets, want 2", len(got)) + } +} diff --git a/backend/internal/handlers/buckets_getinfo_test.go b/backend/internal/handlers/buckets_getinfo_test.go new file mode 100644 index 0000000..d41af04 --- /dev/null +++ b/backend/internal/handlers/buckets_getinfo_test.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/authz" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func TestGetBucketInfoPopulatesEffectivePermissions(t *testing.T) { + admin := &mocks.AdminMock{} + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + h := NewBucketHandler(admin, nil) + + app := fiber.New() + app.Get("/buckets/:name", func(c fiber.Ctx) error { + c.Locals(authz.SubjectLocalsKey, teamSubject()) + return h.GetBucketInfo(c) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/backend-api", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body struct { + Data models.GarageBucketInfo `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if len(body.Data.EffectivePermissions) == 0 { + t.Error("effective_permissions must be populated for a subject in scope of the bucket") + } +} diff --git a/backend/internal/handlers/capabilities.go b/backend/internal/handlers/capabilities.go index 638a1a6..6c8eb75 100644 --- a/backend/internal/handlers/capabilities.go +++ b/backend/internal/handlers/capabilities.go @@ -1,6 +1,9 @@ package handlers import ( + "sort" + + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/services" @@ -8,20 +11,64 @@ import ( ) type CapabilitiesHandler struct { - apiVersion string - capabilities services.Capabilities + apiVersion string + capabilities services.Capabilities + accessControlEnabled bool } -func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities) *CapabilitiesHandler { +func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities, accessControlEnabled bool) *CapabilitiesHandler { return &CapabilitiesHandler{ - apiVersion: apiVersion, - capabilities: capabilities, + apiVersion: apiVersion, + capabilities: capabilities, + accessControlEnabled: accessControlEnabled, } } +// accessControlBinding mirrors one compiled binding, unflattened: "read on +// backend-*" plus "write on data-*" must never merge into both-on-both. +type accessControlBinding struct { + BucketPrefixes []string `json:"bucket_prefixes"` + Permissions []string `json:"permissions"` +} + +type accessControlBlock struct { + Enabled bool `json:"enabled"` + Subject string `json:"subject,omitempty"` + IsAdmin bool `json:"is_admin,omitempty"` + Bindings []accessControlBinding `json:"bindings,omitempty"` + ClusterPermissions []string `json:"cluster_permissions,omitempty"` +} + func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error { + ac := accessControlBlock{Enabled: h.accessControlEnabled} + if h.accessControlEnabled { + if subj, ok := authz.SubjectFrom(c); ok { + ac.Subject = subj.ID + ac.IsAdmin = subj.IsAdmin + for _, b := range subj.Bindings { + ac.Bindings = append(ac.Bindings, accessControlBinding{ + BucketPrefixes: b.BucketPrefixes, + Permissions: sortedPerms(b.Permissions), + }) + } + ac.ClusterPermissions = sortedPerms(subj.ClusterPerms) + } + } return c.JSON(models.SuccessResponse(fiber.Map{ "garageApiVersion": h.apiVersion, "features": h.capabilities, + "access_control": ac, })) } + +func sortedPerms(set authz.PermSet) []string { + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out +} diff --git a/backend/internal/handlers/capabilities_test.go b/backend/internal/handlers/capabilities_test.go index abc4d41..67b25f1 100644 --- a/backend/internal/handlers/capabilities_test.go +++ b/backend/internal/handlers/capabilities_test.go @@ -2,10 +2,12 @@ package handlers import ( "encoding/json" + "io" "net/http" "net/http/httptest" "testing" + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/services" "github.com/gofiber/fiber/v3" @@ -13,7 +15,7 @@ import ( func TestCapabilities_V2(t *testing.T) { app := fiber.New() - h := NewCapabilitiesHandler("v2", services.CapabilitiesV2()) + h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false) app.Get("/capabilities", h.GetCapabilities) req := httptest.NewRequest(http.MethodGet, "/capabilities", nil) @@ -30,7 +32,7 @@ func TestCapabilities_V2(t *testing.T) { var body struct { Success bool `json:"success"` Data struct { - GarageApiVersion string `json:"garageApiVersion"` + GarageApiVersion string `json:"garageApiVersion"` Features services.Capabilities `json:"features"` } `json:"data"` } @@ -50,7 +52,7 @@ func TestCapabilities_V2(t *testing.T) { func TestCapabilities_V1(t *testing.T) { app := fiber.New() - h := NewCapabilitiesHandler("v1", services.CapabilitiesV1()) + h := NewCapabilitiesHandler("v1", services.CapabilitiesV1(), false) app.Get("/capabilities", h.GetCapabilities) req := httptest.NewRequest(http.MethodGet, "/capabilities", nil) @@ -62,7 +64,7 @@ func TestCapabilities_V1(t *testing.T) { var body struct { Data struct { - GarageApiVersion string `json:"garageApiVersion"` + GarageApiVersion string `json:"garageApiVersion"` Features services.Capabilities `json:"features"` } `json:"data"` } @@ -76,3 +78,84 @@ func TestCapabilities_V1(t *testing.T) { t.Errorf("features = %+v, want all false", body.Data.Features) } } + +func TestSortedPermsEmptyReturnsNil(t *testing.T) { + if got := sortedPerms(authz.PermSet{}); got != nil { + t.Errorf("sortedPerms(empty) = %v, want nil", got) + } + if got := sortedPerms(nil); got != nil { + t.Errorf("sortedPerms(nil) = %v, want nil", got) + } +} + +func TestGetCapabilitiesAccessControlDisabled(t *testing.T) { + h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false) + app := fiber.New() + app.Get("/capabilities", h.GetCapabilities) + resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + var envelope struct { + Data struct { + AccessControl struct { + Enabled bool `json:"enabled"` + } `json:"access_control"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.AccessControl.Enabled { + t.Error("enabled should be false when access control is off") + } +} + +func TestGetCapabilitiesAccessControlSubject(t *testing.T) { + h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), true) + app := fiber.New() + app.Get("/capabilities", func(c fiber.Ctx) error { + c.Locals(authz.SubjectLocalsKey, authz.Subject{ + ID: "alice@example.com", + Bindings: []authz.Binding{{ + BucketPrefixes: []string{"backend-"}, + Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}}, + }}, + ClusterPerms: authz.PermSet{"cluster.status": {}}, + }) + return h.GetCapabilities(c) + }) + resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + var envelope struct { + Data struct { + AccessControl struct { + Enabled bool `json:"enabled"` + Subject string `json:"subject"` + IsAdmin bool `json:"is_admin"` + ClusterPermissions []string `json:"cluster_permissions"` + Bindings []struct { + BucketPrefixes []string `json:"bucket_prefixes"` + Permissions []string `json:"permissions"` + } `json:"bindings"` + } `json:"access_control"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatal(err) + } + ac := envelope.Data.AccessControl + if !ac.Enabled || ac.Subject != "alice@example.com" || ac.IsAdmin { + t.Errorf("unexpected access_control header fields: %+v", ac) + } + if len(ac.Bindings) != 1 || len(ac.Bindings[0].Permissions) != 2 { + t.Errorf("bindings not mirrored unflattened: %+v", ac.Bindings) + } + if len(ac.ClusterPermissions) != 1 || ac.ClusterPermissions[0] != "cluster.status" { + t.Errorf("cluster_permissions = %v", ac.ClusterPermissions) + } +} diff --git a/backend/internal/models/admin.go b/backend/internal/models/admin.go index 35a2d2e..891b449 100644 --- a/backend/internal/models/admin.go +++ b/backend/internal/models/admin.go @@ -86,6 +86,10 @@ type GarageBucketInfo struct { UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` Quotas *BucketQuotas `json:"quotas,omitempty"` + + // EffectivePermissions is the caller's prefix-scoped permissions on this + // bucket, computed server-side. Omitted when access control is disabled. + EffectivePermissions []string `json:"effective_permissions,omitempty"` } // BucketWebsiteConfig represents website configuration for a bucket diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index 29f49b5..50b4ca6 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -48,6 +48,10 @@ type BucketInfo struct { WebsiteAccess bool `json:"websiteAccess"` WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"` Quotas *BucketQuotas `json:"quotas,omitempty"` + + // EffectivePermissions is the caller's prefix-scoped permissions on this + // bucket, computed server-side. Omitted when access control is disabled. + EffectivePermissions []string `json:"effective_permissions,omitempty"` } // BucketListResponse represents a list of buckets diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index cea8275..5c64921 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" "Noooste/garage-ui/internal/middleware" @@ -30,6 +31,7 @@ func SetupRoutes( clusterHandler *handlers.ClusterHandler, monitoringHandler *handlers.MonitoringHandler, capabilitiesHandler *handlers.CapabilitiesHandler, + az *authz.Middleware, ) { // Apply CORS middleware globally app.Use(middleware.CORSMiddleware(&cfg.CORS)) @@ -53,31 +55,34 @@ func SetupRoutes( // Apply authentication middleware to all API routes api.Use(middleware.AuthMiddleware(&cfg.Auth, authService)) + // Resolve the authz Subject once per request, right after authentication. + api.Use(az.ResolveSubject()) + api.Get("/capabilities", capabilitiesHandler.GetCapabilities) // Bucket routes buckets := api.Group("/buckets") { - buckets.Get("/", bucketHandler.ListBuckets) // List all buckets - buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket - buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info - buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket - buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions - buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration - buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas + buckets.Get("/", az.Require(authz.ScopeNone, authz.PermBucketList), bucketHandler.ListBuckets) // List all buckets + buckets.Post("/", az.Require(authz.BucketFromBody(), authz.PermBucketCreate), bucketHandler.CreateBucket) // Create a new bucket + buckets.Get("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketRead), bucketHandler.GetBucketInfo) // Get bucket info + buckets.Delete("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketDelete), bucketHandler.DeleteBucket) // Delete a bucket + buckets.Post("/:name/permissions", az.Require(authz.BucketFromParam("name"), authz.PermAllowBucketKey, authz.PermDenyBucketKey), bucketHandler.GrantBucketPermission) // Grant bucket permissions (allow+deny) + buckets.Put("/:name/website", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketWebsite) // Update bucket website configuration + buckets.Put("/:name/quotas", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketQuotas) // Update bucket quotas } // Object routes objects := api.Group("/buckets/:bucket/objects") { - objects.Get("/", objectHandler.ListObjects) // List objects in bucket - objects.Post("/", objectHandler.UploadObject) // Upload object (multipart) - objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects - objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects + objects.Get("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectList), objectHandler.ListObjects) // List objects in bucket + objects.Post("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadObject) // Upload object (multipart) + objects.Post("/upload-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadMultipleObjects) // Upload multiple objects + objects.Post("/delete-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectHandler.DeleteMultipleObjects) // Delete multiple objects } // Directory routes (zero-byte directory markers) - api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory) + api.Post("/buckets/:bucket/directories", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.CreateDirectory) // Fiber v3 does not auto-decode wildcard params; fall back to the raw // value when QueryUnescape fails. @@ -114,38 +119,41 @@ func SetupRoutes( return objectHandler.GetObjectMetadata(c) } - // Register with auth middleware - app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler) - app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler) - app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler) + // Register with auth middleware. Although these routes live on app, not + // api, the api group's .Use() middlewares (AuthMiddleware, ResolveSubject) + // cascade onto them by path prefix, so ResolveSubject is not repeated here + // (TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks that in). + app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectWildcardHandler) + app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectDeleteHandler) + app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectHeadHandler) // User/Key management routes users := api.Group("/users") { - users.Get("/", userHandler.ListUsers) // List all users/keys - users.Post("/", userHandler.CreateUser) // Create new user/key - users.Get("/:access_key", userHandler.GetUser) // Get user info - users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key - users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key - users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions + users.Get("/", az.Require(authz.ScopeNone, authz.PermKeyList), userHandler.ListUsers) // List all users/keys + users.Post("/", az.Require(authz.ScopeNone, authz.PermKeyCreate), userHandler.CreateUser) // Create new user/key + users.Get("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyRead), userHandler.GetUser) // Get user info + users.Get("/:access_key/secret", az.Require(authz.ScopeNone, authz.PermKeyReadSecret), userHandler.GetUserSecretKey) // Get user secret key + users.Delete("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyDelete), userHandler.DeleteUser) // Delete user/key + users.Patch("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyUpdate), userHandler.UpdateUserPermissions) // Update user permissions } // Cluster management routes cluster := api.Group("/cluster") { - cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health - cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status - cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics - cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info - cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics + cluster.Get("/health", az.Require(authz.ScopeNone, authz.PermClusterHealth), clusterHandler.GetHealth) // Get cluster health + cluster.Get("/status", az.Require(authz.ScopeNone, authz.PermClusterStatus), clusterHandler.GetStatus) // Get cluster status + cluster.Get("/statistics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), clusterHandler.GetStatistics) // Get cluster statistics + cluster.Get("/nodes/:node_id", az.Require(authz.ScopeNone, authz.PermNodeInfo), clusterHandler.GetNodeInfo) // Get node info + cluster.Get("/nodes/:node_id/statistics", az.Require(authz.ScopeNone, authz.PermNodeStatistics), clusterHandler.GetNodeStatistics) // Get node statistics } // Monitoring routes monitoring := api.Group("/monitoring") { - monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics - monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health - monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics + monitoring.Get("/metrics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetMetrics) // Get Prometheus metrics + monitoring.Get("/admin-health", az.Require(authz.ScopeNone, authz.PermClusterHealth), monitoringHandler.CheckAdminHealth) // Check Admin API health + monitoring.Get("/dashboard", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetDashboardMetrics) // Get dashboard metrics } // Admin auth login endpoint (only if admin is enabled) @@ -233,10 +241,11 @@ func SetupRoutes( }) } - // Enforce admin role if configured. Roles are often absent from the - // ID token and the userinfo endpoint (Keycloak emits resource_access - // only in the access token by default), so fall back to the access - // token and then the userinfo endpoint before denying access. + // With access_control configured, non-admin users may log in: + // they get team-scoped (possibly zero) permissions and + // default-deny protects everything else. Without it, the + // admin role remains the only thing standing between an IdP + // account and full cluster access, so keep the historical gate. adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles() if len(adminRoles) > 0 { if !authService.IsAdmin(userInfo) { @@ -249,7 +258,7 @@ func SetupRoutes( userInfo.Roles = ui.Roles } } - if !authService.IsAdmin(userInfo) { + if cfg.AccessControl == nil && !authService.IsAdmin(userInfo) { logger.Warn(). Str("username", userInfo.Username). Strs("required_roles", adminRoles). @@ -261,6 +270,19 @@ func SetupRoutes( } } + // Teams follow the same claim-location fallbacks as roles. + if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 { + if teams := authService.ExtractTeamsFromAccessToken(token.AccessToken); len(teams) > 0 { + userInfo.Teams = teams + } + } + if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 { + if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Teams) > 0 { + userInfo.Teams = ui.Teams + } + } + userInfo.AuthMethod = "oidc" + // Generate JWT session token sessionToken, err := authService.GenerateSessionToken(userInfo) if err != nil { diff --git a/backend/internal/routes/routes_authz_test.go b/backend/internal/routes/routes_authz_test.go new file mode 100644 index 0000000..5b75c46 --- /dev/null +++ b/backend/internal/routes/routes_authz_test.go @@ -0,0 +1,231 @@ +package routes + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/authz" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing +// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled +// so every /api/v1 route is reachable), and returns the *fiber.App directly +// for route-table inspection. +func newTestAppWithAuthz(t *testing.T) *fiber.App { + t.Helper() + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + return f.App +} + +// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee: +// a new /api/v1 route without an authz.Require declaration fails this test +// (and would also refuse to boot via the same check in main). +func TestEveryAPIRouteDeclaresPermission(t *testing.T) { + app := newTestAppWithAuthz(t) + if err := authz.VerifyRouteCoverage(app); err != nil { + t.Fatalf("route coverage: %v", err) + } +} + +// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED +// access-control policy (one team, claim "g-t", bucket prefix "allowed-", +// permissions bucket.list + object.list + object.read) and returns the +// fixture plus a Bearer session token for a member of that team. Admin auth +// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts +// the signed AuthMethod claim ("oidc"), so the user resolves through the +// team policy, not as the synthetic admin. +func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) { + t.Helper() + + cfg := &config.Config{ + Server: config.ServerConfig{ + Port: 8080, + Environment: "test", + }, + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{ + Enabled: true, + Username: "admin", + Password: "pw", + }, + }, + CORS: config.CORSConfig{}, + AccessControl: &config.AccessControlConfig{ + Teams: []config.TeamConfig{{ + Name: "team-t", + ClaimValues: []string{"g-t"}, + Bindings: []config.BindingConfig{{ + BucketPrefixes: []string{"allowed-"}, + Permissions: []string{"bucket.list", "object.list", "object.read"}, + }}, + }}, + }, + } + + svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + policy, err := authz.CompilePolicy(cfg.AccessControl) + if err != nil { + t.Fatalf("CompilePolicy: %v", err) + } + az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer()) + + admin := &mocks.AdminMock{} + s3 := &mocks.S3Mock{} + + app := fiber.New() + SetupRoutes( + app, + cfg, + svc, + handlers.NewHealthHandler("test"), + handlers.NewBucketHandler(admin, s3), + handlers.NewObjectHandler(s3), + handlers.NewUserHandler(admin), + handlers.NewClusterHandler(admin), + handlers.NewMonitoringHandler(admin, s3), + handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false), + az, + ) + + token, err := svc.GenerateSessionToken(&auth.UserInfo{ + Username: "team-user", + Email: "team-user@example.com", + Teams: []string{"g-t"}, + AuthMethod: "oidc", + }) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token +} + +// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber +// behavior the wildcard wiring relies on: the api group's .Use() middlewares +// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard +// object routes registered directly on app, so those routes do NOT repeat +// ResolveSubject themselves. If the cascade ever stopped covering them, the +// allowed-bucket request below would 403 with reason no_subject instead of +// reaching the handler. +func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) { + f, token := newEnabledPolicyFixture(t) + + f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil + } + + do := func(method, path string) int { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test(%s %s): %v", method, path, err) + } + return resp.StatusCode + } + + // In-scope bucket + held permission (object.read) → the request passes + // Require and reaches the handler. This is the discriminating assertion: + // it can only succeed if the group-level ResolveSubject ran for this + // wildcard route (no subject → Require denies everything). + if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 { + t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code) + } + + // Out-of-scope bucket → default deny. + if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 { + t.Errorf("GET denied bucket: status = %d, want 403", code) + } + + // DELETE requires object.delete, which the team does not hold, so it is denied + // even on an in-scope bucket. + if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 { + t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code) + } + + // HEAD wildcard is denied on an out-of-scope bucket too. + if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 { + t.Errorf("HEAD denied bucket: status = %d, want 403", code) + } +} + +// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the +// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it +// drives GET /api/v1/buckets through the full authz-wired route (not just the +// handler in isolation) for a team-scoped session, with the mocked admin +// service returning buckets both inside and outside the team's "allowed-" +// prefix. Only in-scope buckets should come back, each carrying the caller's +// effective_permissions. +func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) { + f, token := newEnabledPolicyFixture(t) + + f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return []models.ListBucketsResponseItem{ + {ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}}, + {ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}}, + {ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}}, + }, nil + } + f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil + } + + req := httptest.NewRequest("GET", "/api/v1/buckets", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body struct { + Data models.BucketListResponse `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Data.Count != 2 { + t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets) + } + + seen := map[string]bool{} + for _, b := range body.Data.Buckets { + seen[b.Name] = true + if !strings.HasPrefix(b.Name, "allowed-") { + t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name) + } + if len(b.EffectivePermissions) == 0 { + t.Errorf("bucket %q: effective_permissions missing", b.Name) + } + } + if !seen["allowed-a"] || !seen["allowed-b"] { + t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets) + } + if seen["denied-x"] { + t.Error("denied-x should not be visible to a team without bucket.list on that prefix") + } +} diff --git a/backend/internal/routes/routes_teams_test.go b/backend/internal/routes/routes_teams_test.go new file mode 100644 index 0000000..902aeb6 --- /dev/null +++ b/backend/internal/routes/routes_teams_test.go @@ -0,0 +1,103 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/config" +) + +// newOIDCTeamFixture builds an OIDC-enabled fixture with team_attribute_path +// set and no admin-role gate, so a non-admin user can complete the callback +// and have their teams resolved. +func newOIDCTeamFixture(t *testing.T, teamPath string) (*routeFixture, *testIssuer) { + t.Helper() + iss := newTestIssuer(t) + f := newTestApp(t, func(c *config.Config) { + c.Server.RootURL = "https://app.example" + c.Auth.OIDC = config.OIDCConfig{ + Enabled: true, + ClientID: iss.ClientID, + ClientSecret: "secret", + IssuerURL: iss.Server.URL, + Scopes: []string{"openid", "profile", "email"}, + AdminRole: "", // no role gate: non-admin OIDC users may log in + UsernameAttribute: "preferred_username", + EmailAttribute: "email", + NameAttribute: "name", + RoleAttributePath: "resource_access.test-client.roles", + TeamAttributePath: teamPath, + CookieName: "session", + CookieHTTPOnly: true, + CookieSameSite: "Lax", + SessionMaxAge: 3600, + } + }) + return f, iss +} + +// runCallback drives the OIDC callback happy path and returns the resolved +// session's user info (decoded from the session cookie). +func runCallbackTeams(t *testing.T, f *routeFixture) []string { + t.Helper() + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("callback: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("callback status = %d, want 303", resp.StatusCode) + } + var sess *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session" && c.Value != "" { + sess = c + } + } + if sess == nil { + t.Fatal("no session cookie in callback response") + } + info, err := f.Auth.ValidateSessionToken(sess.Value) + if err != nil { + t.Fatalf("ValidateSessionToken: %v", err) + } + return info.Teams +} + +func TestRoutes_OIDCCallback_TeamsFromIDToken(t *testing.T) { + f, iss := newOIDCTeamFixture(t, "groups") + // The verified ID token carries the team claim directly. + iss.DefaultIDClaims["groups"] = []any{"garage-team-backend"} + + teams := runCallbackTeams(t, f) + if len(teams) != 1 || teams[0] != "garage-team-backend" { + t.Errorf("Teams = %v, want [garage-team-backend] from the ID token", teams) + } +} + +func TestRoutes_OIDCCallback_TeamsFromAccessTokenFallback(t *testing.T) { + f, iss := newOIDCTeamFixture(t, "groups") + // ID token carries no team claim; the access token does. + iss.DefaultAccessClaims["groups"] = []any{"garage-team-data"} + + teams := runCallbackTeams(t, f) + if len(teams) != 1 || teams[0] != "garage-team-data" { + t.Errorf("Teams = %v, want [garage-team-data] from the access-token fallback", teams) + } +} + +func TestRoutes_OIDCCallback_TeamsFromUserInfoFallback(t *testing.T) { + f, iss := newOIDCTeamFixture(t, "groups") + // Neither token carries the team claim; only the userinfo endpoint does. + iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"user-1","preferred_username":"alice","email":"alice@example.com","groups":["garage-team-ops"]}`)) + } + + teams := runCallbackTeams(t, f) + if len(teams) != 1 || teams[0] != "garage-team-ops" { + t.Errorf("Teams = %v, want [garage-team-ops] from the userinfo fallback", teams) + } +} diff --git a/backend/internal/routes/routes_test.go b/backend/internal/routes/routes_test.go index e05e5be..fa250e5 100644 --- a/backend/internal/routes/routes_test.go +++ b/backend/internal/routes/routes_test.go @@ -12,6 +12,7 @@ import ( "time" "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" "Noooste/garage-ui/internal/services" @@ -57,6 +58,12 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { admin := &mocks.AdminMock{} s3 := &mocks.S3Mock{} + policy, err := authz.CompilePolicy(nil) + if err != nil { + t.Fatalf("CompilePolicy: %v", err) + } + az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer()) + app := fiber.New() SetupRoutes( app, @@ -68,7 +75,8 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { handlers.NewUserHandler(admin), handlers.NewClusterHandler(admin), handlers.NewMonitoringHandler(admin, s3), - handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()), + handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false), + az, ) return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg} diff --git a/backend/main.go b/backend/main.go index 432c677..daa1458 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,6 +11,7 @@ import ( "time" "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/authz" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" appmw "Noooste/garage-ui/internal/middleware" @@ -115,7 +116,7 @@ func main() { logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API") } adminService := adminResult.Service - capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities) + capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities, cfg.AccessControl != nil) logger.Info().Msg("Initializing S3 service") s3Service := services.NewS3Service(&cfg.Garage, adminService) @@ -140,6 +141,15 @@ func main() { logger.Fatal().Err(err).Msg("Failed to initialize auth service") } + policy, err := authz.CompilePolicy(cfg.AccessControl) + if err != nil { + logger.Fatal().Err(err).Msg("Invalid access_control configuration") + } + if cfg.AccessControl != nil && !cfg.Auth.OIDC.Enabled { + logger.Warn().Msg("access_control is configured but OIDC is disabled: admin and token logins are always full-admin in v1, so the policy currently gates nothing") + } + azMiddleware := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, cfg.Auth.OIDC.EffectiveAdminRoles()), authz.NewAuthorizer()) + // Initialize handlers healthHandler := handlers.NewHealthHandler(version) bucketHandler := handlers.NewBucketHandler(adminService, s3Service) @@ -212,8 +222,13 @@ func main() { clusterHandler, monitoringHandler, capabilitiesHandler, + azMiddleware, ) + if err := authz.VerifyRouteCoverage(app); err != nil { + logger.Fatal().Err(err).Msg("authz route coverage check failed") + } + // Start server in a goroutine go func() { addr := cfg.GetAddress() diff --git a/config.example.yaml b/config.example.yaml index b137744..81bc6b1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -75,6 +75,8 @@ auth: # Role-based access (optional) role_attribute_path: "resource_access.garage-ui.roles" + # Team-based access control (optional, see access_control below). + # team_attribute_path: "groups" # Single admin role (backward-compatible). admin_role: "admin" # Multiple admin roles: a user is granted admin if ANY of their roles @@ -94,6 +96,29 @@ auth: cookie_http_only: true cookie_same_site: "lax" # lax, strict, none +# Optional: team-based access control (issue #33). +# Absent -> every authenticated user has full access (historical behavior). +# Present -> default-deny: OIDC users get only what their teams grant; users +# matching no team get 403 everywhere. admin_role users, admin +# password logins, and token logins are always full-admin in v1. +# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the +# Garage admin token or S3 keys bypasses it entirely. +# +# access_control: +# presets: +# bucket_readonly: [bucket.list, bucket.read, object.list, object.read] +# bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update, +# bucket.delete, object.write, object.delete] +# teams: +# - name: backend +# claim_values: ["garage-team-backend"] # matched against team_attribute_path claim +# bindings: +# - bucket_prefixes: ["backend-"] +# permissions: ["preset:bucket_owner"] +# - bucket_prefixes: ["shared-"] +# permissions: ["preset:bucket_readonly"] +# cluster_permissions: [cluster.status, cluster.health] + # CORS Configuration (for frontend) cors: enabled: true diff --git a/docs/access-control.md b/docs/access-control.md new file mode 100644 index 0000000..031b9b5 --- /dev/null +++ b/docs/access-control.md @@ -0,0 +1,170 @@ +# Multi-User Access Control + +Garage UI can limit what each user sees and does, based on the teams in their OIDC claims. + +It's optional. With no config, every authenticated user has full access, exactly like before. + +## Not a security boundary + +Read this before using access control for anything sensitive. + +Garage UI talks to Garage with one admin token and one set of S3 keys. Access control lives in the UI only; Garage itself does not enforce it. Anyone holding the underlying admin token or raw S3 keys bypasses it completely. + +Use it to give teams a convenient, scoped UI. Don't use it as a replacement for real per-tenant credentials or network isolation. + +## Configuration + +Two settings drive access control: + +1. `team_attribute_path`: the OIDC claim that lists a user's teams. +2. `access_control`: maps teams to permissions. + +```yaml +auth: + oidc: + # Existing keys unchanged. New: + team_attribute_path: "groups" # go-jmespath, same convention as role_attribute_path + +access_control: # absent = full access for everyone; present = default-deny + presets: + bucket_readonly: [bucket.list, bucket.read, object.list, object.read] + bucket_owner: ["preset:bucket_readonly", bucket.create, bucket.update, + bucket.delete, object.write, object.delete] + teams: + - name: backend + claim_values: ["garage-team-backend"] # matched against the team_attribute_path claim + bindings: + - bucket_prefixes: ["backend-"] + permissions: ["preset:bucket_owner"] + - bucket_prefixes: ["shared-"] + permissions: ["preset:bucket_readonly"] + cluster_permissions: [cluster.status, cluster.health] +``` + +A few things to know: + +- `team_attribute_path` is a [go-jmespath](https://github.com/jmespath/go-jmespath) expression evaluated against the OIDC claims, the same way `role_attribute_path` works. It's required when `access_control.teams` is set and OIDC is on. If it's missing, startup fails with a clear error. +- `access_control` can only be set in the config file. There's no environment variable for it, because nested team and binding lists don't fit flat `GARAGE_UI_*` variables. +- If `access_control` is present but OIDC is off, the server still starts but logs a warning. Without OIDC users the policy gates nothing, since admin-password and token logins are always full admin (see [Admin model](#admin-model)). + +## How it works + +### Default-deny + +With `access_control` set, an OIDC user who matches no team gets a 403 on every `/api/v1` endpoint. The one exception is `GET /api/v1/capabilities`, which returns their (empty) permissions so the frontend can show a "no access" screen. + +### Union of teams + +A user who matches several teams gets everything those teams grant. + +Bindings stay separate, though. Say one binding grants `read` on `backend-*` and another grants `write` on `data-*`. The user does not end up with both permissions on both prefixes. Each binding keeps its own prefixes and its own permissions. + +### Prefix match + +`bucket_prefixes` are plain string prefixes on bucket names (no globbing on the name itself). Use `"*"` to match every bucket. + +### Presets + +Reference a preset with the `preset:` prefix inside any `permissions` or `cluster_permissions` list, for example `"preset:bucket_owner"`. Presets can reference other presets. Unknown references and cycles both fail startup. + +### Permission globs + +A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when config loads. Use scoped globs: + +- `bucket.*`, `object.*` inside a binding's `permissions` +- `cluster.*`, `node.*`, `worker.*`, `block.*` under `cluster_permissions` + +A bare `*` is technically a glob, but it almost always fails validation: it mixes prefix-scoped and global-scoped permissions, and a permission placed in the wrong scope is rejected at startup. Globs never include admin-only permissions, and in v1 there's no team-level way to grant those. + +### Admin model + +These identities become a synthetic admin: + +- OIDC users with a configured `admin_role` / `admin_roles` +- all non-OIDC logins (admin-password, Garage admin token) + +An admin gets every permission on every bucket, plus every cluster permission. Admins run through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check. + +### Startup validation + +The server refuses to start when the policy is invalid: an unknown permission, an unknown or cyclic preset, an admin-only permission granted to a team, a duplicate team name, a team with empty `claim_values`, or a team with no bindings and no cluster permissions. + +It also refuses to start if any `/api/v1` route has no declared permission, so a route can never ship un-gated (see [Troubleshooting](#troubleshooting)). + +## Not in v1 + +- **No non-OIDC team mapping.** Admin-password and Garage-admin-token logins are always full admin. Only OIDC users can be scoped to a team. +- **`ListKeys` is not filtered.** Anyone with `key.list` sees every access key. Everything past `key.list` / `key.read` is admin-only (`key.read_secret`, `key.create`, `key.import`, `key.update`, `key.delete`). +- **No `admin_token.*` permissions.** Direct access to the raw Garage admin token is admin-only and not part of the vocabulary. +- **No ABAC, policy language, database-backed policy, or per-user grants.** Policy is YAML, compiled once at startup. + +## Permission vocabulary (v1) + +Permission names are lowercase and dot-separated: two segments, or three for `cluster.layout.*`. The source of truth is `backend/internal/authz/vocabulary.go`. This table mirrors it by hand, and there's no doc generation in v1, so update the table whenever you change the registry. + +| Permission | Scope | Admin-only v1 | Garage endpoint / backing | +|---|---|---|---| +| `bucket.list` | prefix | | ListBuckets (response-filtered) | +| `bucket.read` | prefix | | GetBucketInfo | +| `bucket.create` | prefix | | CreateBucket (new name must match a prefix) | +| `bucket.update` | prefix | | UpdateBucket | +| `bucket.delete` | prefix | | DeleteBucket | +| `bucket.cleanup_uploads` | prefix | | CleanupIncompleteUploads | +| `bucket.inspect_object` | prefix | | InspectObject | +| `bucket_alias.add` | prefix | | AddBucketAlias | +| `bucket_alias.remove` | prefix | | RemoveBucketAlias | +| `object.list` | prefix | | S3 data plane (ListObjects) | +| `object.read` | prefix | | S3 data plane (Get/Head/Metadata/Presign; presign is download-only) | +| `object.write` | prefix | | S3 data plane (Upload, CreateDirectory) | +| `object.delete` | prefix | | S3 data plane (Delete, DeleteMultiple) | +| `permission.allow_bucket_key` | prefix | | AllowBucketKey | +| `permission.deny_bucket_key` | prefix | | DenyBucketKey | +| `key.list` | global | | ListKeys (unfiltered in v1; grantee sees all keys) | +| `key.read` | global | | GetKeyInfo (without secret) | +| `key.read_secret` | global | yes | GetKeyInfo with secret material | +| `key.create` | global | yes | CreateKey | +| `key.import` | global | yes | ImportKey | +| `key.update` | global | yes | UpdateKey | +| `key.delete` | global | yes | DeleteKey | +| `cluster.status` | global | | GetClusterStatus | +| `cluster.health` | global | | GetClusterHealth | +| `cluster.statistics` | global | | GetClusterStatistics | +| `cluster.connect_nodes` | global | | ConnectClusterNodes | +| `cluster.layout.read` | global | | GetClusterLayout | +| `cluster.layout.history` | global | | GetClusterLayoutHistory | +| `cluster.layout.apply` | global | | ApplyClusterLayout | +| `cluster.layout.skip_dead_nodes` | global | | ClusterLayoutSkipDeadNodes | +| `node.info` | global | | GetNodeInfo | +| `node.statistics` | global | | GetNodeStatistics | +| `node.snapshot` | global | | CreateMetadataSnapshot | +| `node.repair` | global | | LaunchRepairOperation | +| `worker.list` | global | | ListWorkers | +| `worker.info` | global | | GetWorkerInfo | +| `worker.get_variable` | global | | GetWorkerVariable | +| `worker.set_variable` | global | | SetWorkerVariable | +| `block.list_errors` | global | | ListBlockErrors | +| `block.info` | global | | GetBlockInfo | + +Some permissions have no UI route yet: `bucket.cleanup_uploads`, `bucket.inspect_object`, `bucket_alias.*`, `cluster.layout.*`, `worker.*`, `block.*`, `cluster.connect_nodes`, `node.snapshot`, `node.repair`, and `key.import`. They're valid in config but don't gate anything in the UI yet. The vocabulary is complete up front so your config keeps working as the UI grows. + +Dangerous operations (`cluster.layout.apply`, `node.repair`, `worker.set_variable`) are separate, individually grantable permissions. They're never bundled into a read-only preset, so you can give a team cluster visibility without also giving it the power to break the cluster. + +`POST /api/v1/buckets/:name/permissions` sets permissions by doing an allow and a deny in one call, so it needs **both** `permission.allow_bucket_key` and `permission.deny_bucket_key`. One of the two alone is not enough. + +## Troubleshooting + +**A user gets 403s they shouldn't.** Open `GET /api/v1/capabilities` while logged in as that user. The `access_control` block shows their resolved `bindings` and `cluster_permissions` (empty arrays mean they matched no team). Check that the IdP actually sends the claim named by `team_attribute_path`, and that its values match a team's `claim_values` exactly (string match, no wildcards on the claim value). + +**403 responses name the missing permission.** The message is `Missing permission: `. That's the exact permission that was denied, so you know which binding, preset, or `cluster_permissions` entry to add. + +**Decision logs.** Every check logs one line, `authz_decision`, with fields `subject`, `action`, `resource`, `decision` (`allow` / `deny`), and `reason` (such as `binding_match`, `any_binding`, `no_matching_binding`, `cluster_permission`, `no_cluster_permission`, `no_subject`). Denials log at `warn`, allows at `debug`. Set `logging.level` to `debug` to see successful checks too. There's no separate audit log in v1; this goes through the normal application logger. + +**Startup fails with `access_control: ...` or `authz: routes without Require permission declaration: ...`.** Both are intentional fail-closed checks, not bugs: + +- An invalid policy (unknown permission, bad preset reference, admin-only permission handed to a team, duplicate team name, empty `claim_values`, or a team with no bindings or cluster permissions) stops startup with an error naming the problem. +- A `/api/v1` route wired without a permission requirement also stops startup. This is a build-time safety net, not something you trigger by editing config, but it can show up after a `git pull` that adds a route without its enforcement wiring. + +## See also + +- [config.example.yaml](../config.example.yaml): the full commented `access_control` example. +- [garage-setup.md](garage-setup.md): general Garage UI and Garage setup. diff --git a/frontend/src/components/buckets/BucketListView.tsx b/frontend/src/components/buckets/BucketListView.tsx index 65e089a..84fc4c9 100644 --- a/frontend/src/components/buckets/BucketListView.tsx +++ b/frontend/src/components/buckets/BucketListView.tsx @@ -12,6 +12,7 @@ import { import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react'; import { formatBytes } from '@/lib/file-utils'; import { formatDate } from '@/lib/utils'; +import { useBucketCan } from '@/hooks/usePermissions'; import type { Bucket } from '@/types'; interface BucketListViewProps { @@ -35,6 +36,7 @@ export function BucketListView({ onDeleteBucket, onWebsiteSettings, }: BucketListViewProps) { + const canBucket = useBucketCan(); const filteredBuckets = buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchQuery.toLowerCase()) ); @@ -118,31 +120,39 @@ export function BucketListView({ View Objects - { - e.stopPropagation(); - onOpenSettings(bucket); - }}> - - Settings - - { - e.stopPropagation(); - onWebsiteSettings(bucket); - }}> - - Website Settings - - - { - e.stopPropagation(); - onDeleteBucket(bucket); - }} - > - - Delete - + {canBucket(bucket, 'bucket.update') && ( + <> + { + e.stopPropagation(); + onOpenSettings(bucket); + }}> + + Settings + + { + e.stopPropagation(); + onWebsiteSettings(bucket); + }}> + + Website Settings + + + )} + {canBucket(bucket, 'bucket.delete') && ( + <> + + { + e.stopPropagation(); + onDeleteBucket(bucket); + }} + > + + Delete + + + )} diff --git a/frontend/src/components/buckets/ObjectBrowserView.tsx b/frontend/src/components/buckets/ObjectBrowserView.tsx index 7ae6d72..ef82886 100644 --- a/frontend/src/components/buckets/ObjectBrowserView.tsx +++ b/frontend/src/components/buckets/ObjectBrowserView.tsx @@ -25,11 +25,11 @@ interface ObjectBrowserViewProps { onDeepSearchChange: (enabled: boolean) => void; onNavigateToFolder: (path: string) => void; onBackToBuckets: () => void; - onUploadFiles: (files: File[]) => Promise; + onUploadFiles?: (files: File[]) => Promise; uploadTasks: UploadTask[]; - onDeleteObject: (key: string) => Promise; - onDeleteMultipleObjects: (keys: string[]) => Promise; - onCreateDirectory: (name: string) => Promise; + onDeleteObject?: (key: string) => Promise; + onDeleteMultipleObjects?: (keys: string[]) => Promise; + onCreateDirectory?: (name: string) => Promise; onRefresh: () => Promise; onPageChange: (token?: string) => void; onItemsPerPageChange: (count: number) => void; @@ -75,6 +75,8 @@ export function ObjectBrowserView({ const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop: async (acceptedFiles, _fileRejections, event) => { + if (!onUploadFiles) return; + // Get files with their full paths from DataTransferItems API const filesWithPaths: File[] = []; @@ -101,6 +103,7 @@ export function ObjectBrowserView({ setShowUploadZone(false); }, noClick: true, + disabled: !onUploadFiles, }); // Helper function to traverse file/directory tree @@ -153,13 +156,14 @@ export function ObjectBrowserView({ }; const handleBulkDeleteFiles = async () => { - if (selectedFileKeys.size === 0) return; + if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return; await onDeleteMultipleObjects(Array.from(selectedFileKeys)); setSelectedFileKeys(new Set()); }; const handleDeleteObject = async (key: string): Promise => { + if (!onDeleteObject) return false; const success = await onDeleteObject(key); if (success) { setDeleteObjectDialogOpen(false); @@ -169,6 +173,7 @@ export function ObjectBrowserView({ }; const uploadFiles = async (files: File[]) => { + if (!onUploadFiles) return; await onUploadFiles(files); setShowUploadZone(false); }; @@ -232,7 +237,7 @@ export function ObjectBrowserView({
- {selectedFileKeys.size > 0 && ( + {onDeleteMultipleObjects && selectedFileKeys.size > 0 && ( - + {onUploadFiles && ( + + )} + {onCreateDirectory && ( + + )} @@ -257,7 +266,7 @@ export function ObjectBrowserView({
{/* Upload Zone */} - {showUploadZone && uploadTasks.length === 0 && ( + {onUploadFiles && showUploadZone && uploadTasks.length === 0 && (
@@ -384,10 +393,10 @@ export function ObjectBrowserView({ nextContinuationToken={nextContinuationToken} itemsPerPage={itemsPerPage} onNavigateToFolder={onNavigateToFolder} - onDeleteObject={(obj) => { + onDeleteObject={onDeleteObject ? (obj) => { setSelectedObject(obj); setDeleteObjectDialogOpen(true); - }} + } : undefined} onToggleFileSelection={handleToggleFileSelection} onSelectAllFiles={handleSelectAllFiles} onPageChange={onPageChange} @@ -399,12 +408,14 @@ export function ObjectBrowserView({
{/* Create Directory Dialog */} - + {onCreateDirectory && ( + + )} {/* Delete Object Dialog */} b.name === bucketName); + const canBucket = useBucketCan(); + const canDelete = canBucket(bucket, 'object.delete'); + const [metadata, setMetadata] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -161,9 +168,11 @@ export function ObjectDetailsView() { - + {canDelete && ( + + )}
diff --git a/frontend/src/components/buckets/ObjectsTable.tsx b/frontend/src/components/buckets/ObjectsTable.tsx index 20e4b2c..07362e6 100644 --- a/frontend/src/components/buckets/ObjectsTable.tsx +++ b/frontend/src/components/buckets/ObjectsTable.tsx @@ -31,7 +31,7 @@ interface ObjectsTableProps { nextContinuationToken?: string; itemsPerPage: number; onNavigateToFolder: (key: string) => void; - onDeleteObject: (object: S3Object) => void; + onDeleteObject?: (object: S3Object) => void; onToggleFileSelection: (key: string) => void; onSelectAllFiles: () => void; onPageChange: (token?: string) => void; @@ -66,6 +66,7 @@ export function ObjectsTable({ initialItemsPerPage, }: ObjectsTableProps) { const navigate = useNavigate(); + const canDelete = Boolean(onDeleteObject); const [sortColumn, setSortColumn] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); // Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...] @@ -214,16 +215,18 @@ export function ObjectsTable({ - - !obj.isFolder).length > 0 && - selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length - } - onCheckedChange={onSelectAllFiles} - aria-label="Select all files" - /> - + {canDelete && ( + + !obj.isFolder).length > 0 && + selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length + } + onCheckedChange={onSelectAllFiles} + aria-label="Select all files" + /> + + )} handleSort('name')} @@ -250,7 +253,7 @@ export function ObjectsTable({ {isLoading ? ( - +
Loading objects... @@ -259,7 +262,7 @@ export function ObjectsTable({ ) : filteredObjects.length === 0 ? ( - + {searchQuery ? 'No objects found matching your search' : isDragActive @@ -270,22 +273,24 @@ export function ObjectsTable({ ) : ( pageObjects.map((obj) => ( - - {obj.isFolder ? ( - - ) : ( - onToggleFileSelection(obj.key)} - aria-label={`Select file ${obj.key}`} - /> - )} - + {canDelete && ( + + {obj.isFolder ? ( + + ) : ( + onToggleFileSelection(obj.key)} + aria-label={`Select file ${obj.key}`} + /> + )} + + )}
{obj.isFolder ? ( @@ -390,14 +395,18 @@ export function ObjectsTable({ Download - - onDeleteObject(obj)} - > - - Delete - + {onDeleteObject && ( + <> + + onDeleteObject(obj)} + > + + Delete + + + )} )} diff --git a/frontend/src/components/layout/bucket-detail-shell.tsx b/frontend/src/components/layout/bucket-detail-shell.tsx index 4b25f27..1b5c6de 100644 --- a/frontend/src/components/layout/bucket-detail-shell.tsx +++ b/frontend/src/components/layout/bucket-detail-shell.tsx @@ -5,19 +5,21 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { useBuckets } from '@/hooks/useApi'; +import { useBucketCan } from '@/hooks/usePermissions'; import { toast } from 'sonner'; interface TabSpec { to: string; label: string; end?: boolean; + perms?: string[]; } const tabs: TabSpec[] = [ - { to: 'objects', label: 'Objects' }, - { to: 'permissions', label: 'Permissions' }, - { to: 'website', label: 'Website' }, - { to: 'settings', label: 'Settings' }, + { to: 'objects', label: 'Objects', perms: ['object.list'] }, + { to: 'permissions', label: 'Permissions', perms: ['permission.allow_bucket_key', 'permission.deny_bucket_key'] }, + { to: 'website', label: 'Website', perms: ['bucket.update'] }, + { to: 'settings', label: 'Settings', perms: ['bucket.update'] }, ]; function formatBytes(n?: number) { @@ -36,6 +38,8 @@ export function BucketDetailShell() { const { bucketName = '' } = useParams<{ bucketName: string }>(); const { data: buckets = [] } = useBuckets(); const bucket = buckets.find((b) => b.name === bucketName); + const canBucket = useBucketCan(); + const visibleTabs = tabs.filter((t) => !t.perms || t.perms.every((p) => canBucket(bucket, p))); const s3Url = `s3://${bucketName}`; const copyUrl = async () => { @@ -68,16 +72,18 @@ export function BucketDetailShell() { - + {canBucket(bucket, 'object.write') && ( + + )}
{/* Tabs */}