# Atrium: security assessment

Assessed 25 October 2026 (S19), against the code at that date. A dated record: re-assess at each major release.

**Clavis's sign-in at `id` is assessed apart, in [SECURITY_ASSESSMENT_ID.md](SECURITY_ASSESSMENT_ID.md) (7 November 2026), Tabula's money paths in [SECURITY_REVIEW_TABULA.md](SECURITY_REVIEW_TABULA.md) (8 November 2026), Nuntius's money and send paths in [SECURITY_REVIEW_NUNTIUS.md](SECURITY_REVIEW_NUNTIUS.md), Tessera's code paths in [SECURITY_REVIEW_TESSERA.md](SECURITY_REVIEW_TESSERA.md), and Legatus, the client library products install, in [SECURITY_REVIEW_LEGATUS.md](SECURITY_REVIEW_LEGATUS.md) (all 9 November 2026).** **This is the foundation's assessment, inherited at 2.1.1.** Atrium's own risks as a platform (app keys and module scopes; a
request naming its organisation; the hosts; OIDC; signed webhooks; one breach reaching every product) are assessed in
[SECURITY_ASSESSMENT_ATRIUM.md](SECURITY_ASSESSMENT_ATRIUM.md) (8 November 2026, 7.9 / 10).

**Method:** the code was read, file by file, with `php artisan route:list -v` for every route and its middleware,
`composer audit` and `npm audit` for the dependencies, and a search of the tracked files and the git history for
secrets. A score is earned by code that was read, not by a plan or a reference page. The test suite was read but not
run for this assessment (it rebuilds the test database, and the slice was being built in the same tree); S19's own run
of it is in its task review. Where a claim rests on a test, the test is named.

**Calibration:** an app that holds people's personal data (names, contacts, birth dates, national ID numbers, e-mail
history) for many organisations ("workspaces") in one shared database. Not an average Laravel project.

**Scope:** Atrium as committed at `6c6134e` (S18), plus S19's backups and deployment kit (section 9, re-read once
they were finished, the same day).

---

## Scoring scale

| Score | Meaning |
|---|---|
| 1–3 | Unsafe for production handling of personal data |
| 4–6 | Workable for staging, or a pilot with friendly users |
| 7–8 | Production-ready with documented caveats |
| 9–10 | Enterprise-grade, with formal compliance |

---

## Per-dimension scores

### 1. Tenant isolation: **8 / 10** (was 7; re-scored 25 October 2026, plan SP)

Row isolation alone would earn 9. The platform-wide person record pulled it down to 7; Priority 1 closed it on
25 October 2026 (struck through below), and a worse hole found on the way, an account reaching workspaces that never
vouched for it, closed with it. What keeps it from 9 is the absence of a database backstop and of a query-builder guard.

**What's in place:**
- A fail-closed global scope: with no current workspace a query matches nothing (`app/Support/Tenancy/TenantScope.php:20-28`).
- Writes are guarded too: a new row takes the current workspace or is refused; a row of another workspace is never
  created, changed or deleted, even through a model loaded earlier; a row never moves
  (`app/Models/Concerns/BelongsToTenant.php:28-65`).
- The workspace comes only from the person's own active memberships, read afresh each request, never from a request
  value (`app/Http/Middleware/ResolveTenant.php:52-89`), and it is resolved before route model binding
  (`bootstrap/app.php:48`). An API token carries its membership's workspace; a parameter naming another is ignored
  (`app/Http/Middleware/ResolveTokenTenant.php:31-51`; proven in `tests/Feature/Tenancy/TenantSweepTest.php:61-74`).
- The current workspace is a scoped singleton, so a queue worker starts each job with none; every queued class must
  carry its workspace (`app/Support/Tenancy/CurrentTenant.php`, the `TenantAware` guard in
  `tests/Feature/Guards/TenantGuardTest.php:92-96`).
- The sweep (`tests/Support/TenantSweep.php`) makes a row in one workspace for every scoped model, found by its trait,
  and tries 12 ways to reach it from another workspace and from none: listed, found by id, bulk update and delete, every
  list's export, a model loaded earlier, written into, moved. It also covers downloads, the API and search
  (`TenantSweepTest.php:44-97`), and proves itself against a model with the scope removed (`:99-126`).
- **The database ties every child to its parent's workspace** (plan SN slices B and C, 30 October 2026): each parent
  is unique on `(id, tenant_id)` and each child's key holds both, so a row pointing into another workspace is refused
  whatever wrote it, `DB::table()` included. That is a partial database backstop for the scope: it governs what may be
  written, not what is read. Grants first: `model_has_role` keys to its role and its membership, so a grant can't pair
  one workspace's role with another's member; the two-factor rule, the role list and a role's deletion read the table
  directly, so before this a mismatched row (which Spatie's own reads ignore) would have counted. Then the other twelve
  links, the workspace's logo among them (`database/migrations/2026_10_30_000100_tie_role_links_to_their_workspace.php`,
  `…000200_child_rows_keep_their_workspace.php`; `tests/Feature/Access/RoleTieTest.php`,
  `tests/Feature/Tenancy/TenantTieTest.php`; `Guards/NormalFormGuardTest` fails on an untied key).
