mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
docs: improve clarity and structure
This commit is contained in:
@@ -78,11 +78,17 @@ helm install garage-ui garage-ui/garage-ui \
|
||||
--set garage.adminToken=your-token
|
||||
```
|
||||
|
||||
Access at http://localhost:8080
|
||||
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
|
||||
|
||||
### Quick Start with garage.toml
|
||||
```bash
|
||||
kubectl port-forward svc/garage-ui 8080:80
|
||||
```
|
||||
|
||||
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
|
||||
Then open http://localhost:8080
|
||||
|
||||
### Reusing your garage.toml
|
||||
|
||||
If you already have a running Garage instance, you can point Garage UI straight at your `garage.toml` and skip `config.yaml` entirely:
|
||||
|
||||
```bash
|
||||
./garage-ui --garage-toml /etc/garage.toml
|
||||
@@ -90,7 +96,7 @@ If you already have a running Garage instance, you can point Garage UI directly
|
||||
|
||||
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
|
||||
|
||||
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside containers this won't work -- override the endpoint explicitly with environment variables or a config file.
|
||||
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside a container this won't work, so override the endpoints explicitly with environment variables or a config file.
|
||||
|
||||
**Docker:**
|
||||
|
||||
@@ -152,7 +158,7 @@ GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
|
||||
|
||||
#### Loading sensitive values from files (`_FILE` suffix)
|
||||
|
||||
For Docker/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file causes startup to fail.
|
||||
For Docker and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui uses the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file stops startup.
|
||||
|
||||
Supported vars:
|
||||
|
||||
@@ -179,7 +185,7 @@ secrets:
|
||||
file: ./admin_password.txt
|
||||
```
|
||||
|
||||
This matches the convention used by the official Postgres and MySQL Docker images. Helm users do not need this — the chart already injects secrets via `existingSecret` references.
|
||||
This matches the convention used by the official Postgres and MySQL Docker images. Helm users don't need it; the chart already injects secrets via `existingSecret` references.
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
@@ -234,49 +240,19 @@ logging:
|
||||
|
||||
## Roadmap
|
||||
|
||||
Ideas being considered. Contributions welcome.
|
||||
Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/issues) to push something up the list.
|
||||
|
||||
**Object browser**
|
||||
- [ ] Inline preview (images, PDF, video, text/markdown, code)
|
||||
- [ ] Resumable multipart uploads with pause/resume
|
||||
- [ ] Folder uploads preserving prefix structure
|
||||
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
|
||||
- [ ] Command palette (Cmd-K) and keyboard navigation
|
||||
|
||||
**Sharing**
|
||||
- [ ] Presigned download links with expiry + QR code
|
||||
- [ ] Presigned upload drop-zones ("send me a file" pages)
|
||||
|
||||
**Buckets**
|
||||
- [ ] Bucket alias manager (global vs. user-scoped)
|
||||
- [ ] Quota editor with live usage bar
|
||||
- [ ] Lifecycle editor (expiration + abort-multipart)
|
||||
- [ ] CORS editor with built-in test request
|
||||
- [ ] Website config (index/error docs) with live link
|
||||
- [ ] Per-bucket usage graph over time
|
||||
|
||||
**Access keys**
|
||||
- [ ] Permission matrix view (keys x buckets)
|
||||
- [ ] Key rotation helper
|
||||
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
|
||||
|
||||
**Cluster**
|
||||
- [X] Support Garage v1 to latest
|
||||
- [ ] Visual layout editor with staged vs. applied diff
|
||||
- [ ] Capacity planner / simulation
|
||||
- [ ] Rebalance progress and node health timeline
|
||||
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
|
||||
|
||||
**Observability**
|
||||
- [ ] Dashboard with dedup/compression savings
|
||||
- [ ] Metrics explorer pulling from Garage `/metrics`
|
||||
- [ ] Admin audit log
|
||||
|
||||
**Polish**
|
||||
- [ ] i18n (FR/EN)
|
||||
- [ ] Mobile-friendly object browser
|
||||
- [ ] First-run onboarding wizard
|
||||
- [ ] GitOps export (layout + buckets + keys as YAML)
|
||||
- [x] **Fine-grained access control**: OIDC teams with per-bucket-prefix permissions, see [docs/access-control.md](docs/access-control.md)
|
||||
- [x] **Object search**: recursive substring search across a bucket
|
||||
- [x] **Bucket quotas**: size and object count limits from bucket settings
|
||||
- [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token
|
||||
- [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files
|
||||
- [ ] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60))
|
||||
- [ ] **Presigned share links**: time-limited download links from the object browser
|
||||
- [ ] **Resumable uploads**: multipart uploads that survive a dropped connection
|
||||
- [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes
|
||||
- [ ] **Admin audit log**: who changed what, building on access control
|
||||
- [ ] **Table and detail polish**: sortable columns, clearer node details ([#36](https://github.com/Noooste/garage-ui/issues/36), [#37](https://github.com/Noooste/garage-ui/issues/37))
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+3
-3
@@ -102,11 +102,11 @@ auth:
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax" # lax, strict, none
|
||||
|
||||
# Optional: team-based access control (issue #33).
|
||||
# Absent -> every authenticated user has full access (historical behavior).
|
||||
# Optional: team-based access control (see docs/access-control.md).
|
||||
# Absent -> every authenticated user has full access.
|
||||
# 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.
|
||||
# password logins, and token logins are always full-admin.
|
||||
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
|
||||
# Garage admin token or S3 keys bypasses it entirely.
|
||||
#
|
||||
|
||||
+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