Skip to content

Add federated-authorization example - #48

Merged
sohanmaheshwar merged 15 commits into
mainfrom
federated-authorization-example
Oct 1, 2026
Merged

sohanmaheshwar merged 15 commits into
mainfrom
federated-authorization-example

Conversation

@sohanmaheshwar

Copy link
Copy Markdown
Contributor

What

Adds a new federated-authorization example: a self-contained demo of federated authentication with centralized fine-grained authorization in SpiceDB.

Users log in through different identity providers (Keycloak OIDC and GitHub OAuth). Each external account (keycloak_account, github_account) binds to a canonical internal user, and a single SpiceDB instance enforces document access control across all of them — so users from different IdPs can share resources with each other.

Contents

  • FastAPI app (app.py) with Keycloak + GitHub login flows
  • SpiceDB schema (spicedb/schema.zed) with the *_account → bound_to → user binding pattern
  • docker-compose.yml bringing up SpiceDB, Keycloak, and the app
  • Preconfigured Keycloak realm with demo users, HTML templates, and a README with setup steps

Everything runs locally via docker compose. Secrets are demo placeholders only; no real credentials are included.

Notes

  • The related docs tutorial lives at spicedb/tutorials/federated-authorization.

A self-contained demo of federated authentication with centralized fine-grained authorization in SpiceDB: users log in through different identity providers (Keycloak OIDC and GitHub OAuth), each external account binds to a canonical internal user, and a single SpiceDB instance enforces document access control across all of them.

Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
Comment thread federated-authorization/app.py Fixed
Comment thread federated-authorization/app.py Fixed
Comment thread federated-authorization/app.py Fixed
Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
Add document-start marker and quote all string values to satisfy the repo's quoted-strings yamllint rule.

Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
The edit/share/unshare handlers interpolated the user-controlled doc_id path parameter straight into a redirect Location, which CodeQL flagged as URL redirection from a remote source. Document ids are always uuid.uuid4(), so normalize doc_id through uuid.UUID() at the top of each handler: invalid ids now return 404, and the redirect target can only contain hex digits and hyphens, so it cannot escape the /documents/ path.

Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
@sohanmaheshwar
sohanmaheshwar force-pushed the federated-authorization-example branch from e4c2d14 to 1d43f54 Compare July 23, 2026 08:51
SpiceDB's REST gateway listens on :8443 inside the container, but the port was mapped from host 8090 to container 8080, so the documented http://localhost:8090 endpoint was unreachable (connection refused). Map 8090:8443 so the REST API is actually exposed.

Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
Signed-off-by: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com>
Replace the diagram with the corrected version (identity bindings shown stored in SpiceDB, resolved via a LookupSubjects bound_to call) and update the image alt text to match.
…on README

The image diagram (architecture.png) is the source of truth; the ASCII version still showed the old framing (bound_to binding inside the app rather than in SpiceDB).
Comment thread federated-authorization/templates/base.html
@sohanmaheshwar

Copy link
Copy Markdown
Contributor Author

I was testing this sample and stumbled upon a bug which I think could use some discussion. Basically there is a duplication of users if you restart the container, this is because in-memory SpiceDB loses IdP bindings on restart while SQLite persists → duplicate rows. (this also shows up when a user can share a document with themself).

To fix this, claude recommends:

Keep SpiceDB in-memory; the stack stays self-contained with no extra services.

- Add idp_type + idp_subject columns to the users table, with a UNIQUE(idp_type, idp_subject) constraint.
- On login, get_or_create_user becomes:
  a. Look up the SpiceDB binding (fast path) — if present, use it.
  b. Otherwise look up the internal UUID by (idp_type, idp_subject) in SQLite; if found, reuse it and re-write the SpiceDB binding (self-heals after a restart wiped it).
  c. Only mint a brand-new UUID when the identity is genuinely new.
- Footprint: small — one table change + logic in one function. App stays keycloak + spicedb(memory) + app.
- Trade-off: narrative shifts slightly — SpiceDB becomes the authorization store, and the app's own DB owns the identity mapping. (Arguably a cleaner separation, but it changes how the README tells the story.)

Option 2 — Persist SpiceDB via Postgres

Keep SpiceDB as the single source of truth for identity resolution, exactly as the README describes today (no app-side identity table).

- Switch SpiceDB from --datastore-engine=memory to postgres.
- Add a Postgres service (with its own volume) and run spicedb migrate head once before serve.
- Bindings then survive restarts, so no duplicates.
- Footprint: heavier — new service, a migration step, and startup ordering (app waits on spicedb, spicedb waits on migrate, migrate waits on postgres). App code unchanged.
- Trade-off: the "self-contained, zero-setup, in-memory" quality of the demo goes away.

I think for a sample app Option 1 is better. Would this resemble a production-ish pattern? AppDB owns identity mapping and SpiceDB does only authZ. I think @tstirrat15 's comment on the docs was something to this effect. imo, the postgres option is overkill for a demo.

