Patient Portal Service — Security Model
Status: populated
Owner: TBD
Last updated: 2026-04-18
Companion: Service Template · 13 security-compliance-tenancy · 02 DDD
1. Authentication
| Mechanism | Details |
|---|
| Protocol | OIDC / OAuth2 with PKCE (no implicit grant) |
| Token issuer | Keycloak — dedicated patient realm per tenant |
| MFA requirement | MFA is mandatory for all portal accounts (mfaEnabled enforced at account activation) |
| MFA method | TOTP (Authenticator app) or SMS OTP |
| Session | JWT access token (15 min TTL) + refresh token (24h). Refresh rotates on use. |
| Token validation | Portal BFF validates JWT signature + iss, aud, iat, exp claims on every request |
| ACR check | Sensitive actions (export, account deletion) require acr >= 2 in JWT claims |
2. Authorization — SMART on FHIR Scope Matrix
| Scope | Resource type | Operations permitted |
|---|
patient/Patient.read | Patient | GET demographics, account info |
patient/Patient.write | Patient | Demographics update request, proxy delegation |
patient/Observation.read | Observation | Lab results (policy-filtered) |
patient/DiagnosticReport.read | DiagnosticReport | Radiology results (policy-filtered) |
patient/MedicationRequest.read | MedicationRequest | Medication list |
patient/MedicationRequest.write | MedicationRequest | Refill request |
patient/Immunization.read | Immunization | Immunization records |
patient/Condition.read | Condition | Problem list |
patient/AllergyIntolerance.read | AllergyIntolerance | Allergy list |
patient/Appointment.read | Appointment | Appointment list |
patient/Appointment.write | Appointment | Appointment request, cancellation |
patient/Coverage.read | Coverage | Insurance coverage |
patient/ExplanationOfBenefit.read | ExplanationOfBenefit | EOB read |
patient/Communication.read | Communication | Secure messages |
patient/DocumentReference.read | DocumentReference | Documents |
3. Proxy Access Enforcement
When a proxy session is active (proxy PortalAccount acting on behalf of a patient), the BFF enforces:
- The
ProxyDelegation record must have status = active.
ProxyDelegation.validFrom <= today <= validTo (if validTo is set).
- The requested resource type must be present in
ProxyDelegation.scope.
- Proxy cannot exceed the permissions of the grantor patient.
- All access events are recorded with
actingAsProxy = true and proxyDelegationId.
4. Data Classification and Encryption
| Classification | Applies to | At-rest encryption | In-transit |
|---|
| PHI | portal_accounts, proxy_delegations, demographics_update_requests, portal_access_events | PostgreSQL TDE (AES-256) | TLS 1.3 |
| PII | portal_accounts.idp_subject, ip_hash | AES-256 at rest | TLS 1.3 |
| Operational | export_jobs, outbox | AES-256 at rest | TLS 1.3 |
| No PHI | Cached FHIR read projections | Redis: encrypted at rest | TLS 1.3 |
ip_hash is a SHA-256 hash of the client IP. The raw IP is never stored. Push notification tokens are stored in Redis with TTL; never in Postgres.
5. Result Release Policy Enforcement
Lab and radiology results are only surfaced to patients when the upstream service marks them as patient-visible. The BFF passes ?releasePolicy=patient-visible in all calls to laboratory-service and radiology-service. Results with releasePolicy=clinician_release or timed (time-lock not expired) are never included in portal responses.
6. PHI Boundary Rules
| Rule | Description |
|---|
| No PHI in push notifications | Push payloads contain only notification type + deep-link URL |
| No PHI in AI prompts | Portal navigation assistant prompts contain no patient identifiers or clinical data |
| No PHI in logs | Request logging redacts Authorization header, patient ID body fields, and result content |
| No cross-tenant data | RLS policy tenant_id = current_setting('app.tenant_id') enforced on all tables |
| Proxy scope isolation | Proxy cannot read resource types not included in their DelegationScope |
7. RBAC: Portal-Specific Roles
| Role | Permissions |
|---|
portal:patient | All patient/* scopes above; own data only |
portal:proxy | Scopes subset as defined by ProxyDelegation.scope |
portal:admin | Suspend/reinstate accounts, review demographics update requests |
8. Audit Events
All the following actions produce immutable PortalAccessEvent records (local) and are forwarded to audit-service via NATS:
| Event | Audit fields |
|---|
| Login | accountId, patientId, mfaUsed, ipHash |
| Record viewed | accountId, resourceType, resourceId, actingAsProxy |
| Result viewed | accountId, observationId/reportId, actingAsProxy |
| Appointment requested | accountId, requestId |
| Demographics update submitted | accountId, requestId |
| Proxy delegation granted/revoked | delegationId, grantorPatientId, actorId |
| Export requested | accountId, exportJobId |
| Account suspended/reinstated | accountId, actorId |
9. GDPR / Data Rights Participation
| Right | Mechanism |
|---|
| Right to access | FHIR export endpoint (POST /v1/portal/export) provides full PHR Bundle |
| Right to erasure | Account deletion request routes to registration-service for full data removal; portal tables purged after retention period |
| Right to portability | FHIR Bundle export in application/fhir+ndjson format |
| Data residency | All PHI remains in the tenant-designated region; no cross-border transfer |
10. Threat Model Summary
| Threat | Mitigation |
|---|
| JWT token theft | Short-lived access tokens (15 min); MFA required; refresh token rotation |
| Account takeover | MFA mandatory; anomalous login patterns trigger suspension (future) |
| Proxy scope escalation | Server-side scope enforcement on every request; delegation checked against DB |
| FHIR result leakage | Release policy check enforced server-side; cached separately from unreleased data |
| PHI in AI prompts | Prompt construction rules enforced at code level; validated in code review |
| Tenant data leakage | PostgreSQL RLS on all tables; app.tenant_id session variable set per request |