Operator Manual¶
Prerequisites¶
Docker and Docker Compose
An OIDC-compliant identity provider (Keycloak, Entra ID, Google, or any IDP that supports Authorization Code + PKCE)
Quick Start¶
Create a docker-compose.yml:
services:
api:
image: ghcr.io/exhuma/docroot/backend:1.0.0
environment:
DOCROOT_API_DATA_ROOT: /data
DOCROOT_API_OAUTH_JWKS_URL: ${DOCROOT_API_OAUTH_JWKS_URL}
DOCROOT_API_OAUTH_AUDIENCE: ${DOCROOT_API_OAUTH_AUDIENCE}
volumes:
- docroot_data:/data
web:
image: ghcr.io/exhuma/docroot/nginx:1.0.0
ports:
- "80:80"
environment:
DOCROOT_WEB_OIDC_ISSUER: ${DOCROOT_WEB_OIDC_ISSUER}
DOCROOT_WEB_OIDC_CLIENT_ID: ${DOCROOT_WEB_OIDC_CLIENT_ID}
volumes:
- docroot_data:/data
volumes:
docroot_data:
Fill in the environment values for your IDP (see the Environment Variables section below), then start the stack:
docker compose up -d
Open http://localhost in a browser.
Environment Variables¶
API container (DOCROOT_API_ prefix)¶
Set them in deploy/compose/.env or pass them directly to the
API container.
Variable |
Default |
Description |
|---|---|---|
|
|
Filesystem path for stored data |
|
(empty) |
JWKS endpoint for JWT validation ( |
|
(empty) |
Expected |
|
(empty) |
Path to a PEM CA cert/bundle for verifying the JWKS endpoint. Set when the IDP uses an internal or self-signed CA. |
|
|
Set |
|
|
Role extractor. Only |
|
|
Comma-separated allowed origins, or |
|
|
Set |
|
|
|
|
|
Maximum files in an uploaded ZIP. |
|
|
Maximum extracted ZIP size in MB. |
nginx/UI container (DOCROOT_WEB_ prefix)¶
Variable |
Default |
Description |
|---|---|---|
|
(empty) |
OIDC issuer URL. Empty disables the Login button. |
|
(empty) |
Public client ID for the browser login flow. |
Volume¶
Mount a single host directory at /data.
All data is stored there; the container is stateless.
volumes:
- /your/host/path:/data
Kubernetes¶
The nginx image accepts two environment variables to control where
it routes API requests. This is needed when the container hostname
api is not resolvable (e.g. single-pod deployments where both
containers share a network namespace):
Variable |
Default |
Description |
|---|---|---|
|
|
Hostname or IP of the FastAPI container. Set to |
|
|
TCP port of the FastAPI container. |
Single-pod (sidecar) deployment: Both containers share the pod
network namespace, so localhost resolves to the API container:
containers:
- name: accelerator
image: ghcr.io/exhuma/docroot/nginx:1.0.0
env:
- name: API_HOST
value: localhost
- name: api
image: ghcr.io/exhuma/docroot/backend:1.0.0
Multi-pod deployment (separate Deployments + Service): Set
API_HOST to the Kubernetes service name that fronts the API pods:
env:
- name: API_HOST
value: docroot-api # name of the k8s Service for the backend
OIDC Authentication¶
Docroot uses a two-client authentication architecture:
UI (public client) — the browser performs an Authorization Code + PKCE flow. No client secret is used.
API (resource server) — the backend validates Bearer tokens by verifying their signature against the JWKS endpoint. It does not redirect users; it only accepts or rejects tokens.
Session cookie (docs bridge) — after login the UI obtains an
HttpOnlysession cookie that gates access to served documentation. No server-side session state is stored.
Configure both clients for every IDP:
# Back-end: where to fetch signing keys, and the expected audience
DOCROOT_API_OAUTH_JWKS_URL=https://<idp>/.well-known/jwks.json
DOCROOT_API_OAUTH_AUDIENCE=<api-audience>
# Front-end (nginx container): issuer and public client id
DOCROOT_WEB_OIDC_ISSUER=https://<idp>
DOCROOT_WEB_OIDC_CLIENT_ID=<public-client-id>
Register the following redirect URIs at your IDP. No client secret is needed (PKCE only).
Path |
Purpose |
|---|---|
|
Full authorization-code redirect after login |
|
Silent-renew iframe callback |
Audience validation¶
DOCROOT_API_OAUTH_AUDIENCE must match the aud claim in the
access token. This is the trickiest part and differs between
IDPs — see the provider-specific sections below.
JWKS key rotation¶
The backend fetches the JWKS endpoint automatically when an
unknown kid arrives. Standard IDP key rotation needs no
operator action.
Keycloak¶
1. Create a realm¶
In the Keycloak admin console, create a realm (e.g. docroot).
2. Back-end client (confidential)¶
Clients → Create client; Client ID:
docroot-apiClient authentication: ON; Service accounts: ON
These steps give you the values for the API container:
DOCROOT_API_OAUTH_JWKS_URL=https://keycloak.example.com/realms/docroot/protocol/openid-connect/certs
DOCROOT_API_OAUTH_AUDIENCE=docroot-api
3. Front-end client (public)¶
Clients → Create client; Client ID:
docroot-uiClient authentication: OFF
Valid redirect URIs:
https://docroot.example.com/oidc-callbackhttps://docroot.example.com/oidc-silent
Web origins:
https://docroot.example.com
These steps give you the values for the nginx container:
DOCROOT_WEB_OIDC_ISSUER=https://keycloak.example.com/realms/docroot
DOCROOT_WEB_OIDC_CLIENT_ID=docroot-ui
4. Roles¶
Create realm roles (e.g. docroot-editor) under
Realm roles → Create role and assign them to users via
Users → Role mappings. Reference the exact role name in
namespace ACL entries.
Realm roles appear as-is (e.g. docroot-editor).
Client-scoped roles are prefixed with the client ID and a slash
(e.g. docroot-api/editor). Use the prefixed form in namespace
ACL entries when granting access based on a client-specific role.
To limit which clients contribute roles, set one or both of:
Variable |
Default |
Description |
|---|---|---|
|
(empty) |
Comma-separated list of client IDs to include. When non-empty, only roles from these clients are considered. |
|
(empty) |
Comma-separated list of client IDs to exclude. Applied after the allowlist. |
The allowlist is processed first (when non-empty, only listed clients pass). The denylist is then applied on top. A client ID that appears in both lists is excluded.
Getting the audience into the token¶
Keycloak does not add an aud claim for the back-end
client by default. Two approaches:
Transparent (role-based): When a user is assigned a role
that is scoped to docroot-api, Keycloak automatically
includes docroot-api in the aud claim. No mapper needed —
assigning the role is enough. The trade-off is that audience
presence is tied to role assignment.
Explicit (audience mapper): Add a Hardcoded audience
mapper on the docroot-ui client:
Clients → docroot-ui → Client scopes → dedicated → Add mapper → Hardcoded audience → Included audience: docroot-api.
This guarantees aud is always present regardless of roles.
The trade-off is a small amount of manual configuration.
Microsoft Entra ID (Azure AD)¶
1. Register an application¶
Azure AD → App registrations → New registration
Redirect URI type: Single-page application (SPA); values:
https://docroot.example.com/oidc-callbackandhttps://docroot.example.com/oidc-silentNote the Application (client) ID and Directory (tenant) ID.
2. Expose an API¶
Under Expose an API, set the Application ID URI
(e.g. api://<client-id>).
This becomes your DOCROOT_API_OAUTH_AUDIENCE.
Token version: Entra ID issues v1 tokens by default. In the app Manifest, set
"accessTokenAcceptedVersion": 2to get v2 tokens; the JWKS URL below only works with v2.
DOCROOT_API_OAUTH_JWKS_URL=https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
DOCROOT_API_OAUTH_AUDIENCE=api://<client-id>
DOCROOT_WEB_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
DOCROOT_WEB_OIDC_CLIENT_ID=<client-id>
Google¶
DOCROOT_API_OAUTH_JWKS_URL=https://www.googleapis.com/oauth2/v3/certs
DOCROOT_API_OAUTH_AUDIENCE=<google-client-id>
DOCROOT_WEB_OIDC_ISSUER=https://accounts.google.com
DOCROOT_WEB_OIDC_CLIENT_ID=<google-client-id>
In the Google Cloud Console:
APIs & Services → Credentials → OAuth client ID;
application type Web application; add both
https://docroot.example.com/oidc-callback and
https://docroot.example.com/oidc-silent as authorised
redirect URIs.
Providers without native OIDC support¶
GitHub and Facebook do not expose a standards-compliant OIDC endpoint for browser user logins. To use them, run an OIDC-compliant proxy such as Keycloak (identity brokering), Dex, or Zitadel in front of them and point Docroot at the proxy.
Namespace ACL¶
Each namespace has an access control list that you can manage via the Manage Access button (shield icon) in the UI, or through the ACL API described below.
Field |
Description |
|---|---|
|
|
|
Informational display name for the creator. Set automatically on login; may lag behind the IDP. |
|
Human-readable label for the namespace shown in the UI. |
|
Sort scheme: |
|
Allow unauthenticated read access to docs. |
|
Allow unauthenticated callers to list the namespace and its projects/versions without granting doc access (default |
Role |
Grant read or write access to users holding that role from the JWT. Matching is case-insensitive. |
Note
ACL settings are persisted in a namespace.toml file inside
each namespace directory on the host. You can inspect or edit
this file directly — changes are picked up automatically
without a restart. The file format is shown below for
reference; see the Data Layout section for the storage path.
# Namespace configuration example.
#
# This file is managed via the web UI or the ACL API.
# See the operator manual for details.
creator = "alice@example.com"
# Human-readable label for the creator. Informational only; updated
# automatically when the creator logs in. May lag behind the IDP.
creator_display_name = "Alice Example"
# Human-readable display name for this namespace shown in the UI.
display_name = "My Project Docs"
versioning = "semver"
[access]
# Set to true to allow anyone to read docs without logging in.
public_read = false
# Allow the namespace to appear in listings even for unauthenticated
# users, without granting access to the documentation content.
browsable = true
[[access.roles]]
role = "docroot-editor"
read = true
write = true
[[access.roles]]
role = "docroot-reader"
read = true
write = false
ACL API¶
All ACL mutations require a valid JWT with write access to the namespace.
# Read the current ACL (write access required)
curl -H "Authorization: Bearer $TOKEN" \
https://docroot.example.com/api/namespaces/myns/acl
# Grant a role read+write access
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"read": true, "write": true}' \
https://docroot.example.com/api/namespaces/myns/acl/roles/docroot-editor
# Revoke a role
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
https://docroot.example.com/api/namespaces/myns/acl/roles/docroot-editor
The Manage Access button (shield icon) in the UI provides a graphical alternative: it shows a table of the current user’s JWT roles with toggles for read/write permissions.
Ownership transfer¶
The creator field controls who may delete the namespace.
If the original creator is no longer available, any user with
write access can claim ownership:
curl -X PATCH \
-H "Authorization: Bearer $TOKEN" \
https://docroot.example.com/api/namespaces/myns/owner
After this call the caller becomes the new creator.
Data Layout¶
/data/
namespaces/
<namespace>/
namespace.toml
projects/
<project>/
versions/
<version>/
<locale>/
index.html
…
refs/
<refname> -> ../versions/<version> (symlink)
Development Setup¶
To build from source, clone the repository first:
git clone https://github.com/exhuma/docroot.git
cd docroot
Then generate a test token:
task gen-token -- --sub alice --roles editor
See README.md and Taskfile.yml for the full local dev
workflow.
Local Keycloak overlay¶
docker compose \
-f deploy/compose/docker-compose.yml \
-f deploy/compose/docker-compose.dev.yml \
up
Both the browser and the API container reach Keycloak at
http://keycloak.127.0.0.1.nip.io:8080.
Pre-loaded accounts:
Username |
Password |
Role |
|---|---|---|
|
|
|
|
|
|
Admin: http://keycloak.127.0.0.1.nip.io:8080/admin
(admin / admin)
For local development only. Do not expose to the internet.