- Guards (`TenantGuardTest.php`): every table with a `tenant_id` is a scoped model's or listed with its reason; every
  uniqueness on such a table includes `tenant_id`; `withoutGlobalScope(s)` appears only in 13 named files, each with a
  reason.
- **Each workspace's own record of a person** (plan SP): names, birth date, e-mail and phone are on the membership
  (`database/migrations/2026_10_25_000700_person_details_per_workspace.php`); the person keeps the account's name and a
  phone they confirmed, and the database refuses an unconfirmed one there (`person_phone_confirmed_chk`); the address
  they sign in with is the account's, held once (plan SN D3). Every workspace surface reads the membership; `Guards/PersonDetailGuardTest` refuses a
  read of the person's columns outside three named files and proves itself on planted lines. Proven end to end in
  `People/PersonIsolationTest` (two workspaces, one person: the page, edit form, list, export, search, API and
  personal-data copy each show only that workspace's; an edit in one leaves the other's).
- **An account reaches a workspace only through the e-mail that workspace holds for it** (`Membership::scopeLetting`,
  `app/Support/Tenancy/PersonWorkspaces.php`, used by ResolveTenant, the switcher, API tokens, `Acting`, `Seeing`).
  Proven in `PersonIsolationTest` ("lets an account into a workspace only through the e-mail…"), which failed before
  the fix.
- ID numbers are unique per workspace (`person_identifier_source_kind_value_unique`); a number someone elsewhere holds
  is saved without a word and recorded for the operator (`person.possible_duplicate`, `person:duplicates`).

**Gaps:**
- **No database backstop.** Isolation is app-level only: Atrium took TuteDesk's scope without its row-level security
  (`CurrentTenant.php:14-15`). One missed check is a leak, with nothing beneath it.
- **The query builder is not guarded.** The guard catches `withoutGlobalScope`, not `DB::table()` or raw SQL, which skip
  the scope. About 20 such reads and writes on workspace tables add `where tenant_id` by hand (for example
  `app/Support/Privacy/Parts/EmailPart.php:53`, `NotificationPart.php:37`, `ImportPart.php:54-61`,
  `app/Support/Setup/Setup.php:110`). Each is correct today; none is checked by a guard.
- ~~**The person is shared across workspaces, and so are their details.**~~ Closed 25 October 2026 (plan SP): each
  workspace's details are on its membership, as described above. As it was: a person row is platform-wide (Decision 3).
  Adding someone whose national ID number, verified e-mail or sign-in e-mail matches a person elsewhere links that
  person silently (`app/Support/People/PersonMatcher.php:28-46`, `app/Actions/People/AddPerson.php`). The person page
  then shows the phone and birth date another workspace entered (`app/Http/Controllers/People/PersonController.php:102-105`),
  and an edit here changes them there (`app/Actions/People/UpdatePerson.php:28-47`). So an admin of any workspace who
  knows someone's e-mail address can read the phone number and birth date another organisation holds for them. The
  plan's CORE-D7 says a linked person shows "this workspace only what it entered": only the ID number does that
  (filtered by `source_tenant_id`, `PersonController.php:91`).
- ~~**An ID-number oracle.**~~ Saving a number another person already has was refused as "taken"
  (`UpdatePerson.php:62-65`), which told a workspace that the number exists on the platform. Closed 25 October 2026:
  refused only when someone in the same workspace has it. The import's check no longer says "on the platform" either
  (`PersonImport`: a row that links is checked as new).
- ~~**An account walked into every workspace of the person** (found 25 October 2026, plan SP §5).~~ A workspace that
  linked someone by their ID number could put an address it controls on its record, invite it, and the account made
  reached every membership of the person (`PersonWorkspaces::active` read them all). Closed the same day: a workspace
  lets an account in only through the e-mail it holds for that person.
- ~~**Residual: a squatted account.**~~ A workspace that links someone by their ID and invites an address it controls
  still makes the person's one account: it reaches only that workspace, but the real person can then not be invited
  elsewhere ("has an account"). Remedied 25 October 2026 (plan PS): the operator's `person:split` moves that workspace's
  record and the account onto a new person (`app/Actions/People/SplitPerson.php`; proven in
  `People/PersonSplitTest`: the squat reproduced, split, and the real person invited and let in by their own workspace).
  The operator learns of it from the workspace that can't invite; nothing finds it by itself.
- 14 tables with a `tenant_id` are platform data with no scope (`TenantGuardTest.php:22-37`: the e-mail log, files,
  notifications, settings and others). Each is read through one named path that checks the workspace by hand.

**Closes the gap:** ~~make the person's shared fields per workspace~~ (done, plan SP); ~~`person:split` for a squatted
account~~ (done, plan PS); add a guard that refuses `DB::table('<workspace table>')` outside an allow-list; add PostgreSQL
row-level security on the workspace tables as a backstop (handbook Decision 1 names TuteDesk as the model).

---

### 2. Access control: **8 / 10**

**What's in place:**
- One permission catalogue, each key with its level (`workspace` or `platform`), and flags for read-only, holder-only and
  elevated keys (`app/Support/Access/Permissions.php:31-77`).
