A self-contained demo proving federated authentication + centralized fine-grained authorization using SpiceDB.
Different organizations use different identity providers, but a single SpiceDB instance enforces fine-grained document access control across all of them — and users from different IdPs can share resources with each other.
Key insight: Every IdP gets its own SpiceDB object type, bound to a canonical internal user object. Document permissions are always granted to user:<uuid>, never to raw IdP subjects. This means a Keycloak user and a GitHub user can share resources without either IdP knowing about the other.
Login flow (both providers):
- User completes OAuth/OIDC flow → app receives an IdP-specific subject ID
- App checks SpiceDB: does
keycloak_account:<sub>#bound_to(orgithub_account:<id>#bound_to) exist? - If yes → use the existing
user:<uuid>from that relationship - If no → mint a new UUID, write the binding to SpiceDB, store profile in SQLite
- Session cookie stores the internal
user:<uuid>— all subsequent checks use only this
SpiceDB schema:
definition user {}
definition keycloak_account {
relation bound_to: user
}
definition github_account {
relation bound_to: user
}
definition document {
relation owner: user
relation editor: user
relation viewer: user
permission edit = editor + owner
permission view = viewer + edit
permission share = owner
}
SpiceDB is backed by PostgreSQL (--datastore-engine=postgres), so every relationship — the IdP→user bindings and the document grants — survives a restart of the stack. A one-shot datastore migrate head service initializes the Postgres schema before SpiceDB starts serving.
This matters: with SpiceDB's in-memory engine, a restart wipes every binding while the app's own database persists, so returning users get minted a fresh user:<uuid> — duplicate users, and documents that silently lose their grants. Persisting SpiceDB fixes both at the source. The app's SQLite database holds only user profiles (for display) and document metadata; SpiceDB remains the single source of truth for identity bindings and authorization.
For a real deployment you would still harden the parts kept deliberately simple here: TLS on the SpiceDB gRPC channel, real secrets instead of the demo preshared key and Postgres password, and a managed/replicated Postgres.
- Docker and Docker Compose
- A GitHub account (to test cross-IdP sharing)
- A GitHub OAuth App (instructions below)
- Go to github.com/settings/developers
- Click OAuth Apps → New OAuth App
- Fill in:
- Application name:
FedAuthZ Demo(or anything) - Homepage URL:
http://localhost:8000 - Authorization callback URL:
http://localhost:8000/auth/github/callback
- Application name:
- Click Register application
- Note the Client ID
- Click Generate a new client secret and note the Client Secret
Copy .env.example to .env and fill in your GitHub credentials:
cp .env.example .envEdit .env:
GITHUB_CLIENT_ID=your_actual_github_client_id
GITHUB_CLIENT_SECRET=your_actual_github_client_secret
SESSION_SECRET=some-long-random-string-hereThe Keycloak values are pre-configured for local development and do not normally need to change. Two separate addresses exist because of Docker networking:
| Variable | Default | Used for |
|---|---|---|
KEYCLOAK_ISSUER |
http://keycloak:8080/realms/org-a |
Server-to-server: token exchange, JWKS fetch, userinfo. Resolved from inside the app container via the Docker bridge network. |
KEYCLOAK_PUBLIC_ISSUER |
http://localhost:8080/realms/org-a |
Browser-facing: the authorization redirect URL sent to the user's browser. The host port 8080 is what Keycloak exposes to the outside world. |
The hostname keycloak is only resolvable from inside the Docker network; the user's
browser on the host cannot reach it. Conversely, localhost:8080 refers to the host's
own loopback inside a container, not to the Keycloak container, so the app cannot use it
for server-to-server calls.
docker-compose up --buildWait for all services to be healthy. Keycloak takes ~60 seconds to start the first time.
- App: http://localhost:8000
- Keycloak Admin: http://localhost:8080 (admin / admin)
- SpiceDB HTTP: http://localhost:8090
The stack also runs PostgreSQL (SpiceDB's datastore) and a one-shot migrate step that
initializes its schema. Because SpiceDB persists to Postgres, your users, documents, and shares
survive docker compose restart and docker compose down / up. Run docker compose down -v
when you want a clean slate.
Open http://localhost:8000 in a browser.
- Click Log in with Keycloak (Org A)
- Log in as
alice@keycloak.com/alice123 - You are redirected to the dashboard — no documents yet
What SpiceDB recorded:
keycloak_account:<alice-sub>#bound_to@user:<alice-uuid>
- Click + New Document, give it a title and some content, click Create Document
What SpiceDB recorded:
document:<doc-id>#owner@user:<alice-uuid>
- Note the document URL:
http://localhost:8000/documents/<doc-id>
Open a private/incognito browser window and go to http://localhost:8000.
- Click Log in with GitHub (Org B)
- Authorize the app with your GitHub account
- You land on the dashboard — no documents visible (correct!)
What SpiceDB recorded:
github_account:<github-numeric-id>#bound_to@user:<bob-uuid>
- Try visiting Alice's document URL directly → you get 403 Access Denied
SpiceDB's CheckPermission returns
PERMISSIONSHIP_NO_PERMISSION
Back in Alice's browser:
- Open the document, scroll to Share This Document
- Bob's GitHub account appears in the dropdown (fetched from SQLite
userstable — for display only) - Select Bob, choose Viewer, click Share
What SpiceDB recorded:
document:<doc-id>#viewer@user:<bob-uuid>
Back in Bob's browser:
- Refresh the dashboard — Alice's document now appears (LookupResources finds it)
- Click the document — content is visible (CheckPermission:
view→PERMISSIONSHIP_HAS_PERMISSION) - No edit form or Share section visible (Bob only has
viewerpermission)
Alice can click Revoke next to Bob's entry. Bob's document disappears from his dashboard immediately on the next request — SpiceDB is the live source of truth.
The app leans on SpiceDB for two jobs: resolving a federated login to a canonical user, and gating document access. Under the hood that's a handful of relationship writes plus a few reads and checks — the same operations whether the login came from Keycloak or GitHub.
flowchart LR
U(["<b>User actions</b><br/>log in · create · share · open · revoke"])
U --> W["<b>Writes</b><br/>WriteRelationships · DeleteRelationships"]
U --> R["<b>Reads and checks</b><br/>LookupSubjects · CheckBulkPermissions<br/>LookupResources · ReadRelationships"]
W --> B["<b>Identity bindings</b><br/>keycloak_account / github_account #bound_to @user"]
W --> G["<b>Document grants</b><br/>document #owner / #editor / #viewer @user"]
R --> B
R --> G
- Writes happen on login (the
#bound_tobinding), document creation (#owner), and sharing (#viewer/#editor); revoking deletes the grant. - Reads resolve a returning login (
LookupSubjects), gate each document open (CheckBulkPermissions), list a user's viewable docs (LookupResources), and show current shares (ReadRelationships).
