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. |
Related Non-Gate Work¶
- #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 inpublic. - [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-GOtoGO. - 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, andtask brakeman. - Record the hosted decision date after every reconciliation. The beta remains closed while any matrix row is
NO-GO.