M02 — Identity and Authorization

One-line goal: real authentication, real RBAC, real audit — a user can log in with MFA, the platform issues a scoped token, every action is authorized at multiple layers, and every state-changing action is captured in a tamper-evident log.

This milestone is what separates a real system from a prototype. After M02, no API endpoint accepts unauthenticated traffic, no query crosses tenant boundaries, and no operator action goes unrecorded.


Tracks involved

  • Backend — primary. NestJS auth + RBAC middleware, audit log interceptor, user/role/building services.
  • Cloud Infra — Keycloak production-grade configuration, realm-as-code, MFA setup.
  • Frontend — login flow, MFA enrolment UI, role-aware navigation, building-scope picker.

Dependencies

  • M01 complete. Specifically: Keycloak deployed; Postgres ready with PostGIS; packages/contracts/ has auth-related types defined.

Deliverables

1. Postgres schema — auth domain

Migrations create these tables (with the column shapes from the Submission Docs Digest §5, treated as authoritative):

  • user — UUID PK, email (unique), full_name, password_hash, phone, is_active, mfa_enabled, mfa_secret (encrypted at application layer), preferred_lang (ar/en), last_login_at, failed_logins, locked_until, created_at, updated_at, deleted_at (soft delete).
  • role — UUID PK, name (unique), description, is_system, priority. System roles seeded: super_admin, national_hq_operator, regional_commander, building_admin, building_manager, operator, fd_dispatcher, auditor. System roles are immutable (a check constraint plus an application-layer guard).
  • permission — UUID PK, resource, action. Granular permissions like alert.acknowledge, camera.view, building.configure, user.create, etc.
  • user_role — junction (user_id, role_id), assigned_at, assigned_by FK to user.
  • role_permission — junction (role_id, permission_id).
  • user_building — junction (user_id, building_id), access_level (full / read_only / alerts_only), granted_at, granted_by FK to user.
  • user_session — UUID PK, user_id FK, token_hash, ip_address, user_agent, expires_at, created_at, revoked_at.
  • audit_log — partitioned by performed_at (monthly). Columns: log_id, tenant_id (nullable for cross-tenant admin actions), actor_user_id, actor_service, category, action, entity_type, entity_id, changes (JSONB), ip_address, user_agent, performed_at, prev_hash (bytea), self_hash (bytea). Append-only; no UPDATE or DELETE except the partition-archival job.

RLS policies on every multi-tenant table created in M03 will reference current_setting('app.tenant_id') — but the auth-domain tables themselves are not tenant-scoped (a user can exist across tenants).

2. Migration system

  • node-pg-migrate (or Prisma Migrate if the team prefers — decision in M02 week 1) for managed migrations.
  • Migrations live in apps/api/migrations/. Numbered, with reversible up and down.
  • CI runs migrations against an ephemeral Postgres in the test stage; fails if a migration breaks.
  • Production migrations are gated behind a manual approval step.

3. Keycloak realm — production-grade

  • Realm-as-code: the realm’s JSON configuration (clients, roles, identity providers, MFA flows, password policies, session settings) lives in infra/keycloak/realm-wiqaia.json and is applied via kcadm.sh in CI. No clicking around the admin UI in production.
  • Clients: one per app — wiqaia-dashboard, wiqaia-fd-view, wiqaia-edge-dashboard, wiqaia-api-machine (for service-to-service). PKCE for browser clients; client credentials for machine.
  • Roles mirror the Postgres role table. Composite-role pattern: national_hq_operator includes operator permissions, etc.
  • Password policy: min 12 chars, upper + lower + digit + special, no last-5 reuse, expire every 90 days for admin roles, 365 days for operator roles.
  • Account lockout: 5 failed attempts → 15-minute lock; 10 failed → 24-hour lock + alert.
  • Session settings: access token 15 minutes, refresh token 8 hours for operator roles (4 hours for admin), idle timeout 30 minutes.
  • MFA: mandatory for super_admin, national_hq_operator, regional_commander, building_admin, auditor. Optional for building_manager and operator (a building manager handling daily ops can be MFA-optional; admin roles cannot).
  • MFA methods: TOTP (Google Authenticator / 1Password compatible) for everyone; WebAuthn / passkeys for admin roles where available. Backup codes generated on enrolment.
  • Internationalization: Keycloak login screens in Arabic and English.

