| name | hc-obo-auth |
| description | Wire OBO (On-Behalf-Of) authentication into a service or BFF route. Use when the user asks to "add a route", "wire OBO", "set up authentication", "connect to Lakebase as the user", "extract user identity", or anything about user-passthrough auth. Enforces the canonical pattern where every Postgres connection is opened with the calling user's OAuth credential and Unity Catalog enforces access at the data layer. |
hc-obo-auth
The non-negotiable auth rule in this architecture: every Postgres session opens with the calling user's OAuth credential. No service-principal fallback in production code, no shortcuts.
This skill is the source of truth for the canonical patterns. Any deviation needs to be argued for in a PR description.
When to use
- "Add a new route to
patient-svc that lists allergies"
- "Wire OBO into the BFF client for
lab-svc"
- "How do I connect to Lakebase as the user?"
- "Set up
db.py for the new service"
- The user is touching anything that crosses the trust boundary (HTTP route, DB connection, downstream service call)
When NOT to use
- The user is writing pure unit tests against a mock — OBO doesn't apply.
- The user is writing a one-off ETL script — script auth is its own thing, use a service principal there but document it as outside the OBO trust path.
The four pillars
- Token in. Every backend route reads
X-Forwarded-Access-Token from the request — Databricks Apps injects this on every call.
- Token to UC. When the service needs UC operations, it builds
WorkspaceClient(token=user_token), never WorkspaceClient() without a token in production.
- Token to Lakebase. When the service connects to Postgres, it calls
WorkspaceClient(token=user_token, auth_type="pat").postgres.generate_database_credential(endpoint=ENDPOINT_NAME) and uses the returned token as the Postgres password. The auth_type="pat" is required when the code runs inside a Databricks App: the platform auto-injects DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET for the app's service principal, and the SDK refuses ambiguous oauth+pat config unless one method is forced. Connections are pooled with max_lifetime=2700 (45 min) so a stale token never lives longer than 1h.
- Token forward. When the BFF or any service calls another service, it copies the user token into both
Authorization: Bearer ... and X-Forwarded-Access-Token headers. The downstream service treats it as if the user called directly.
The detailed canonical code (drop-in copies for every service) lives in references/canonical-patterns.md.
Quick patterns
Extracting the user token in a FastAPI route
from fastapi import Header, HTTPException
async def user_token(
x_forwarded_access_token: str | None = Header(default=None, alias="X-Forwarded-Access-Token"),
) -> str:
if not x_forwarded_access_token:
raise HTTPException(401, "Missing X-Forwarded-Access-Token")
return x_forwarded_access_token
Use it as a dependency:
@router.get("/patients", response_model=list[PatientOut], operation_id="listPatients")
async def list_patients(token: str = Depends(user_token)):
async with patient_db_pool(token).connection() as conn:
...
Building a per-request DB pool
Don't build one global pool. Build one per user token, cached briefly. The full helper is in references/canonical-patterns.md, but the shape:
from psycopg_pool import AsyncConnectionPool
import psycopg
from databricks.sdk import WorkspaceClient
import os, asyncio
_pools: dict[str, AsyncConnectionPool] = {}
_lock = asyncio.Lock()
class OAuthConnection(psycopg.AsyncConnection):
@classmethod
async def connect(cls, conninfo: str = "", **kwargs):
token = kwargs.pop("_user_token", None)
if not token:
raise RuntimeError("OAuthConnection requires _user_token kwarg")
ws = WorkspaceClient(token=token, auth_type="pat")
cred = ws.postgres.generate_database_credential(endpoint=os.environ["ENDPOINT_NAME"])
kwargs["password"] = cred.token
return await super().connect(conninfo, **kwargs)
def patient_db_pool(user_token: str) -> AsyncConnectionPool:
if user_token not in _pools:
host = os.environ["PGHOST"]
user = os.environ["PGUSER"]
_pools[user_token] = AsyncConnectionPool(
conninfo=f"host={host} port=5432 dbname=databricks_postgres user={user} sslmode=require",
connection_class=OAuthConnection,
max_lifetime=2700,
kwargs={"_user_token": user_token},
open=False,
)
return _pools[user_token]
⚠️ The _pools[token] cache leaks if many distinct tokens hit the service. The full pattern in references/ uses an LRU keyed by hash(token) with a TTL.
Forwarding the token from the BFF
URL resolution: explicit <SVC>_SVC_URL override → runtime-derived from DATABRICKS_APP_URL. See references/canonical-patterns.md for the full _BaseSvcClient shape.
class PatientClient(_BaseSvcClient):
def __init__(self, user_token: str):
super().__init__(user_token, service_slug="patient")
The BFF route extracts the token the same way as services and constructs one client per request:
@router.get("/patient-summary/{patient_id}", operation_id="getPatientSummary")
async def patient_summary(patient_id: str, token: str = Depends(user_token)):
async with PatientClient(token) as p, AppointmentClient(token) as a:
patient, appts = await asyncio.gather(
p.get(patient_id),
a.list_for_patient(patient_id, limit=3),
)
return {"patient": patient, "appointments": appts}
Local development
When running apx dev start outside Databricks Apps, there's no X-Forwarded-Access-Token injected. Two options, both supported:
-
Pass-through from the dev's CLI auth. The apx lakebase addon can inject the dev's token into the env so requests pretend to be OBO. The dev's identity is then used for Lakebase. Set LOCAL_DEV_TOKEN_FROM_CLI=true in .env and the canonical pattern reads it.
-
Explicit header. When testing with curl, include -H "X-Forwarded-Access-Token: $(databricks auth token -p hc-dev | jq -r .access_token)".
What the canonical pattern does not support: a service-principal fallback when running locally. Local dev still uses the developer's user identity to talk to Lakebase. This keeps prod-vs-dev behavior identical and prevents "works on my machine, fails in prod" auth bugs.
Anti-patterns to reject in code review
| Pattern | Why it's wrong |
|---|
WorkspaceClient() (no token) in route handlers | Falls back to the app's service principal, defeats OBO |
| Storing a connection pool keyed only on env (one global pool) | All users share one connection → all queries run as the SP |
| Reading the token from a query param or cookie instead of the header | The header is what Databricks Apps injects; anything else is a different trust path |
Forwarding the token but stripping Authorization and only sending X-Forwarded-Access-Token | Some downstream libraries only check Authorization — send both |
Caching generate_database_credential results across requests | Tokens are user-bound — caching them across users leaks identity |
Verification
After wiring OBO into a route:
curl -H "X-Forwarded-Access-Token: <user-1-token>" .../patients/me returns user-1's data.
curl -H "X-Forwarded-Access-Token: <user-2-token>" .../patients/me returns user-2's data, not user-1's.
curl .../patients/me (no header) returns 401.
- In Lakebase,
SELECT current_user; from the route returns the calling user's email, not the app's service principal email. Add a debug route that echoes current_user to confirm during initial wiring.
Pointer
Full canonical code (db.py, auth.py, BFF client) is in references/canonical-patterns.md. Copy from there into the service; do not invent new shapes.