- The gate answers a workspace key from the acting membership's roles in the current workspace; no membership, no
  permission (`app/Providers/AccessServiceProvider.php:41-64`). An API token's abilities narrow it further (`:43`).
- Every signed-in route names a catalogue key (`can:`), or is listed with why it needs none; every policy method decides
  by a permission; a `role:` middleware is refused (`tests/Feature/Guards/PermissionGateTest.php`). Of 172 app routes,
  every `platform.*` and workspace page carries `can:`.
- Roles are rows; only `StandardRoles` names them (`NoRoleNamesTest`). The grant ceiling stops anyone giving what they
  don't hold or acting on someone who holds more (`app/Support/Access/Ceiling.php`); nobody changes their own roles or a
  role they hold (`app/Support/Access/OwnAccessGuard.php`).
- The Service is named by a hash of its e-mail in `.env`, compared in constant time, never by a column; it can't be
  deleted or have its e-mail changed from the web (`app/Models/Concerns/HasServiceIdentity.php`). E-mail is unique
  case-insensitively (`database/migrations/2026_10_24_000300_create_person_membership_user.php:82`).
- The lens: the Service enters a workspace only as one active member, with a reason, for a set time, after its
  password again; look-only refuses every non-GET at the transport (`app/Http/Middleware/ApplyLens.php:51`); platform
  keys are closed inside it; it is recorded in three logs and the workspace is told (`app/Actions/Lens/OpenLens.php`).
- Step-up (`password.confirm`) on role changes, ownership, tokens, erasure, platform settings, purges and the lens.

**Gaps:**
- **The Service passes every check outside a lens**, an unknown key included (`AccessServiceProvider.php:47-48`). The
  fail-closed scope keeps workspace records away from it, but the platform lists do not: the platform e-mail log holds
  every workspace's mail, bodies included, and the platform audit log every workspace's changes
  (`app/Lists/PlatformEmailLogList.php`, `PlatformAuditList.php`, both `crossWorkspace()`).
- **The Service's reads leave no trace.** Only its changes are logged (`app/Http/Middleware/RecordServiceAction.php:31`
  skips safe methods). Reading a workspace's mail outside a lens needs no reason and tells no one.
- ~45 signed-in routes open without a permission and rely on the controller (`PermissionGateTest.php:17-65`), each
  with a stated reason. Reasonable, but each is a hand-written check.
- Suspending a workspace (`platform.workspace.status`) and exporting a list (`list.export`, a GET) need no step-up.

**Closes the gap:** put workspace mail and workspace audit rows behind a lens (or record each read with a reason);
log the Service's reads of personal data; add step-up to suspension and to exports above a row count.

---

### 3. Authentication: **8 / 10** (7 when assessed)

Priority 2's three items closed on 25 October 2026 and are struck through below: **7 → 8**. What keeps it from 9 is
the rest of the list: step-up asks the password only, the lock confirms an account exists, and no phishing-resistant
factor.

**What's in place:**
- Fortify's controllers behind the app's own routes, each with a named limiter; Fortify's own routes are off
  (`app/Providers/FortifyServiceProvider.php:46-49`, `routes/auth.php`). Limiters are per address and IP, per pending
  sign-in, per token, per person (`app/Support/Settings/Limiters.php`), and a plain `throttle:N,M` is refused
  (`NamedLimiterTest`).
- A locked account is refused before its password is checked; failures in a row (wrong password or wrong code) lock it
  and tell its owner (`app/Http/Auth/RefuseLockedAccount.php`, `app/Support/Auth/AccountLock.php`).
- Two-factor by authenticator app: a new secret waits in the session until a code confirms it, so a working one is
  never replaced by one that doesn't work; recovery codes; a used code is mailed
  (`app/Support/Auth/TwoFactor.php`). A role can require it; the Service must use it in production
  (`config/service.php`). Whoever must is sent to set it up on every request (`app/Http/Middleware/RequireTwoFactor.php`).
- In production passwords are 12 characters, mixed, and checked against breaches (`AppServiceProvider.php:96-104`).
  The reset form answers the same whether the address exists or not (`PasswordResetLinkSent`).
- The idle clock ends a session after `security.idle_minutes`; background requests don't count
  (`app/Http/Middleware/EndIdleSession.php`). Step-up's window is a setting (`RequirePassword.php`).
- Every sign-in, failure, second step, lock, reset, sign-out, token and lens is in `auth_event`.
- A new password ends every other session of the account and every browser kept signed in: one door,
  `app/Support/Auth/PasswordChange.php`, used by the reset and `service:reset`, the session rows deleted and the
  remember token changed (`Sessions::endOthers`), in `auth_event` as `password_changed`. A guard refuses a password
  written anywhere else (`Guards/PasswordRuleTest`); proven in `Auth/PasswordChangeTest`.
- "Keep me signed in" lasts `security.remember_days` (14; 0 doesn't offer it), judged on the server from a second
  encrypted cookie that keeps when it was given, so a shorter setting ends browsers already kept; someone who must use
  two-factor isn't kept unless `security.remember_two_factor` allows it (off) (`app/Support/Auth/RememberMe.php`,
  `app/Http/Auth/ApplyRememberRule.php`, `EndIdleSession.php`). Proven in `Auth/RememberMeTest`.
