diff --git a/README.md b/README.md index a4fbdfb..286b885 100644 --- a/README.md +++ b/README.md @@ -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]. diff --git a/federated-authorization/.env.example b/federated-authorization/.env.example new file mode 100644 index 0000000..7dfc8f0 --- /dev/null +++ b/federated-authorization/.env.example @@ -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 diff --git a/federated-authorization/.gitignore b/federated-authorization/.gitignore new file mode 100644 index 0000000..f8bde4a --- /dev/null +++ b/federated-authorization/.gitignore @@ -0,0 +1,12 @@ +.env +__pycache__/ +*.py[cod] +*.db +*.sqlite +data/documents/* +!data/documents/.gitkeep +*.egg-info/ +dist/ +build/ +.venv/ +venv/ diff --git a/federated-authorization/Dockerfile b/federated-authorization/Dockerfile new file mode 100644 index 0000000..80f845b --- /dev/null +++ b/federated-authorization/Dockerfile @@ -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"] diff --git a/federated-authorization/README.md b/federated-authorization/README.md new file mode 100644 index 0000000..4430716 --- /dev/null +++ b/federated-authorization/README.md @@ -0,0 +1,261 @@ +# 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: external accounts from swappable IdPs each bind to one internal user, and SpiceDB governs document access for internal users only](architecture.png) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Enterprise AuthZ Layer │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Keycloak │ │ GitHub │ │ SpiceDB │ │ +│ │ (Org A) │ │ OAuth │ │ (AuthZ) │ │ +│ │ │ │ (Org B) │ │ │ │ +│ │ alice │ │ bob (any │ │ Relationships│ │ +│ │ carol │ │ GH account) │ │ + Schema │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └──────────┬────────┘ │ │ +│ │ │ │ +│ ┌─────▼────────────────────────────▼──────┐ │ +│ │ FastAPI App (Python) │ │ +│ │ │ │ +│ │ 1. OAuth/OIDC callback → extract sub │ │ +│ │ 2. Resolve/create internal user UUID │ │ +│ │ 3. All authz checks → SpiceDB only │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 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:`, 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:#bound_to` (or `github_account:#bound_to`) exist? +3. If yes → use the existing `user:` 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:` — 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 +} +``` + +--- + +## 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 + +--- + +## 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:#bound_to@user: +``` + +4. Click **+ New Document**, give it a title and some content, click **Create Document** + +**What SpiceDB recorded:** +``` +document:#owner@user: +``` + +5. Note the document URL: `http://localhost:8000/documents/` + +### 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:#bound_to@user: +``` + +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:#viewer@user: +``` + +### 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. + +--- + +## SpiceDB Relationships Written During Walkthrough + +| Step | Relationship written | +|------|---------------------| +| Alice first login | `keycloak_account:#bound_to@user:` | +| Bob first login | `github_account:#bound_to@user:` | +| Alice creates document | `document:#owner@user:` | +| Alice shares with Bob (viewer) | `document:#viewer@user:` | +| Alice shares with Bob (editor) | `document:#editor@user:` | +| Alice revokes Bob's access | (relationship deleted) | + +--- + +## SpiceDB API Calls Made by the App + +| Operation | SpiceDB API | Used for | +|-----------|-------------|----------| +| First login (new user) | `WriteRelationships` | Record IdP→user binding | +| Login (returning user) | `ReadRelationships` | Look up existing binding | +| Dashboard load | `LookupResources` | Find all viewable docs | +| Document open | `CheckPermission(view)` | Gate document access | +| Edit form display | `CheckPermission(edit)` | Show/hide edit UI | +| Share section display | `CheckPermission(share)` | Show/hide share UI | +| Save edit | `CheckPermission(edit)` | Server-side auth check | +| Share action | `WriteRelationships` | Grant access | +| Revoke action | `DeleteRelationships` | Revoke access | +| Share page sharees | `ReadRelationships` | List current access | + +--- + +## File Layout + +``` +federated-authz-demo/ +├── app.py # FastAPI application +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── .env.example +├── .gitignore +├── spicedb/ +│ └── schema.zed # SpiceDB schema (source of truth) +├── keycloak/ +│ └── realm-export.json # Pre-seeded Org A realm with alice + carol +├── templates/ # Jinja2 server-rendered HTML +│ ├── base.html +│ ├── login.html +│ ├── dashboard.html +│ ├── document_new.html +│ ├── document_view.html +│ └── error.html +├── static/ +│ └── style.css +└── data/ + └── documents/ # Document files (volume-mounted) +``` diff --git a/federated-authorization/app.py b/federated-authorization/app.py new file mode 100644 index 0000000..f39b09a --- /dev/null +++ b/federated-authorization/app.py @@ -0,0 +1,759 @@ +""" +Federated AuthZ Demo +==================== +FastAPI application demonstrating federated identity (Keycloak + GitHub OAuth) +with centralized fine-grained authorization via SpiceDB. +""" +import os +import uuid +import json +import sqlite3 +from pathlib import Path +from datetime import datetime +from contextlib import asynccontextmanager +from typing import Optional + +import grpc +import httpx +from authlib.integrations.starlette_client import OAuth +from fastapi import FastAPI, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from dotenv import load_dotenv + +from authzed.api.v1 import ( + SyncClient as Client, + WriteRelationshipsRequest, + ReadRelationshipsRequest, + DeleteRelationshipsRequest, + CheckPermissionRequest, + CheckPermissionResponse, + LookupResourcesRequest, + LookupSubjectsRequest, + Relationship, + RelationshipUpdate, + ObjectReference, + SubjectReference, + RelationshipFilter, + SubjectFilter, + WriteSchemaRequest, + Consistency, + ZedToken, +) + +load_dotenv() + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +SPICEDB_ENDPOINT = os.environ.get("SPICEDB_ENDPOINT", "spicedb:50051") +SPICEDB_KEY = os.environ.get("SPICEDB_PRESHARED_KEY", "demo-token-do-not-use-in-prod") +KEYCLOAK_ISSUER = os.environ.get("KEYCLOAK_ISSUER", "http://keycloak:8080/realms/org-a") +KEYCLOAK_PUBLIC_ISSUER = os.environ.get("KEYCLOAK_PUBLIC_ISSUER", "http://localhost:8080/realms/org-a") +KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "federated-authz-demo") +KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "federated-authz-demo-secret") +GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "") +GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "") +SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-session-secret-change-in-prod") +APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8000") + +DB_PATH = os.environ.get("DB_PATH", "/data/app.db") +DOCUMENTS_DIR = Path(os.environ.get("DOCUMENTS_DIR", "/data/documents")) + +SCHEMA_PATH = Path(__file__).parent / "spicedb" / "schema.zed" + +# --------------------------------------------------------------------------- +# SpiceDB client (module-level singleton set on startup) +# --------------------------------------------------------------------------- + +class _BearerTokenInterceptor( + grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor +): + """Attaches the SpiceDB preshared key as an ``authorization`` header. + + gRPC refuses to attach access-token *call credentials* to an insecure + channel, and the ``local_channel_credentials`` workaround only permits + loopback peers — neither works when the app reaches SpiceDB over the + Docker network. Sending the token as request metadata on a plain insecure + channel does, which is what this interceptor does. (Insecure transport is + fine for this local demo; use TLS in production.) + """ + + def __init__(self, token: str): + self._extra_metadata = [("authorization", f"Bearer {token}")] + + def _with_token(self, details): + metadata = list(details.metadata or []) + self._extra_metadata + return details._replace(metadata=metadata) + + def intercept_unary_unary(self, continuation, client_call_details, request): + return continuation(self._with_token(client_call_details), request) + + def intercept_unary_stream(self, continuation, client_call_details, request): + return continuation(self._with_token(client_call_details), request) + + +def make_spicedb_client(endpoint: str, token: str) -> Client: + """Build a synchronous SpiceDB client over an insecure channel.""" + channel = grpc.intercept_channel( + grpc.insecure_channel(endpoint), _BearerTokenInterceptor(token) + ) + client = Client.__new__(Client) + for stub in Client.__bases__: + stub.__init__(client, channel) + return client + + +_spicedb_client: Optional[Client] = None + + +def get_spicedb() -> Client: + if _spicedb_client is None: + raise RuntimeError("SpiceDB client not initialized") + return _spicedb_client + + +# --------------------------------------------------------------------------- +# Database helpers +# --------------------------------------------------------------------------- +def get_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True) + conn = get_db() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT, + display_name TEXT, + provider TEXT, + created_at TEXT + ); + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + filename TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + """) + conn.commit() + conn.close() + + +# --------------------------------------------------------------------------- +# SpiceDB helpers +# The authzed Client exposes RPC methods directly; the preshared key is +# injected as request metadata by _BearerTokenInterceptor (see above). +# --------------------------------------------------------------------------- + +def _obj(object_type: str, object_id: str) -> ObjectReference: + return ObjectReference(object_type=object_type, object_id=object_id) + + +def _subj(object_type: str, object_id: str) -> SubjectReference: + return SubjectReference(object=_obj(object_type, object_id)) + + +def _consistency(zedtoken: Optional[str]) -> Consistency: + """Consistency for reads. + + With a ZedToken (returned by a prior write), request ``at_least_as_fresh`` + so the caller reliably sees its own writes without forcing a fully + consistent (slower) read. Without one, fall back to ``minimize_latency``. + """ + if zedtoken: + return Consistency(at_least_as_fresh=ZedToken(token=zedtoken)) + return Consistency(minimize_latency=True) + + +def write_relationship(client: Client, resource_type: str, resource_id: str, + relation: str, subject_type: str, subject_id: str) -> str: + """Write a relationship; return the ZedToken of the write for read-your-writes.""" + resp = client.WriteRelationships(WriteRelationshipsRequest( + updates=[RelationshipUpdate( + operation=RelationshipUpdate.OPERATION_TOUCH, + relationship=Relationship( + resource=_obj(resource_type, resource_id), + relation=relation, + subject=_subj(subject_type, subject_id), + ), + )] + )) + return resp.written_at.token + + +def delete_relationship(client: Client, resource_type: str, resource_id: str, + relation: str, subject_type: str, subject_id: str) -> str: + """Delete a relationship; return the ZedToken of the delete for read-your-writes.""" + resp = client.DeleteRelationships(DeleteRelationshipsRequest( + relationship_filter=RelationshipFilter( + resource_type=resource_type, + optional_resource_id=resource_id, + optional_relation=relation, + optional_subject_filter=SubjectFilter( + subject_type=subject_type, + optional_subject_id=subject_id, + ), + ) + )) + return resp.deleted_at.token + + +def check_permission(client: Client, resource_type: str, resource_id: str, + permission: str, subject_id: str, + zedtoken: Optional[str] = None) -> bool: + resp = client.CheckPermission(CheckPermissionRequest( + resource=_obj(resource_type, resource_id), + permission=permission, + subject=_subj("user", subject_id), + consistency=_consistency(zedtoken), + )) + return resp.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION + + +def lookup_viewable_documents(client: Client, user_id: str, + zedtoken: Optional[str] = None) -> list[str]: + doc_ids = [] + try: + for resp in client.LookupResources(LookupResourcesRequest( + resource_object_type="document", + permission="view", + subject=_subj("user", user_id), + consistency=_consistency(zedtoken), + )): + doc_ids.append(resp.resource_object_id) + except Exception: + pass + return doc_ids + + +def find_idp_binding(client: Client, idp_type: str, idp_subject: str) -> Optional[str]: + """Return the internal user UUID bound to this IdP account, or None. + + Identity resolution across logins depends on this lookup, so errors are + NOT swallowed: treating a transient SpiceDB failure as "no binding" would + mint a fresh user UUID on every login and orphan the previous identity's + documents. An account with no binding simply yields no subjects, which + returns None without raising. + + This resolves which internal user an external IdP account maps to. We use + LookupSubjects on the bound_to relation rather than the ReadRelationships + escape hatch: it fits the "who is the subject of this relation" query + shape and, unlike a raw ReadRelationships datastore read, benefits from + SpiceDB's computed-permission cache. + """ + for resp in client.LookupSubjects(LookupSubjectsRequest( + resource=_obj(idp_type, idp_subject), + permission="bound_to", + subject_object_type="user", + )): + return resp.subject.subject_object_id + return None + + +def read_document_relationships(client: Client, doc_id: str, + zedtoken: Optional[str] = None) -> list[dict]: + results = [] + try: + # Deliberate use of the ReadRelationships escape hatch. This is NOT an + # access decision (those go through check_permission). The sharing UI + # needs the *specific grant relation* — viewer vs editor — so it can + # display and individually revoke each grant. LookupSubjects on the + # "view" permission would flatten that distinction away and also pull + # in the owner, so reading the raw grants is the right tool here. + for resp in client.ReadRelationships(ReadRelationshipsRequest( + relationship_filter=RelationshipFilter( + resource_type="document", + optional_resource_id=doc_id, + ), + consistency=_consistency(zedtoken), + )): + rel = resp.relationship + results.append({ + "relation": rel.relation, + "subject_type": rel.subject.object.object_type, + "subject_id": rel.subject.object.object_id, + }) + except Exception: + pass + return results + + +# --------------------------------------------------------------------------- +# OAuth setup (Authlib) +# --------------------------------------------------------------------------- +oauth = OAuth() + +oauth.register( + name="keycloak", + client_id=KEYCLOAK_CLIENT_ID, + client_secret=KEYCLOAK_CLIENT_SECRET, + # Browser-facing: the user's browser is redirected here to log in. + # Must use the host-reachable address (localhost), not the container-network name. + authorize_url=f"{KEYCLOAK_PUBLIC_ISSUER}/protocol/openid-connect/auth", + # Server-to-server: the app container calls these directly; must use the + # container-network name ('keycloak'), not localhost, which is unreachable + # from inside the container. + access_token_url=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/token", + # jwks_uri and userinfo_endpoint are stored as server_metadata (Authlib + # treats unrecognised register() kwargs as static server metadata), so + # parse_id_token / fetch_jwk_set can find them without an OIDC discovery + # HTTP request, and they point at the internal container-network address. + jwks_uri=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs", + userinfo_endpoint=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/userinfo", + client_kwargs={"scope": "openid email profile"}, +) + +oauth.register( + name="github", + client_id=GITHUB_CLIENT_ID, + client_secret=GITHUB_CLIENT_SECRET, + authorize_url="https://github.com/login/oauth/authorize", + access_token_url="https://github.com/login/oauth/access_token", + client_kwargs={"scope": "read:user user:email"}, +) + + +# --------------------------------------------------------------------------- +# App lifespan +# --------------------------------------------------------------------------- +@asynccontextmanager +async def lifespan(app: FastAPI): + global _spicedb_client + init_db() + + # Connect to SpiceDB with insecure channel + bearer token + _spicedb_client = make_spicedb_client(SPICEDB_ENDPOINT, SPICEDB_KEY) + + # Write schema on startup (idempotent — safe to re-run) + schema = SCHEMA_PATH.read_text() + try: + _spicedb_client.WriteSchema(WriteSchemaRequest(schema=schema)) + print("[startup] SpiceDB schema written.") + except Exception as e: + print(f"[startup] Schema write warning: {e}") + + yield + + +# --------------------------------------------------------------------------- +# FastAPI app +# --------------------------------------------------------------------------- +app = FastAPI(lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, max_age=86400) + +app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) + + +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- +def current_user(request: Request) -> Optional[dict]: + return request.session.get("user") + + + +def get_or_create_user(idp_type: str, idp_subject: str, email: str, display_name: str, provider: str) -> str: + """ + Resolve or create an internal user, writing the SpiceDB IdP binding. + Returns the internal user UUID (canonical identity across all IdPs). + """ + client = get_spicedb() + + existing = find_idp_binding(client, idp_type, idp_subject) + if existing: + return existing + + user_id = str(uuid.uuid4()) + conn = get_db() + conn.execute( + "INSERT OR IGNORE INTO users (id, email, display_name, provider, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, email, display_name, provider, datetime.utcnow().isoformat()), + ) + conn.commit() + conn.close() + + write_relationship(client, idp_type, idp_subject, "bound_to", "user", user_id) + + return user_id + + +# --------------------------------------------------------------------------- +# Routes: Landing page +# --------------------------------------------------------------------------- +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + user = current_user(request) + if not user: + return templates.TemplateResponse("login.html", {"request": request}) + + client = get_spicedb() + doc_ids = lookup_viewable_documents(client, user["id"], request.session.get("zedtoken")) + + db = get_db() + docs = [] + if doc_ids: + placeholders = ",".join("?" * len(doc_ids)) + docs = db.execute( + f"SELECT * FROM documents WHERE id IN ({placeholders})", doc_ids + ).fetchall() + db.close() + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "user": user, + "documents": docs, + }) + + +# --------------------------------------------------------------------------- +# Routes: Keycloak OAuth +# --------------------------------------------------------------------------- +@app.get("/auth/keycloak/login") +async def keycloak_login(request: Request): + redirect_uri = f"{APP_BASE_URL}/auth/keycloak/callback" + return await oauth.keycloak.authorize_redirect(request, redirect_uri) + + +@app.get("/auth/keycloak/callback") +async def keycloak_callback(request: Request): + try: + token = await oauth.keycloak.authorize_access_token(request) + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"Keycloak login failed: {e}", + }) + + # Authlib populates userinfo from the OIDC userinfo endpoint automatically + userinfo = token.get("userinfo") or {} + + # Fall back to manually decoding the id_token JWT payload if needed + if not userinfo.get("sub"): + import base64 + parts = token.get("id_token", "..").split(".") + if len(parts) >= 2: + padded = parts[1] + "=" * (4 - len(parts[1]) % 4) + userinfo = json.loads(base64.urlsafe_b64decode(padded)) + + sub = userinfo.get("sub", "unknown") + email = userinfo.get("email", f"{sub}@keycloak.org") + display_name = userinfo.get("name") or userinfo.get("preferred_username") or email + + user_id = get_or_create_user( + idp_type="keycloak_account", + idp_subject=sub, + email=email, + display_name=display_name, + provider="keycloak", + ) + + request.session["user"] = { + "id": user_id, + "email": email, + "display_name": display_name, + "provider": "Keycloak (Org A)", + } + return RedirectResponse("/", status_code=302) + + +# --------------------------------------------------------------------------- +# Routes: GitHub OAuth +# --------------------------------------------------------------------------- +@app.get("/auth/github/login") +async def github_login(request: Request): + if not GITHUB_CLIENT_ID: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "GitHub OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env", + }) + redirect_uri = f"{APP_BASE_URL}/auth/github/callback" + return await oauth.github.authorize_redirect(request, redirect_uri) + + +@app.get("/auth/github/callback") +async def github_callback(request: Request): + try: + token = await oauth.github.authorize_access_token(request) + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"GitHub login failed: {e}", + }) + + # Fetch GitHub user profile + async with httpx.AsyncClient() as http: + resp = await http.get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {token['access_token']}", + "Accept": "application/vnd.github+json"}, + ) + gh_user = resp.json() + + # Fetch primary email if not in profile + email = gh_user.get("email") + if not email: + email_resp = await http.get( + "https://api.github.com/user/emails", + headers={"Authorization": f"Bearer {token['access_token']}", + "Accept": "application/vnd.github+json"}, + ) + emails = email_resp.json() + primary = next((e for e in emails if e.get("primary")), None) + email = primary["email"] if primary else f"{gh_user['login']}@github.local" + + github_id = str(gh_user["id"]) + display_name = gh_user.get("name") or gh_user.get("login") + + user_id = get_or_create_user( + idp_type="github_account", + idp_subject=github_id, + email=email, + display_name=display_name, + provider="github", + ) + + request.session["user"] = { + "id": user_id, + "email": email, + "display_name": display_name, + "provider": "GitHub (Org B)", + } + return RedirectResponse("/", status_code=302) + + +# --------------------------------------------------------------------------- +# Routes: Logout +# --------------------------------------------------------------------------- +@app.get("/auth/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/", status_code=302) + + +# --------------------------------------------------------------------------- +# Routes: Documents +# --------------------------------------------------------------------------- +@app.get("/documents/new", response_class=HTMLResponse) +async def new_document_form(request: Request): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse("document_new.html", {"request": request, "user": user}) + + +@app.post("/documents/new") +async def create_document(request: Request, title: str = Form(...), content: str = Form(...)): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + + doc_id = str(uuid.uuid4()) + filename = f"{doc_id}.md" + filepath = DOCUMENTS_DIR / filename + + DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True) + filepath.write_text(content, encoding="utf-8") + + db = get_db() + db.execute( + "INSERT INTO documents (id, title, filename, owner_user_id, created_at) VALUES (?, ?, ?, ?, ?)", + (doc_id, title, filename, user["id"], datetime.utcnow().isoformat()), + ) + db.commit() + db.close() + + # Write owner relationship to SpiceDB; remember the ZedToken so the very + # next read (the redirect below) reliably sees this write. + client = get_spicedb() + token = write_relationship(client, "document", doc_id, "owner", "user", user["id"]) + request.session["zedtoken"] = token + + return RedirectResponse(f"/documents/{doc_id}", status_code=302) + + +@app.get("/documents/{doc_id}", response_class=HTMLResponse) +async def view_document(request: Request, doc_id: str): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + + db = get_db() + doc = db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone() + db.close() + + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + client = get_spicedb() + zedtoken = request.session.get("zedtoken") + + if not check_permission(client, "document", doc_id, "view", user["id"], zedtoken): + return templates.TemplateResponse("error.html", { + "request": request, + "error": "Access denied. You do not have permission to view this document.", + "user": user, + }, status_code=403) + + can_edit = check_permission(client, "document", doc_id, "edit", user["id"], zedtoken) + can_share = check_permission(client, "document", doc_id, "share", user["id"], zedtoken) + + content = "" + filepath = DOCUMENTS_DIR / doc["filename"] + if filepath.exists(): + content = filepath.read_text(encoding="utf-8") + + sharees = [] + if can_share: + # Read all relationships and enrich with display names + rels = read_document_relationships(client, doc_id, zedtoken) + db2 = get_db() + for rel in rels: + if rel["relation"] in ("viewer", "editor") and rel["subject_type"] == "user": + uid = rel["subject_id"] + row = db2.execute("SELECT display_name, email, provider FROM users WHERE id = ?", (uid,)).fetchone() + sharees.append({ + "user_id": uid, + "relation": rel["relation"], + "display_name": row["display_name"] if row else uid, + "email": row["email"] if row else "", + "provider": row["provider"] if row else "", + }) + db2.close() + + # All other users for the sharing dropdown. List by internal user id (the + # UUID) — NOT deduped by email. Each internal user is a distinct shareable + # identity, and its id is exactly the UUID that identity authenticates as + # and that document relationships reference. Collapsing by email would hand + # back a UUID nobody logs in as (breaking the share) and would also merge + # two legitimately-separate identities that share an email across providers. + all_users = [] + if can_share: + db3 = get_db() + rows = db3.execute( + "SELECT id, display_name, email, provider FROM users WHERE id != ? ORDER BY display_name", + (user["id"],), + ).fetchall() + db3.close() + sharee_ids = {s["user_id"] for s in sharees} + all_users = [r for r in rows if r["id"] not in sharee_ids] + + return templates.TemplateResponse("document_view.html", { + "request": request, + "user": user, + "doc": doc, + "content": content, + "can_edit": can_edit, + "can_share": can_share, + "sharees": sharees, + "all_users": all_users, + }) + + +def _safe_doc_id(doc_id: str) -> str: + """Return the canonical form of a document id, or 404 on anything invalid. + + Document ids are always generated with ``uuid.uuid4()`` (see + ``create_document``), so a value that isn't a UUID can only be a bad or + malicious request. Normalizing through ``uuid.UUID`` also guarantees the id + is safe to interpolate into a redirect ``Location``: it can contain only hex + digits and hyphens, so it cannot break out of the ``/documents/`` path. + """ + try: + return str(uuid.UUID(doc_id)) + except ValueError: + raise HTTPException(status_code=404, detail="Document not found") + + +@app.post("/documents/{doc_id}/edit") +async def edit_document(request: Request, doc_id: str, content: str = Form(...)): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + + doc_id = _safe_doc_id(doc_id) + + client = get_spicedb() + if not check_permission(client, "document", doc_id, "edit", user["id"], + request.session.get("zedtoken")): + raise HTTPException(status_code=403, detail="You cannot edit this document") + + db = get_db() + doc = db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone() + db.close() + if not doc: + raise HTTPException(status_code=404) + + filepath = DOCUMENTS_DIR / doc["filename"] + filepath.write_text(content, encoding="utf-8") + + return RedirectResponse(f"/documents/{doc_id}", status_code=302) + + +@app.post("/documents/{doc_id}/share") +async def share_document( + request: Request, + doc_id: str, + target_user_id: str = Form(...), + permission_level: str = Form(...), +): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + + doc_id = _safe_doc_id(doc_id) + + client = get_spicedb() + if not check_permission(client, "document", doc_id, "share", user["id"], + request.session.get("zedtoken")): + raise HTTPException(status_code=403, detail="Only the document owner can share") + + if permission_level not in ("viewer", "editor"): + raise HTTPException(status_code=400, detail="Invalid permission level") + + # Sharing with yourself is a no-op: as owner you already have view/edit/share. + # Reject it so a redundant viewer/editor grant can't be created (and later + # "revoked" with no visible effect). + if target_user_id == user["id"]: + raise HTTPException(status_code=400, detail="You cannot share a document with yourself") + + token = write_relationship(client, "document", doc_id, permission_level, "user", target_user_id) + request.session["zedtoken"] = token + + return RedirectResponse(f"/documents/{doc_id}", status_code=302) + + +@app.post("/documents/{doc_id}/unshare") +async def unshare_document( + request: Request, + doc_id: str, + target_user_id: str = Form(...), + relation: str = Form(...), +): + user = current_user(request) + if not user: + return RedirectResponse("/", status_code=302) + + doc_id = _safe_doc_id(doc_id) + + client = get_spicedb() + if not check_permission(client, "document", doc_id, "share", user["id"], + request.session.get("zedtoken")): + raise HTTPException(status_code=403, detail="Only the document owner can revoke access") + + if relation not in ("viewer", "editor"): + raise HTTPException(status_code=400, detail="Invalid relation") + + token = delete_relationship(client, "document", doc_id, relation, "user", target_user_id) + request.session["zedtoken"] = token + + return RedirectResponse(f"/documents/{doc_id}", status_code=302) diff --git a/federated-authorization/architecture.png b/federated-authorization/architecture.png new file mode 100644 index 0000000..2bdc569 Binary files /dev/null and b/federated-authorization/architecture.png differ diff --git a/federated-authorization/data/documents/.gitkeep b/federated-authorization/data/documents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/federated-authorization/docker-compose.yml b/federated-authorization/docker-compose.yml new file mode 100644 index 0000000..ac018ee --- /dev/null +++ b/federated-authorization/docker-compose.yml @@ -0,0 +1,79 @@ +--- +services: + keycloak: + image: "quay.io/keycloak/keycloak:24.0" + command: "start-dev --import-realm" + environment: + KEYCLOAK_ADMIN: "admin" + KEYCLOAK_ADMIN_PASSWORD: "admin" + volumes: + - "./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" + ports: + - "8080:8080" + # The Keycloak 24 image ships no curl/wget, so the healthcheck uses bash's + # built-in /dev/tcp to issue a raw HTTP request and confirm the realm is + # actually serving (200 OK), not just that the port is open. + healthcheck: + test: + - "CMD-SHELL" + - "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /realms/org-a HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && grep -q '200 OK' <&3" + interval: "15s" + timeout: "10s" + retries: 10 + start_period: "40s" + + spicedb: + image: "authzed/spicedb:latest" + command: > + serve + --grpc-preshared-key=demo-token-do-not-use-in-prod + --datastore-engine=memory + --http-enabled + ports: + - "50051:50051" # gRPC + - "8090:8443" # HTTP API (SpiceDB serves REST on 8443; mapped to 8090 on host to avoid conflict with Keycloak) + healthcheck: + test: ["CMD", "grpc_health_probe", "-addr=:50051"] + interval: "10s" + timeout: "5s" + retries: 5 + start_period: "10s" + + app: + build: "." + ports: + - "8000:8000" + volumes: + - "app-data:/data" + environment: + # SpiceDB + SPICEDB_ENDPOINT: "spicedb:50051" + SPICEDB_PRESHARED_KEY: "demo-token-do-not-use-in-prod" + # Keycloak — two addresses are needed because of Docker networking: + # KEYCLOAK_ISSUER is the container-network address used for server-to-server + # calls (token exchange, JWKS fetch, userinfo). Only the app container can + # resolve 'keycloak'; the user's browser cannot. + KEYCLOAK_ISSUER: "http://keycloak:8080/realms/org-a" + # KEYCLOAK_PUBLIC_ISSUER is the host-facing address used only to build the + # browser redirect URL for the authorization endpoint. 'localhost:8080' is + # what the host exposes, not reachable inside the app container by that name. + KEYCLOAK_PUBLIC_ISSUER: "http://localhost:8080/realms/org-a" + KEYCLOAK_CLIENT_ID: "federated-authz-demo" + KEYCLOAK_CLIENT_SECRET: "federated-authz-demo-secret" + # GitHub — must be supplied via .env or environment + GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}" + GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}" + # App + SESSION_SECRET: "${SESSION_SECRET:-dev-session-secret-change-in-prod}" + APP_BASE_URL: "http://localhost:8000" + DB_PATH: "/data/app.db" + DOCUMENTS_DIR: "/data/documents" + depends_on: + keycloak: + condition: "service_healthy" + spicedb: + condition: "service_started" + restart: "on-failure" + +volumes: + app-data: diff --git a/federated-authorization/keycloak/realm-export.json b/federated-authorization/keycloak/realm-export.json new file mode 100644 index 0000000..ed725cb --- /dev/null +++ b/federated-authorization/keycloak/realm-export.json @@ -0,0 +1,81 @@ +{ + "realm": "org-a", + "enabled": true, + "displayName": "Org A", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "clients": [ + { + "clientId": "federated-authz-demo", + "enabled": true, + "protocol": "openid-connect", + "clientAuthenticatorType": "client-secret", + "secret": "federated-authz-demo-secret", + "publicClient": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8000/auth/keycloak/callback", + "http://app:8000/auth/keycloak/callback" + ], + "webOrigins": [ + "http://localhost:8000", + "http://app:8000" + ], + "fullScopeAllowed": true, + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "users": [ + { + "username": "alice", + "email": "alice@keycloak.com", + "emailVerified": true, + "enabled": true, + "firstName": "Alice", + "lastName": "Smith", + "credentials": [ + { + "type": "password", + "value": "alice123", + "temporary": false + } + ], + "requiredActions": [] + }, + { + "username": "carol", + "email": "carol@keycloak.com", + "emailVerified": true, + "enabled": true, + "firstName": "Carol", + "lastName": "Jones", + "credentials": [ + { + "type": "password", + "value": "carol123", + "temporary": false + } + ], + "requiredActions": [] + } + ] +} diff --git a/federated-authorization/requirements.txt b/federated-authorization/requirements.txt new file mode 100644 index 0000000..21ed4fa --- /dev/null +++ b/federated-authorization/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +authlib==1.4.0 +httpx==0.28.1 +authzed==0.17.0 +itsdangerous==2.2.0 +jinja2==3.1.5 +python-multipart==0.0.20 +python-dotenv==1.0.1 +starlette==0.41.3 +grpcio==1.68.1 +protobuf==5.29.3 +SQLAlchemy==2.0.36 diff --git a/federated-authorization/spicedb/schema.zed b/federated-authorization/spicedb/schema.zed new file mode 100644 index 0000000..57c5963 --- /dev/null +++ b/federated-authorization/spicedb/schema.zed @@ -0,0 +1,19 @@ +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 +} diff --git a/federated-authorization/static/style.css b/federated-authorization/static/style.css new file mode 100644 index 0000000..d31c212 --- /dev/null +++ b/federated-authorization/static/style.css @@ -0,0 +1,165 @@ +/* FedAuthZ Demo – minimal, clean CSS */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #4f46e5; + --primary-dark: #3730a3; + --keycloak: #e8590c; + --github: #24292f; + --danger: #dc2626; + --success: #16a34a; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-600: #4b5563; + --gray-800: #1f2937; + --radius: 8px; + --shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08); + --shadow-lg: 0 4px 12px rgba(0,0,0,.15); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--gray-100); + color: var(--gray-800); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Navbar */ +.navbar { + background: #fff; + border-bottom: 1px solid var(--gray-200); + padding: 0 2rem; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: var(--shadow); + position: sticky; + top: 0; + z-index: 100; +} +.nav-brand { font-weight: 700; font-size: 1.1rem; display: flex; align-items: center; gap: .5rem; } +.logo { font-size: 1.3rem; } +.nav-user { display: flex; align-items: center; gap: 1rem; } +.user-info { display: flex; flex-direction: column; line-height: 1.2; font-size: .85rem; } +.user-info strong { font-size: .95rem; } +.user-info small { color: var(--gray-600); } + +/* Container */ +.container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; flex: 1; } + +/* Footer */ +.footer { text-align: center; padding: 1rem; color: var(--gray-600); font-size: .8rem; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: .5rem; + padding: .5rem 1rem; border-radius: var(--radius); + font-size: .9rem; font-weight: 500; cursor: pointer; + text-decoration: none; border: none; + transition: opacity .15s, transform .1s; +} +.btn:hover { opacity: .9; } +.btn:active { transform: scale(.98); } +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-dark); opacity: 1; } +.btn-outline { background: transparent; color: var(--gray-800); border: 1px solid var(--gray-200); } +.btn-outline:hover { background: var(--gray-100); opacity: 1; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-sm { padding: .3rem .7rem; font-size: .8rem; } +.btn-lg { padding: 1rem 1.5rem; font-size: 1rem; } +.btn-keycloak { background: var(--keycloak); color: #fff; width: 100%; } +.btn-github { background: var(--github); color: #fff; width: 100%; } +.btn-icon { font-size: 1.4rem; } +.btn-title { font-weight: 600; } +.btn-sub { font-size: .78rem; opacity: .85; } + +/* Badges */ +.badge { + display: inline-block; padding: .2rem .5rem; border-radius: 4px; + font-size: .75rem; font-weight: 600; letter-spacing: .02em; +} +.badge-keycloak { background: #fff0eb; color: var(--keycloak); border: 1px solid #f8c3a8; } +.badge-github { background: #f0f0f0; color: var(--github); border: 1px solid #ccc; } +.badge-owner { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; } +.badge-perm-owner { background: #fef3c7; color: #92400e; } +.badge-perm-editor { background: #dbeafe; color: #1e40af; } +.badge-perm-viewer { background: #dcfce7; color: #166534; } + +/* Login page */ +.login-page { justify-content: center; background: linear-gradient(135deg, #e0e7ff 0%, #f0f9ff 100%); } +.login-container { display: flex; align-items: center; justify-content: center; flex: 1; padding: 2rem; } +.login-card { + background: #fff; border-radius: 12px; box-shadow: var(--shadow-lg); + padding: 2.5rem; width: 100%; max-width: 420px; +} +.login-header { text-align: center; margin-bottom: 2rem; } +.logo-large { font-size: 3rem; } +.login-header h1 { font-size: 1.6rem; margin-top: .5rem; } +.subtitle { color: var(--gray-600); font-size: .9rem; margin-top: .3rem; } +.login-divider { display: flex; align-items: center; gap: 1rem; margin: 1.5rem 0; color: var(--gray-600); font-size: .85rem; } +.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--gray-200); } +.login-options { display: flex; flex-direction: column; gap: 1rem; } +.login-info { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--gray-200); } +.login-info h3 { font-size: .9rem; color: var(--gray-600); margin-bottom: .5rem; } +.creds-table { width: 100%; font-size: .85rem; } +.creds-table td { padding: .25rem .5rem; } + +/* Dashboard */ +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; } +.page-header h2 { font-size: 1.4rem; } +.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; } +.doc-card { + background: #fff; border-radius: var(--radius); padding: 1.2rem; + box-shadow: var(--shadow); text-decoration: none; color: inherit; + transition: box-shadow .15s, transform .1s; + border: 1px solid var(--gray-200); +} +.doc-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-1px); } +.doc-title { font-weight: 600; margin-bottom: .5rem; } +.doc-meta { font-size: .8rem; color: var(--gray-600); display: flex; align-items: center; gap: .5rem; } +.empty-state { text-align: center; padding: 3rem; color: var(--gray-600); } + +/* Document view */ +.doc-content-card { + background: #fff; border-radius: var(--radius); padding: 1.5rem; + box-shadow: var(--shadow); margin-bottom: 1.5rem; + border: 1px solid var(--gray-200); +} +.doc-content { white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: .9rem; line-height: 1.6; } +.section { background: #fff; border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow); border: 1px solid var(--gray-200); } +.section h3 { margin-bottom: 1rem; font-size: 1.1rem; } +.section h4 { margin-bottom: .75rem; font-size: .95rem; color: var(--gray-600); } +.doc-meta-footer { color: var(--gray-600); font-size: .8rem; margin-top: 1rem; } + +/* Forms */ +.doc-form { display: flex; flex-direction: column; gap: 1rem; } +.form-group { display: flex; flex-direction: column; gap: .3rem; } +.form-group label { font-weight: 500; font-size: .9rem; } +.form-control { + padding: .6rem .8rem; border: 1px solid var(--gray-200); border-radius: var(--radius); + font-size: .9rem; font-family: inherit; width: 100%; + transition: border-color .15s; +} +.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,.15); } +textarea.form-control { resize: vertical; } +.form-actions { display: flex; gap: .75rem; } +.share-row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; } +.select-inline { width: auto; flex: 1; min-width: 160px; } + +/* Table */ +.table { width: 100%; border-collapse: collapse; font-size: .88rem; } +.table th { text-align: left; padding: .5rem .75rem; border-bottom: 2px solid var(--gray-200); color: var(--gray-600); font-size: .8rem; text-transform: uppercase; } +.table td { padding: .6rem .75rem; border-bottom: 1px solid var(--gray-200); } + +/* Error */ +.error-card { background: #fff; border-radius: var(--radius); padding: 2rem; box-shadow: var(--shadow); max-width: 500px; margin: 3rem auto; text-align: center; } +.error-card h2 { margin-bottom: 1rem; color: var(--danger); } +.error-card p { color: var(--gray-600); margin-bottom: 1.5rem; } + +/* Hints */ +.hint { font-size: .78rem; color: var(--gray-600); font-style: italic; margin-top: .5rem; } +.hint code { background: var(--gray-100); padding: .1rem .3rem; border-radius: 3px; font-style: normal; } diff --git a/federated-authorization/templates/base.html b/federated-authorization/templates/base.html new file mode 100644 index 0000000..3fc2b4b --- /dev/null +++ b/federated-authorization/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}FedAuthZ Demo{% endblock %} + + + +{% if user %} + +{% endif %} + +
+ {% block content %}{% endblock %} +
+ +
+ Federated AuthZ Demo — SpiceDB + Keycloak + GitHub OAuth +
+ + diff --git a/federated-authorization/templates/dashboard.html b/federated-authorization/templates/dashboard.html new file mode 100644 index 0000000..80396de --- /dev/null +++ b/federated-authorization/templates/dashboard.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Dashboard — FedAuthZ Demo{% endblock %} +{% block content %} + + +{% if documents %} + +{% else %} +
+

