Authorization reference
This page is the operator and integrator reference for S3 Lens authorization. For OIDC setup see Authentication; for writing policy YAML see Access policies.
End-to-end flow
Section titled “End-to-end flow”The diagrams below show how configuration becomes runtime checks, how HTTP requests are authenticated and authorized, and how UI permission endpoints relate to mutating routes. They render interactively in the docs site (Mermaid); the source blocks remain readable in the repository if JavaScript is disabled.
1. Startup — build configuration into PolicyEngine
Section titled “1. Startup — build configuration into PolicyEngine”At startup, S3 Lens reads s3lens.yaml and builds in-memory structures used for every later request:
flowchart TB subgraph yaml ["s3lens.yaml"] policy["policy.policies<br/>(allow/deny rules)"] roles["roles<br/>(policy bundles)"] auth["auth.bindings / local_users<br/>(identity mapping)"] end
policy --> engineLoad["PolicyEngine::load"] roles --> engineLoad auth --> bindings["BindingResolver<br/>principal → roles/policies"]
engineLoad --> engine["PolicyEngine"]
engine --> state["AppState<br/>(stored for process lifetime)"] bindings --> state
style engine fill:#1e3a5f,color:#fff style state fill:#1e3a5f,color:#fffPolicy or role changes require a restart — compiled policies are not reloaded at runtime.
2. Request — session, authentication, and handler authorization
Section titled “2. Request — session, authentication, and handler authorization”Every protected API request passes through session middleware before a handler runs its own policy check:
sequenceDiagram autonumber participant Browser participant MW as inject_auth_context participant Session as SessionStore participant Handler participant Eval as PolicyEngine participant S3 as S3 client
Browser->>MW: HTTP request (+ s3lens_session cookie) MW->>Session: Resolve principal from cookie
alt auth.enabled and no valid session on /api/* MW-->>Browser: 401 Unauthorized else auth.enabled and no session on SPA route MW-->>Browser: 302 → /api/auth/login?return_to=… else session OK or auth disabled MW->>Handler: Request + AuthContext { principal, grants } end
Handler->>Handler: Build Context<br/>(provider, bucket, key) Handler->>Eval: allowed(grants, ctx, action)
alt policy denies Eval-->>Handler: false Handler-->>Browser: 403 Forbidden else policy allows Eval-->>Handler: true Handler->>S3: Storage operation S3-->>Handler: Result Handler-->>Browser: 200 JSON / stream endPublic routes (/api/health, /api/auth/login, /api/auth/callback, /api/auth/logout, /api/auth/me) skip the session requirement.
3. Policy evaluation — how PolicyEngine.allowed decides allow or deny
Section titled “3. Policy evaluation — how PolicyEngine.allowed decides allow or deny”When a handler (or permission endpoint) calls PolicyEngine::allowed, it uses request-scoped Grants built once in middleware:
flowchart TD start(["PolicyEngine.allowed<br/>(grants, ctx, action)"]) --> policyOff{policy.enabled?}
policyOff -->|no| allowAll["Return true"] policyOff -->|yes| loop["For each CompiledPolicy in grants"]
loop --> evalRule["Evaluate rules in order"] evalRule --> match{rule matches<br/>ctx + action?} match -->|no| nextRule{more rules?} nextRule -->|yes| evalRule nextRule -->|no| nextDoc{more policies?} nextDoc -->|yes| loop nextDoc -->|no| implicitDeny["Return false<br/>(no allow matched)"]
match -->|yes, Deny| denyGlobal["Return false<br/>(deny wins)"] match -->|yes, Allow| setAllow["allow = true"] setAllow --> nextRule
style allowAll fill:#14532d,color:#fff style denyGlobal fill:#7f1d1d,color:#fff style implicitDeny fill:#7f1d1d,color:#fffResource matching uses three dimensions on Context:
| Dimension | Role in matching |
|---|---|
provider |
Provider name from config, or "*" |
bucket |
Bucket name; empty at provider scope |
key |
Object path; must be empty for bucket-level actions |
Within one compiled policy, deny beats allow. Across all compiled policies for the principal, the first deny wins; otherwise any allow grants access.
4. UI permissions vs mutating routes
Section titled “4. UI permissions vs mutating routes”The web UI fetches fixed-schema permission objects to show or hide controls. Mutating routes always call allowed independently — UI permissions never grant access on their own.
flowchart TB subgraph ui ["Web UI"] provPage["Provider page<br/>(create bucket)"] bucketPage["Bucket explorer<br/>(upload, delete, presign)"] bucketTable["Bucket table<br/>(delete bucket per row)"] end
subgraph permAPI ["Permission endpoints (read-only)"] getProv["GET /api/providers/{provider}/permissions"] getBucket["GET /api/providers/{provider}/buckets/{bucket}/permissions"] end
subgraph schemas ["Response"] provSchema["{ actions: providers:read, buckets:read, … }"] bucketSchema["{ actions: objects:read, objects:write, … }"] end
subgraph mutate ["Mutating routes (authoritative)"] createBucket["POST …/buckets → buckets:create"] deleteBucket["DELETE …/buckets/{b} → buckets:delete"] upload["PUT …/objects/{key} → objects:write"] deleteObj["DELETE …/objects/{key} → objects:delete"] presign["POST …/presigned-urls → objects:presign"] end
engine["PolicyEngine<br/>(Grants on AuthContext)"]
provPage --> getProv bucketPage --> getBucket bucketTable --> getBucket
getProv --> engine getBucket --> engine
engine --> provSchema engine --> bucketSchema
createBucket --> engine deleteBucket --> engine upload --> engine deleteObj --> engine presign --> engine
provSchema -.->|"UI visibility only"| provPage bucketSchema -.->|"UI visibility only"| bucketPage bucketSchema -.->|"UI visibility only"| bucketTable
style engine fill:#1e3a5f,color:#fff style mutate fill:#422006,color:#fff style permAPI fill:#1e3a5f,color:#fff5. Identity resolution — OIDC groups and local users
Section titled “5. Identity resolution — OIDC groups and local users”flowchart LR subgraph login ["Sign-in"] oidc["OIDC IdP<br/>(Authorization Code + PKCE)"] callback["GET /api/auth/callback"] cookie["s3lens_session cookie"] end
subgraph principal ["Principal"] sub["subject (JWT sub)"] groups["groups (JWT groups claim)"] end
subgraph mapping ["auth configuration"] bindings["auth.bindings<br/>groups/subjects → role or policies"] local["auth.local_users<br/>subject → roles (dev)"] end
subgraph access ["Effective access"] roles["roles → compiled policies"] policies["direct policy assignments"] engine["PolicyEngine"] end
oidc --> callback --> cookie cookie --> sub cookie --> groups
sub --> bindings groups --> bindings sub --> local
bindings --> roles bindings --> policies local --> roles
roles --> engine policies --> engine
style engine fill:#1e3a5f,color:#fff6. List endpoints — visibility without global read
Section titled “6. List endpoints — visibility without global read”Cross-provider lists do not require providers:read on "*". Each configured provider is included when the principal has providers:read OR buckets:read on that provider:
flowchart TD req["GET /api/providers or GET /api/buckets"] --> authReq["require_session<br/>(session required when policy on)"] authReq --> loop["For each configured provider"] loop --> cap["can_access_provider(principal, provider)"] cap --> orCheck{"providers:read<br/>OR buckets:read<br/>on this provider?"} orCheck -->|yes| include["Include in response"] orCheck -->|no| skip["Omit from response"] include --> loop skip --> loopStack overview
Section titled “Stack overview”Authorization combines several layers built at startup and evaluated on every protected request:
policy definitions → roles → bindings → PolicyEngine + Grants → route handlers (rules) (bundles) (who gets what) (deny-wins) (#[enforce])| Layer | Config | Responsibility |
|---|---|---|
| Policy definitions | policy.policies |
Allow/deny rules on actions and resource patterns |
| Built-in templates | policy.defaults |
viewer, editor, admin |
| Roles | roles |
Named bundles of policy names |
| Bindings | auth.bindings, auth.local_users |
Map IdP groups, subjects, or local users to roles/policies |
| PolicyEngine + Grants | (runtime) | Resolve compiled policies once per request; deny beats allow |
| Route enforcement | (code) | Each handler requires a specific action at the correct context |
| UI permissions | Permission endpoints | Fixed boolean schemas per resource |
When auth.enabled is false, all requests are allowed (local development only). When policy.enabled is false but auth is on, authenticated users may perform any action.
Actions
Section titled “Actions”| Action | Typical use |
|---|---|
providers:read |
List providers, view provider detail, OpenAPI/Scalar |
buckets:read |
List buckets on a provider |
buckets:create |
Create a bucket |
buckets:delete |
Delete a bucket |
buckets:policy |
Manage the provider’s native bucket policy |
objects:read |
List, download, bulk download objects |
objects:write |
Upload, copy, rename (destination), create folders |
objects:delete |
Delete objects, bulk delete, rename (source) |
objects:presign |
Generate presigned GET URLs |
Word aliases (read, write, delete, admin) are rejected at startup — use canonical names only. See Access policies — Actions.
Request context
Section titled “Request context”Every check uses three dimensions: (provider, bucket, key).
| Context | Values | Used for |
|---|---|---|
| Cross-provider | ("*", "", "") |
Global provider scope (OpenAPI, status) |
| Provider | (provider, "", "") |
Provider detail, bucket list, provider permissions |
| Bucket | (provider, bucket, "") |
List objects, bucket permissions, bucket delete |
| Object | (provider, bucket, key) |
Download, upload, delete, presign on one key |
Important: bucket-level operations must use an empty key. A non-empty key means an object path; buckets:create and similar actions will not match.
Matching highlights
Section titled “Matching highlights”providers:readmatches on the provider pattern only — bucket and key are ignored.buckets:read,buckets:create,buckets:delete, andbuckets:policyrequire provider + bucket match and emptykey.objects:readat bucket scope (emptykey) allows listing when the bucket matches; out-of-prefix keys are filtered viakey_readable.- Prefix-scoped object grants at bucket scope (empty
key) can surfaceobjects:write/objects:delete/objects:presignin bucket permission responses when the prefix pattern could match keys in that bucket — UI may show controls before you navigate into the allowed prefix; mutating routes still enforce per-key policy.
Route-to-action mapping
Section titled “Route-to-action mapping”Mutating routes enforce policy independently of permission responses. Representative mappings:
| Method | Route | Action | Context notes |
|---|---|---|---|
| GET | /api/providers |
(auth only) | Handler filters rows with can_access_provider |
| GET | /api/providers/{provider} |
providers:read |
Provider scope |
| GET | /api/providers/{provider}/buckets |
buckets:read |
Provider scope |
| POST | /api/providers/{provider}/buckets |
buckets:create |
Bucket name from request body |
| DELETE | /api/providers/{provider}/buckets/{bucket} |
buckets:delete |
Bucket scope |
| GET/PUT/DELETE | /api/providers/{provider}/buckets/{bucket}/policy |
buckets:policy |
Bucket scope; native provider policy document |
| GET | /api/providers/{provider}/buckets/{bucket}/objects |
objects:read |
Bucket scope; response keys filtered per prefix |
| GET | /api/providers/{provider}/buckets/{bucket}/objects/{key} |
objects:read |
Object scope |
| PUT | /api/providers/{provider}/buckets/{bucket}/objects/{key} |
objects:write |
Object scope |
| DELETE | /api/providers/{provider}/buckets/{bucket}/objects/{key} |
objects:delete |
Object scope |
| POST | /api/providers/{provider}/buckets/{bucket}/object-copies |
objects:write |
Destination key from body |
| POST | /api/providers/{provider}/buckets/{bucket}/object-renames |
objects:write + objects:delete |
Destination + source keys (both required) |
| POST | /api/providers/{provider}/buckets/{bucket}/object-deletions |
objects:delete |
Per-key check in handler loop |
| POST | /api/providers/{provider}/buckets/{bucket}/object-archives |
objects:read |
Per-key check in handler loop |
| POST | /api/providers/{provider}/buckets/{bucket}/presigned-urls |
objects:presign + objects:read |
Key from request body; composite check |
OpenAPI (/api/openapi.json) and Scalar (/api/scalar) require providers:read at cross-provider scope.
List visibility
Section titled “List visibility”Cross-provider list endpoints (GET /api/providers, GET /api/buckets) require authentication when policy is enabled but do not require global providers:read on "*". A provider appears in listings when the principal has providers:read or buckets:read on that provider. This lets provider-scoped roles see their backend without granting access to every configured provider.
Composite operations
Section titled “Composite operations”Some operations require multiple actions:
- Rename —
objects:writeon the destination key andobjects:deleteon the source key. A read-only user cannot rename even with partial write access on one side. - Bulk delete / bulk download — the handler checks the relevant action (
objects:deleteorobjects:read) for each key in the request.
UI permissions
Section titled “UI permissions”Permission endpoints return canonical policy action strings — the same names used in policy YAML and route enforcement:
{ "actions": ["providers:read", "buckets:read", "buckets:create"] }There is no separate field mapping (buckets.list → buckets:read). The frontend checks membership in actions using the same strings as policy docs.
Which actions each endpoint evaluates
Section titled “Which actions each endpoint evaluates”Defined once on the Rust Action enum (re-exported in s3lens and mirrored in the web UI as PolicyAction):
| Endpoint | Actions considered (in order) |
|---|---|
GET /api/providers/{provider}/permissions |
providers:read, buckets:read, buckets:create |
GET /api/providers/{provider}/buckets/{bucket}/permissions |
objects:read, objects:write, objects:delete, objects:presign, buckets:delete, buckets:policy |
Only allowed actions appear in the response. A compile-time test asserts the endpoint action lists use canonical actions without overlap.
Mutating routes still call PolicyEngine::allowed independently. Permission responses drive UI visibility only.
Example config reference (s3lens.yaml.example)
Section titled “Example config reference (s3lens.yaml.example)”The tables below list exact actions arrays each permission endpoint returns for the Keycloak test users in s3lens.yaml.example. Verified by cargo test --test permissions_example.
Configured providers: garage-local, seaweed-local
Sample bucket used below: demo (any bucket name behaves the same when policies use bucket: "*").
How to verify live
Section titled “How to verify live”With local dependencies running (docker compose up -d):
# Sign in as a Keycloak user in the browser, then from devtools or curl with the session cookie:curl -b cookies.txt http://127.0.0.1:8085/api/providers/garage-local/permissionscurl -b cookies.txt http://127.0.0.1:8085/api/providers/garage-local/buckets/demo/permissionsviewer (role: viewer → built-in viewer)
Section titled “viewer (role: viewer → built-in viewer)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read"] |
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read"] |
editor (role: editor → built-in editor)
Section titled “editor (role: editor → built-in editor)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read", "buckets:create"] |
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read", "objects:write", "objects:delete", "objects:presign"] |
Same on seaweed-local. Built-in editor includes objects:presign but not buckets:delete.
garage (role: garage-reader → garage-readonly only)
Section titled “garage (role: garage-reader → garage-readonly only)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read"] |
GET …/providers/seaweed-local/permissions |
[] |
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read"] |
seaweed (role: seaweed-editor → viewer, seaweed-readwrite)
Section titled “seaweed (role: seaweed-editor → viewer, seaweed-readwrite)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read"] |
GET …/providers/seaweed-local/permissions |
["providers:read", "buckets:read"] |
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read"] |
GET …/providers/seaweed-local/buckets/demo/permissions |
["objects:read", "objects:write", "objects:delete", "buckets:delete"] |
uploader (role: uploader → viewer, uploads-prefix)
Section titled “uploader (role: uploader → viewer, uploads-prefix)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read"] |
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read", "objects:write"] |
Prefix-scoped write appears at bucket scope when the uploads prefix could match keys in that bucket (uploads-prefix policy on "*/*/uploads/*").
admin (role: admin → built-in admin)
Section titled “admin (role: admin → built-in admin)”| Endpoint | actions |
|---|---|
GET …/providers/garage-local/permissions |
["providers:read", "buckets:read", "buckets:create"] |
GET …/providers/seaweed-local/buckets/demo/permissions |
["objects:read", "objects:write", "objects:delete", "objects:presign", "buckets:delete"] |
nopresign (role: viewer-no-presign)
Section titled “nopresign (role: viewer-no-presign)”Same as viewer — objects:presign never appears:
| Endpoint | actions |
|---|---|
GET …/providers/garage-local/buckets/demo/permissions |
["objects:read"] |
Policy evaluation order
Section titled “Policy evaluation order”- Resolve the principal from the session (subject + groups).
- Collect compiled policies from direct bindings and role bundles.
- Evaluate each compiled policy against the request context and action.
- Within one compiled policy: deny beats allow.
- Across compiled policies: first deny wins globally; otherwise any allow grants access; no allow → deny.
Limitations (current release)
Section titled “Limitations (current release)”| Feature | Status |
|---|---|
IAM JSON Condition blocks |
Not supported — rejected at startup |
NotAction / NotResource |
Not supported |
| AWS STS assume-role | Not implemented |
| Persistent sessions across restarts | Requires S3LENS_SESSION_SECRET |
| Frontend action gating | Partial — major mutation controls gated; not every UI action |
| Per-provider capability flags | Planned — policy ∩ capabilities on mutating routes |
| Provider validation probes | Planned — startup and on-demand read-only health checks |
Storage-side IAM (credentials on each provider) is separate from S3 Lens policy — see Provider credentials and permissions.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| Empty provider list despite being signed in | No providers:read or buckets:read on any configured provider |
| Can list buckets but not create | Missing buckets:create (check role policies and provider scope) |
| Upload button hidden but API upload works | Prefix-scoped write — permission endpoint may still show write at bucket scope; API checks the concrete key |
| Upload enabled but API returns 403 | UI permission is optimistic at bucket scope; object-level deny or missing write on that key |
| Rename fails with 403 | Need both objects:write (destination) and objects:delete (source) |
| Policy change has no impact | Policies build at startup — restart S3 Lens after editing YAML |
For interactive exploration, use /api/scalar on a running instance (requires providers:read when auth is enabled).