- The account holder's role requires two-factor in every workspace from the start while the platform's
  `security.holder_two_factor` is on (as shipped); no workspace can turn it off, on the role page or in the wizard
  (`Role::requiresTwoFactor()`, `Role::requiringTwoFactor()`, `RoleEditor::twoFactor`). Proven in
  `Auth/HolderTwoFactorTest`.

**Gaps:**
- ~~**"Keep me signed in" lasts 400 days and skips the idle clock entirely**~~ (`EndIdleSession.php:33`; Laravel's
  remember cookie is 576,000 minutes). Closed 25 October 2026: 14 days by a setting, refused for two-factor by another.
- ~~**A password reset leaves other sessions signed in.**~~ Fortify rotates the remember token, but no
  `AuthenticateSession` middleware runs and nothing deletes the user's session rows
  (`app/Actions/Fortify/ResetUserPassword.php`). Closed 25 October 2026: `PasswordChange` ends them.
- ~~**Two-factor is off by default for every role, the Owner included**~~ (`database/migrations/2026_10_24_000700_extend_role.php:26`,
  `StandardRoles.php`). The first-run wizard asks, but the account holder of a workspace full of personal data can
  decline. Closed 25 October 2026: `security.holder_two_factor`, on, requires it of the account holder's role.
- Step-up asks for the password only, never the second factor.
- The lock says the account is locked and until when (`RefuseLockedAccount.php:25-26`): it confirms an account
  exists, and anyone can lock a known account, the Service's included, by failing on purpose.
- No phishing-resistant factor (passkeys / WebAuthn).

**Closes the gap:** end the user's other sessions in the `PasswordReset` listener; cap "Keep me signed in" with a
setting (days) and let a role that requires two-factor refuse it; require two-factor for the account holder's role
from the start; ask for the code at step-up when the person has one.

---

### 4. Audit trails: **8 / 10**

**What's in place:**
- owen-it/laravel-auditing on ten models (Person, User, Membership, Role, Tenant, PersonIdentifier, PrivacyRequest,
  the lookups) and `AuditTrail::record()` for what isn't one model's change (`app/Support/Audit/AuditTrail.php`). Each
  row has the workspace, the actor and their name as it was, the channel, the lens, the URL, address and browser.
  Secrets are never logged (`config/audit.php` `exclude`).
- `auth_event`: sign-ins and privileged use, with an event list fixed by a CHECK
  (`2026_10_24_000100_create_audit_and_auth_event.php:75`).
- Append-only by database trigger: no UPDATE ever, DELETE only inside the purge (`:77-89`). The trigger is on nine
  tables: audit, auth_event, file_access, person_merge, tenant_status_change, setup_event, privacy_request_event,
  consent, and (with its own rules) lens_session and email_message (`2026_10_25_000300_create_privacy.php`).
- The purge door: by age, with floors per log (audit at least 365 days, `app/Support/Settings/Catalogue.php:104-108`),
  archived to the vault with a SHA-256 before removal, and its own `log_purge` row (`app/Console/Commands/LogsPurgeCommand.php`).
- The erasure door may only empty the columns a log's trigger names as personal
  (`create_privacy.php:104-136`); the audit log names none and is sealed instead (section 6).

