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-svcthat lists allergies" - "Wire OBO into the BFF client for
lab-svc" - "How do I connect to Lakebase as the user?"
- "Set up
db.pyfor 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-Tokenfrom the request — Databricks Apps injects this on every call. - Token to UC. When the service needs UC operations, it builds
WorkspaceClient(token=user_token), neverWorkspaceClient()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. Theauth_type="pat"is required when the code runs inside a Databricks App: the platform auto-injectsDATABRICKS_CLIENT_ID+DATABRICKS_CLIENT_SECRETfor the app's service principal, and the SDK refuses ambiguous oauth+pat config unless one method is forced. Connections are pooled withmax_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 ...andX-Forwarded-Access-Tokenheaders. 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")
# auth_type="pat" disambiguates from the SP credentials Apps auto-injects.
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:
# Cache pools per token (short-lived). In production you'd cache by token's iss+sub claim
# to avoid leaking memory if many distinct tokens hit the service.
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 inreferences/uses an LRU keyed byhash(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
apxlakebase 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. SetLOCAL_DEV_TOKEN_FROM_CLI=truein.envand 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/mereturns user-1's data.curl -H "X-Forwarded-Access-Token: <user-2-token>" .../patients/mereturns 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 echoescurrent_userto 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.