@miparnisari

miparnisari commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

What's wrong with postgres?

  • Trade-off: the "self-contained, zero-setup, in-memory" quality of the demo goes away.

It would still be self-contained and zero-setup. You can have a docker-compose that does all of that for you. We have one that you can use, I use it all the time :) https://github.com/authzed/spicedb/blob/main/docker-compose.postgres.yaml

@sohanmaheshwar

Copy link
Copy Markdown
Contributor Author

It would still be self-contained and zero-setup. You can have a docker-compose that does all of that for you. We have one that you can use, I use it all the time :) https://github.com/authzed/spicedb/blob/main/docker-compose.postgres.yaml

Yep, like for the RAG demos I use a vector db in a docker container as well. just wondering if in this case, having a postgres instance to do state management takes away from the central teaching concept. Like the architecture diagram gets one more block, and there's a new section in the docs etc.

OTOH, the 'add a new field' is a classic LLM way to solve a problem although in this particular case it maybe ok?

Not sure which is the better approach here.

samkim added a commit to authzed/docs that referenced this pull request Sep 25, 2026
- FAQ: drop the trailing slash from the link to the Zanzibar concepts
  page. authzed.com serves the trailing-slash URL with a 200 instead of
  redirecting, so the page's relative links resolved one level too deep.
- Update moved SpiceDB source links (gc.go, schemadsl lexer), the F1
  paper PDF, and LangChain's FAISS entry.
- Remove links to the retired Motia docs and to the unmerged
  federated-authorization example (authzed/examples#48).
- Skip URLs that fail for the CI crawler but work in a browser:
  Akamai/Cloudflare challenges, x.com, hackmd uploads, nbviewer, and
  dbfiddle, plus mcp.authzed.com, an MCP endpoint rather than a page.
  linkcheck exits non-zero on warnings as well as errors, so each of
  these kept the daily full check red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Clearing the app session cookie alone left Keycloak's browser SSO
session alive, so the next "Log in with Keycloak" silently
re-authenticated and logout appeared to do nothing.

Store the Keycloak id_token on login and, on logout, redirect the
browser to Keycloak's end-session endpoint (front-channel,
RP-initiated) with id_token_hint + post_logout_redirect_uri. Register
the post-logout redirect URIs on the realm client so Keycloak accepts
the redirect back to the app.
SpiceDB ran with --datastore-engine=memory, so restarting the stack wiped
every relationship. Because the app's SQLite users table persisted, a
returning user found no bound_to binding, minted a fresh UUID, and became a
duplicate user row; documents also silently lost their grants.

Switch SpiceDB to the postgres datastore:
- add a postgres service (with track_commit_timestamp=on, SpiceDB's
  recommended setting) and a persistent volume
- add a one-shot 'spicedb datastore migrate head' that runs before serve
  (service_completed_successfully) so SpiceDB never starts unmigrated
- point 'spicedb serve' at the postgres conn URI; the app now waits for
  spicedb to be healthy

Bindings and document grants now persist across 'docker compose restart' and
down/up, fixing the duplicate-user bug at the source with no application
changes. README documents the datastore and its rationale.

Verified: after restarting SpiceDB, a returning login resolves to the same
user UUID (one users row, not two) and a previously-owned document is still
viewable.
… via env

