mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-11 22:16:54 +00:00
docs: improve clarity and structure
This commit is contained in:
+55
-43
@@ -1,38 +1,40 @@
|
||||
# Multi-User Access Control
|
||||
|
||||
Garage UI can limit what each user sees and does, based on the teams in their OIDC claims.
|
||||
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.
|
||||
|
||||
It's optional. With no config, every authenticated user has full access, exactly like before.
|
||||
This is optional. If your config has no `access_control` section, every authenticated user has full access.
|
||||
|
||||
## Not a security boundary
|
||||
|
||||
Read this before using access control for anything sensitive.
|
||||
Read this before relying on 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.
|
||||
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 teams a convenient, scoped UI. Don't use it as a replacement for real per-tenant credentials or network isolation.
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
## Setup
|
||||
|
||||
Two settings drive access control:
|
||||
Get OIDC login working first. Access control only applies to OIDC users, and [config.example.yaml](../config.example.yaml) covers the OIDC settings.
|
||||
|
||||
1. `team_attribute_path`: the OIDC claim that lists a user's teams.
|
||||
2. `access_control`: maps teams to permissions.
|
||||
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.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
oidc:
|
||||
# Existing keys unchanged. New:
|
||||
team_attribute_path: "groups" # go-jmespath, same convention as role_attribute_path
|
||||
# ...your existing OIDC settings...
|
||||
team_attribute_path: "groups"
|
||||
|
||||
access_control: # absent = full access for everyone; present = default-deny
|
||||
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
|
||||
claim_values: ["garage-team-backend"]
|
||||
bindings:
|
||||
- bucket_prefixes: ["backend-"]
|
||||
permissions: ["preset:bucket_owner"]
|
||||
@@ -41,17 +43,28 @@ access_control: # absent = full access for everyone; present
|
||||
cluster_permissions: [cluster.status, cluster.health]
|
||||
```
|
||||
|
||||
A few things to know:
|
||||
Reading the example top to bottom:
|
||||
|
||||
- `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.
|
||||
- `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](https://github.com/jmespath/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 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)).
|
||||
- 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](#admins)).
|
||||
|
||||
## How it works
|
||||
### 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](#troubleshooting) covers the usual reasons.
|
||||
|
||||
## How permissions are resolved
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
@@ -61,7 +74,7 @@ Bindings stay separate, though. Say one binding grants `read` on `backend-*` and
|
||||
|
||||
### Prefix match
|
||||
|
||||
`bucket_prefixes` are plain string prefixes on bucket names (no globbing on the name itself). Use `"*"` to match every bucket.
|
||||
`bucket_prefixes` are plain string prefixes on bucket names, with no globbing on the name itself. Use `"*"` to match every bucket.
|
||||
|
||||
### Presets
|
||||
|
||||
@@ -69,21 +82,21 @@ Reference a preset with the `preset:` prefix inside any `permissions` or `cluste
|
||||
|
||||
### Permission globs
|
||||
|
||||
A trailing-star glob like `bucket.*`, `object.*`, or `cluster.layout.*` expands against the permission vocabulary when config loads. Use scoped 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.*`, `object.*` inside a binding's `permissions`
|
||||
- `cluster.*`, `node.*`, `worker.*`, `block.*` under `cluster_permissions`
|
||||
- `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 include admin-only permissions, and in v1 there's no team-level way to grant those.
|
||||
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.
|
||||
|
||||
### Admin model
|
||||
### Admins
|
||||
|
||||
These identities become a synthetic admin:
|
||||
|
||||
- OIDC users with a configured `admin_role` / `admin_roles`
|
||||
- all non-OIDC logins (admin-password, Garage admin token)
|
||||
- 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 run through the same authorizer as any team; there's no `IsAdmin` shortcut that skips the check.
|
||||
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
|
||||
|
||||
@@ -91,18 +104,18 @@ The server refuses to start when the policy is invalid: an unknown permission, a
|
||||
|
||||
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
|
||||
## Current limitations
|
||||
|
||||
- **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`).
|
||||
- **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 vocabulary (v1)
|
||||
## 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 mirrors it by hand, and there's no doc generation in v1, so update the table whenever you change the registry.
|
||||
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 v1 | Garage endpoint / backing |
|
||||
| Permission | Scope | Admin-only | Garage endpoint / backing |
|
||||
|---|---|---|---|
|
||||
| `bucket.list` | prefix | | ListBuckets (response-filtered) |
|
||||
| `bucket.read` | prefix | | GetBucketInfo |
|
||||
@@ -119,7 +132,7 @@ Permission names are lowercase and dot-separated: two segments, or three for `cl
|
||||
| `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.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 |
|
||||
@@ -145,24 +158,23 @@ Permission names are lowercase and dot-separated: two segments, or three for `cl
|
||||
| `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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
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 (string match, no wildcards on the claim value).
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
- **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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user