Skip to content

User Management Service Design

Overview

A standalone NestJS service that manages user accounts (clinicians, admins, patients), organization memberships, roles, and permissions for the SA Platform. Delegates authentication to AWS Cognito and focuses on platform-specific identity enrichment — who can do what, where.

Goals

  • Unified user model for clinicians, admins, and patients
  • AWS Cognito integration for authentication (login, MFA, password reset)
  • Per-org roles with fine-grained permissions registered by each service
  • Product access control per user per org
  • Lightweight patient account layer — clinical data stays in clinical-api, linked by ID
  • Rich X-Actor-Context with profile, role, permissions, product access
  • Migrate AdminUser from clinical-api into this service

Boundaries

Owned by user management

  • User accounts — CRUD, linked to Cognito
  • Cognito wrapping — AdminCreateUser, AdminDisableUser, AdminResetUserPassword, etc.
  • Practitioner profiles — professional ID, speciality, credentials
  • Patient accounts — lightweight identity layer, links to clinical-api's Patient by ID
  • Org memberships — which org(s) a user belongs to, with what role
  • Roles — named bundles of permissions, configurable per org
  • Permission registry — services register their permissions
  • Product access — which products a user can use within their org
  • User context endpoint — resolves full profile + permissions for X-Actor-Context
  • User domain events — role.changed, deactivated, permissions.changed

NOT owned by user management

  • Authentication mechanics (login, passwords, MFA) — Cognito handles this
  • Patient clinical data (demographics, cases, findings) — stays in clinical-api
  • API client auth (machine-to-machine) — auth service
  • Organisations and Products — stay in clinical-api for now

Data Model

Own Prisma schema and MySQL database (sa_users).

User

Field Type Notes
id UUID v7 Primary key
externalId string Cognito sub, unique
email string Unique
displayName string
userType enum clinician, admin, patient
status enum active, suspended, deactivated
createdAt datetime
updatedAt datetime

PractitionerProfile

One-to-one with User (only for userType: clinician).

Field Type Notes
id UUID v7 Primary key
userId UUID v7 FK to User, unique
professionalId string Nullable, e.g. GMC number
professionalIdType string Nullable, e.g. "GMC", "NPI"
speciality string Nullable
credentials string Nullable, e.g. "MBChB, FRCP"
createdAt datetime
updatedAt datetime

PatientAccount

One-to-one with User (only for userType: patient). Lightweight identity — clinical data stays in clinical-api.

Field Type Notes
id UUID v7 Primary key
userId UUID v7 FK to User, unique
clinicalPatientId string Nullable, links to clinical-api's Patient.id
contactPreferences JSON Nullable, e.g. { email: true, sms: false }
createdAt datetime
updatedAt datetime

OrgMembership

Users can belong to multiple orgs with different roles.

Field Type Notes
id UUID v7 Primary key
userId UUID v7 FK to User
organisationId string Reference to org in clinical-api
roleId UUID v7 FK to Role
createdAt datetime

Unique constraint on (userId, organisationId).

Role

Roles are per-org. Each org can define its own role names and permission bundles.

Field Type Notes
id UUID v7 Primary key
organisationId string Reference to org
name string e.g. "Senior Clinician", "Triage Nurse"
description string Nullable
isDefault boolean Default role for new users in this org
createdAt datetime
updatedAt datetime

Unique constraint on (organisationId, name).

RolePermission

Many-to-many: a role bundles multiple permissions.

Field Type Notes
id UUID v7 Primary key
roleId UUID v7 FK to Role
permissionId UUID v7 FK to RegisteredPermission

Unique constraint on (roleId, permissionId).

RegisteredPermission

Services register their permissions (same pattern as scope registration in auth service).

Field Type Notes
id UUID v7 Primary key
permission string Unique, e.g. clinical:cases:diagnose
serviceId string Which service registered it
description string Human-readable
createdAt datetime
updatedAt datetime

ProductAccess

Which products a user can access within an org.

Field Type Notes
id UUID v7 Primary key
orgMembershipId UUID v7 FK to OrgMembership
productId string Reference to product in clinical-api
createdAt datetime

API Endpoints

User-facing (require valid platform token)

GET /v1/users/me — Current user's profile, memberships, roles, permissions PATCH /v1/users/me — Update own profile (display name, contact preferences)

Admin endpoints (require users:admin permission or ADMIN_API_SECRET fallback)

Users:

  • POST /v1/admin/users — Create user (creates Cognito account + platform profile)
  • GET /v1/admin/users — List users (filter by org, type, status, role)
  • GET /v1/admin/users/{id} — Get user with memberships
  • PATCH /v1/admin/users/{id} — Update user (status, display name)
  • DELETE /v1/admin/users/{id} — Deactivate (disables Cognito, sets status deactivated)
  • POST /v1/admin/users/{id}/reset-password — Trigger Cognito password reset

Org Memberships:

  • POST /v1/admin/users/{id}/memberships — Add user to org with role
  • PATCH /v1/admin/users/{id}/memberships/{membershipId} — Change role
  • DELETE /v1/admin/users/{id}/memberships/{membershipId} — Remove from org
  • POST /v1/admin/users/{id}/memberships/{membershipId}/products — Grant product access
  • DELETE /v1/admin/users/{id}/memberships/{membershipId}/products/{productId} — Revoke product access

