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-Contextwith 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 |
| 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 membershipsPATCH /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 rolePATCH /v1/admin/users/{id}/memberships/{membershipId}— Change roleDELETE /v1/admin/users/{id}/memberships/{membershipId}— Remove from orgPOST /v1/admin/users/{id}/memberships/{membershipId}/products— Grant product accessDELETE /v1/admin/users/{id}/memberships/{membershipId}/products/{productId}— Revoke product access
Roles:
POST /v1/admin/roles— Create role for an orgGET /v1/admin/roles— List roles (filter by org)GET /v1/admin/roles/{id}— Get role with permissionsPATCH /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 permissionsGET /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/liveGET /health/ready
Auth for the service itself¶
- Admin endpoints: user permissions from
X-Actor-Context(with staticADMIN_API_SECRETfallback 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¶
- Product redirects user to Cognito hosted UI (or uses Cognito SDK)
- Cognito authenticates → issues ID token + access token
- Product calls
GET /v1/users/by-external-id/{cognitoSub}to resolve platform user - Product calls
GET /v1/users/{id}/context?org_id=xxxfor role + permissions - Product builds
X-Actor-ContextJWT with full profile - Product sends requests to platform services with
X-Actor-Contextheader
Caching Strategy¶
- Common case:
X-Actor-Contextcontains everything — no callback needed - Sensitive operations: Service calls
GET /v1/users/{id}/contextfor 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 changedusers.deactivated— user deactivatedusers.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¶
ActorContextinterface extended with new optional fields:userId,userType,orgId,permissions,productIds,specialityActorContextMiddlewareupdated to parse richer payload- All new fields optional for backward compatibility
clinical-api¶
AdminUsermodel removed from Prisma schema (migrated to user management)AdminAuthGuardupdated to checkX-Actor-Contextpermissions instead of static bearer token (with static token fallback during migration)- Admin controllers remain in clinical-api — they manage clinical-specific entities
Migration path¶
- Build user management service with all endpoints
- Update auth-client's ActorContext type
- Migrate AdminUser from clinical-api → create equivalent users in user management with admin role
- Update clinical-api's admin guard to check user permissions
- Products start using Cognito login + user management context flow