**Gaps:**
- **The triggers stop bugs, not an attacker with the app's database login.** Any session can `SET LOCAL
  app.log_purge = 'on'`; the table owner (the app's own role) can drop a trigger; a row trigger doesn't fire on
  `TRUNCATE`. Nothing makes the log tamper-evident (no hash chain) or keeps a copy off the server.
- **Reads are not audited.** Who opened a person's record, or searched for them, is not recorded; only file downloads
  are (`file_access`).
- The audit log keeps the address and browser of each change in plain text, and erasure can't empty them.

**Closes the gap:** run the app under a role that doesn't own the log tables, with the purge as a `SECURITY DEFINER`
function and `TRUNCATE` revoked; chain each row's hash to the previous one, or ship the log off the server; record
views of a person's record.

---

### 5. Encryption: **8 / 10 in code · 6 / 10 in default configuration**

**What's in place:**
- The file vault: a random 256-bit key per file, AES-256-GCM in 1 MB pieces, each bound to its workspace, file, place
  and whether it is the last, so pieces can't be swapped, reordered, cut or moved; the file key wrapped by the master
  key with the same binding; a key id that doesn't give the key away; `vault:rewrap` to change the master key without
  touching files (`app/Support/Files/FileVault.php`). Nothing else touches the disk (`VaultGuardTest`).
- Crypto-shredding: one key per person per workspace, AES-256-GCM with a fresh nonce, wrapped with APP_KEY; destroying
  it makes every sealed audit value read "Former member 7K2QXM" (`app/Support/Privacy/PersonKeys.php`,
  `app/Support/Privacy/Sealed.php`, proven in `tests/Feature/Guards/PersonalDataGuardTest.php:88-114`).
- Two-factor secrets and recovery codes are encrypted (Fortify). Mail jobs are `ShouldBeEncrypted`, and one-time secrets
  (invitation links, reset links, copy codes) stay as tokens in the stored e-mail, travelling only in the encrypted job
  (`app/Support/Notifications/TemplateRenderer.php:16`, `Outbox.php:16-17`, `SendEmailMessage.php:27`).
- API tokens and copy links are kept only as SHA-256 hashes (`app/Support/Api/MintToken.php:36-38`, `Requests.php:120-121`).
- Backups are sealed to a public key whose private half is never on the server (section 9).

**Gaps:**
- **`SESSION_ENCRYPT=false`** in `.env.example`, and the readiness check doesn't ask. Sessions live in the database, so a
  two-factor secret being set up sits there in plain text (`TwoFactor.php:58-65`).
- **No field-level encryption for personal data:** national ID numbers (`person_identifier.value`), phone, birth date and
  the e-mail log's subjects and bodies are plain in the database. A database dump is the whole story.
- **APP_KEY now guards more than sessions:** every person key and every two-factor secret. There is no command to
  re-wrap them when APP_KEY changes, and no page that says so. Lose it, or rotate it carelessly, and the audit log's
  names and everyone's two-factor go with it.
- The vault's piece size is read from config at read time, not kept on each file (`FileVault.php:251-254`). Changing
  `FILE_VAULT_CHUNK_BYTES` would make every stored file unreadable. Availability, not confidentiality.
- All keys sit in `.env` beside the data. That is normal at this size, but a server breach reads everything but the
  backups.

**Closes the gap:** `SESSION_ENCRYPT=true` in production, checked by readiness; a blind index plus encryption for ID
numbers; an `app-key:rewrap` for person keys and two-factor secrets, documented; store the piece size on the file row.

---

### 6. Personal data: **7 / 10**

**What's in place:**
- Requests (copy, fix, erase, stop), logged by staff or asked for from My data, due within `privacy.due_days`, each
  step kept and append-only, reminded before they fall due (`app/Support/Privacy/Requests.php`).
- A copy is a zip with a PDF, made in the background into the vault, sent as a link that works for set days and asks
  for a code sent to the same address, so a forwarded link alone opens nothing (`app/Http/Controllers/Privacy/CopyLinkController.php`).
  Dompdf has remote loading off (`CopyFile.php:50-51`).
- Erasure runs every part, in one transaction, after step-up and the name typed exactly; it destroys the person's key
  in that workspace, or everywhere when no other workspace holds them (`Requests.php:162-176`,
  `app/Support/Privacy/PersonalData.php`).
- A guard finds every column that can hold a person's data (foreign keys, `*_name`, contacts, devices) and requires a
  part to cover it or a stated reason (`PersonalDataGuardTest.php:66-77`). `ErasureTest` gives one person everything
  and then reads every table as text for their traces after erasure.
- Consents: one row each, given or withdrawn, with the wording and its version, never edited (`app/Support/Privacy/Consents.php`).

**Gaps:**
- ~~**The shared person record** (section 1): one workspace can read and change details another entered.~~ Closed
  25 October 2026 (plan SP): a workspace's copy holds only its own record, and its erasure removes it with the
  membership.
- **An invitee's e-mail survives erasure in the audit log.** `membership.invited` records the address on a Membership
  (`app/Actions/People/InvitePerson.php:51`), but only `original_ref` is sealed for a Membership
  (`app/Support/Privacy/PersonalFields.php:29`), so the address is stored in plain text and outlives the key.
  `ErasureTest` doesn't catch it: its person never had an invitation. File names in `file.uploaded` are not sealed
  either, and often name people.
- **A restored backup brings erased people back**, with their destroyed keys. Nothing records erasures outside the
  database to re-apply after a restore (section 9).
- No retention rule for people themselves: a membership turned off is kept until someone erases it.
- A wrong copy code doesn't spend the code; only the limiter (per link and address) slows guessing
  (`Requests.php:143-152`, `Limiters.php:43-44`).
- No record of processing, no data-protection impact assessment template, no sub-processor list: a product built on
  Atrium starts without them.

**Closes the gap:** seal by key, not by model (any `email`, `name`, `phone` in an audit row's values is sealed with its
subject's key), and extend `ErasureTest` to an invited person; keep an erasure ledger outside the database and replay
it after every restore; spend a copy code after three wrong tries.

---

### 7. Web hardening: **6 / 10**

**What's in place:**
- `SecurityHeaders` on every page: `nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy`, and HSTS for a year in
  production over HTTPS (`app/Http/Middleware/SecurityHeaders.php`). Readiness fails in production without secure
  cookies, an `https://` APP_URL or this middleware (`app/Support/Operations/Readiness.php`, `security()`), and with
  debug on.
- CSRF: Laravel 13's same-origin check plus the token; a form sent after its session ended keeps what was typed
  (`bootstrap/app.php:81-84`).
- The API's errors are problem+json and never say more than that something went wrong (`app/Exceptions/Problem.php`).
- Mass assignment: no model is unguarded; every one lists `$guarded`, and `tenant_id` and `public_id` are guarded where
  a request could reach them. Controllers pass validated data.
