Files
garage-ui/docs/access-control.md
2026-07-11 22:53:40 +02:00

12 KiB

Multi-User Access Control

Garage UI can scope what each user sees and does, based on the teams in their OIDC claims. A typical setup: the backend team manages every backend-* bucket, can read the shared ones, and sees nothing else.

This is optional. If your config has no access_control section, every authenticated user has full access.

Not a security boundary

Read this before relying on access control for anything sensitive.

Garage UI talks to Garage with a single admin token and a single set of S3 keys. Access control is enforced by the UI, not by Garage. Anyone who holds the underlying admin token or raw S3 keys bypasses it completely.

Use it to give each team a convenient, scoped view of the cluster. Don't use it as a substitute for real per-tenant credentials or network isolation.

Setup

Get OIDC login working first. Access control only applies to OIDC users, and config.example.yaml covers the OIDC settings.

From there, it takes two additions to your config:

  1. team_attribute_path tells Garage UI which OIDC claim lists a user's teams.
  2. access_control maps those teams to permissions.
auth:
  oidc:
    # ...your existing OIDC settings...
    team_attribute_path: "groups"

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"]
      bindings:
        - bucket_prefixes: ["backend-"]
          permissions: ["preset:bucket_owner"]
        - bucket_prefixes: ["shared-"]
          permissions: ["preset:bucket_readonly"]
      cluster_permissions: [cluster.status, cluster.health]

Reading the example top to bottom:

  • presets are named permission bundles you can reuse across teams. They're optional; you can always list permissions directly.
  • A user whose groups claim contains garage-team-backend lands in the backend team.
  • That team owns buckets starting with backend-, can read buckets starting with shared-, and can view cluster status and health.
  • Everyone else, including OIDC users who match no team, gets a 403 on everything. The moment access_control exists, the UI switches to default-deny.

A few things to know before writing your own:

  • team_attribute_path is a go-jmespath expression evaluated against the OIDC claims, the same convention as role_attribute_path. It's required whenever access_control.teams is set and OIDC is enabled. If it's missing, the server refuses to start and the error says why.
  • 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 disabled, the server still starts but logs a warning. The policy would gate nothing, since admin-password and token logins are always full admin (see Admins).

Check that it works

Log in as a test user and open GET /api/v1/capabilities. The access_control block in the response shows the user's resolved bindings and cluster_permissions. Empty arrays mean the user matched no team; Troubleshooting covers the usual reasons.

How permissions are resolved

Default-deny

Once access_control is 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, with 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 the config loads. Keep globs inside their scope:

  • bucket.* and object.* go in a binding's permissions
  • cluster.*, node.*, worker.*, and block.* go 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 expand to admin-only permissions, and there's no way to grant those to a team.

Admins

These identities become a synthetic admin:

  • OIDC users holding 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 go 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).

Current limitations

  • Only OIDC users can be scoped to a team. Admin-password and Garage-admin-token logins are always full admin.
  • ListKeys is not filtered. Anyone with key.list sees every access key. Everything past key.list and 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 reference

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 is maintained by hand, so update it whenever the registry changes.

Permission Scope Admin-only 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; 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 so far. 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.

One endpoint needs two permissions: POST /api/v1/buckets/:name/permissions performs an allow and a deny in a single call, so it requires 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. It's a plain string match, with 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; 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 safety net for developers, 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