Skip to content

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:

  1. an X-Dev-User request header,
  2. an ?as=<name> query parameter,
  3. the first name in the DEV_USERS list,
  4. a fallback dev user 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:

  1. Read the token's kid and resolve the matching signing key from the tenant's JWKS, which is cached in-process (24-hour TTL) and refreshed on a kid miss.
  2. Verify the signature (RS256), the issuer, the audience, and expiry — with a small leeway for clock skew.
  3. Confirm the token's tenant claim matches the configured tenant; reject otherwise.
  4. 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:

  1. reads project_id from the URL,
  2. resolves the current user,
  3. looks up the user's membership row,
  4. 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_members has a primary key and two provenance columns (added_at, added_by) and no role. 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_admin is a plain boolean, seeded once by migration from the AVAILABILITY_ADMIN_EMAIL environment variable (documented in backend/.env.example, and deliberately not a field on Settings — nothing in the application should look like the knob that moves the admin, because nothing is). It is a bootstrap parameter, read from os.environ at migration time to find a row — never compared against the caller, and changing it afterwards does nothing. Transferring administration is a one-row UPDATE (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.