Skip to content

Security Model

This document describes the authentication, authorisation, encryption, tenancy, and secrets model implemented in the Skin Analytics clinical-data-model platform. Each claim below is verifiable from the cited source files.


1. Authentication

The platform uses the OAuth 2.0 client credentials flow exclusively for machine-to-machine authentication. There is no end-user login flow in this service layer; human actors are identified via an additional X-Actor-Context JWT header (see §2).

Token issuance is handled by the auth service (services/auth/src/token/token.controller.ts, token.service.ts):

  • The /v1/auth/oauth/token endpoint accepts grant_type=client_credentials and verifies the client's hashed secret against ApiClientSecret.secretHash in the auth database (services/auth/prisma/schema.prismaApiClient, ApiClientSecret models).
  • On successful authentication a RS256-signed JWT is issued. The token payload includes sub (client ID), org_id, product_id, scopes, and aud: "sa-platform".
  • The signing key is fetched from SigningKey rows in the auth DB. Only keys with status = "active" are used for signing (services/auth/src/keys/keys.service.ts).

JWKS and key rotation:

  • The auth service exposes public keys at GET /.well-known/jwks.json (services/auth/src/jwks/jwks.controller.ts).
  • A POST /v1/auth/admin/keys/rotate endpoint generates a new RSA key pair, sets it active, and publishes an auth.key.rotated event to Redis so downstream services can invalidate cached JWKS (services/auth/src/events/auth-events.service.ts).
  • Each downstream service verifies tokens against the cached JWKS via JwksService in packages/auth-client/src/jwks/jwks.service.ts. The cache TTL is configurable (AUTH_JWKS_CACHE_TTL_MS).

Actor context (human users):

Downstream services decode the optional X-Actor-Context header (a JWT that is decoded but not verified — trust is provided by the outer OAuth token) to capture human actor identity for audit purposes. This is handled by ActorContextMiddleware (packages/auth-client/src/actor-context/actor-context.middleware.ts). The header carries external_user_id, display_name, role, org_id, and optional professional_id fields.

Admin SSO (Google OIDC)

Internal admin staff sign in to the admin console via Google OpenID Connect. The flow is brokered by services/admin-api (the admin BFF) and finalised by services/auth:

  1. Browser → admin-api GET /api/auth/google/start → 302 to Google's consent URL with a single-use state nonce stored in Redis (5 min TTL).
  2. Google → admin-api GET /api/auth/google/callback. admin-api verifies the state nonce is present and unused, exchanges the OAuth code for an id_token against https://oauth2.googleapis.com/token, and forwards the id_token to the auth service over a service-to-service call (Bearer SERVICE_AUTH_TOKEN).
  3. auth service verifies the id_token signature against Google's JWKS, asserts email_verified === true, checks the email's domain against ADMIN_DOMAIN_ALLOWLIST, and calls back to admin-api POST /internal/admin-users/resolve to find or create the AdminUser row. Pending or disabled users are rejected.
  4. auth service issues a platform JWT (RS256, same KeysService as the client_credentials path) with scopes admin:read admin:cross-tenant, aud: "admin-api", and an actor_context claim carrying email / displayName / role. The audience differs from the client_credentials flow's aud: "sa-platform" so backing services can distinguish them.
  5. admin-api stores the JWT in a Redis-backed session (TTL SESSION_TTL_HOURS, default 8h), sets an admin_session httpOnly cookie on the browser, and 302s to the SPA root.

The browser never sees the platform JWT. The session cookie is httpOnly, Secure (in production), SameSite=Lax, scoped to the admin domain. MFA is enforced upstream by Google Workspace at the IDP level; this layer trusts Google's email_verified assertion.

Source:

  • services/auth/src/oauth/ — id_token verification, AdminApiClient, token issuer
  • services/admin-api/src/auth/ — OIDC start/callback/logout, AuthServiceClient, GoogleOAuthClient
  • services/admin-api/src/session/ — Redis-backed session store, SessionGuard, REDIS_SESSION_CLIENT
  • services/admin-api/src/admin-users/ — InternalTokenGuard + /internal/admin-users/resolve

2. Authorisation

Authorisation is scope-based. The full set of valid scopes is declared in:

packages/auth-client/src/auth.types.ts

Examples of scopes currently registered:

Scope Purpose
patients:read / patients:write Patient CRUD
cases:read / cases:write Case management
images:read / images:write Image upload and retrieval
ai-review:create / ai-review:read AI review lifecycle
human-review:claim / human-review:submit Human reviewer actions
human-review:read-cross-tenant Cross-org read (elevated)
orchestrator:read-instances / orchestrator:intervene Workflow management
consents:read / consents:write Consent records

Scope enforcement is applied at each request via the global ScopesGuard (packages/auth-client/src/guards/scopes.guard.ts), which reads required scopes from the @RequireScopes(...) decorator on each controller method (packages/auth-client/src/decorators/require-scopes.decorator.ts).