Logout (finding #1): the id_token was minted over the internal address
(keycloak:8080) so its iss was http://keycloak:8080/..., but the end-session
redirect targets the public address (localhost:8080). Keycloak rejects an
id_token_hint whose issuer doesn't match the request host, so the SSO session
was never ended and the next login silently re-authenticated. Pin the realm's
frontendUrl to http://localhost:8080 so every token carries iss=localhost:8080,
matching the end-session endpoint, and fix the misleading code comment.

docker-compose (findings #4/#5): move the SpiceDB datastore engine and
connection URI into a shared YAML anchor passed as environment
(SPICEDB_DATASTORE_*). The Postgres credential is now defined once and no
longer appears on the process command line (visible via docker inspect),
matching the canonical docker-compose.postgres.yaml.

Verified live: a token minted over keycloak:8080 now has
iss=http://localhost:8080/realms/org-a; SpiceDB connects via env with no
conn URI in argv.
…iew checks

Audit follow-up (spicedb-best-practices):

- check_permission now fails CLOSED: a SpiceDB error is logged and returned as
  'no permission' instead of propagating to a 500, so an outage can never fall
  open into granting access. Every call site is 'if not check(...): deny'.
- Add check_permissions_bulk and use it in view_document: view/edit/share are
  resolved in a single CheckBulkPermissions round-trip (was three sequential
  CheckPermission calls). It also fails closed — every permission defaults to
  denied, and a per-item or request-level error leaves it denied.
- lookup_viewable_documents and read_document_relationships no longer swallow
  errors silently ('except Exception: pass'): they log and return an empty list,
  so a SpiceDB outage shows nothing rather than a misleading partial listing.

Verified live against Postgres-backed SpiceDB: owner -> all granted, stranger ->
all denied, and an invalid permission is logged and left denied while valid
permissions in the same request still resolve.
- Pin authzed/spicedb to v1.56.1 (the version :latest currently resolves to)
  for both the migrate and serve services, matching the already-pinned
  keycloak:24.0 and postgres:16-alpine. Avoids non-reproducible pulls now that
  the migrate step applies schema migrations to a persistent Postgres volume.
- README: say 'datastore migrate head' to match the compose command (the older
  'migrate head' alias still works, but the docs should match what runs).
Replace the two tables (relationships written during the walkthrough, and API
calls made by the app) with a single 'How the App Uses SpiceDB' section: a
Mermaid diagram mapping user actions to writes vs. reads/checks and the two
relationship kinds (bold heading per box), plus two summary bullets. Remove the
File Layout section.
Regenerate architecture.png with a PostgreSQL datastore cylinder beneath the
SpiceDB spine ('persists relationships', plus a one-shot migrate chip) and a
caption noting that bindings and grants survive a restart. Update the image alt
text to match.
@sohanmaheshwar

Copy link
Copy Markdown
Contributor Author

Resolved the duplicate-users discussion: back SpiceDB with Postgres

Picked this back up and went with @miparnisari's suggestion — switched SpiceDB from the in-memory engine to a persistent Postgres datastore. That turned out to be the real root cause, and it's broader than just duplicate users: with --datastore-engine=memory, a restart wipes every SpiceDB relationship — the IdP bindings and the document grants. The app's SQLite table survived, so a returning user found no bound_to binding, minted a fresh UUID, and became a duplicate users row; shared documents also silently lost their grants. Persisting SpiceDB fixes all of it at the source, with no application-code change.

The major change, at a glance

Before — in-memory; a restart wipes everything:

flowchart LR
    U["User's Browser"]
    subgraph IdP["Identity Providers"]
      KC["Keycloak (Org A)<br/>OIDC"]
      GH["GitHub (Org B)<br/>OAuth"]
    end
    A["FastAPI app"]
    DB[("SQLite<br/>profiles + doc metadata")]
    S["SpiceDB"]
    MEM[/"in-memory datastore<br/>lost on restart"/]:::warn
    U <--> A
    U -->|login| KC
    U -->|login| GH
    A -->|token exchange| KC
    A -->|token exchange| GH
    A -->|gRPC| S
    A --> DB
    S --- MEM
    classDef warn fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#7c2d12;
Loading

After — Postgres-backed; bindings and grants survive a restart:

flowchart LR
    U["User's Browser"]
    subgraph IdP["Identity Providers"]
      KC["Keycloak (Org A)<br/>OIDC"]
      GH["GitHub (Org B)<br/>OAuth"]
    end
    A["FastAPI app<br/>(unchanged)"]
    DB[("SQLite<br/>profiles + doc metadata")]
    S["SpiceDB"]
    PG[("PostgreSQL<br/>durable relationship store")]:::new
    MIG["datastore migrate head<br/>one-shot on startup"]:::new
    U <--> A
    U -->|login| KC
    U -->|login| GH
    A -->|token exchange| KC
    A -->|token exchange| GH
    A -->|"gRPC: Check / Lookup / Write"| S
    A --> DB
    S -->|reads + writes| PG
    MIG -.->|creates schema| PG
    classDef new fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d;
Loading

The whole delta is the two green blocks — a postgres service and a one-shot datastore migrate head. The federated-identity story (IdPs → app → SpiceDB → documents) is untouched; app code and the SpiceDB schema are unchanged.

Verified: after docker compose restart spicedb, a returning Keycloak login resolves to the same user:<uuid> (one users row, not two) and a previously-owned document is still viewable.

Also in this update

  • RP-initiated Keycloak logout that actually ends the SSO session. The realm's frontendUrl is pinned to the public address so the id_token's issuer matches the end-session endpoint — without it Keycloak rejected the id_token_hint and the next login silently re-authenticated.
  • Permission checks fail closed everywhere — a SpiceDB error denies (and logs) instead of 500-ing or being silently swallowed — and the document-open path now uses a single CheckBulkPermissions instead of three sequential checks.
  • Pinned authzed/spicedb to a release tag, and refreshed the docs: a persistence section, a visual "How the app uses SpiceDB" overview, and the architecture diagram (now showing the Postgres datastore).

@tstirrat15 tstirrat15 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@sohanmaheshwar
sohanmaheshwar merged commit 0f044a5 into main Oct 1, 2026
7 checks passed
@sohanmaheshwar
sohanmaheshwar deleted the federated-authorization-example branch October 1, 2026 16:39
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants