mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-30 08:49:29 +00:00
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
This commit is contained in:
@@ -65,5 +65,6 @@ backend/docs/
|
|||||||
config.yaml
|
config.yaml
|
||||||
docs/**/*.md
|
docs/**/*.md
|
||||||
!docs/garage-setup.md
|
!docs/garage-setup.md
|
||||||
|
!docs/access-control.md
|
||||||
|
|
||||||
**/worktrees
|
**/worktrees
|
||||||
@@ -30,6 +30,7 @@ A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garag
|
|||||||
- **Access key management** - create keys, assign per-bucket permissions
|
- **Access key management** - create keys, assign per-bucket permissions
|
||||||
- **Cluster overview** - monitor node status, layout configuration, and storage usage
|
- **Cluster overview** - monitor node status, layout configuration, and storage usage
|
||||||
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
|
- **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
|
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ type Service struct {
|
|||||||
|
|
||||||
// UserInfo represents authenticated user information
|
// UserInfo represents authenticated user information
|
||||||
type UserInfo struct {
|
type UserInfo struct {
|
||||||
Username string
|
Username string
|
||||||
Email string
|
Email string
|
||||||
Name string
|
Name string
|
||||||
Roles []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
|
// 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)
|
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.authConfig.OIDC.TeamAttributePath != "" {
|
||||||
|
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
|
||||||
|
}
|
||||||
|
|
||||||
return userInfo, nil
|
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)
|
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.authConfig.OIDC.TeamAttributePath != "" {
|
||||||
|
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
|
||||||
|
}
|
||||||
|
|
||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +262,33 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
|||||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
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.
|
// IsAdmin checks if the user has any of the configured admin roles.
|
||||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||||
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
||||||
@@ -388,9 +425,11 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &UserInfo{
|
return &UserInfo{
|
||||||
Username: claims.Username,
|
Username: claims.Username,
|
||||||
Email: claims.Email,
|
Email: claims.Email,
|
||||||
Name: claims.Name,
|
Name: claims.Name,
|
||||||
Roles: claims.Roles,
|
Roles: claims.Roles,
|
||||||
|
Teams: claims.Teams,
|
||||||
|
AuthMethod: claims.AuthMethod,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,12 @@ type StateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionClaims struct {
|
type SessionClaims struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
|
Teams []string `json:"teams,omitempty"`
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +162,12 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
|
|||||||
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
||||||
|
|
||||||
claims := SessionClaims{
|
claims := SessionClaims{
|
||||||
Username: userInfo.Username,
|
Username: userInfo.Username,
|
||||||
Email: userInfo.Email,
|
Email: userInfo.Email,
|
||||||
Name: userInfo.Name,
|
Name: userInfo.Name,
|
||||||
Roles: userInfo.Roles,
|
Roles: userInfo.Roles,
|
||||||
|
Teams: userInfo.Teams,
|
||||||
|
AuthMethod: userInfo.AuthMethod,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,12 @@ import (
|
|||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Garage GarageConfig `mapstructure:"garage"`
|
Garage GarageConfig `mapstructure:"garage"`
|
||||||
Auth AuthConfig `mapstructure:"auth"`
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
CORS CORSConfig `mapstructure:"cors"`
|
CORS CORSConfig `mapstructure:"cors"`
|
||||||
Logging LoggingConfig `mapstructure:"logging"`
|
Logging LoggingConfig `mapstructure:"logging"`
|
||||||
|
AccessControl *AccessControlConfig `mapstructure:"access_control"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig contains server-related configuration
|
// ServerConfig contains server-related configuration
|
||||||
@@ -81,6 +82,7 @@ type OIDCConfig struct {
|
|||||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||||
NameAttribute string `mapstructure:"name_attribute"`
|
NameAttribute string `mapstructure:"name_attribute"`
|
||||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||||
|
TeamAttributePath string `mapstructure:"team_attribute_path"`
|
||||||
AdminRole string `mapstructure:"admin_role"`
|
AdminRole string `mapstructure:"admin_role"`
|
||||||
AdminRoles []string `mapstructure:"admin_roles"`
|
AdminRoles []string `mapstructure:"admin_roles"`
|
||||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||||
@@ -130,6 +132,33 @@ type LoggingConfig struct {
|
|||||||
Format string `mapstructure:"format"`
|
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.
|
// LoadOption configures optional behaviour of Load.
|
||||||
type LoadOption func(*loadOptions)
|
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)
|
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
|
// Validate the configuration
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
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.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
|
||||||
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_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.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_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
|
||||||
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
|
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")
|
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 {
|
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||||
}
|
}
|
||||||
// Every authenticated route on this service grants full admin
|
// With access_control configured, default-deny protects unmatched
|
||||||
// access — there is no separate authorization layer. Empty
|
// users, so admin roles become optional. Without it, every
|
||||||
// admin role configuration would therefore promote every user
|
// authenticated route grants full admin access, so an empty admin
|
||||||
// in the IdP realm to cluster admin. Require operators to opt
|
// role list would promote every IdP user to cluster admin.
|
||||||
// in explicitly via admin_role or admin_roles.
|
if c.AccessControl == nil && len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
|
||||||
if 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")
|
||||||
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")
|
}
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -329,6 +329,17 @@ func TestValidate(t *testing.T) {
|
|||||||
mutate: applyValidOIDC,
|
mutate: applyValidOIDC,
|
||||||
wantErrContains: "",
|
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",
|
name: "oidc disabled ignores missing client_id",
|
||||||
mutate: func(c *Config) {
|
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) {
|
func TestIsProduction(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
env string
|
env string
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
|||||||
|
|
||||||
// Create user info object
|
// Create user info object
|
||||||
userInfo := &auth.UserInfo{
|
userInfo := &auth.UserInfo{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
|
AuthMethod: "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT session token
|
// Generate JWT session token
|
||||||
@@ -135,7 +136,8 @@ func (h *AuthHandler) LoginToken(c fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userInfo := &auth.UserInfo{
|
userInfo := &auth.UserInfo{
|
||||||
Username: "admin-token",
|
Username: "admin-token",
|
||||||
|
AuthMethod: "token",
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
|
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/models"
|
"Noooste/garage-ui/internal/models"
|
||||||
"Noooste/garage-ui/internal/services"
|
"Noooste/garage-ui/internal/services"
|
||||||
|
|
||||||
@@ -80,6 +81,10 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
|||||||
buckets = append(buckets, bucketInfo)
|
buckets = append(buckets, bucketInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if subj, ok := authz.SubjectFrom(c); ok {
|
||||||
|
buckets = filterBucketsForSubject(buckets, subj)
|
||||||
|
}
|
||||||
|
|
||||||
response := models.BucketListResponse{
|
response := models.BucketListResponse{
|
||||||
Buckets: buckets,
|
Buckets: buckets,
|
||||||
Count: len(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))
|
return c.JSON(models.SuccessResponse(bucketInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,3 +500,18 @@ func (h *BucketHandler) UpdateBucketQuotas(c fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(models.SuccessResponse(result))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/models"
|
"Noooste/garage-ui/internal/models"
|
||||||
"Noooste/garage-ui/internal/services"
|
"Noooste/garage-ui/internal/services"
|
||||||
|
|
||||||
@@ -8,20 +11,64 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CapabilitiesHandler struct {
|
type CapabilitiesHandler struct {
|
||||||
apiVersion string
|
apiVersion string
|
||||||
capabilities services.Capabilities
|
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{
|
return &CapabilitiesHandler{
|
||||||
apiVersion: apiVersion,
|
apiVersion: apiVersion,
|
||||||
capabilities: capabilities,
|
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 {
|
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{
|
return c.JSON(models.SuccessResponse(fiber.Map{
|
||||||
"garageApiVersion": h.apiVersion,
|
"garageApiVersion": h.apiVersion,
|
||||||
"features": h.capabilities,
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/services"
|
"Noooste/garage-ui/internal/services"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -13,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func TestCapabilities_V2(t *testing.T) {
|
func TestCapabilities_V2(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2())
|
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
|
||||||
app.Get("/capabilities", h.GetCapabilities)
|
app.Get("/capabilities", h.GetCapabilities)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||||
@@ -30,7 +32,7 @@ func TestCapabilities_V2(t *testing.T) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
GarageApiVersion string `json:"garageApiVersion"`
|
GarageApiVersion string `json:"garageApiVersion"`
|
||||||
Features services.Capabilities `json:"features"`
|
Features services.Capabilities `json:"features"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -50,7 +52,7 @@ func TestCapabilities_V2(t *testing.T) {
|
|||||||
|
|
||||||
func TestCapabilities_V1(t *testing.T) {
|
func TestCapabilities_V1(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1())
|
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1(), false)
|
||||||
app.Get("/capabilities", h.GetCapabilities)
|
app.Get("/capabilities", h.GetCapabilities)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||||
@@ -62,7 +64,7 @@ func TestCapabilities_V1(t *testing.T) {
|
|||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
GarageApiVersion string `json:"garageApiVersion"`
|
GarageApiVersion string `json:"garageApiVersion"`
|
||||||
Features services.Capabilities `json:"features"`
|
Features services.Capabilities `json:"features"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -76,3 +78,84 @@ func TestCapabilities_V1(t *testing.T) {
|
|||||||
t.Errorf("features = %+v, want all false", body.Data.Features)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ type GarageBucketInfo struct {
|
|||||||
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
||||||
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
||||||
Quotas *BucketQuotas `json:"quotas,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BucketWebsiteConfig represents website configuration for a bucket
|
// BucketWebsiteConfig represents website configuration for a bucket
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ type BucketInfo struct {
|
|||||||
WebsiteAccess bool `json:"websiteAccess"`
|
WebsiteAccess bool `json:"websiteAccess"`
|
||||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||||
Quotas *BucketQuotas `json:"quotas,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
|
// BucketListResponse represents a list of buckets
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Noooste/garage-ui/internal/auth"
|
"Noooste/garage-ui/internal/auth"
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/config"
|
"Noooste/garage-ui/internal/config"
|
||||||
"Noooste/garage-ui/internal/handlers"
|
"Noooste/garage-ui/internal/handlers"
|
||||||
"Noooste/garage-ui/internal/middleware"
|
"Noooste/garage-ui/internal/middleware"
|
||||||
@@ -30,6 +31,7 @@ func SetupRoutes(
|
|||||||
clusterHandler *handlers.ClusterHandler,
|
clusterHandler *handlers.ClusterHandler,
|
||||||
monitoringHandler *handlers.MonitoringHandler,
|
monitoringHandler *handlers.MonitoringHandler,
|
||||||
capabilitiesHandler *handlers.CapabilitiesHandler,
|
capabilitiesHandler *handlers.CapabilitiesHandler,
|
||||||
|
az *authz.Middleware,
|
||||||
) {
|
) {
|
||||||
// Apply CORS middleware globally
|
// Apply CORS middleware globally
|
||||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||||
@@ -53,31 +55,34 @@ func SetupRoutes(
|
|||||||
// Apply authentication middleware to all API routes
|
// Apply authentication middleware to all API routes
|
||||||
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
|
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)
|
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
|
||||||
|
|
||||||
// Bucket routes
|
// Bucket routes
|
||||||
buckets := api.Group("/buckets")
|
buckets := api.Group("/buckets")
|
||||||
{
|
{
|
||||||
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
|
buckets.Get("/", az.Require(authz.ScopeNone, authz.PermBucketList), bucketHandler.ListBuckets) // List all buckets
|
||||||
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
|
buckets.Post("/", az.Require(authz.BucketFromBody(), authz.PermBucketCreate), bucketHandler.CreateBucket) // Create a new bucket
|
||||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
buckets.Get("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketRead), bucketHandler.GetBucketInfo) // Get bucket info
|
||||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
buckets.Delete("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketDelete), bucketHandler.DeleteBucket) // Delete a bucket
|
||||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
buckets.Post("/:name/permissions", az.Require(authz.BucketFromParam("name"), authz.PermAllowBucketKey, authz.PermDenyBucketKey), bucketHandler.GrantBucketPermission) // Grant bucket permissions (allow+deny)
|
||||||
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
|
buckets.Put("/:name/website", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
|
||||||
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
|
buckets.Put("/:name/quotas", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketQuotas) // Update bucket quotas
|
||||||
}
|
}
|
||||||
|
|
||||||
// Object routes
|
// Object routes
|
||||||
objects := api.Group("/buckets/:bucket/objects")
|
objects := api.Group("/buckets/:bucket/objects")
|
||||||
{
|
{
|
||||||
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
objects.Get("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectList), objectHandler.ListObjects) // List objects in bucket
|
||||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
objects.Post("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadObject) // Upload object (multipart)
|
||||||
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
|
objects.Post("/upload-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadMultipleObjects) // Upload multiple objects
|
||||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
objects.Post("/delete-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directory routes (zero-byte directory markers)
|
// 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
|
// Fiber v3 does not auto-decode wildcard params; fall back to the raw
|
||||||
// value when QueryUnescape fails.
|
// value when QueryUnescape fails.
|
||||||
@@ -114,38 +119,41 @@ func SetupRoutes(
|
|||||||
return objectHandler.GetObjectMetadata(c)
|
return objectHandler.GetObjectMetadata(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register with auth middleware
|
// Register with auth middleware. Although these routes live on app, not
|
||||||
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
|
// api, the api group's .Use() middlewares (AuthMiddleware, ResolveSubject)
|
||||||
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
|
// cascade onto them by path prefix, so ResolveSubject is not repeated here
|
||||||
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
|
// (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
|
// User/Key management routes
|
||||||
users := api.Group("/users")
|
users := api.Group("/users")
|
||||||
{
|
{
|
||||||
users.Get("/", userHandler.ListUsers) // List all users/keys
|
users.Get("/", az.Require(authz.ScopeNone, authz.PermKeyList), userHandler.ListUsers) // List all users/keys
|
||||||
users.Post("/", userHandler.CreateUser) // Create new user/key
|
users.Post("/", az.Require(authz.ScopeNone, authz.PermKeyCreate), userHandler.CreateUser) // Create new user/key
|
||||||
users.Get("/:access_key", userHandler.GetUser) // Get user info
|
users.Get("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyRead), userHandler.GetUser) // Get user info
|
||||||
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
|
users.Get("/:access_key/secret", az.Require(authz.ScopeNone, authz.PermKeyReadSecret), userHandler.GetUserSecretKey) // Get user secret key
|
||||||
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
|
users.Delete("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyDelete), userHandler.DeleteUser) // Delete user/key
|
||||||
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
|
users.Patch("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyUpdate), userHandler.UpdateUserPermissions) // Update user permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cluster management routes
|
// Cluster management routes
|
||||||
cluster := api.Group("/cluster")
|
cluster := api.Group("/cluster")
|
||||||
{
|
{
|
||||||
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
|
cluster.Get("/health", az.Require(authz.ScopeNone, authz.PermClusterHealth), clusterHandler.GetHealth) // Get cluster health
|
||||||
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
|
cluster.Get("/status", az.Require(authz.ScopeNone, authz.PermClusterStatus), clusterHandler.GetStatus) // Get cluster status
|
||||||
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
|
cluster.Get("/statistics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), clusterHandler.GetStatistics) // Get cluster statistics
|
||||||
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
|
cluster.Get("/nodes/:node_id", az.Require(authz.ScopeNone, authz.PermNodeInfo), clusterHandler.GetNodeInfo) // Get node info
|
||||||
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
|
cluster.Get("/nodes/:node_id/statistics", az.Require(authz.ScopeNone, authz.PermNodeStatistics), clusterHandler.GetNodeStatistics) // Get node statistics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitoring routes
|
// Monitoring routes
|
||||||
monitoring := api.Group("/monitoring")
|
monitoring := api.Group("/monitoring")
|
||||||
{
|
{
|
||||||
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
|
monitoring.Get("/metrics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetMetrics) // Get Prometheus metrics
|
||||||
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
|
monitoring.Get("/admin-health", az.Require(authz.ScopeNone, authz.PermClusterHealth), monitoringHandler.CheckAdminHealth) // Check Admin API health
|
||||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
monitoring.Get("/dashboard", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin auth login endpoint (only if admin is enabled)
|
// 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
|
// With access_control configured, non-admin users may log in:
|
||||||
// ID token and the userinfo endpoint (Keycloak emits resource_access
|
// they get team-scoped (possibly zero) permissions and
|
||||||
// only in the access token by default), so fall back to the access
|
// default-deny protects everything else. Without it, the
|
||||||
// token and then the userinfo endpoint before denying access.
|
// 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()
|
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
|
||||||
if len(adminRoles) > 0 {
|
if len(adminRoles) > 0 {
|
||||||
if !authService.IsAdmin(userInfo) {
|
if !authService.IsAdmin(userInfo) {
|
||||||
@@ -249,7 +258,7 @@ func SetupRoutes(
|
|||||||
userInfo.Roles = ui.Roles
|
userInfo.Roles = ui.Roles
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !authService.IsAdmin(userInfo) {
|
if cfg.AccessControl == nil && !authService.IsAdmin(userInfo) {
|
||||||
logger.Warn().
|
logger.Warn().
|
||||||
Str("username", userInfo.Username).
|
Str("username", userInfo.Username).
|
||||||
Strs("required_roles", adminRoles).
|
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
|
// Generate JWT session token
|
||||||
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"Noooste/garage-ui/internal/auth"
|
"Noooste/garage-ui/internal/auth"
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/config"
|
"Noooste/garage-ui/internal/config"
|
||||||
"Noooste/garage-ui/internal/handlers"
|
"Noooste/garage-ui/internal/handlers"
|
||||||
"Noooste/garage-ui/internal/services"
|
"Noooste/garage-ui/internal/services"
|
||||||
@@ -57,6 +58,12 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
|
|||||||
admin := &mocks.AdminMock{}
|
admin := &mocks.AdminMock{}
|
||||||
s3 := &mocks.S3Mock{}
|
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()
|
app := fiber.New()
|
||||||
SetupRoutes(
|
SetupRoutes(
|
||||||
app,
|
app,
|
||||||
@@ -68,7 +75,8 @@ func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
|
|||||||
handlers.NewUserHandler(admin),
|
handlers.NewUserHandler(admin),
|
||||||
handlers.NewClusterHandler(admin),
|
handlers.NewClusterHandler(admin),
|
||||||
handlers.NewMonitoringHandler(admin, s3),
|
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}
|
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
|
||||||
|
|||||||
+16
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"Noooste/garage-ui/internal/auth"
|
"Noooste/garage-ui/internal/auth"
|
||||||
|
"Noooste/garage-ui/internal/authz"
|
||||||
"Noooste/garage-ui/internal/config"
|
"Noooste/garage-ui/internal/config"
|
||||||
"Noooste/garage-ui/internal/handlers"
|
"Noooste/garage-ui/internal/handlers"
|
||||||
appmw "Noooste/garage-ui/internal/middleware"
|
appmw "Noooste/garage-ui/internal/middleware"
|
||||||
@@ -115,7 +116,7 @@ func main() {
|
|||||||
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
|
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
|
||||||
}
|
}
|
||||||
adminService := adminResult.Service
|
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")
|
logger.Info().Msg("Initializing S3 service")
|
||||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||||
@@ -140,6 +141,15 @@ func main() {
|
|||||||
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
|
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
|
// Initialize handlers
|
||||||
healthHandler := handlers.NewHealthHandler(version)
|
healthHandler := handlers.NewHealthHandler(version)
|
||||||
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
|
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
|
||||||
@@ -212,8 +222,13 @@ func main() {
|
|||||||
clusterHandler,
|
clusterHandler,
|
||||||
monitoringHandler,
|
monitoringHandler,
|
||||||
capabilitiesHandler,
|
capabilitiesHandler,
|
||||||
|
azMiddleware,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if err := authz.VerifyRouteCoverage(app); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("authz route coverage check failed")
|
||||||
|
}
|
||||||
|
|
||||||
// Start server in a goroutine
|
// Start server in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
addr := cfg.GetAddress()
|
addr := cfg.GetAddress()
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ auth:
|
|||||||
|
|
||||||
# Role-based access (optional)
|
# Role-based access (optional)
|
||||||
role_attribute_path: "resource_access.garage-ui.roles"
|
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).
|
# Single admin role (backward-compatible).
|
||||||
admin_role: "admin"
|
admin_role: "admin"
|
||||||
# Multiple admin roles: a user is granted admin if ANY of their roles
|
# Multiple admin roles: a user is granted admin if ANY of their roles
|
||||||
@@ -94,6 +96,29 @@ auth:
|
|||||||
cookie_http_only: true
|
cookie_http_only: true
|
||||||
cookie_same_site: "lax" # lax, strict, none
|
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 Configuration (for frontend)
|
||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -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: <permission.name>`. 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.
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
|
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
|
||||||
import { formatBytes } from '@/lib/file-utils';
|
import { formatBytes } from '@/lib/file-utils';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import { useBucketCan } from '@/hooks/usePermissions';
|
||||||
import type { Bucket } from '@/types';
|
import type { Bucket } from '@/types';
|
||||||
|
|
||||||
interface BucketListViewProps {
|
interface BucketListViewProps {
|
||||||
@@ -35,6 +36,7 @@ export function BucketListView({
|
|||||||
onDeleteBucket,
|
onDeleteBucket,
|
||||||
onWebsiteSettings,
|
onWebsiteSettings,
|
||||||
}: BucketListViewProps) {
|
}: BucketListViewProps) {
|
||||||
|
const canBucket = useBucketCan();
|
||||||
const filteredBuckets = buckets.filter((bucket) =>
|
const filteredBuckets = buckets.filter((bucket) =>
|
||||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -118,31 +120,39 @@ export function BucketListView({
|
|||||||
<FolderIcon className="h-4 w-4" />
|
<FolderIcon className="h-4 w-4" />
|
||||||
View Objects
|
View Objects
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={(e) => {
|
{canBucket(bucket, 'bucket.update') && (
|
||||||
e.stopPropagation();
|
<>
|
||||||
onOpenSettings(bucket);
|
<DropdownMenuItem onClick={(e) => {
|
||||||
}}>
|
e.stopPropagation();
|
||||||
<Settings className="h-4 w-4" />
|
onOpenSettings(bucket);
|
||||||
Settings
|
}}>
|
||||||
</DropdownMenuItem>
|
<Settings className="h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={(e) => {
|
Settings
|
||||||
e.stopPropagation();
|
</DropdownMenuItem>
|
||||||
onWebsiteSettings(bucket);
|
<DropdownMenuItem onClick={(e) => {
|
||||||
}}>
|
e.stopPropagation();
|
||||||
<Globe className="h-4 w-4" />
|
onWebsiteSettings(bucket);
|
||||||
Website Settings
|
}}>
|
||||||
</DropdownMenuItem>
|
<Globe className="h-4 w-4" />
|
||||||
<DropdownMenuSeparator />
|
Website Settings
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
className="text-destructive"
|
</>
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
{canBucket(bucket, 'bucket.delete') && (
|
||||||
onDeleteBucket(bucket);
|
<>
|
||||||
}}
|
<DropdownMenuSeparator />
|
||||||
>
|
<DropdownMenuItem
|
||||||
<Trash2 className="h-4 w-4" />
|
className="text-destructive"
|
||||||
Delete
|
onClick={(e) => {
|
||||||
</DropdownMenuItem>
|
e.stopPropagation();
|
||||||
|
onDeleteBucket(bucket);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ interface ObjectBrowserViewProps {
|
|||||||
onDeepSearchChange: (enabled: boolean) => void;
|
onDeepSearchChange: (enabled: boolean) => void;
|
||||||
onNavigateToFolder: (path: string) => void;
|
onNavigateToFolder: (path: string) => void;
|
||||||
onBackToBuckets: () => void;
|
onBackToBuckets: () => void;
|
||||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
onUploadFiles?: (files: File[]) => Promise<boolean>;
|
||||||
uploadTasks: UploadTask[];
|
uploadTasks: UploadTask[];
|
||||||
onDeleteObject: (key: string) => Promise<boolean>;
|
onDeleteObject?: (key: string) => Promise<boolean>;
|
||||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
|
||||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
onCreateDirectory?: (name: string) => Promise<boolean>;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
onPageChange: (token?: string) => void;
|
onPageChange: (token?: string) => void;
|
||||||
onItemsPerPageChange: (count: number) => void;
|
onItemsPerPageChange: (count: number) => void;
|
||||||
@@ -75,6 +75,8 @@ export function ObjectBrowserView({
|
|||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop: async (acceptedFiles, _fileRejections, event) => {
|
onDrop: async (acceptedFiles, _fileRejections, event) => {
|
||||||
|
if (!onUploadFiles) return;
|
||||||
|
|
||||||
// Get files with their full paths from DataTransferItems API
|
// Get files with their full paths from DataTransferItems API
|
||||||
const filesWithPaths: File[] = [];
|
const filesWithPaths: File[] = [];
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ export function ObjectBrowserView({
|
|||||||
setShowUploadZone(false);
|
setShowUploadZone(false);
|
||||||
},
|
},
|
||||||
noClick: true,
|
noClick: true,
|
||||||
|
disabled: !onUploadFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to traverse file/directory tree
|
// Helper function to traverse file/directory tree
|
||||||
@@ -153,13 +156,14 @@ export function ObjectBrowserView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDeleteFiles = async () => {
|
const handleBulkDeleteFiles = async () => {
|
||||||
if (selectedFileKeys.size === 0) return;
|
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
|
||||||
|
|
||||||
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
||||||
setSelectedFileKeys(new Set());
|
setSelectedFileKeys(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
||||||
|
if (!onDeleteObject) return false;
|
||||||
const success = await onDeleteObject(key);
|
const success = await onDeleteObject(key);
|
||||||
if (success) {
|
if (success) {
|
||||||
setDeleteObjectDialogOpen(false);
|
setDeleteObjectDialogOpen(false);
|
||||||
@@ -169,6 +173,7 @@ export function ObjectBrowserView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uploadFiles = async (files: File[]) => {
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
if (!onUploadFiles) return;
|
||||||
await onUploadFiles(files);
|
await onUploadFiles(files);
|
||||||
setShowUploadZone(false);
|
setShowUploadZone(false);
|
||||||
};
|
};
|
||||||
@@ -232,7 +237,7 @@ export function ObjectBrowserView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{selectedFileKeys.size > 0 && (
|
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBulkDeleteFiles}
|
onClick={handleBulkDeleteFiles}
|
||||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||||
@@ -242,14 +247,18 @@ export function ObjectBrowserView({
|
|||||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
{onUploadFiles && (
|
||||||
<Upload className="h-4 w-4" />
|
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||||
<span className="hidden sm:inline">Upload</span>
|
<Upload className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">Upload</span>
|
||||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
</Button>
|
||||||
<FolderPlus className="h-4 w-4" />
|
)}
|
||||||
<span className="hidden sm:inline">Add Directory</span>
|
{onCreateDirectory && (
|
||||||
</Button>
|
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||||
|
<FolderPlus className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Add Directory</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
|
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
|
||||||
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -257,7 +266,7 @@ export function ObjectBrowserView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Zone */}
|
{/* Upload Zone */}
|
||||||
{showUploadZone && uploadTasks.length === 0 && (
|
{onUploadFiles && showUploadZone && uploadTasks.length === 0 && (
|
||||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex-shrink-0 flex items-center justify-center">
|
<div className="flex-shrink-0 flex items-center justify-center">
|
||||||
@@ -384,10 +393,10 @@ export function ObjectBrowserView({
|
|||||||
nextContinuationToken={nextContinuationToken}
|
nextContinuationToken={nextContinuationToken}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
onNavigateToFolder={onNavigateToFolder}
|
onNavigateToFolder={onNavigateToFolder}
|
||||||
onDeleteObject={(obj) => {
|
onDeleteObject={onDeleteObject ? (obj) => {
|
||||||
setSelectedObject(obj);
|
setSelectedObject(obj);
|
||||||
setDeleteObjectDialogOpen(true);
|
setDeleteObjectDialogOpen(true);
|
||||||
}}
|
} : undefined}
|
||||||
onToggleFileSelection={handleToggleFileSelection}
|
onToggleFileSelection={handleToggleFileSelection}
|
||||||
onSelectAllFiles={handleSelectAllFiles}
|
onSelectAllFiles={handleSelectAllFiles}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
@@ -399,12 +408,14 @@ export function ObjectBrowserView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Directory Dialog */}
|
{/* Create Directory Dialog */}
|
||||||
<CreateDirectoryDialog
|
{onCreateDirectory && (
|
||||||
open={createDirDialogOpen}
|
<CreateDirectoryDialog
|
||||||
onOpenChange={setCreateDirDialogOpen}
|
open={createDirDialogOpen}
|
||||||
currentPath={currentPath}
|
onOpenChange={setCreateDirDialogOpen}
|
||||||
onCreateDirectory={onCreateDirectory}
|
currentPath={currentPath}
|
||||||
/>
|
onCreateDirectory={onCreateDirectory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Object Dialog */}
|
{/* Delete Object Dialog */}
|
||||||
<DeleteObjectDialog
|
<DeleteObjectDialog
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
import { objectsApi } from '@/lib/api';
|
import { objectsApi } from '@/lib/api';
|
||||||
|
import { useBuckets } from '@/hooks/useApi';
|
||||||
|
import { useBucketCan } from '@/hooks/usePermissions';
|
||||||
import type { ObjectMetadata } from '@/types';
|
import type { ObjectMetadata } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -36,6 +38,11 @@ export function ObjectDetailsView() {
|
|||||||
const { bucketName, '*': encodedObjectKey } = useParams();
|
const { bucketName, '*': encodedObjectKey } = useParams();
|
||||||
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
||||||
|
|
||||||
|
const { data: buckets = [] } = useBuckets();
|
||||||
|
const bucket = buckets.find((b) => b.name === bucketName);
|
||||||
|
const canBucket = useBucketCan();
|
||||||
|
const canDelete = canBucket(bucket, 'object.delete');
|
||||||
|
|
||||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -161,9 +168,11 @@ export function ObjectDetailsView() {
|
|||||||
<Button variant="secondary" onClick={handleDownload}>
|
<Button variant="secondary" onClick={handleDownload}>
|
||||||
<Download className="h-4 w-4" /> Download
|
<Download className="h-4 w-4" /> Download
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
{canDelete && (
|
||||||
<Trash2 className="h-4 w-4" /> Delete
|
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface ObjectsTableProps {
|
|||||||
nextContinuationToken?: string;
|
nextContinuationToken?: string;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
onNavigateToFolder: (key: string) => void;
|
onNavigateToFolder: (key: string) => void;
|
||||||
onDeleteObject: (object: S3Object) => void;
|
onDeleteObject?: (object: S3Object) => void;
|
||||||
onToggleFileSelection: (key: string) => void;
|
onToggleFileSelection: (key: string) => void;
|
||||||
onSelectAllFiles: () => void;
|
onSelectAllFiles: () => void;
|
||||||
onPageChange: (token?: string) => void;
|
onPageChange: (token?: string) => void;
|
||||||
@@ -66,6 +66,7 @@ export function ObjectsTable({
|
|||||||
initialItemsPerPage,
|
initialItemsPerPage,
|
||||||
}: ObjectsTableProps) {
|
}: ObjectsTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const canDelete = Boolean(onDeleteObject);
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||||
@@ -214,16 +215,18 @@ export function ObjectsTable({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px]">
|
{canDelete && (
|
||||||
<Checkbox
|
<TableHead className="w-[50px]">
|
||||||
checked={
|
<Checkbox
|
||||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
checked={
|
||||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||||
}
|
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||||
onCheckedChange={onSelectAllFiles}
|
}
|
||||||
aria-label="Select all files"
|
onCheckedChange={onSelectAllFiles}
|
||||||
/>
|
aria-label="Select all files"
|
||||||
</TableHead>
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
@@ -250,7 +253,7 @@ export function ObjectsTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-12">
|
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12">
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
<span>Loading objects...</span>
|
<span>Loading objects...</span>
|
||||||
@@ -259,7 +262,7 @@ export function ObjectsTable({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : filteredObjects.length === 0 ? (
|
) : filteredObjects.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12 text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? 'No objects found matching your search'
|
? 'No objects found matching your search'
|
||||||
: isDragActive
|
: isDragActive
|
||||||
@@ -270,22 +273,24 @@ export function ObjectsTable({
|
|||||||
) : (
|
) : (
|
||||||
pageObjects.map((obj) => (
|
pageObjects.map((obj) => (
|
||||||
<TableRow key={obj.key}>
|
<TableRow key={obj.key}>
|
||||||
<TableCell className="w-[50px]">
|
{canDelete && (
|
||||||
{obj.isFolder ? (
|
<TableCell className="w-[50px]">
|
||||||
<Checkbox
|
{obj.isFolder ? (
|
||||||
disabled
|
<Checkbox
|
||||||
checked={false}
|
disabled
|
||||||
className="opacity-50 cursor-not-allowed bg-muted"
|
checked={false}
|
||||||
aria-label="Folders cannot be selected"
|
className="opacity-50 cursor-not-allowed bg-muted"
|
||||||
/>
|
aria-label="Folders cannot be selected"
|
||||||
) : (
|
/>
|
||||||
<Checkbox
|
) : (
|
||||||
checked={selectedFileKeys.has(obj.key)}
|
<Checkbox
|
||||||
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
checked={selectedFileKeys.has(obj.key)}
|
||||||
aria-label={`Select file ${obj.key}`}
|
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
||||||
/>
|
aria-label={`Select file ${obj.key}`}
|
||||||
)}
|
/>
|
||||||
</TableCell>
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{obj.isFolder ? (
|
{obj.isFolder ? (
|
||||||
@@ -390,14 +395,18 @@ export function ObjectsTable({
|
|||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Download
|
Download
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
{onDeleteObject && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
className="text-destructive"
|
<DropdownMenuSeparator />
|
||||||
onClick={() => onDeleteObject(obj)}
|
<DropdownMenuItem
|
||||||
>
|
className="text-destructive"
|
||||||
<Trash2 className="h-4 w-4" />
|
onClick={() => onDeleteObject(obj)}
|
||||||
Delete
|
>
|
||||||
</DropdownMenuItem>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useBuckets } from '@/hooks/useApi';
|
import { useBuckets } from '@/hooks/useApi';
|
||||||
|
import { useBucketCan } from '@/hooks/usePermissions';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface TabSpec {
|
interface TabSpec {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
end?: boolean;
|
end?: boolean;
|
||||||
|
perms?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: TabSpec[] = [
|
const tabs: TabSpec[] = [
|
||||||
{ to: 'objects', label: 'Objects' },
|
{ to: 'objects', label: 'Objects', perms: ['object.list'] },
|
||||||
{ to: 'permissions', label: 'Permissions' },
|
{ to: 'permissions', label: 'Permissions', perms: ['permission.allow_bucket_key', 'permission.deny_bucket_key'] },
|
||||||
{ to: 'website', label: 'Website' },
|
{ to: 'website', label: 'Website', perms: ['bucket.update'] },
|
||||||
{ to: 'settings', label: 'Settings' },
|
{ to: 'settings', label: 'Settings', perms: ['bucket.update'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatBytes(n?: number) {
|
function formatBytes(n?: number) {
|
||||||
@@ -36,6 +38,8 @@ export function BucketDetailShell() {
|
|||||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||||
const { data: buckets = [] } = useBuckets();
|
const { data: buckets = [] } = useBuckets();
|
||||||
const bucket = buckets.find((b) => b.name === bucketName);
|
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 s3Url = `s3://${bucketName}`;
|
||||||
const copyUrl = async () => {
|
const copyUrl = async () => {
|
||||||
@@ -68,16 +72,18 @@ export function BucketDetailShell() {
|
|||||||
<Button variant="secondary" onClick={copyUrl}>
|
<Button variant="secondary" onClick={copyUrl}>
|
||||||
<Copy /> Copy URL
|
<Copy /> Copy URL
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
|
{canBucket(bucket, 'object.write') && (
|
||||||
<Upload /> Upload
|
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
|
||||||
</Button>
|
<Upload /> Upload
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
|
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
|
||||||
{tabs.map((t) => (
|
{visibleTabs.map((t) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={t.to}
|
key={t.to}
|
||||||
to={t.to}
|
to={t.to}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useState, useMemo } from 'react';
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
|
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import { NoAccess } from '@/pages/NoAccess';
|
||||||
|
|
||||||
function useCrumbs(): BreadcrumbItem[] {
|
function useCrumbs(): BreadcrumbItem[] {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -35,6 +37,7 @@ function useCrumbs(): BreadcrumbItem[] {
|
|||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const crumbs = useCrumbs();
|
const crumbs = useCrumbs();
|
||||||
|
const { noAccess } = usePermissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
|
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
|
||||||
@@ -59,7 +62,7 @@ export function Layout() {
|
|||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<TopBar crumbs={crumbs} />
|
<TopBar crumbs={crumbs} />
|
||||||
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
||||||
<Outlet />
|
{noAccess ? <NoAccess /> : <Outlet />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
|
|||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { healthApi, garageApi } from '@/lib/api';
|
import { healthApi, garageApi } from '@/lib/api';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
visible?: (p: ReturnType<typeof usePermissions>) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -22,13 +24,15 @@ const navGroups: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Storage',
|
label: 'Storage',
|
||||||
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
|
items: [
|
||||||
|
{ title: 'Buckets', href: '/buckets', icon: Database, visible: (p) => p.hasAnyPerm('bucket.list') },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cluster',
|
label: 'Cluster',
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Cluster', href: '/cluster', icon: Server },
|
{ title: 'Cluster', href: '/cluster', icon: Server, visible: (p) => p.hasAnyClusterAccess },
|
||||||
{ title: 'Access Control', href: '/access', icon: Key },
|
{ title: 'Access Control', href: '/access', icon: Key, visible: (p) => p.hasClusterPerm('key.list') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -41,6 +45,7 @@ interface SidebarProps {
|
|||||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { config } = useAuthStore();
|
const { config } = useAuthStore();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const { data: uiVersion } = useQuery({
|
const { data: uiVersion } = useQuery({
|
||||||
queryKey: ['ui-version'],
|
queryKey: ['ui-version'],
|
||||||
@@ -77,38 +82,42 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
|
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
|
||||||
{navGroups.map((group, gi) => (
|
{navGroups.map((group, gi) => {
|
||||||
<div key={gi}>
|
const visibleItems = group.items.filter((item) => !item.visible || item.visible(perms));
|
||||||
{group.label && (
|
if (visibleItems.length === 0) return null;
|
||||||
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
return (
|
||||||
{group.label}
|
<div key={gi}>
|
||||||
</div>
|
{group.label && (
|
||||||
)}
|
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||||
<ul className="space-y-0.5">
|
{group.label}
|
||||||
{group.items.map((item) => {
|
</div>
|
||||||
const Icon = item.icon;
|
)}
|
||||||
const active = isActive(item.href);
|
<ul className="space-y-0.5">
|
||||||
return (
|
{visibleItems.map((item) => {
|
||||||
<li key={item.href}>
|
const Icon = item.icon;
|
||||||
<Link
|
const active = isActive(item.href);
|
||||||
to={item.href}
|
return (
|
||||||
onClick={onClose}
|
<li key={item.href}>
|
||||||
className={cn(
|
<Link
|
||||||
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
|
to={item.href}
|
||||||
active
|
onClick={onClose}
|
||||||
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
|
className={cn(
|
||||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
|
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
|
||||||
)}
|
active
|
||||||
>
|
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
|
||||||
<Icon className="h-4 w-4" />
|
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
|
||||||
{item.title}
|
)}
|
||||||
</Link>
|
>
|
||||||
</li>
|
<Icon className="h-4 w-4" />
|
||||||
);
|
{item.title}
|
||||||
})}
|
</Link>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
|
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { useDashboardData, useBuckets } from './useApi';
|
export { useDashboardData, useBuckets } from './useApi';
|
||||||
export { useBucketObjects } from './useBucketObjects';
|
export { useBucketObjects } from './useBucketObjects';
|
||||||
|
export { usePermissions, bucketCan, useBucketCan } from './usePermissions';
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCapabilities } from './useCapabilities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission view derived from /api/v1/capabilities.
|
||||||
|
*
|
||||||
|
* Fail-closed: while capabilities are loading (or errored), every check
|
||||||
|
* returns false. Gated UI stays hidden until the server has spoken.
|
||||||
|
* When access control is disabled server-side, every check returns true.
|
||||||
|
*/
|
||||||
|
export function usePermissions() {
|
||||||
|
const { data, isLoading, isError } = useCapabilities();
|
||||||
|
const ac = data?.access_control;
|
||||||
|
const settled = !isLoading && !isError && data !== undefined;
|
||||||
|
const enabled = ac?.enabled ?? false;
|
||||||
|
const isAdmin = settled && (!enabled || (ac?.is_admin ?? false));
|
||||||
|
const bindings = ac?.bindings ?? [];
|
||||||
|
const clusterPerms = ac?.cluster_permissions ?? [];
|
||||||
|
|
||||||
|
const hasClusterPerm = (perm: string): boolean => {
|
||||||
|
if (!settled) return false;
|
||||||
|
if (!enabled || isAdmin) return true;
|
||||||
|
return clusterPerms.includes(perm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyPerm = (perm: string): boolean => {
|
||||||
|
if (!settled) return false;
|
||||||
|
if (!enabled || isAdmin) return true;
|
||||||
|
return bindings.some((b) => b.permissions.includes(perm));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyClusterAccess =
|
||||||
|
settled &&
|
||||||
|
(!enabled || isAdmin || clusterPerms.some((p) => p.startsWith('cluster.') || p.startsWith('node.')));
|
||||||
|
|
||||||
|
const noAccess =
|
||||||
|
settled && enabled && !isAdmin && bindings.length === 0 && clusterPerms.length === 0;
|
||||||
|
|
||||||
|
return { loading: !settled, enabled, isAdmin, hasClusterPerm, hasAnyPerm, hasAnyClusterAccess, noAccess };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-bucket check against the server-computed effective_permissions carried
|
||||||
|
* on bucket payloads. Does not know about loading/enabled state, so it treats
|
||||||
|
* a missing effective_permissions field as "access control is disabled" and
|
||||||
|
* allows. That's only correct when access control really is disabled, so
|
||||||
|
* this is kept for internal use (by useBucketCan below); UI code should call
|
||||||
|
* useBucketCan() instead, which fails closed when access control is enabled.
|
||||||
|
*/
|
||||||
|
export function bucketCan(
|
||||||
|
bucket: { effective_permissions?: string[] } | undefined,
|
||||||
|
perm: string,
|
||||||
|
): boolean {
|
||||||
|
const perms = bucket?.effective_permissions;
|
||||||
|
if (!perms) return true;
|
||||||
|
return perms.includes(perm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook returning a per-bucket permission check closed over the current
|
||||||
|
* loading/enabled state from usePermissions().
|
||||||
|
*
|
||||||
|
* Fail-closed: while capabilities are loading, every check denies. When
|
||||||
|
* access control is enabled, a bucket without effective_permissions (not
|
||||||
|
* loaded yet, or filtered out of the bucket list) also denies instead of
|
||||||
|
* falling open like bucketCan does. When access control is disabled, every
|
||||||
|
* check allows, same as bucketCan.
|
||||||
|
*/
|
||||||
|
export function useBucketCan() {
|
||||||
|
const { loading, enabled } = usePermissions();
|
||||||
|
return (bucket: { effective_permissions?: string[] } | undefined, perm: string): boolean => {
|
||||||
|
if (loading) return false;
|
||||||
|
if (!enabled) return true;
|
||||||
|
if (!bucket?.effective_permissions) return false;
|
||||||
|
return bucketCan(bucket, perm);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,12 +2,20 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||||
|
import { useBuckets } from '@/hooks/useApi';
|
||||||
|
import { useBucketCan } from '@/hooks/usePermissions';
|
||||||
|
|
||||||
export function BucketObjects() {
|
export function BucketObjects() {
|
||||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const { data: buckets = [] } = useBuckets();
|
||||||
|
const bucket = buckets.find((b) => b.name === bucketName);
|
||||||
|
const canBucket = useBucketCan();
|
||||||
|
const canWrite = canBucket(bucket, 'object.write');
|
||||||
|
const canDelete = canBucket(bucket, 'object.delete');
|
||||||
|
|
||||||
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [deepSearch, setDeepSearch] = useState(false);
|
const [deepSearch, setDeepSearch] = useState(false);
|
||||||
@@ -79,10 +87,12 @@ export function BucketObjects() {
|
|||||||
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
|
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => uploadInputRef.current?.click();
|
const handler = () => {
|
||||||
|
if (canWrite) uploadInputRef.current?.click();
|
||||||
|
};
|
||||||
document.addEventListener('bucket:upload', handler);
|
document.addEventListener('bucket:upload', handler);
|
||||||
return () => document.removeEventListener('bucket:upload', handler);
|
return () => document.removeEventListener('bucket:upload', handler);
|
||||||
}, []);
|
}, [canWrite]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -112,11 +122,11 @@ export function BucketObjects() {
|
|||||||
onDeepSearchChange={setDeepSearch}
|
onDeepSearchChange={setDeepSearch}
|
||||||
onNavigateToFolder={handleNavigateToFolder}
|
onNavigateToFolder={handleNavigateToFolder}
|
||||||
onBackToBuckets={handleBackToBuckets}
|
onBackToBuckets={handleBackToBuckets}
|
||||||
onUploadFiles={uploadFiles}
|
onUploadFiles={canWrite ? uploadFiles : undefined}
|
||||||
uploadTasks={uploadTasks}
|
uploadTasks={uploadTasks}
|
||||||
onDeleteObject={deleteObject}
|
onDeleteObject={canDelete ? deleteObject : undefined}
|
||||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
onDeleteMultipleObjects={canDelete ? deleteMultipleObjects : undefined}
|
||||||
onCreateDirectory={createDirectory}
|
onCreateDirectory={canWrite ? createDirectory : undefined}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onItemsPerPageChange={handleItemsPerPageChange}
|
onItemsPerPageChange={handleItemsPerPageChange}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||||
|
import { useBucketCan } from '@/hooks/usePermissions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
@@ -86,6 +87,7 @@ export function BucketSettings() {
|
|||||||
const bucket = buckets.find((b) => b.name === bucketName);
|
const bucket = buckets.find((b) => b.name === bucketName);
|
||||||
const deleteMutation = useDeleteBucket();
|
const deleteMutation = useDeleteBucket();
|
||||||
const updateQuotasMutation = useUpdateBucketQuotas();
|
const updateQuotasMutation = useUpdateBucketQuotas();
|
||||||
|
const canBucket = useBucketCan();
|
||||||
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -295,25 +297,27 @@ export function BucketSettings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Danger zone */}
|
{/* Danger zone */}
|
||||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
{canBucket(bucket, 'bucket.delete') && (
|
||||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||||
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
||||||
Destructive actions for this bucket.
|
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
||||||
</p>
|
Destructive actions for this bucket.
|
||||||
</header>
|
</p>
|
||||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
</header>
|
||||||
<div className="min-w-0">
|
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="text-[14px] font-medium">Delete bucket</div>
|
<div className="min-w-0">
|
||||||
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
<div className="text-[14px] font-medium">Delete bucket</div>
|
||||||
All objects in this bucket will be permanently removed.
|
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
||||||
|
All objects in this bucket will be permanently removed.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete bucket
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
</section>
|
||||||
Delete bucket
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DangerousConfirmDialog
|
<DangerousConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||||
@@ -16,6 +17,7 @@ export function Buckets() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const { hasAnyPerm } = usePermissions();
|
||||||
const { data: buckets = [], isLoading } = useBuckets();
|
const { data: buckets = [], isLoading } = useBuckets();
|
||||||
const createMutation = useCreateBucket();
|
const createMutation = useCreateBucket();
|
||||||
const deleteMutation = useDeleteBucket();
|
const deleteMutation = useDeleteBucket();
|
||||||
@@ -48,9 +50,11 @@ export function Buckets() {
|
|||||||
title="Buckets"
|
title="Buckets"
|
||||||
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
|
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
hasAnyPerm('bucket.create') && (
|
||||||
<Plus /> Create bucket
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
</Button>
|
<Plus /> Create bucket
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ShieldOff } from 'lucide-react';
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
|
||||||
|
/** Shown to authenticated users whose identity matches no team. */
|
||||||
|
export function NoAccess() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-6 py-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={<ShieldOff />}
|
||||||
|
tone="neutral"
|
||||||
|
title="You don't have access"
|
||||||
|
description="Your account is signed in but isn't assigned to any team on this Garage UI. Contact your administrator to be added to a team."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface Bucket {
|
|||||||
errorDocument?: string;
|
errorDocument?: string;
|
||||||
};
|
};
|
||||||
quotas?: BucketQuotas | null;
|
quotas?: BucketQuotas | null;
|
||||||
|
effective_permissions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BucketDetails extends Bucket {
|
export interface BucketDetails extends Bucket {
|
||||||
@@ -238,6 +239,19 @@ export interface MultiNodeStatisticsResponse {
|
|||||||
error: Record<string, string>;
|
error: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccessControlBinding {
|
||||||
|
bucket_prefixes: string[];
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessControlInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
subject?: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
bindings?: AccessControlBinding[];
|
||||||
|
cluster_permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GarageCapabilities {
|
export interface GarageCapabilities {
|
||||||
garageApiVersion: string;
|
garageApiVersion: string;
|
||||||
features: {
|
features: {
|
||||||
@@ -245,6 +259,7 @@ export interface GarageCapabilities {
|
|||||||
nodeInfo: boolean;
|
nodeInfo: boolean;
|
||||||
nodeStatistics: boolean;
|
nodeStatistics: boolean;
|
||||||
};
|
};
|
||||||
|
access_control?: AccessControlInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
|
|||||||
@@ -225,6 +225,45 @@ config:
|
|||||||
# ... additional OIDC settings
|
# ... additional OIDC settings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Multi-User Access Control (optional)
|
||||||
|
|
||||||
|
Scope what each OIDC user can see and do, based on the teams in their token
|
||||||
|
claims. **Absent by default**, so every authenticated user keeps full access.
|
||||||
|
When set, authorization becomes default-deny.
|
||||||
|
|
||||||
|
> **Not a security boundary.** This is UI-layer policy only. Anyone holding the
|
||||||
|
> Garage admin token or raw S3 keys bypasses it. See
|
||||||
|
> [docs/access-control.md](../../docs/access-control.md) for the full model and
|
||||||
|
> permission vocabulary.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
config:
|
||||||
|
auth:
|
||||||
|
oidc:
|
||||||
|
enabled: true
|
||||||
|
# OIDC claim (go-jmespath) listing the user's teams.
|
||||||
|
team_attribute_path: "groups"
|
||||||
|
# admin_role stays optional once access_control is set: unmatched users
|
||||||
|
# are denied rather than promoted to admin.
|
||||||
|
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 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]
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin-password and Garage-admin-token logins are always full admin in v1; only
|
||||||
|
OIDC users can be scoped to a team.
|
||||||
|
|
||||||
#### CORS Configuration
|
#### CORS Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -322,11 +322,24 @@
|
|||||||
"description": "Path to roles in the OIDC token claims",
|
"description": "Path to roles in the OIDC token claims",
|
||||||
"default": "resource_access.garage-ui.roles"
|
"default": "resource_access.garage-ui.roles"
|
||||||
},
|
},
|
||||||
|
"team_attribute_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "go-jmespath path to the team/group claim used by config.access_control (same convention as role_attribute_path). Required only when access_control.teams is set; empty disables team resolution",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
"admin_role": {
|
"admin_role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Role name that grants admin privileges",
|
"description": "Role name that grants admin privileges",
|
||||||
"default": "admin"
|
"default": "admin"
|
||||||
},
|
},
|
||||||
|
"admin_roles": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Additional admin role names. A user is granted admin if ANY of their roles matches admin_role or any entry here",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
"tls_skip_verify": {
|
"tls_skip_verify": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Skip TLS certificate verification (only for testing)",
|
"description": "Skip TLS certificate verification (only for testing)",
|
||||||
@@ -429,6 +442,77 @@
|
|||||||
"default": "json"
|
"default": "json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"access_control": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional team-based access control (issue #33). When present (even empty) authorization is default-deny: OIDC users get only what their teams grant, and users matching no team are denied everywhere. When omitted, every authenticated user has full access. UI-layer policy only, NOT a security boundary: anyone holding the Garage admin token or S3 keys bypasses it. Requires auth.oidc.team_attribute_path when teams are set. See docs/access-control.md",
|
||||||
|
"properties": {
|
||||||
|
"presets": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Named, reusable permission lists referenced from bindings/cluster_permissions via the 'preset:<name>' syntax. Presets may reference other presets",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Maps IdP claim values to bucket-prefix bindings and cluster-level permissions",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "claim_values"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique team name"
|
||||||
|
},
|
||||||
|
"claim_values": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Values matched (exact string, no wildcards) against the team_attribute_path claim",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Prefix-scoped permission grants over buckets whose names match one of the prefixes",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["bucket_prefixes", "permissions"],
|
||||||
|
"properties": {
|
||||||
|
"bucket_prefixes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Bucket-name prefixes this binding applies to. Use '*' to match every bucket",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Prefix-scoped permissions, preset references (preset:<name>), or trailing-star globs (e.g. bucket.*, object.*)",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cluster_permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Global (cluster-level) permissions granted to the team, e.g. cluster.status, cluster.health",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ config:
|
|||||||
role_attribute_path: "resource_access.garage-ui.roles"
|
role_attribute_path: "resource_access.garage-ui.roles"
|
||||||
admin_role: "admin"
|
admin_role: "admin"
|
||||||
admin_roles: []
|
admin_roles: []
|
||||||
|
# Team-based access control (optional). OIDC claim (go-jmespath, same
|
||||||
|
# convention as role_attribute_path) listing the user's teams. Required
|
||||||
|
# only when config.access_control.teams is set; leave empty to disable.
|
||||||
|
# See config.access_control below and docs/access-control.md.
|
||||||
|
team_attribute_path: ""
|
||||||
# TLS settings
|
# TLS settings
|
||||||
tls_skip_verify: false
|
tls_skip_verify: false
|
||||||
# Session settings
|
# Session settings
|
||||||
@@ -147,6 +152,21 @@ config:
|
|||||||
# Options: json, text
|
# Options: json, text
|
||||||
format: "json"
|
format: "json"
|
||||||
|
|
||||||
|
# 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 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]
|
||||||
|
|
||||||
# Pod annotations
|
# Pod annotations
|
||||||
podAnnotations: {}
|
podAnnotations: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user