Skip to content

Hosted Multi-Tenant Security Hardening Audit

Date: 2026-07-14

This audit is the launch gate for serving more than one independent household from one Rails application and one PostgreSQL database. It was reconciled against main for #1614. A control is GO only when current repository evidence proves it. A planned control described in documentation is not proof that the control works.

Beta Decision

Current status: NO-GO

Decision recorded on 2026-07-14. Twenty-one of twenty-two controls are GO. The hosted private beta remains closed solely because a current production-like restore rehearsal has not yet been performed and linked as durable evidence.

Decisions

  • Tenant boundary: household.
  • Deployment target: shared Rails app and shared PostgreSQL database.
  • Platform admin: in-app operator capability, separate from household membership.
  • Owner promotion: platform-admin-only during hosted beta.
  • MFA/passkey: required before household admin pages are usable. Sensitive actions also require fresh verification.
  • API writes: in scope and must use membership-bound credentials, idempotency, version preconditions, and tenant-scoped authorization.
  • Professional/carer access: household-only for this gate.
  • Retention: configurable per deployer. Hosted beta defaults to export + purge unless a documented retention hold exists.

Requirements Matrix

Requirement Current evidence Gap / decision Severity Owner issue Tests required Beta status
FR1 SchemaInventory classifies every primary application table, and schema_inventory_spec.rb verifies the inventory. Closed: every tenant-owned table is identified and carries household_id. Global tables are explicitly classified. High Closed schema_inventory_spec.rb. GO
FR2 20260624091000_enforce_strict_household_tenant_boundary.rb backfills ownership that can be derived and rejects ambiguous roots. It also enforces the non-null tenant keys represented in schema.rb. Closed: tenant-owned rows cannot remain unowned after migration. Critical Closed schema_inventory_spec.rb. GO
FR3 Forced RLS and default-denial are implemented by the tenant-boundary migrations and verified against live PostgreSQL policies. Closed: runtime access without tenant context is denied and cross-household reads are isolated. Critical Closed household_row_level_security_spec.rb. GO
FR4 database.yml, compose.yaml, and init-roles.sql separate the owner-capable migrator from med_tracker_app. Closed: the runtime role is NOBYPASSRLS and cannot create in public. It does not inherit the owner role. Critical Closed database_role_config_spec.rb, yaml_compose_spec.rb, and household_row_level_security_spec.rb. GO
FR5 Api::V1::BaseController binds credentials to an active household membership, TenantContext sets database context, and support access is represented separately from membership. Closed: web, API, and support-mode tenant context is derived from authorized account state rather than request-controlled identity. Critical Closed write_resources_spec.rb, base_controller_spec.rb, and support_access_sessions_spec.rb. GO
FR6 tenant_safety_spec.rb rejects unsafe tenant lookup patterns and verifies tenant context in jobs. API request specs exercise cross-household write rejection. Closed: current web, API, attachment, and background-job boundaries have automated tenant-safety coverage. High Closed tenant_safety_spec.rb, write_resources_spec.rb, and sync_safety_spec.rb. GO
FR7 Households::AccessChange centralises membership and person-grant changes and advances permission versions. It invalidates stale credentials and records sanitised audit events. Admin::OwnerGovernance limits owner promotion to authorised platform administrators. Closed: every supported access-change path has one authorization, invalidation, owner-governance, and audit contract. Critical Closed access_change_spec.rb, user_mutation_boundary_spec.rb, and platform_owner_promotion_spec.rb. GO
FR8 SupportAccessSessions::ExpiryProcessor, its scheduled job, and task support-access:expire atomically claim natural expiries and append one sanitized audit event. Closed: support access can be created and ended explicitly. Natural expiry, retry, and concurrent processing are authorised and auditable. Critical Closed support_access_sessions_spec.rb, expiry_processor_spec.rb, and task_support_access_expire_spec.rb. GO
FR9 invitations_spec.rb proves pinned email, rejected expired or reused tokens, cross-household isolation, and successful acceptance under the deployed med_tracker_app role. Audit versions exclude raw tokens. Closed: invitation lookup and acceptance are tenant-isolated and proven through the forced-RLS runtime boundary. High Closed invitations_spec.rb, household_invitation_spec.rb, and household_invitation_policy_spec.rb. GO
FR10 Administration uses the valid Rodauth login and current household/platform permissions. Superseded by the agreed optional-MFA and central session-lifetime policy. Bespoke action freshness gates have been removed; configured login MFA and credential-management checks remain. Critical Superseded admin_mfa_gate_spec.rb, settings_spec.rb, and admin_api_spec.rb. Policy revised
FR11 API sessions, app tokens, and OAuth grants are membership-bound and reject stale permission versions. Households::AccessChange advances the version and revokes affected credentials for every effective membership or person-grant change. Closed: credentials issued before an access change fail closed across web-adjacent and direct API boundaries. Critical Closed access_change_spec.rb, api_session_household_spec.rb, api_app_token_spec.rb, and sync_safety_spec.rb. GO
FR12 Notification jobs reload household and person state in tenant context. They enforce recipient preferences and access, and use private generic content. Push service specs verify sensitive payload and log redaction. Closed: notification privacy and recipient authorization are covered for current delivery paths. Durable provider delivery remains separately tracked in #1611 and is not this security control. Critical Closed low_stock_notification_job_spec.rb, missed_dose_notification_job_spec.rb, medication_reminder_job_spec.rb, and push_notification_service_spec.rb. GO
FR13 Tenant-owned notification and reminder jobs accept a household identifier, enter TenantContext, and reload records inside the context. Architecture coverage inventories those boundaries. Closed: current tenant-owned jobs carry tenant context and do not serialize sensitive model payloads as job arguments. High Closed tenant_safety_spec.rb and the notification job specs listed for FR12. GO
FR14 The Audit::Event contract records tenant, actor, request, authorization, and redacted metadata. Export, retention-hold, offboarding, purge, and expiry workflows append lifecycle evidence to immutable audit sources and the chained ledger. Closed: hosted data-lifecycle actions have sanitized append-only audit coverage, and purge explicitly preserves all audit source records. High Closed hosted_export_spec.rb, retention_hold_manager_spec.rb, purger_spec.rb, and the audit service specs. GO
FR15 Households::HostedExport, RetentionHoldManager, Offboarder, and Purger provide the complete hosted exit lifecycle with authorization, revocation, holds, retries, storage cleanup, and shared-login preservation. Closed: a household can be exported, held, offboarded, and purged without crossing tenant boundaries or deleting immutable audit history. Critical Closed hosted_export_spec.rb, retention_hold_manager_spec.rb, purger_spec.rb, and household_lifecycle_tasks_spec.rb. GO
FR16 Households::HostedExport generates the complete portable household archive, HostedExportTransfer performs authorized protected transfer, and expiry removes artifacts at the configured boundary unless a hold is active. Closed: export generation, download, checksum verification, retention, expiry, and audit events are complete and tenant-scoped. High Closed hosted_export_spec.rb, hosted_export_transfer_spec.rb, and export_expiry_processor_spec.rb. GO
FR17 Active Storage attachments carry non-null household_id, forced RLS covers attachment rows, direct uploads are disabled by default, and avatars are served through an application-owned authorized route. Closed: current tenant-owned attachment records and delivery routes are covered by tenant isolation checks. High Closed household_row_level_security_spec.rb, tenant_safety_spec.rb, and profile/avatar request specs. GO
FR18 AllowlistedSpanExporter restricts telemetry attributes. Audit context/event and notification specs reject sensitive metadata and content in logs. Closed: current logs, spans, audit metadata, job payloads, and push paths have explicit redaction coverage. New export/offboarding paths must satisfy the same contract before their owning controls can close. High Closed allowlisted_span_exporter_spec.rb, audit context/event specs, and the FR12 notification specs. GO
NFR1 Every FR/NFR row names current automated evidence or the exact missing tests owned by a linked issue. This matrix is parsed by schema_inventory_hosted_multitenant_hardening_documentation_spec.rb. Closed: the test inventory is explicit and unresolved test work cannot be marked GO without updating this executable gate. High Closed This documentation spec plus every focused path named above. GO
NFR2 The strict tenant migration is idempotent for assigned rows, fails ambiguous ownership with diagnostics, and the pre-0.5 upgrade runbook documents owner/runtime roles and preflight. Closed: migration/backfill strategy and runtime-role compatibility are implemented and documented. Critical Closed schema_inventory_spec.rb, task_pre_0_5_database_upgrade_preflight_spec.rb, and database role specs. GO
NFR3 The migration preserves an unambiguous existing self-hosted household. Legacy credentials without an exact membership binding and current permission version fail closed, while every effective access change advances that version atomically. Closed: hosted conversion does not implicitly trust legacy credentials, and later authority changes invalidate previously issued access. High Closed access_change_spec.rb, api_session_household_spec.rb, and the hosted credential compatibility contract below. GO
NFR4 The hosted private beta runbook defines task hosted-restore:rehearse, owner/runtime/verifier role separation, build-baked image binding, transaction-scoped RLS checks, exact restored/WORM head inclusion, repository-excluding durable-root containment, atomically published sanitized evidence, quarterly cadence, and invalidation triggers. Safe executable automation exists, but no current production-like database and attachment restore evidence is linked. Launch impact: operators cannot yet demonstrate recovery for the intended deployment, so the beta is blocked. Critical #1621 runtime_verifier_spec.rb, audit_verifier_spec.rb, rehearsal_spec.rb, plus an independently inspected real rehearsal evidence link required by #1621. NO-GO