- Output: templates escape everything but four inline marks (`TemplateRenderer.php`); four `v-html` uses, each fed by
  server-built markup; CSV cells that a spreadsheet would run are made text (`app/Support/Lists/CsvExport.php:43-46`).
- The API reads tokens only, never the session (`config/sanctum.php` `guard => []`).

**Gaps:**
- **No Content-Security-Policy**, not even report-only (`SecurityHeaders.php:13-14`). One stored-XSS bug would run with
  the victim's full session.
- The API gets none of these headers: `SecurityHeaders` is in the `web` group only (`bootstrap/app.php:63-64`).
- No `TrustProxies` set-up. Behind a TLS-terminating proxy, `isSecure()` is false (no HSTS), and every limiter and log
  line keyed by address sees the proxy's.
- The `local` disk has `serve => true` (`config/filesystems.php:36`), which registers signed `GET` and `PUT`
  `/storage/{path}` routes that nothing in the app uses.
- No `Permissions-Policy` or `Cross-Origin-Opener-Policy`.

**Closes the gap:** CSP report-only first with nonces for Vite and Inertia, then enforced; headers on the API group
too; `trustProxies` from an env list; `serve => false` on `local`.

---

### 8. Files and uploads: **8 / 10**

**What's in place:**
- An upload's extension must be in the `upload_type` list and its bytes must read as one of that type's MIME types,
  within its size limit (`app/Support/Files/FileIntake.php:29-57`).
- It waits in a holding folder under a random name, mode 0600, while a queued job scans it
  (`FileIntake.php:44-54`). ClamAV is sent the bytes (INSTREAM); anything but a clean answer counts as unavailable, so
  a file waits rather than passing unchecked, and is removed after `file.give_up_hours`
  (`app/Support/Files/ClamAvScanner.php`). A fake scanner fails readiness in production.
- A JPEG loses its location before it is stored (`PhotoLocation`). Then it is encrypted into the vault (section 5).
- One checked route: the file must be stored, of the current workspace or the platform's, and its record's `download`
  policy must say yes; anything else is a 404, so a file's existence isn't given away
  (`app/Http/Controllers/File/FileController.php:23-30`). Proven across workspaces in `TenantSweepTest.php:44-59`.
- Responses: only images and PDF inline (never SVG), `nosniff`, `private, no-store`, ranges, and every opening in
  `file_access` (`app/Support/Files/FileResponse.php`).

**Gaps:**
- The plain upload stays in the holding folder until it is scanned: hours, if ClamAV is down.
- No `Content-Security-Policy: sandbox` on inline responses; a PDF opens in the app's origin.
- Location is stripped from JPEG only; PNG and WebP can carry it too.
- Readiness checks that ClamAV answers, not how old its signatures are (its version line carries the date).

**Closes the gap:** encrypt the holding copy with a throwaway key, or hold uploads while the scanner is down; add
`sandbox` to inline file responses; strip metadata from every image type; warn when signatures are over two days old.

---

### 9. Operations, backup and recovery: **7 / 10**

First read while S19 was being built (6 / 10); re-read when it was finished. Three of the gaps below closed in the same
slice and are struck through: the score rose to 7.

**What's in place:**
- Readiness: 13 checks (queue, scheduler, mail, vault, scanner, debug, cookies and headers, backups, languages,
  migrations, lists, notifications, brand), each `ok`, `warn`, `off` or `fail` (`app/Support/Operations/Readiness.php`).
  `app:check` runs every ten minutes and mails the Service when a check starts failing; the scheduler pings a
  monitoring address (`routes/console.php`).
- `app:deploy` runs the deployment's steps in order and stops at the first that fails (`app/Console/Commands/AppDeployCommand.php`).
- Backups (S19, `app/Support/Backup/`, `app/Console/Commands/Backup*.php`): `pg_dump` plus the vault and private folders
  in one file, encrypted with a fresh key sealed to an X25519 public key and libsodium's secretstream, so a changed,
  reordered or cut-short file doesn't open (`BackupCipher.php`). The private half is never on the server
  (`BackupKey.php`). Written whole or not at all, mode 0600, copied off the server by rclone where a remote is named,
  pruned by days, weeks and months (`Backups.php`, `Retention.php`). Restore asks twice, puts the app into maintenance,
  restores the database in one transaction, swaps the folders; `--into` restores into a new database as a drill
  (`BackupRestoreCommand.php`). Nightly at 02:00 once set up. Tests: `tests/Feature/Backup/BackupTest.php`.

**Gaps (what it must do to score 8):**
- ~~**Backups not set up is "off", not a failure, even in production.**~~ Closed in S19: `fail.not_set_up` in
  production (`Readiness::backups`, its case in `ReadinessTest`).
- **No drill on a schedule**, and nothing records when a restore last worked. A backup that has never been restored is
  a hope.
- **The off-site copy can be deleted from the server** when its credentials may delete. Partly closed in S19:
  `BACKUP_REMOTE_PRUNE=false` leaves the remote to a write-only key or an object lock and the bucket's lifecycle rule
  (`../BACKUP.md`); but the default still prunes, and readiness checks the local copy only.
