diff --git a/README.md b/README.md index 6dddae5..9753462 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.example.yaml b/config.example.yaml index 4d916e0..b7d837c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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. # diff --git a/docs/access-control.md b/docs/access-control.md index 031b9b5..770bc31 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -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: `. 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: `. 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