The AuthModule.forRoot() registers both AuthGuard (JWT verification) and ScopesGuard as global APP_GUARDs (packages/auth-client/src/auth.module.ts). Every service that imports AuthModule gets both guards applied to all routes automatically.


3. Encryption at Rest

The platform uses AES-256-GCM envelope encryption for patient-identifying fields.

CryptoService (packages/common/src/crypto/crypto.service.ts) provides the primitive: encrypt(plaintext, dek) and decrypt(ciphertext, dek), encoding output as base64(iv):base64(ciphertext):base64(authTag) using a per-call random 96-bit IV and 128-bit auth tag. Algorithm constant: aes-256-gcm.

Per-patient Data Encryption Keys (DEKs):

Each Patient row carries an encryptedDek column (VARCHAR(512)). The DEK is a 256-bit random key generated by DekResolver.generateAndStoreDek() (packages/common/src/crypto/dek-resolver.ts). On each request the DEK is unwrapped from the database, used to decrypt/encrypt fields, and discarded; there is a per-request in-memory cache via RequestContext.cacheDek().

Key providers (KeyProvider interface — packages/common/src/crypto/key-provider.interface.ts):

  • LocalKeyProvider (packages/common/src/crypto/local-key-provider.ts) — wraps DEKs under a 256-bit master key supplied via ENCRYPTION_MASTER_KEY env var (AES-256-GCM). Used in development/test. The master key format is validated on startup (must be a 64-char hex string).
  • KmsKeyProvider (packages/common/src/crypto/kms-key-provider.ts) — delegates to AWS KMS Encrypt/Decrypt commands using @aws-sdk/client-kms. Selected when ENCRYPTION_PROVIDER=kms and KMS_CMK_ARN is set. Used in production.

Which fields are encrypted: See the Data Model + PHI Classification document for a per-model breakdown.

AiReviewResult stores the raw DERM API response in three separate columns (raw_ciphertext, raw_iv, raw_auth_tag) as binary blobs, encrypted at the application layer (services/ai-review/prisma/schema.prisma).


4. Encryption in Transit

  • External traffic: TLS is terminated at the load balancer. All client-facing HTTP traffic is HTTPS-only. (Operational configuration — not in application code.)
  • Service-to-service traffic: Services communicate over private VPC networking. No application-layer TLS between services. (See Architecture for topology.)

5. Tenancy Isolation

Every persisted row that holds clinical or patient data carries an org_id (or organisation_id) foreign key. This applies across all services:

  • clinical-api: Patient, Case, SkinFinding, Diagnosis, Image, AuditLog, etc.
  • ai-review: AiReview carries org_id
  • orchestrator: WorkflowInstance carries org_id
  • human-review: Review carries org_id
  • consent: ConsentRecord carries organisation_id
  • notifications: Notification carries org_id

Read endpoints filter by the organisationId extracted from the verified OAuth token (set on AuthenticatedClient.organisationId in packages/auth-client/src/auth.guard.ts). Cross-org reads require the human-review:read-cross-tenant or cross_product_read scopes (packages/auth-client/src/auth.types.ts).


6. Secrets Handling

All secrets are injected via environment variables. Dev defaults are hard-coded in application code but are rejected at boot in production via the prodRequired() helper:

// services/clinical-api/src/config/app-config.ts
private prodRequired(name: string, devDefault: string): string {
  const value = process.env[name];
  if (value) return value;
  if (process.env.NODE_ENV === 'production') {
    throw new Error(`${name} is required in production`);
  }
  return devDefault;
}

This pattern is replicated across all services. The orchestrator additionally checks that neither ADMIN_API_SECRET nor SERVICE_AUTH_TOKEN are the known dev-default strings at boot (services/orchestrator/src/config/app-config.ts).

Secrets covered:

Variable Purpose
ENCRYPTION_MASTER_KEY DEK wrapping master key (local provider)
KMS_CMK_ARN AWS KMS CMK ARN (production)
ADMIN_API_SECRET Admin-tier API authentication
SERVICE_AUTH_TOKEN Service-to-service Bearer token
DATABASE_URL MySQL connection string
REDIS_URL Redis connection string
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY S3 credentials

7. Service-to-Service Authentication

Internal HTTP calls between services (e.g. orchestrator calling clinical-api, ai-review calling consent service, notifications calling user-management) use a shared SERVICE_AUTH_TOKEN passed as a Bearer token in the Authorization header (services/ai-review/src/consent/consent-client.service.ts, services/notifications/src/recipients/user-management.client.ts, services/human-review/src/clients/clients.module.ts).

This token is validated by the receiving service's AuthGuard. The service token carries a separate, restricted scope set. Dev defaults (service-dev-token) are rejected in production by the orchestrator's AppConfigService.