Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Examples in this repository include:
- How to set up SpiceDB with tracing: see [tracing](./tracing)
- How to invoke SpiceDB as a library: see [library](./spicedb-as-library)
- How to run SpiceDB in a Kubernetes cluster: see [kubernetes](./kubernetes)
- How to build an agentic RAG system with fine-grained authorization: see [agentic-rag-authorization](./agentic-rag-authorization)
- How to federate authorization across multiple identity providers: see [federated-authorization](./federated-authorization)
- CI/CD Workflows

Have questions? Join our [Discord].
Expand Down
33 changes: 33 additions & 0 deletions federated-authorization/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SpiceDB
SPICEDB_ENDPOINT=spicedb:50051
SPICEDB_PRESHARED_KEY=demo-token-do-not-use-in-prod

# Keycloak (self-hosted, defaults match docker-compose)
#
# Two separate addresses are required due to Docker networking:
#
# KEYCLOAK_ISSUER — container-network address for server-to-server calls:
# token exchange, JWKS fetch, userinfo endpoint. The hostname 'keycloak'
# is only resolvable from inside the Docker network (i.e. from the app
# container), not from the user's browser on the host.
KEYCLOAK_ISSUER=http://keycloak:8080/realms/org-a
#
# KEYCLOAK_PUBLIC_ISSUER — host/browser-facing address used only to build
# the authorization redirect URL that the user's browser is sent to.
# 'localhost:8080' is the port Keycloak exposes to the host; the app
# container itself cannot reach Keycloak via 'localhost' (that resolves
# to the container's own loopback, not the Keycloak container).
KEYCLOAK_PUBLIC_ISSUER=http://localhost:8080/realms/org-a
#
KEYCLOAK_CLIENT_ID=federated-authz-demo
KEYCLOAK_CLIENT_SECRET=federated-authz-demo-secret

# GitHub OAuth (register at github.com/settings/developers)
# Homepage URL: http://localhost:8000
# Authorization callback URL: http://localhost:8000/auth/github/callback
GITHUB_CLIENT_ID=your_github_client_id_here
GITHUB_CLIENT_SECRET=your_github_client_secret_here

# App
SESSION_SECRET=change-me-in-production-use-a-long-random-string
APP_BASE_URL=http://localhost:8000
12 changes: 12 additions & 0 deletions federated-authorization/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.env
__pycache__/
*.py[cod]
*.db
*.sqlite
data/documents/*
!data/documents/.gitkeep
*.egg-info/
dist/
build/
.venv/
venv/
25 changes: 25 additions & 0 deletions federated-authorization/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt

COPY . .

# Runtime directories (overridden by volume mounts in compose)
RUN mkdir -p /data/documents

ENV DB_PATH=/data/app.db
ENV DOCUMENTS_DIR=/data/documents
# Stream print() output straight to the container logs (no stdout buffering)
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
211 changes: 211 additions & 0 deletions federated-authorization/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# Federated AuthZ Demo

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.

---

## Architecture

![Many identity providers, one federated authorization: the app resolves each login from a swappable IdP to an internal user via a bound_to lookup in SpiceDB, which stores the identity bindings and governs document access for internal users, and persists its relationships to a PostgreSQL datastore so they survive restarts](architecture.png)

### The Federated Identity Pattern

**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):**
1. User completes OAuth/OIDC flow → app receives an IdP-specific subject ID
2. App checks SpiceDB: does `keycloak_account:<sub>#bound_to` (or `github_account:<id>#bound_to`) exist?
3. If yes → use the existing `user:<uuid>` from that relationship
4. If no → mint a new UUID, write the binding to SpiceDB, store profile in SQLite
5. 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
}
```

### Persistence

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.

---

## Prerequisites

- Docker and Docker Compose
- A GitHub account (to test cross-IdP sharing)
- A GitHub OAuth App (instructions below)

---

## Step 1: Register a GitHub OAuth App

1. Go to [github.com/settings/developers](https://github.com/settings/developers)
2. Click **OAuth Apps** → **New OAuth App**
3. Fill in:
- **Application name**: `FedAuthZ Demo` (or anything)
- **Homepage URL**: `http://localhost:8000`
- **Authorization callback URL**: `http://localhost:8000/auth/github/callback`
4. Click **Register application**
5. Note the **Client ID**
6. Click **Generate a new client secret** and note the **Client Secret**

---

## Step 2: Configure Environment

Copy `.env.example` to `.env` and fill in your GitHub credentials:

```bash
cp .env.example .env
```

Edit `.env`:
```env
GITHUB_CLIENT_ID=your_actual_github_client_id
GITHUB_CLIENT_SECRET=your_actual_github_client_secret
SESSION_SECRET=some-long-random-string-here
```

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

---

## Step 3: Start the Stack

```bash
docker-compose up --build
```

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

---

## Demo Walkthrough

### Session 1 — Alice (Keycloak / Org A)

Open http://localhost:8000 in a browser.

1. Click **Log in with Keycloak (Org A)**
2. Log in as `alice@keycloak.com` / `alice123`
3. You are redirected to the dashboard — no documents yet

**What SpiceDB recorded:**
```
keycloak_account:<alice-sub>#bound_to@user:<alice-uuid>
```

4. Click **+ New Document**, give it a title and some content, click **Create Document**

**What SpiceDB recorded:**
```
document:<doc-id>#owner@user:<alice-uuid>
```

5. Note the document URL: `http://localhost:8000/documents/<doc-id>`

### Session 2 — Bob (GitHub / Org B)

Open a **private/incognito browser window** and go to http://localhost:8000.

1. Click **Log in with GitHub (Org B)**
2. Authorize the app with your GitHub account
3. You land on the dashboard — no documents visible (correct!)

**What SpiceDB recorded:**
```
github_account:<github-numeric-id>#bound_to@user:<bob-uuid>
```

4. Try visiting Alice's document URL directly → you get **403 Access Denied**
SpiceDB's CheckPermission returns `PERMISSIONSHIP_NO_PERMISSION`

### Session 1 — Alice shares with Bob

Back in Alice's browser:

5. Open the document, scroll to **Share This Document**
6. Bob's GitHub account appears in the dropdown (fetched from SQLite `users` table — for display only)
7. Select Bob, choose **Viewer**, click **Share**

**What SpiceDB recorded:**
```
document:<doc-id>#viewer@user:<bob-uuid>
```

### Session 2 — Bob accesses the shared document

Back in Bob's browser:

8. Refresh the dashboard — Alice's document now appears (LookupResources finds it)
9. Click the document — content is visible (CheckPermission: `view` → `PERMISSIONSHIP_HAS_PERMISSION`)
10. No edit form or Share section visible (Bob only has `viewer` permission)

### Verify Revocation

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.

---

## How the App Uses SpiceDB

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.

```mermaid
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_to` binding), 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`).
Loading
Loading