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 likealert.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 byperformed_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 reversibleupanddown. - 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.jsonand is applied viakcadm.shin 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
roletable. Composite-role pattern:national_hq_operatorincludesoperatorpermissions, 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 forbuilding_managerandoperator(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), extractsuser_id,roles,building_scopes, and attaches to the request context.TenantResolver— for tenant-scoped requests, resolvestenant_idfrom the JWT’sbuilding_scopesclaim or from a URL path segment, validates the user has access. Storestenant_idon the request context and setsapp.tenant_idon 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 inuser_buildingwith sufficientaccess_levelfor 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
AuditLogEntrywith the canonical shape frompackages/contracts/. - Computes
prev_hashfrom the previous row in the audit log (per tenant, fetched cheaply via covering index) andself_hashfrom 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
- A test user logs in successfully. Through the dashboard, with valid credentials, receives a session cookie, and is redirected to the home view.
- MFA enrolment works. A user with an MFA-required role is forced through TOTP enrolment on first login; subsequent logins prompt for TOTP code.
- 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.
- API endpoints reject unauthenticated requests.
curl https://api.dev.wiqaia.sa/v1/userswithout a token returns 401 with a generic error (no information leak). - Cross-building access denied at the API. A user with
user_buildingscope to Building A cannot retrieve Building B’s records: a request returns 404 (no information leak about Building B’s existence). - Cross-tenant access denied at the database. Manually constructing an SQL query in the API context with
app.tenant_idset to tenant A cannot read tenant B’s rows: RLS returns empty. Verified with a direct integration test. - 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. - 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.
- 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.
- 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.
- 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.
- EN/AR + RTL works through the auth flow. The Arabic login screen renders correctly, password and TOTP fields work, error messages are localized.
Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Keycloak realm drift between dev and prod | Medium | Realm-as-code; kcadm.sh import as CI step |
| Token validation latency adds API overhead | Low | JWKS cached 10 min; benchmarked in M01 |
| Audit log volume creates Postgres pressure | Low | Monthly partitioning; archive job moves >12mo to GCS Coldline |
| RLS misconfiguration leaks tenants | High impact / low probability | Integration tests as part of CI explicitly cover cross-tenant denial; manual audit before sign-off |
| Operators forget passwords during Hajj | High | Password reset flow live; help-desk runbook in docs/runbooks/ |
| Keycloak compromise | High impact / low probability | Keycloak 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 gatesdocs.rizoma.savia Caddyforward_authagainst Gitea’s/api/v1/userendpoint. When Keycloak is live in this milestone:- Register an OIDC client in Keycloak for Gitea, then configure Gitea (
oauth2_clientinapp.ini) to delegate authentication to it. Disable local Gitea account creation. - Register a second OIDC client for the docs site and replace Caddy’s
forward_authtarget so the docs are gated by Keycloak directly (e.g. viaoauth2-proxyin front of Caddy, or via Caddy’scaddy-securityplugin). - 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.- Register an OIDC client in Keycloak for Gitea, then configure Gitea (
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.