Authentication & authorization¶
Two separate concerns, handled in two places:
- Authentication — who are you? — is a pluggable provider in
backend/app/auth/. - Authorization — what may you do on this project? — is a per-project role check in
app/auth/membership.py.
The provider abstraction¶
Authentication is a Protocol with a single method (ADR 0006):
class AuthProvider(Protocol):
def get_user(self, request: Request, db: Session) -> User: ...
A provider takes a request, authenticates it, upserts the user into the users table on first sight, and returns the ORM row — or raises 401 if the credential is bad, or 503 if a dependency it needs (Entra's JWKS endpoint) can't be reached. Which provider is active is chosen once per process by the AUTH_PROVIDER setting (cached), and the choice is guarded: running local-dev in a production environment is refused at startup.
The get_current_user FastAPI dependency delegates to the active provider, so no handler ever knows which one is in play.
Authentication failure vs dependency failure¶
get_current_user sorts what comes back out of the provider into three arms (ADR 0065). This matters more than it looks: the user lookup a provider performs while authenticating is the first database contact in any request, so a database outage arrives here before it arrives anywhere else.
| The provider raises | Result |
|---|---|
an HTTPException |
re-raised verbatim — the provider's verdict is final |
an unreachable database (OperationalError, InterfaceError, TimeoutError, DisconnectionError) |
503 with Retry-After: 5 |
| anything else | logged with its traceback, then re-raised → 500 |
So 401 means the credential, and only the credential. The list in the middle row is deliberately a named set rather than a base class: TimeoutError (pool exhaustion) isn't a DBAPIError at all, and IntegrityError / ProgrammingError are — but they're defects, not outages, and "retry shortly" would be a fresh mislabel for a missing column.
The 503 is declared once on the /api/v1 router, which is exact only while every route under it authenticates. Adding an unauthenticated /api/v1 route invalidates that declaration.
LocalDevProvider — development and tests¶
app/auth/local_dev.py. There's no real login locally; you assert an identity. The provider resolves the user in this order:
- an
X-Dev-Userrequest header, - an
?as=<name>query parameter, - the first name in the
DEV_USERSlist, - a fallback
devuser when none of the above is set.
Each dev name maps to a stable synthetic identity (local:<name>) and is auto-provisioned on first use, so repeat requests reuse the same row. An unknown name fails closed with 401 — a typo doesn't silently become a new user.
EntraIDProvider — production¶
app/auth/entra_id.py (ADR 0023) validates a Microsoft Entra ID JWT. It accepts the token either as a Bearer header or — for the SSE stream, which can't set headers — as a ?token= query parameter. Validation, in outline:
- Read the token's
kidand resolve the matching signing key from the tenant's JWKS, which is cached in-process (24-hour TTL) and refreshed on akidmiss. - Verify the signature (RS256), the issuer, the audience, and expiry — with a small leeway for clock skew.
- Confirm the token's tenant claim matches the configured tenant; reject otherwise.
- Upsert the user from the token's stable object-ID claim, deriving email and display name from standard claims.
The JWKS cache degrades gracefully: if a refresh fails but a usable cached key exists, it's served; with no key available the request fails cleanly rather than hanging.
No secrets here
The tenant ID, client ID, and any client secret are supplied through environment variables at deploy time and live in Azure Key Vault — never in the code or in these docs. The provider reads them from configuration; the values are not part of the design.
Authorization: per-project roles¶
Once a request has a user, access to a project is a separate check (ADR 0021). The roles are a two-level hierarchy:
- editor — read everything in the project, edit tasks and dependencies.
- admin — all of the above, plus project metadata, membership management, and project deletion.
The check is a FastAPI dependency factory, require_project_role("editor") or require_project_role("admin"), mounted on each project-scoped route. It:
- reads
project_idfrom the URL, - resolves the current user,
- looks up the user's membership row,
- and returns a
ProjectAccess(user, project_id, role)value the handler uses — or raises.
The failure modes encode the information-hiding rule:
| Situation | Result |
|---|---|
| Not a member of the project | 404 — existence hidden |
| A member, but role too low for the action | 403 |
| A member with sufficient role | proceeds, handler gets ProjectAccess |
Handlers take ProjectAccess as a dependency-injected argument rather than re-checking membership themselves, so authorization lives in exactly one place and every project-scoped route gets it uniformly.
Last-admin protection¶
Because losing the last admin would orphan a project, demoting or removing the only admin is refused with a 409. This lives alongside the membership endpoints in the projects router.
Authorization: availability membership¶
The availability board is a third, deliberately degenerate authorization surface (ADR 0063). It is workspace-level, not project-scoped, so project roles say nothing about it.
- A bare set.
availability_membershas a primary key and two provenance columns (added_at,added_by) and norole. There is nothing for a role to vary: every member reads the whole board and writes only their own rows, so the sole privileged capability is managing the roster. - One flag for the administrator.
users.is_availability_adminis a plain boolean, seeded once by migration from theAVAILABILITY_ADMIN_EMAILenvironment variable (documented inbackend/.env.example, and deliberately not a field onSettings— nothing in the application should look like the knob that moves the admin, because nothing is). It is a bootstrap parameter, read fromos.environat migration time to find a row — never compared against the caller, and changing it afterwards does nothing. Transferring administration is a one-rowUPDATE(backend/scripts/grant_availability_admin.py), not a code change and a deploy. - The admin is implicitly a member. The flag grants access with no row, which makes the last-admin failure mode structurally impossible rather than forbidden by a rule: no membership gesture can delete a column. Aiming the remove route at the admin returns
409.
| Situation | Result |
|---|---|
| Not a member (and not the admin) | 403 — the feature is not a secret; "ask to be added" is actionable |
| A member, on a member-management route | 403 |
| Removing the availability admin | 409 |
The guard is mounted once, on the router prefix, so a route added to the availability router later is protected by being mounted rather than by its author remembering — and a test enumerates router.routes to keep that true. Reads are filtered by the member set rather than by the presence of rows, so removing someone hides their history from every client immediately while leaving it exactly restorable.
How the frontend mirrors this¶
The frontend has a parallel auth-client abstraction (ADR 0010) so the SPA can run against the local-dev provider in development and MSAL/Entra in production without the components knowing the difference — the same swap-the-provider shape as the backend.