- **Restore brings back erased people** (section 6): no erasure ledger.
- The database dump sits in plain text in the work folder while a run is packed (`Backups.php:50-62`).
- APP_KEY and FILE_VAULT_KEY are rightly not in the backup, but nothing says where they are kept. Without them a
  restore can't open a file, a two-factor secret or an audit name.
- ~~No stated recovery-point or recovery-time targets, no `BACKUP.md`, no `deploy/server/release.sh`.~~ Closed in S19:
  `../BACKUP.md` (targets, where the keys are kept, the drill, the restore) and the release script, which backs up
  before every deployment. Still no incident runbook.

**Closes the gap:** fail readiness in production while backups are off; a monthly `backup:restore --into` drill on the
schedule, its result kept and shown on the operations page; an append-only remote (object lock, or rclone credentials
that can't delete, with pruning done elsewhere); stream the dump straight into the cipher; write `BACKUP.md` with the
targets, the key escrow and the restore steps.

---

### 10. Supply chain and secrets: **6 / 10**

**What's in place:**
- `composer audit` and `npm audit`: no known vulnerabilities on 25 October 2026. Both lock files are committed.
- `.env` and `.env.*` are ignored; `.env.example` holds no values. A search of the tracked files and the whole git
  history for app and vault keys, private keys, and AWS, GitHub and Slack tokens found nothing but the test vault key in
  `phpunit.xml`, which is a fixed test value.
- No machine path in application code (`NoMachinePathTest`). The demo seeder refuses to run outside local and testing
  (`database/seeders/DatabaseSeeder.php:29-33`).
- A small dependency set: 14 PHP packages, 15 JavaScript ones, all mainstream.

**Gaps:**
- **No CI.** There is no `.github` or other pipeline: tests, Pint and the audits run only when someone remembers.
- No static analysis (no Larastan or PHPStan) and no automated dependency updates.
- Sanctum's `token_prefix` is empty, so a leaked API token can't be recognised by secret scanners.
- Secrets are plain values in `.env` on the server.

**Closes the gap:** one pipeline that runs Pest, Pint, Larastan, `composer audit` and `npm audit` on every push;
Dependabot or Renovate; a token prefix such as `tc_`.

---

## Critical absences

What an information-security review would raise on day one:

| Item | Status | Risk |
|---|---|---|
| Per-workspace view of a shared person | ✅ Built (25 Oct 2026, plan SP) | ~~One workspace reads and changes the phone and birth date another entered~~ |
| An account reaching only workspaces that vouch for it | ✅ Built (25 Oct 2026, plan SP §5) | ~~A linked workspace's invitation opened every other workspace of the person~~ |
| Database-level tenant backstop (RLS) | ❌ Absent | One missed `where tenant_id` is a cross-workspace leak |
| Content-Security-Policy | ❌ Absent | Any XSS runs with full session rights |
| Sessions after a password reset | ✅ Ended (25 Oct 2026) | ~~A stolen session outlives the reset~~ |
| "Keep me signed in" limit | ✅ 14 days, a setting (25 Oct 2026) | ~~A shared computer stays signed in for a year~~ |
| Two-factor for the account holder | ✅ Required, a platform setting (25 Oct 2026) | ~~The person who sees everything may use a password alone~~ |
| Backup drill and fail-when-off | ⚠️ Being built | Backups unproven; a live system without them passes readiness |
| Tamper-evident audit log | ⚠️ Triggers only | The app's own database login can rewrite or empty it |
| Read audit for personal records | ❌ Absent | No answer to "who looked at this person?" |
| Field-level encryption of ID numbers | ❌ Absent | A dump exposes every national ID |
| CI with dependency and static checks | ❌ Absent | A known CVE ships unnoticed |
| Incident response runbook | ❌ Absent | No written "what to do when" |

---

## Weighted overall: **7.4 / 10** (7.2 at S19; tenant isolation and authentication re-scored 25 October 2026)

| Dimension | Weight | Score | Weighted |
|---|---|---|---|
| 1. Tenant isolation | 15 | 8 | 1.20 |
| 2. Access control | 12 | 8 | 0.96 |
| 3. Authentication | 12 | 8 | 0.96 |
| 4. Audit trails | 10 | 8 | 0.80 |
| 5. Encryption | 10 | 7 | 0.70 |
| 6. Personal data | 12 | 7 | 0.84 |
| 7. Web hardening | 8 | 6 | 0.48 |
| 8. Files and uploads | 7 | 8 | 0.56 |
| 9. Operations and backup | 9 | 7 | 0.63 |
| 10. Supply chain and secrets | 5 | 6 | 0.30 |
| **Overall** | **100** | | **7.43** |

Encryption counts at 7, between its code and its default configuration.

Production-ready with caveats as a foundation: the isolation machinery, the audit design and the vault are well above
what a starter pack usually has, and most of it is proven by guard tests rather than promised. It is not yet ready to
hold many organisations' personal data: the shared person record leaked between workspaces, sessions outlived a reset,
and backups are unproven. The first three priorities below close those; the first two were done on 25 October 2026
(two-factor for the account holder, the third item of Priority 2, is still open).

---

## Remediation priority

In order of risk reduced per hour of work.

### Priority 1: the shared person (1 → isolation 8)
- ~~Show a workspace only the details it entered for a linked person, or link only after the person confirms; an edit
  here never changes another workspace's view.~~ Done 25 October 2026 (plan SP: the details on the membership).
- ~~Stop the ID-number "taken" answer from naming a person elsewhere.~~ Done 25 October 2026 (ID numbers unique per
  workspace).
- Found and done with it: an account reaches a workspace only through the e-mail that workspace holds for it.
- ~~`person:split`, for an account a linking workspace squatted.~~ Done 25 October 2026 (plan PS).

### Priority 2: sessions and second factor (authentication 7 → 8)
- ~~End every other session of the account on `PasswordReset` (and on a password change).~~ Done 25 October 2026.
- ~~"Keep me signed in": a setting in days (14 recommended), and refused for roles that require two-factor.~~ Done
  25 October 2026 (refused by a setting, off as the default, as the Architect chose).
- ~~Require two-factor for the account holder's role from the start.~~ Done 25 October 2026 (a platform setting, on;
  while on, no workspace can turn it off, as the Architect chose).

