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/tokenendpoint acceptsgrant_type=client_credentialsand verifies the client's hashed secret againstApiClientSecret.secretHashin the auth database (services/auth/prisma/schema.prisma—ApiClient,ApiClientSecretmodels). - On successful authentication a RS256-signed JWT is issued. The token payload includes
sub(client ID),org_id,product_id,scopes, andaud: "sa-platform". - The signing key is fetched from
SigningKeyrows in the auth DB. Only keys withstatus = "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/rotateendpoint generates a new RSA key pair, sets it active, and publishes anauth.key.rotatedevent 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
JwksServiceinpackages/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:
- 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). - Google → admin-api
GET /api/auth/google/callback. admin-api verifies the state nonce is present and unused, exchanges the OAuth code for anid_tokenagainsthttps://oauth2.googleapis.com/token, and forwards theid_tokento the auth service over a service-to-service call (BearerSERVICE_AUTH_TOKEN). - auth service verifies the
id_tokensignature against Google's JWKS, assertsemail_verified === true, checks the email's domain againstADMIN_DOMAIN_ALLOWLIST, and calls back to admin-apiPOST /internal/admin-users/resolveto find or create theAdminUserrow. Pending or disabled users are rejected. - auth service issues a platform JWT (RS256, same
KeysServiceas the client_credentials path) with scopesadmin:read admin:cross-tenant,aud: "admin-api", and anactor_contextclaim carrying email / displayName / role. The audience differs from the client_credentials flow'saud: "sa-platform"so backing services can distinguish them. - admin-api stores the JWT in a Redis-backed session (TTL
SESSION_TTL_HOURS, default 8h), sets anadmin_sessionhttpOnlycookie 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 issuerservices/admin-api/src/auth/— OIDC start/callback/logout, AuthServiceClient, GoogleOAuthClientservices/admin-api/src/session/— Redis-backed session store, SessionGuard, REDIS_SESSION_CLIENTservices/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 viaENCRYPTION_MASTER_KEYenv 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 KMSEncrypt/Decryptcommands using@aws-sdk/client-kms. Selected whenENCRYPTION_PROVIDER=kmsandKMS_CMK_ARNis 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:AiReviewcarriesorg_idorchestrator:WorkflowInstancecarriesorg_idhuman-review:Reviewcarriesorg_idconsent:ConsentRecordcarriesorganisation_idnotifications:Notificationcarriesorg_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.