Roles:

  • POST /v1/admin/roles — Create role for an org
  • GET /v1/admin/roles — List roles (filter by org)
  • GET /v1/admin/roles/{id} — Get role with permissions
  • PATCH /v1/admin/roles/{id} — Update role (name, description, permissions)
  • DELETE /v1/admin/roles/{id} — Delete role (fails if users assigned)

Permissions:

  • POST /v1/permissions/register — Service registers its permissions
  • GET /v1/permissions — List all registered permissions (filter by service_id)

Service-to-service (require valid platform token)

GET /v1/users/{id}/context — User's resolved context for X-Actor-Context

  • Profile, org membership, role, flattened permissions, product access
  • Called by products at login to build the actor context JWT

GET /v1/users/by-external-id/{externalId} — Look up user by Cognito sub

  • Used after Cognito authentication to resolve platform user

Health

  • GET /health/live
  • GET /health/ready

Auth for the service itself

  • Admin endpoints: user permissions from X-Actor-Context (with static ADMIN_API_SECRET fallback during bootstrap)
  • Service-to-service endpoints: valid platform token from auth service
  • Permission registration: valid platform token

Cognito Integration

User Pool

One Cognito User Pool per region (UK, US). Configured via Terraform (out of scope). The service needs pool ID and uses IAM role credentials.

SDK Operations

Operation Cognito API Trigger
Create user AdminCreateUser POST /v1/admin/users
Disable user AdminDisableUser DELETE /v1/admin/users/{id}
Enable user AdminEnableUser Reactivating suspended user
Delete user AdminDeleteUser GDPR erasure
Reset password AdminResetUserPassword POST /v1/admin/users/{id}/reset-password
Get user AdminGetUser Verify Cognito state

Local Development

COGNITO_PROVIDER=mock mode skips Cognito calls and generates fake external IDs. No Cognito pool needed for local development.

Configuration

COGNITO_USER_POOL_ID=eu-west-2_xxxxx
COGNITO_REGION=eu-west-2
COGNITO_PROVIDER=cognito  # or 'mock' for local dev

Actor Context

Updated X-Actor-Context JWT

{
  "user_id": "usr_abc123",
  "external_user_id": "cognito-sub-xxx",
  "display_name": "Dr. Sarah Chen",
  "user_type": "clinician",
  "org_id": "org_xyz",
  "role": "senior_clinician",
  "permissions": ["clinical:cases:view", "clinical:cases:diagnose", "clinical:images:view"],
  "product_ids": ["prod_ov2", "prod_aida"],
  "practitioner": {
    "professional_id": "GMC-1234567",
    "professional_id_type": "GMC",
    "speciality": "dermatology"
  }
}

Products build this JWT at login by calling user management. The JWT is an internal header trusted within the service mesh, not cryptographically verified by downstream services.

Authentication Flow

  1. Product redirects user to Cognito hosted UI (or uses Cognito SDK)
  2. Cognito authenticates → issues ID token + access token
  3. Product calls GET /v1/users/by-external-id/{cognitoSub} to resolve platform user
  4. Product calls GET /v1/users/{id}/context?org_id=xxx for role + permissions
  5. Product builds X-Actor-Context JWT with full profile
  6. Product sends requests to platform services with X-Actor-Context header

Caching Strategy

  • Common case: X-Actor-Context contains everything — no callback needed
  • Sensitive operations: Service calls GET /v1/users/{id}/context for fresh permissions
  • Cache: In-memory LRU with 5-minute TTL for context lookups
  • Invalidation: User management publishes events on Redis pub/sub:
  • users.role.changed — user's role changed
  • users.deactivated — user deactivated
  • users.permissions.changed — role's permissions modified

Subscribed services evict relevant cache entries.


Project Structure

services/user-management/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   ├── config/
│   │   ├── app-config.ts
│   │   └── config.module.ts
│   ├── prisma/
│   │   ├── prisma.service.ts    # Own AuthPrismaService (isolated client)
│   │   └── prisma.module.ts
│   ├── health/
│   ├── users/
│   ├── profiles/
│   ├── memberships/
│   ├── roles/
│   ├── permissions/
│   ├── cognito/                  # SDK wrapper + mock provider
│   ├── context/
│   ├── events/
│   └── admin/
│       └── admin-auth.guard.ts
├── test/
│   └── integration/
├── package.json
└── .env.example

Dependencies

  • @sa-platform/common — IdModule, RedisModule, CorrelationIdMiddleware, ProblemJsonFilter
  • @sa-platform/tsconfig, @sa-platform/eslint-config
  • @aws-sdk/client-cognito-identity-provider — Cognito SDK
  • Own Prisma client with custom output path (same pattern as auth service)

Changes to Existing Code

@sa-platform/auth-client

  • ActorContext interface extended with new optional fields: userId, userType, orgId, permissions, productIds, speciality
  • ActorContextMiddleware updated to parse richer payload
  • All new fields optional for backward compatibility

clinical-api

  • AdminUser model removed from Prisma schema (migrated to user management)
  • AdminAuthGuard updated to check X-Actor-Context permissions instead of static bearer token (with static token fallback during migration)
  • Admin controllers remain in clinical-api — they manage clinical-specific entities

Migration path

  1. Build user management service with all endpoints
  2. Update auth-client's ActorContext type
  3. Migrate AdminUser from clinical-api → create equivalent users in user management with admin role
  4. Update clinical-api's admin guard to check user permissions
  5. Products start using Cognito login + user management context flow