Audit Compliance: HIPAA & GDPR Requirements
Status — verified against migrations + code on 2026-08-02.
Shipped. The synchronous audit recorder (
internal/core/audit/, 1A.1), key redaction (1A.5), failed-request logging (401-with-bearer / 403 / 5xx), append-only enforcement (no UPDATE/DELETE RLS policy +REVOKEfromrestartix_app), monthly range partitioning ofaudit_log/audit_ai_provenancewith a forward-roll cron (1A.15), a mechanical coverage guard (cmd/check-audit-coverage, wired intomake check), the platform-wide read APIGET /v1/audit-logswith its Console UI, and the break-glass elevation primitive end-to-end (1B.11 + 1B.11.x) plus its patient-impersonation sibling (1B.13).Not shipped. An org-scoped (clinic-facing) audit read endpoint, CSV export, any retention/aging engine at all (no archival to S3, no purge, nothing drops a partition), read-access auditing inside break-glass sessions, any break-glass review workflow, a breach-records table, and DSAR export / erasure routes.
internal/core/gdpr/anonymize.goexists as a helper with no caller. These are Phase 2 (F11 compliance hardening + operational leftovers) in platform-completion.md.Sections below are marked Shipped or Unbuilt individually. Where a control is unbuilt, this document says so instead of describing it in the present tense — an asserted safeguard with no enforcing mechanism is itself an audit finding.
Overview
This document details how the platform's audit system meets — and where it does not yet meet — HIPAA and GDPR requirements. The audit trail is designed to be tamper-evident, comprehensive, and retained for the required periods. Tamper-evidence and comprehensiveness are enforced today; retention beyond the hot Postgres tier is not.
HIPAA Requirements
164.312(b) - Audit Controls
Requirement: Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information (ePHI).
Our Implementation:
| Requirement | Implementation | Status |
|---|---|---|
| Record all mutations | internal/core/audit records one row per CREATE/UPDATE/DELETE. cmd/check-audit-coverage fails make check when a handler registered via r.Post/r.Put/r.Patch/r.Delete has no reachable audit.Record* call and no // audit-exempt: marker. | Shipped (1A.1) |
| Record read access to ePHI | Not implemented. GETs emit no audit row, including inside a break-glass session — see Reads are not audited. | Unbuilt |
| Examine activity logs | GET /v1/audit-logs (superadmin, platform-wide, filterable by organization_id) + Console /audit-logs and /clinics/{id}/audit pages. No clinic-facing endpoint yet, though the RLS policy and the audit_log.view_org permission that would gate one both exist. | Partly shipped |
| Tamper-evident | No UPDATE or DELETE RLS policy on audit_log (RLS denies by default when no policy matches); restartix_app has INSERT/UPDATE/DELETE/TRUNCATE explicitly REVOKEd on the parent and REVOKE ALL on each leaf partition (partitions.EnsureMonthly re-applies the claw-back on every rolled partition, because parent RLS is not enforced on direct-partition access and new leaves inherit the ALTER DEFAULT PRIVILEGES grant from 000001). | Shipped (1A.1 + 1A.15) |
| Retention | Hot tier only. audit_log is monthly range-partitioned and rolls forward; nothing archives, detaches, or drops a partition. | Hot: shipped. Warm/purge: unbuilt |
What Gets Logged
Every audited mutation captures:
- Who:
actor_id(FK toprincipals.id) +actor_type('human' | 'agent' | 'service_account' | 'system') - What:
entity_type(TEXT),entity_id(UUID) - When:
created_at(TIMESTAMPTZ, UTC) — also the partition key - How:
action—CREATE/UPDATE/DELETEfor mutations;ACCESS_DENIED(401/403) andINTERNAL_ERROR(5xx) for the failure path - Where:
ip_address(INET, client IP via Cloudflare or X-Forwarded-For) - Result:
status_code - Context:
request_path,request_method,request_id,user_agent - Elevation:
action_context(normal/break_glass/impersonation/gdpr_operation/org_entitlement_change/platform_membership_change), plusbreak_glass_idandimpersonation_idwhen the request ran inside an elevated session - Diff:
changes(JSONB, redacted — see Sensitive Data Masking)
AI provenance lives in a sibling table, not on audit_log. audit_ai_provenance carries (audit_log_id, audit_log_created_at, model_id, inputs_hash, confidence) with a composite FK to audit_log(id, created_at) and the same monthly partition windows, so both tables hand off together at archival. It was split out so AI-features schema churn doesn't pollute the core audit table's compliance contract. There is no model_version column anywhere — the model is referenced by model_id FK into ai_models (1C.8).
Failed requests are also logged. The rules in audit.shouldLogFailure:
>= 500— always403— always (authenticated but not authorized)401— only when the request actually carried a Bearer token, i.e. someone tried to authenticate. A tokenless 401 is unauthenticated noise; overridable viaMiddlewareOptions.LogUnauthenticated401.
164.308(a)(1)(ii)(D) - Information System Activity Review
Requirement: Implement procedures to regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports.
Unbuilt — no review procedure is enforced by the platform today. An earlier version of this page asserted a review cadence (monthly log review, weekly failed-access review, break-glass review "within 24 hours by two approvers", quarterly compliance report). None of it is implemented, and the break-glass row in particular described a control the schema cannot express. It has been replaced with what actually exists.
What is actually enforced (technical controls):
| Control | Mechanism | Where |
|---|---|---|
| Break-glass requires a written justification | CHECK (length(btrim(reason_text)) >= 10) + closed reason_category enum (support_ticket, security_incident, dsar_routing, fraud_investigation, platform_engineering) | 000011_break_glass.up.sql |
| Break-glass is time-bounded | CHECK (expires_at > opened_at AND expires_at <= opened_at + INTERVAL '4 hours'). Sessions never auto-renew. | 000011 |
| Break-glass is scope-bounded | scope IN ('patient_list','patient_detail','audit_full','cross_org_lookup','org_management'); a session for one scope does not cover another. RequireBreakGlass(scope) returns 403 break_glass_required / 410 break_glass_expired. | 000011, internal/core/middleware/break_glass.go |
| Break-glass is transparent to the clinic | On open, notify.Send(adminPrincipal, BreakGlassOpened, …) fans out to every org admin, with session_id:admin_principal_id idempotency keys. Not opt-out. | breakglass/service.go, 1A.18 |
| Clinic can see sessions against its own org | RLS SELECT policy: organization_id = current_app_org_id() AND current_app_has_permission('audit_log','view_org') | 000011 |
| Elevated activity is linkable | set_app_break_glass_session_id binds a GUC; the redefined audit_log_insert stamps break_glass_id + action_context='break_glass' on every audit row written downstream — no per-handler plumbing | 000011 |
| Abandoned sessions get closed | breakglass.SweepExpired finalizes closed_at = expires_at for orphan-expired rows and emits an audit row attributed to the system principal | breakglass/sweep.go |
What is NOT enforced (procedural controls, unbuilt):
- No review workflow exists.
break_glass_sessionshas noreviewed_at, noreviewed_by_principal_id, noreview_notes, and no approval columns of any kind. There is exactly one actor column for the elevating principal (principal_id) and one for whoever closed it (closed_by_principal_id). Break-glass is self-service, single-actor, no second approver, no review sign-off. A claim of "two approvers" or "reviewed within 24 hours" cannot be evidenced from the data. - No
opened_from_ipon the session row. The originating IP is recoverable only indirectly, viaaudit_log.ip_addresson rows carrying thatbreak_glass_id. - No scheduled review job, no review dashboard, no quarterly export. There is no
.github/workflows/hipaa-audit-check.ymland nocmd/tools/audit-check. - No anomaly alerting on
audit_log. The Console system-health page has an audit-anomalies card, but there is no alerting pipeline behind mass-access or unusual-pattern detection.
Designing the review columns and workflow is Phase 2 / F11 work — see platform-completion.md → Phase 2. Note for whoever picks it up: 000011 is already applied to production (live since 2026-06-05). Adding columns to break_glass_sessions cannot be done by editing that migration; it needs a new forward migration plus the per-environment catch-up-DDL pattern established in infra/scripts/000023-skip-note-prod.sql.
Queries that work today (superadmin, against the shipped read API or directly):
-- All failed access attempts in the last 7 days
SELECT * FROM audit_log
WHERE action IN ('ACCESS_DENIED', 'INTERNAL_ERROR')
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;
-- High-privilege actions on sensitive entities
SELECT * FROM audit_log
WHERE action IN ('CREATE', 'DELETE')
AND entity_type IN ('patient', 'patient_profile', 'human', 'organization')
AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;
-- All actions by a specific principal, within one org
SELECT * FROM audit_log
WHERE actor_id = $1 -- principals.id (UUID)
AND organization_id = $2 -- organizations.id (UUID)
ORDER BY created_at DESC;
-- Every elevation in the last 30 days, with its justification
SELECT organization_id, principal_id, scope, reason_category, reason_text,
reason_ref, opened_at, expires_at, closed_at
FROM break_glass_sessions
WHERE opened_at > NOW() - INTERVAL '30 days'
ORDER BY opened_at DESC;164.308(a)(8) - Evaluation
Requirement: Perform a periodic technical and nontechnical evaluation … that establishes the extent to which an entity's security policies and procedures meet the requirements of this subpart.
Our Implementation:
| Evaluation | Status | Notes |
|---|---|---|
| Audit coverage completeness | Shipped, per-commit | cmd/check-audit-coverage runs in make check — every mutating handler must reach an audit.Record* call or carry an // audit-exempt: <reason> marker |
| Column classification completeness | Shipped, per-commit | cmd/check-classification fails the build when a migration adds a column with no data-classification.md entry |
| Retention compliance check | Unbuilt | No warm tier exists to verify |
| RLS policy audit | Partly shipped | RLS integration-test harness (1A.2) covers policies under test; the /audit-rls sweep is a manual review, not a scheduled job |
| Break-glass log review | Unbuilt | See above — no review mechanism to verify against |
| Penetration testing | Unbuilt | Phase 2 |
GDPR Requirements
Article 30 - Records of Processing Activities
Requirement: Each controller and, where applicable, the controller's representative, shall maintain a record of processing activities under its responsibility.
Our Implementation: the audit_log table serves as the record of processing activities (ROPA) for all data mutations. Note the controller/processor split: the clinic is the controller, RestartiX is the processor — see decisions.md → Why clinic is controller, platform is processor. The ROPA a clinic files is its own; audit_log is the processing record we maintain on its documented instructions.
| ROPA Element | Audit Log Field | Example |
|---|---|---|
| Name and contact details of the controller | organization_id → organizations.name | "Restartix Clinic" |
| Purposes of the processing | action + action_context | "Patient record update, normal context" |
| Categories of data subjects | entity_type | patient, patient_profile, human |
| Categories of personal data | entity_type + changes JSONB | patient_profile.name, patient_profile.phone |
| Categories of recipients | actor_id joined via organization_memberships.role_id → roles.code | admin, specialist |
| Transfers to third countries | Not applicable (all data stored in eu-central-1) | N/A |
| Time limits for erasure | 6 years floor; per-org override via organization_settings.audit_retention_months (CHECK >= 72) | See retention section |
| Technical and organizational measures | RLS, encryption, audit logging | reference/rbac-permissions.md, reference/encryption.md, reference/rls-policies.md |
Article 32 - Security of Processing
Requirement: The controller and the processor shall implement appropriate technical and organisational measures to ensure a level of security appropriate to the risk, including:
- (a) the pseudonymisation and encryption of personal data
- (b) the ability to ensure the ongoing confidentiality, integrity, availability and resilience of processing systems and services
- (c) the ability to restore the availability and access to personal data in a timely manner in the event of a physical or technical incident
- (d) a process for regularly testing, assessing and evaluating the effectiveness of technical and organisational measures for ensuring the security of the processing
Our Implementation:
| Measure | Implementation | Status |
|---|---|---|
| Pseudonymisation | internal/shared/pseudonym.UserID(uuid) → SHA-256 hex digest of a principals.id. Helper only; the telemetry forwarder that would apply it is not built. (The function name is legacy — the input is a principal id, not a "user" id; there is no users table. Rename to PrincipalID is queued for when the telemetry pipeline ships.) | Helper shipped (1A.5); application unbuilt |
| Encryption at rest (column-level) | AES-256-GCM via internal/core/crypto/, wire format [1-byte version][12-byte nonce][ciphertext+tag] in BYTEA. Live encrypted columns today: organization_billing.tax_id_encrypted (000003), platform_service_providers.credentials_encrypted (000015), outbound_webhook_subscriptions.signing_secret_encrypted + signing_secret_previous_encrypted (000016), organization_integrations.credentials_encrypted + inbound_signing_secret_encrypted (000017). Scope is deliberately narrow — auth_secret and pii_regulated only; see decisions.md → Why most PII is plaintext. | Shipped (1A.3) |
| Patient-side regulated PII (CNP) | patient_profiles has no encrypted column today. CNP capture as opt-in per form template, stored pii_regulated / encrypted BYTEA on patient_profiles, was settled 2026-08-02 and is not built — see platform-completion.md → CNP handling. | Unbuilt |
| Confidentiality | RLS on every tenant table + per-org permission codes (current_app_has_permission) + audit logging | Shipped |
| Integrity | Database constraints, foreign keys, append-only audit_log | Shipped |
| Availability | Production RDS is Multi-AZ (infra/envs/production/main.tf), ECS Fargate across AZs | Shipped (live since 2026-06-05) |
| Restore capability | RDS backup_retention_period = 7 (7-day PITR) plus the Layer-2 logical backup runner (cmd/backup-runner) and restore drill (cmd/restore-drill, ran green 2026-08-02) | Shipped; cross-region replication and a schedule for the drill remain open |
| Resilience | Health checks, ECS restarts, pgbouncer pooling | Shipped |
| Regular testing | make check guards (audit coverage, classification, SOUP, migrations) run per-commit; penetration testing and load gating are not scheduled | Partly shipped |
Article 33 - Breach Notification
Requirement: In the case of a personal data breach, the controller shall without undue delay and, where feasible, not later than 72 hours after having become aware of it, notify the personal data breach to the supervisory authority.
See reference/gdpr-compliance.md for the documented procedure. Because the clinic is the controller, the 72-hour notification obligation to ANSPDCP is the clinic's; the platform's obligation as processor is to notify the clinic without undue delay (Art. 33(2)).
What exists technically:
- All qualifying failed access attempts (401-with-token, 403, 5xx) land in
audit_logwithaction = 'ACCESS_DENIED' | 'INTERNAL_ERROR' - Break-glass sessions are queryable per-org, with justification, and notify org admins on open
- Sentry is wired in the Portal only
What does not exist:
- No
breach_recordstable in any migration. Breach documentation has no schema-backed home today. - No anomaly detection or alerting — nothing watches for mass access (e.g. ">100 records in 1 minute"), and no such rule is implemented anywhere.
- Datadog is not used by this platform. Observability today is CloudWatch alarms plus Sentry (Portal only); "Sentry / Datadog observability tooling" is an open item on the Console system-health page. Earlier revisions of this document referenced Datadog queries as if they existed.
- No severity classification / escalation playbook — flagged as open and unowned in platform-completion.md → Security incident response.
Retention Policy
HIPAA Requirement: 6-Year Minimum
HIPAA 164.316(b)(2)(i) requires retaining documentation for 6 years from the date of its creation or the date when it last was in effect, whichever is later. organization_settings.audit_retention_months (CHECK >= 72) lets an org extend that floor; NULL inherits the platform default.
Target Three-Tier Retention Strategy — the engine is unbuilt
| Tier | Storage | Duration | Queryable | Status |
|---|---|---|---|---|
| Hot | PostgreSQL audit_log (monthly partitions) | 0-12 months | Yes | Shipped |
| Warm | S3 JSONL archives | 12 months - 6 years | On request | Unbuilt |
| Delete | Purged | After 6 years | No | Unbuilt |
Verified state, 2026-08-02:
- No aging engine exists anywhere on the platform. The partition machinery is forward-only:
partitions.EnsureMonthly(internal/core/partitions) is the shared P41 primitive,audit.EnsurePartitionsbinds it toaudit_log+audit_ai_provenance,partitionroll.RollAllfans out across every partitioned table, andcmd/api-partition-roll(default-ahead=3) runs it on a cron. Nothing drops, detaches, or archives a partition.grep -r "DROP PARTITION" services/returns nothing. Every partitioned table grows monotonically. audit_logis the hard case. Break-glass entries, GDPR-operation entries, and key-rotation events are never deleted (CLAUDE.md), andaudit_retention_monthsis a per-org override while a monthly partition holds many orgs — so a naive partition drop would violate one tenant's extension while satisfying another's. Archive-to-S3-then-drop is the only correct shape.s3.AuditArchiveKey(orgID, year, month)ininternal/integration/s3/keys.gois the pre-positioned key builder for the warm tier. It is currently referenced only by its own test.
Full per-table retention windows (notifications 18mo, usage_records 24mo, outbound_webhook_deliveries 90d, etc.) are recorded in platform-completion.md → Per-table retention windows. That table is authoritative; do not duplicate it here.
Archival Process — design sketch, not shipped
No archival job exists. When one is built, two constraints from the current schema bind it:
audit_logis partitioned, andrestartix_appcannot write to it.DELETE FROM audit_log WHERE created_at < $1is the wrong shape twice over — DELETE isREVOKEd on the parent and every leaf partition, and a partitioned table sheds old data byDETACH PARTITION+DROP TABLE, not by row-wise delete. The job runs on the admin pool and operates on whole monthly partitions.audit_ai_provenancemust be handed off in the same window. Its composite FK targetsaudit_log(id, created_at)and it carries identical monthly ranges precisely so both tables move together. Archiving one without the other breaks the FK.
Sketch of the intended shape:
monthly, for each partition older than the retention cutoff:
1. Stream the partition to S3 as gzipped JSONL, grouped by organization_id
→ s3://<bucket>/<s3.AuditArchiveKey(orgID, year, month)>
2. Verify the upload (object exists, row count matches)
3. DETACH the audit_ai_provenance partition, then the audit_log partition
4. DROP both
5. Skip any org whose organization_settings.audit_retention_months still
covers the window — per-org overrides are why this cannot be a blanket drop
6. Separately, purge S3 objects older than 6 years, excluding the
never-deleted classes belowSpecial Retention Rules
Never deleted:
break_glass_sessionsrows (permanent — the session row IS the forensic artifact; the audit rows linked bybreak_glass_idare what the archival job must preserve alongside it)patient_impersonation_sessionsrows, same rationale (1B.13)- Audit entries with
action_context = 'gdpr_operation'(7-year retention)
Extended retention:
- Breach notification records: 7 years per GDPR Art. 33 — no table exists to hold them
- Key rotation events: permanent
Downloading Warm Archives — unbuilt
There is no archive-download endpoint. GET /v1/admin/audit/archive does not exist; neither does the warm tier it would read from. When both ship, the intended shape is a pre-signed S3 URL with a short expiry, gated on superadmin.
What Gets Logged
Logged Entities
audit_log.entity_type is free-text TEXT; these are the values the platform emits today (the read API's filterableEntityTypes allow-list is narrower than this — it exposes only the subset the Console filter UI offers, and lags behind).
| Domain | Entity types emitted | Notes |
|---|---|---|
| Tenancy | organization, organization_membership, organization_settings, organization_domain, organization_billing, organization_entitlements, organization_designation, organization_ownership_transfer, organization_invite, location, platform_membership, subscription_override | |
| Identity | human, patient, patient_profile | There is no user entity type — there is no users table. Human profiles are humans (PK principal_id); the per-org patient link is patients; the portable patient identity is patient_profiles. |
| Elevation | break_glass_session, patient_impersonation_session | Open and close both audit; the session row is the body-of-record |
| Consent & legal | consent, consent_purpose_version, legal_document_template, organization_legal_document | |
| Clinical content | exercise, exercise_render, exercise_tag, protocol, program, program_phase, program_session, program_asset, session, session_exercise, session_run, session_pairing, patient_assignment, assignment_pause | Taxonomy entities carry an exercise_ prefix: exercise_category, exercise_body_region, exercise_equipment, exercise_movement_pattern, exercise_condition, exercise_recovery_phase, exercise_instruction, exercise_contraindication, exercise_prerequisite |
| Pose config | exercise_pose_config, exercise_pose_metric, exercise_pose_landmark, exercise_pose_feedback_rule, pose_data_quality_override | |
| Commerce | access_offer, access_offer_campaign, access_offer_sku_binding, access_offer_fulfillment, patient_content_grant, patient_subscription, patient_subscription_override, patient_tier, catalog_section, catalog_entry, share_link | |
| Integrations | organization_integration, outbound_webhook_subscription, platform_service_provider, ai_model | |
| Failure path | http_request | Written by the audit middleware for ACCESS_DENIED / INTERNAL_ERROR |
Entity types for unbuilt domains — appointment, form, form_template, specialist, custom_field, document — do not exist yet. Those are F1/F3/F4/F5/F6 in platform-completion.md; no tables, no handlers, nothing to audit. segment is out of scope entirely (F8).
Reads are not audited, including under break-glass
GET requests emit no audit row. This is deliberate for ordinary reads (volume, and RLS is the confidentiality control). But it also means:
A break-glass session that only reads produces no
audit_logrows.RequireBreakGlassbinds thebreak_glass_id+action_context = 'break_glass'GUCs so that any audit row written downstream carries the linkage — but if the elevated request is a GET, nothing writes a row. There is noREADorACCESSaction constant ininternal/core/audit(ActionCreate/ActionUpdate/ActionDeleteonly), and the read API's action allow-list confirms it (CREATE,UPDATE,DELETE,ACCESS_DENIED,INTERNAL_ERROR).
What is recorded for a read-only elevation: the break_glass_sessions row itself (who, which org, which scope, why, when opened, when it expires/closed) and the admin-notification fan-out. That establishes that platform staff had access to a scope, with justification and a time window — it does not establish which patient records they opened.
Closing this gap means auditing reads inside elevated sessions. That is F11 work and is not implemented today. Until it is, statements like "break-glass sessions log all actions including reads" are false and must not appear in a compliance representation.
Sensitive Data Masking
Shipped (1A.5). Before writing to audit_log, values under sensitive keys in the changes JSONB are replaced with [REDACTED]. The same predicate backs slog redaction, so logs and audit rows agree.
Masked Patterns
The key list lives in internal/shared/redact:
var sensitiveKeys = []string{
"password", "secret", "token", "apikey",
"authorization", "cookie", "session",
}Matching is separator- and case-insensitive and substring-based, so api_key, api-key, X-API-KEY, refresh_token, and clerk_session_token all match (locked by redact_test.go). Adding a new sensitive key happens in internal/shared/redact — both consumers pick it up.
Example:
// Original request body
{
"name": "John Doe",
"email": "john@example.com",
"password": "hunter2"
}
// Logged in audit_log.changes
{
"name": {"old": "Jane Doe", "new": "John Doe"},
"email": {"old": "jane@example.com", "new": "john@example.com"},
"password": "[REDACTED]"
}PII Masking in Analytics
The Telemetry service stores to PostgreSQL + S3, not ClickHouse — see decisions.md → Why telemetry is PG + S3, not ClickHouse. Earlier revisions of this page described a ClickHouse pipeline; that design was rejected.
The intended masking contract, when the audit→telemetry forwarder ships (it does not exist today — internal/core/audit has no telemetry dependency):
- Principal IDs are pseudonymised with
pseudonym.UserID(SHA-256), never rawprincipals.id - IP addresses may resolve to country/city but the raw address is not forwarded
- Patient names / emails are never forwarded
Querying Audit Logs
Admin API — GET /v1/audit-logs (Shipped)
Superadmin-only (RequireSuperadmin at the route), platform-wide. Reads through the owner pool, which bypasses RLS — org scoping is done by the organization_id filter, not by a URL path segment.
GET /v1/audit-logs?page=1&limit=50&sort=-created_at&action=CREATE,DELETE&organization_id=<uuid>Query parameters (all optional):
| Param | Shape | Notes |
|---|---|---|
q | string | Case-insensitive substring across action, entity_type, request_path |
action | CSV | Allow-list: CREATE, UPDATE, DELETE, ACCESS_DENIED, INTERNAL_ERROR |
entity_type | CSV | Validated against a fixed allow-list in audit/handler.go; unknown values are dropped, not 422'd |
actor_type | CSV | human, agent, service_account, system |
action_context | CSV | normal, break_glass, impersonation, gdpr_operation, org_entitlement_change |
status_class | CSV | 2xx, 4xx, 5xx |
organization_id | CSV of UUIDs | No allow-list — the value space is dynamic |
actor_id | CSV of UUIDs | principals.id |
created_after / created_before | date/timestamp | Parsed by apiquery.ParseDateRange, inclusive |
sort | CSV, - prefix for desc | Allow-list: created_at, action, entity_type, actor_type, status_code, action_context. Default -created_at. |
page, limit | int | Standard apiquery.ParsePage; default 50, hard cap apiquery.MaxLimit (500) |
Response:
{
"data": [
{
"id": "01938b27-7df1-7c8a-9d3a-1b2c3d4e5f60",
"organization_id": "01938b27-7df1-7c8a-9d3a-aabbccddeeff",
"actor_id": "01938b27-7df1-7c8a-9d3a-112233445566",
"actor_type": "human",
"action": "UPDATE",
"entity_type": "patient_profile",
"entity_id": "01938b27-7df1-7c8a-9d3a-998877665544",
"changes": { "phone": { "old": "…", "new": "…" } },
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0...",
"request_path": "/v1/patient-profiles/01938b27-7df1-7c8a-9d3a-998877665544",
"request_method": "PATCH",
"status_code": 200,
"request_id": "01938b27-7df1-7c8a-9d3a-0011aabbccdd",
"action_context": "normal",
"created_at": "2026-02-13T10:00:00Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 1234 }
}AI-provenance fields are not in this payload — they live in audit_ai_provenance and have no read endpoint yet.
Clinic-facing (org-scoped) read — unbuilt
The substrate exists: the audit_log.view_org permission is seeded (000002), it is granted to the admin system role template, and the RLS SELECT policy is
CREATE POLICY audit_select ON audit_log FOR SELECT USING (
organization_id = current_app_org_id()
AND current_app_has_permission('audit_log', 'view_org')
);There is no GET /v1/organizations/{id}/audit-logs handler yet. When one ships it must mount middleware.RequireURLOrgMatchesScope("id") per P47 and read through the app pool so RLS applies.
CSV export — unbuilt
GET /v1/audit-logs/export does not exist. Nothing in internal/core/domain/audit produces CSV.
SQL Queries (Direct Database Access)
All identifiers are UUIDs (P26 — UUIDv7 for new PKs; audit_log.id defaults to gen_random_uuid()). Never integers.
Entity history:
SELECT * FROM audit_log
WHERE entity_type = 'patient'
AND entity_id = '01938b27-7df1-7c8a-9d3a-998877665544'
AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
ORDER BY created_at DESC;Backed by idx_audit_org_entity_time (organization_id, entity_type, entity_id, created_at DESC).
Principal activity:
SELECT * FROM audit_log
WHERE actor_id = '01938b27-7df1-7c8a-9d3a-112233445566'
AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;Backed by idx_audit_org_actor_time.
Failed requests:
SELECT * FROM audit_log
WHERE status_code >= 400
AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;Backed by idx_audit_status (status_code, created_at DESC).
Everything written during one break-glass session:
SELECT * FROM audit_log
WHERE break_glass_id = '01938b27-7df1-7c8a-9d3a-77665544aabb'
ORDER BY created_at;Backed by the partial index idx_audit_breakglass … WHERE break_glass_id IS NOT NULL. Remember this returns mutations only — see Reads are not audited.
Compliance Checklist
HIPAA
- [x] All mutations are logged — 1A.1, mechanically enforced by
cmd/check-audit-coverageinmake check - [x] Audit logs are tamper-evident — no UPDATE/DELETE RLS policy;
restartix_appUPDATE/DELETE/TRUNCATE revoked on parent and every leaf partition - [x] Failed access attempts are logged — 5xx, 403, and 401-with-bearer
- [x] Audit table is partitioned for multi-year retention — 1A.15, monthly range partitions + forward-roll cron
- [x] Break-glass is justified, scoped, time-bounded, and clinic-notified — 1B.11 / 1B.11.x
- [x] A platform-wide audit read surface exists —
GET /v1/audit-logs+ Console pages - [ ] Read access logged inside break-glass sessions — no
READaction exists; elevated GETs write nothing - [ ] Break-glass review workflow — no
reviewed_at/ reviewer / approver columns onbreak_glass_sessions; no second-approver flow; no review tooling.000011is applied to production — adding these needs a new migration plus per-env catch-up DDL. - [ ] 6-year warm-tier retention — no archival job, no purge, nothing drops a partition
- [ ] Clinic-facing audit read endpoint — permission + RLS policy exist, handler does not
- [ ] CSV export
- [ ] Scheduled review procedure / compliance export — no workflow, no
cmd/tools/audit-check
GDPR
- [x] Record of processing activities (ROPA) maintained in
audit_log— 1A.1 - [x] Sensitive-key redaction in audit
changesand in logs — 1A.5, shared predicate - [x] Column-level encryption live for
auth_secret-class columns — 1A.3 - [x] Column classification registry enforced per-PR —
cmd/check-classificationinmake check - [x] Restore capability — 7-day RDS PITR + logical backup runner + a restore drill that ran green
- [x] Break-glass transparency to the clinic (always-on admin notification, per-org RLS visibility)
- [ ] DSAR export end-to-end — no route; only the
internal/core/gdpr/anonymize.gohelper, which has no caller - [ ] Erasure end-to-end — anonymise per Art. 17(3)(c) with the audit trail preserved
- [ ] Pseudonymisation applied — helper ready, telemetry forwarder unbuilt
- [ ] Patient regulated-PII encryption (CNP) — settled 2026-08-02, not built
- [ ] Breach detection / alerting on audit patterns — no rule, no table (
breach_recordsdoes not exist), no anomaly pipeline - [ ] Retention automation — see above
Open items map to Phase 2 (F11 compliance hardening) in platform-completion.md, which is authoritative for sequencing.
Related Documentation
- local-logging.md - API synchronous audit middleware (shipped, 1A.1) — single source of truth for compliance audit
- reference/gdpr-compliance.md - Full GDPR architecture
- implementation-plan/platform-completion.md - Authoritative plan; retention windows and the F11 hardening scope live there
- implementation-plan/foundation.md - 1A.1 / 1A.5 / 1A.15 / 1B.11 / 1B.13 checkbox-level status
- architecture/decisions.md - Controller/processor split, principals-as-root-identity, telemetry storage
- reference/rbac-permissions.md, reference/rls-policies.md, reference/encryption.md - Auth, RLS, and encryption foundations