### Priority 3: backups to 8 (operations 7 → 8)
- ~~Readiness fails in production while backups are off.~~ Done in S19.
- The restore drill's result kept and shown (it runs off the server, by hand, since only the private half opens a
  backup).
- An off-site copy the server can't delete; readiness checks the remote's newest file.
- An erasure ledger replayed after every restore. (`BACKUP.md` with targets and key escrow: done in S19.)

### Priority 4: personal data in the audit log (personal data 7 → 8)
- Seal any personal value in an audit row with its subject's key, whatever the model; extend `ErasureTest` to an
  invited person and an uploaded file.
- `SESSION_ENCRYPT=true` in production, checked by readiness.

### Priority 5: CI (supply chain 6 → 8)
- One pipeline: Pest, Pint, Larastan, `composer audit`, `npm audit`; Dependabot.

### Priority 6: web hardening (6 → 8)
- CSP report-only, then enforced; headers on the API; `trustProxies`; `serve => false` on the `local` disk.

### Priority 7: the platform's own reach (access control 8 → 9, audit 8 → 9)
- Workspace mail and audit rows only through a lens, or each read recorded with a reason.
- The app's database role stops owning the log tables; purge through a `SECURITY DEFINER` function; `TRUNCATE` revoked.
- A guard against `DB::table()` on workspace tables; row-level security as the backstop.

---

## Tracking

| Item | Status | Commit |
|---|---|---|
| This assessment | ✅ Written (S19) | (this slice) |
| Backup module (`backup:run`, `list`, `prune`, `restore`; encrypted to a public key, rclone copy, `--into` drill) | ✅ Built (S19) | (this slice) |
| Shared person: per-workspace view | ✅ Done (25 Oct 2026): the details on the membership, IDs per workspace, `PersonDetailGuardTest` | (plan SP commit) |
| An account reaches only workspaces that hold its e-mail | ✅ Done (25 Oct 2026): `Membership::scopeLetting`, `PersonWorkspaces` | (plan SP commit) |
| `person:split` for a squatted account | ✅ Done (25 Oct 2026): `SplitPerson`, `person:split`, `PersonSplitTest` | (plan PS commit) |
| Other sessions ended on password reset | ✅ Done (25 Oct 2026): `PasswordChange`, `password_changed` | (this change) |
| "Keep me signed in" limit | ✅ Done (25 Oct 2026): `security.remember_days`, `security.remember_two_factor` | (this change) |
| Two-factor required for the account holder | ✅ Done (25 Oct 2026): `security.holder_two_factor` | (this change) |
| A grant can't name another workspace's role or member (database) | ✅ Done (30 Oct 2026): plan SN slice B, `RoleTieTest` | (SN B commit) |
| Every child row tied to its parent's workspace (database) | ✅ Done (30 Oct 2026): plan SN slice C, `TenantTieTest` | (SN C commit) |
| An invitation closes when the member's e-mail changes (no account locked out) | ✅ Done (30 Oct 2026): plan SN D1, `AccountMailTest` | (SN D1 commit) |
| Readiness fails in production while backups are off | ✅ Done (S19) | (this slice) |
| The drill's result recorded | ⏸ Open | — |
| Erasure ledger replayed after restore | ⏸ Open | — |
| `membership.invited` e-mail sealed | ⏸ Open | — |
| `SESSION_ENCRYPT` checked by readiness | ⏸ Open | — |
| CI with audits and static analysis | ⏸ Open | — |
| Content-Security-Policy | ⏸ Open | — |
| Service reads recorded, or behind a lens | ⏸ Open | — |
| Log tables owned by another database role | ⏸ Open | — |
| Guard against `DB::table()` on workspace tables; RLS | ⏸ Open | — |
| Incident response runbook | ⏸ Open | — |

When an item ships, set its status and link the commit. Re-score the dimension it belongs to, with the evidence.