4. NestJS auth middleware

  • AuthMiddleware — validates the incoming JWT (cached JWKS from Keycloak, refreshed every 10 minutes), extracts user_id, roles, building_scopes, and attaches to the request context.
  • TenantResolver — for tenant-scoped requests, resolves tenant_id from the JWT’s building_scopes claim or from a URL path segment, validates the user has access. Stores tenant_id on the request context and sets app.tenant_id on the Postgres connection for RLS.
  • @RequirePermission('alert.acknowledge') decorator — used on controller methods; checks against the user’s roles’ permissions. Denial returns 403 and writes an audit log entry.
  • @RequireBuildingAccess('read'|'full') decorator — for building-scoped endpoints; verifies the user is in user_building with sufficient access_level for the resource.
  • Anything without an auth decorator is 401 by default — opt-in to public is explicit.

5. Audit log interceptor

  • AuditInterceptor — runs on every state-changing endpoint. Captures: actor, action, entity, before/after diff (for mutations), IP, user-agent.
  • Writes an AuditLogEntry with the canonical shape from packages/contracts/.
  • Computes prev_hash from the previous row in the audit log (per tenant, fetched cheaply via covering index) and self_hash from this row.
  • Tee’s the entry to Cloud Logging (CMEK-protected) for defense in depth.
  • Audit-log writes themselves are not audit-logged (would be infinite recursion). They’re tracked via a separate metrics counter.

6. User / building / role management endpoints

  • POST /v1/users — create a user (super_admin only).
  • GET /v1/users/:id, PATCH /v1/users/:id, DELETE /v1/users/:id (soft delete).
  • POST /v1/users/:id/roles — assign roles.
  • POST /v1/users/:id/buildings — assign building scope with access level.
  • POST /v1/users/:id/mfa/enroll — initiate MFA enrolment flow.
  • POST /v1/users/:id/lock / POST /v1/users/:id/unlock — admin lock controls.
  • GET /v1/audit-log — query the audit log; filtered to the user’s scope (auditors see everything; building managers see only their building’s actions).
  • All require appropriate permissions; all audit-logged.

7. Login UI in the dashboard

  • Branded login screen (logo from docs/civil-defence-innovation-submission/pdf-generation/logos/ for now; final brand TBD).
  • Username + password fields with proper labels, error states, autocomplete attributes.
  • MFA challenge screen with TOTP input.
  • Forgot-password flow → Keycloak’s reset flow.
  • First-login MFA enrolment for required roles.
  • Building-scope selector in the top nav for users with access to multiple buildings; default to the most recently used.
  • EN/AR toggle in the header; RTL flip live.
  • Accessibility basics — keyboard nav, focus rings, contrast, ARIA labels.

8. Frontend auth state management

  • Tokens stored in httpOnly secure SameSite=strict cookies, not localStorage. Refresh handled by the API gateway via a refresh-cookie pattern.
  • React Query (or TanStack Query) for API calls with automatic 401 → re-login redirect.
  • Auth context propagated through the app; role-aware navigation hides features the user lacks permission for (but the backend is still the source of truth — UI hiding is convenience, not security).

9. Session management at the API

  • Active sessions visible in the user-management UI.
  • Admin can revoke a session (terminate their tokens immediately).
  • Self-service: user can see and revoke their own sessions.
  • Compromised-credential procedure: revoke all sessions for a user with one action; user must re-authenticate.

10. Production secrets

  • Keycloak admin credentials in Secret Manager.
  • JWT signing keys (managed by Keycloak; we just consume the JWKS).
  • Database password rotated and stored in Secret Manager; NestJS pulls at startup via Workload Identity Federation.