Open Blocker Register

Issue Owner Severity Controls Remediation Launch impact
#1621 Operations/security Critical NFR4 Perform and record a production-like database, attachment, RLS, and WORM restore rehearsal. Disaster-recovery approval remains blocked.
  • #1608 made mobile sync authoritative and conflict-safe in merged PR #1616. It closes a mobile launch dependency without changing a hosted security row here.
  • #1609 completes native MFA and multi-household authentication. It is a mobile launch dependency rather than this hosted web beta gate.
  • #1611 makes native notification delivery durable. Current notification privacy is GO, while durable provider delivery remains a mobile launch dependency.
  • #509 tracks broader forensic audit hardening. Current lifecycle events, chaining, signed checkpoint, WORM delivery, verifier separation, and redaction evidence are reflected above.

Hosted credential compatibility

Credentials created before the hosted membership-version binding are not migrated into an implicitly trusted state. API sessions, app tokens, and OAuth grants must carry both a current household membership binding and the exact current permissions_version. A missing binding or version fails closed and requires the account to authenticate and issue a new credential. Every effective membership role, status, person reassignment, or person-access grant change advances the affected membership version atomically. Credentials issued before that change are rejected on their next web-adjacent API or direct API request.

Go/No-Go Checklist

  • [x] Runtime app DB role is NOBYPASSRLS, is not an owner, and cannot create in public.
  • [x] Tenant-owned tables enforce non-null household ownership.
  • [x] RLS default-denies without tenant context and rejects cross-household access.
  • [x] Current web/API tenant access derives from active membership or explicit support mode.
  • [x] Generic admin user update cannot mutate roles or person grants.
  • [x] Hosted admin pages and privileged mutations require MFA evidence.
  • [x] Current notifications are private by default and recipient-aware.
  • [x] Attachments, logs, audit metadata, and telemetry have tenant/redaction coverage.
  • [x] Platform-admin owner promotion and access-change credential invalidation are complete: #1618.
  • [x] Invitation acceptance is proven under runtime RLS and support expiry is operationally audited: #1619.
  • [x] Household export, retention, offboarding, and purge are implemented and audited without deleting audit records: #1620.
  • [ ] A production-like backup restore rehearsal has passed and its evidence is linked: #1621.

Review Process

  • Reconcile this matrix whenever a pull request changes authentication, authorization, tenancy, invitations, support access, API writes, notifications, exports, retention, deletion, attachments, audit, telemetry, migrations, deployment roles, or restore behavior.
  • The owner of a linked blocker updates its row with code, test, configuration, runbook, and deployment evidence before changing NO-GO to GO.
  • A status change must update the blocker register and checklist in the same pull request. Closing an issue alone is not evidence.
  • Before each hosted release, run this documentation spec, the focused tests linked by the affected rows, task rubocop, task test, and task brakeman.
  • Record the hosted decision date after every reconciliation. The beta remains closed while any matrix row is NO-GO.