No documents yet. Create your first document or ask someone to share one with you.

+

SpiceDB LookupResources found 0 documents you can view.

+
+{% endif %} +{% endblock %} diff --git a/federated-authorization/templates/document_new.html b/federated-authorization/templates/document_new.html new file mode 100644 index 0000000..ed2b003 --- /dev/null +++ b/federated-authorization/templates/document_new.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}New Document{% endblock %} +{% block content %} + + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+

SpiceDB will record document:<id>#owner@user:{{ user.id[:8] }}... on creation.

+{% endblock %} diff --git a/federated-authorization/templates/document_view.html b/federated-authorization/templates/document_view.html new file mode 100644 index 0000000..73c20e9 --- /dev/null +++ b/federated-authorization/templates/document_view.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ doc.title }}{% endblock %} +{% block content %} + + +
+
{{ content }}
+
+ +{% if can_edit %} +
+

Edit Content

+
+
+ +
+ +
+
+{% endif %} + +{% if can_share %} +
+

Share This Document

+

SpiceDB enforces sharing. Only you (the owner) can see this section.

+ + {% if sharees %} +
+

Current Access

+ + + + + + {% for s in sharees %} + + + + + + + + {% endfor %} + +
UserEmailProviderPermission
{{ s.display_name }}{{ s.email }} + + {{ s.provider }} + + {{ s.relation }} +
+ + + +
+
+
+ {% endif %} + + {% if all_users %} + + {% elif not sharees %} +

No other users have logged in yet. Ask your colleague to sign in first, then share with them here.

+ {% endif %} +
+{% endif %} + + +{% endblock %} diff --git a/federated-authorization/templates/error.html b/federated-authorization/templates/error.html new file mode 100644 index 0000000..d9a9cbb --- /dev/null +++ b/federated-authorization/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Error{% endblock %} +{% block content %} +
+

🚫 Error

+

{{ error }}

+ Go Home +
+{% endblock %} diff --git a/federated-authorization/templates/login.html b/federated-authorization/templates/login.html new file mode 100644 index 0000000..c2532c3 --- /dev/null +++ b/federated-authorization/templates/login.html @@ -0,0 +1,54 @@ + + + + + + Sign In — FedAuthZ Demo + + + +
+ +
+ + +