Verification

  1. A test user logs in successfully. Through the dashboard, with valid credentials, receives a session cookie, and is redirected to the home view.
  2. MFA enrolment works. A user with an MFA-required role is forced through TOTP enrolment on first login; subsequent logins prompt for TOTP code.
  3. MFA-required roles cannot bypass MFA. Direct API call to the token endpoint with username/password alone returns a “MFA required” challenge for affected roles.
  4. API endpoints reject unauthenticated requests. curl https://api.dev.wiqaia.sa/v1/users without a token returns 401 with a generic error (no information leak).
  5. Cross-building access denied at the API. A user with user_building scope to Building A cannot retrieve Building B’s records: a request returns 404 (no information leak about Building B’s existence).
  6. Cross-tenant access denied at the database. Manually constructing an SQL query in the API context with app.tenant_id set to tenant A cannot read tenant B’s rows: RLS returns empty. Verified with a direct integration test.
  7. Audit log captures every action. Login, logout, MFA challenge, user creation, role assignment, permission denial — each writes an AuditLogEntry. A test inserts a sequence of 100 actions and verifies the audit chain hashes correctly.
  8. Hash chain detects tampering. A direct Postgres UPDATE to one row in the audit log is detected by the daily chain-verification job within 24 hours and triggers an alert.
  9. Account lockout works. Five failed logins triggers a 15-minute lock; the locked user cannot authenticate during that window even with correct credentials. Lockout itself is audit-logged.
  10. Session revocation is immediate. Admin revokes a user’s session via the UI; within seconds, the user’s existing token returns 401 on subsequent API calls.
  11. Token expiry behaves correctly. A 15-minute-old access token returns 401; a refresh-token call returns a new access token; an expired refresh token requires re-login.
  12. EN/AR + RTL works through the auth flow. The Arabic login screen renders correctly, password and TOTP fields work, error messages are localized.

Risks

RiskLikelihoodMitigation
Keycloak realm drift between dev and prodMediumRealm-as-code; kcadm.sh import as CI step
Token validation latency adds API overheadLowJWKS cached 10 min; benchmarked in M01
Audit log volume creates Postgres pressureLowMonthly partitioning; archive job moves >12mo to GCS Coldline
RLS misconfiguration leaks tenantsHigh impact / low probabilityIntegration tests as part of CI explicitly cover cross-tenant denial; manual audit before sign-off
Operators forget passwords during HajjHighPassword reset flow live; help-desk runbook in docs/runbooks/
Keycloak compromiseHigh impact / low probabilityKeycloak network-isolated; admin access via bastion only; admin credentials in Secret Manager with two-person approval for changes

Follow-ups from earlier milestones to fold in here

  • Swap the ops VM’s identity provider from Gitea to Keycloak. The infra/terraform/envs/ops/ stack (Gitea + docs hosting, stood up 2026-05-13) currently uses Gitea’s built-in user database as the source of truth, and gates docs.rizoma.sa via Caddy forward_auth against Gitea’s /api/v1/user endpoint. When Keycloak is live in this milestone:

    1. Register an OIDC client in Keycloak for Gitea, then configure Gitea (oauth2_client in app.ini) to delegate authentication to it. Disable local Gitea account creation.
    2. Register a second OIDC client for the docs site and replace Caddy’s forward_auth target so the docs are gated by Keycloak directly (e.g. via oauth2-proxy in front of Caddy, or via Caddy’s caddy-security plugin).
    3. Migrate existing Gitea users into Keycloak (one-off script) and disable their local Gitea passwords.

    End state: one identity for everything — Keycloak. Same architecture (forward-auth + redirect-to-login), no new moving parts. Document the cutover in infra/terraform/envs/ops/README.md.

Open questions

  • TOTP vs WebAuthn for MFA defaults. Both supported; start with TOTP everywhere; phase in WebAuthn for admin roles in M10.
  • Should Civil Defence users federate via SAML from their own IDP, or have native Keycloak accounts? Likely native for the pilot; federation in a later milestone.
  • What’s the password policy for the auditor role specifically? Default: same as super_admin (most restrictive). Confirm with whoever owns compliance.
  • Concurrent-session limit per user. Default to 5; revisit if it causes operational issues during Hajj when one operator might log in from multiple stations.

Exit criteria

All 12 verification items pass. RLS isolation specifically demonstrated to a second engineer’s satisfaction (it’s the most consequential safety property). M02 sign-off entry appended to docs/plan/COMPLETION_LOG.md.

WiqAIa+ Docs · Rizoma