Skip to content

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.

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:#fff

Policy 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
end

Public 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:#fff

Resource 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.

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:#fff

5. 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:#fff

6. 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 --> loop

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.

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.

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.

  • providers:read matches on the provider pattern only — bucket and key are ignored.
  • buckets:read, buckets:create, buckets:delete, and buckets:policy require provider + bucket match and empty key.
  • objects:read at bucket scope (empty key) allows listing when the bucket matches; out-of-prefix keys are filtered via key_readable.
  • Prefix-scoped object grants at bucket scope (empty key) can surface objects:write / objects:delete / objects:presign in 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.

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.

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.

Some operations require multiple actions:

  • Renameobjects:write on the destination key and objects:delete on 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:delete or objects:read) for each key in the request.

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.listbuckets:read). The frontend checks membership in actions using the same strings as policy docs.

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: "*").

With local dependencies running (docker compose up -d):

Terminal window
# 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/permissions
curl -b cookies.txt http://127.0.0.1:8085/api/providers/garage-local/buckets/demo/permissions
Endpoint actions
GET …/providers/garage-local/permissions ["providers:read", "buckets:read"]
GET …/providers/garage-local/buckets/demo/permissions ["objects:read"]
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/*").

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"]

Same as viewerobjects:presign never appears:

Endpoint actions
GET …/providers/garage-local/buckets/demo/permissions ["objects:read"]
  1. Resolve the principal from the session (subject + groups).
  2. Collect compiled policies from direct bindings and role bundles.
  3. Evaluate each compiled policy against the request context and action.
  4. Within one compiled policy: deny beats allow.
  5. Across compiled policies: first deny wins globally; otherwise any allow grants access; no allow → deny.
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.

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).