Files
garage-ui/backend/internal/authz/vocabulary.go
T
Noste 0d804fbd39 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
2026-07-11 17:57:28 +02:00

137 lines
6.5 KiB
Go

// 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
}