# Atrium as a platform: security assessment

Assessed 8 November 2026 (plan SEC, slice SEC4), against the code on `develop` after SEC3. The foundation's assessment
(`SECURITY_ASSESSMENT.md`) said Atrium's own risks as a shared platform weren't assessed yet; this is that assessment. It
covers what a product's server can do through Atrium, and what one breach reaches. Sign-in at `id` is
`SECURITY_ASSESSMENT_ID.md`'s; billing's money paths are `SECURITY_REVIEW_TABULA.md`'s. A dated record: re-assess at
each major release and before a product's first live key.

**Method.** A reviewer who hadn't seen the build read the code, with only the three earlier assessments as background, and
reported each finding with its call path, marking it verified or suspected. Every finding below was checked again against
the code before it was fixed, and each fix is proven by a test. A score is earned by code that was read, not by a plan.

**Verdict.** **Isolation is sound; one product key could do too much.** One organisation can't reach another's rows, one
product can't reach another's data, and one host can't do another's work. The weak point was what a single product key
could do, and how slowly it could be cut off: it reached every module, could send company-paid texts with words of its
own, could find out who holds a phone number on the whole platform, and couldn't be revoked on its own. All four serious
findings and seven of the lesser are fixed; the overall goes from 6.2 to 7.9.

---

## Scoring scale

As the foundation's: 1–3 unsafe for production with personal data; 4–6 staging or a friendly pilot; 7–8 production-ready
with documented caveats; 9–10 enterprise-grade with formal compliance.

## 1. Server credentials and scopes: **8 / 10** (5 before SEC4)

**In place.** Keys are `atr_` and 48 random characters, kept only as a SHA-256 found by a unique index; every check fails
closed (live, server switched on, the module granted, the allowed addresses); at most two live keys; issuing and revoking
audited; the secret shown once. **Fixed in SEC4:** the Service chooses the modules a server's keys may call, at connecting
and later from its drawer, and live keys take a change at once (F3); one chosen key can be revoked, and a whole server
switched off, from the console with the password again or by `server:revoke` (F4); keys last a year unless issued with a
date of their own, and readiness warns a month before one ends (F4); calls with a key that isn't right are held per
address (F11). `Console/ServerAccessTest`, `Console/ConsolePagesTest`.

**Gaps.** The trail per call names the server, organisation, module and metric, not the key or the address (F6).

## 2. A request naming its organisation: **8 / 10** (6)

**In place.** A server names an organisation by its public ID, and only one with a live instance and an active
entitlement for the server's product is accepted; an unknown and an unserved organisation get the same 403; records are
found after the organisation is set, inside its scope; each controller checks its own rows. **Fixed in SEC4:** a product
that adds someone whose phone or e-mail already belongs to an account learns nothing of it: the answer is the same either
way, the account's details, its recheck and its changes stay closed to that product until the person takes the place
themselves (`linked_by_product_at`, `ProductReach`; F2); a product reading an account is audited; the recheck answers a
stranger as a stranger (F9); a retry key is unique per organisation, so one school's key can't return another's message
(F8). `Api/AccountApiTest`, `Notify/SendTest`.

**Gaps.** A person whose existing account a product linked has no way yet to take the place at `id`: the invitations
waiting on Home (the open item in `reji/tasks/todo.md`) will give them one. Until then the product simply can't read them.

## 3. The hosts: **9 / 10** (8)

**In place.** `EnforceHost` runs before any route is looked at, keeps the short-link host to its page, the files host to
signed file addresses, `/console` off `id` and OIDC on `id` alone; `/v1` and the Paystack hook are bound to the API host;
`ConsoleDoor` keeps the API host the Service's; the production cookie is `__Host-`. **Fixed in SEC4:** a trusted proxy is
believed about the client's address, scheme and port, never the host, and readiness warns if every address is trusted
(F10); the console's host sends nobody a sign-in code (F13).

## 4. OIDC: **8 / 10** (8; agrees with `SECURITY_ASSESSMENT_ID.md`)

**In place.** Only the code grant and refresh, PKCE S256 for every client, return addresses worked out live and matched
exactly, RS256 tokens with the client as audience, no guard accepting Passport's tokens. **Fixed in SEC4:** a sign-out
signs out at once only when the product shows the ID token it was given for this account; otherwise `id` asks first, so a
link on another site can't sign someone out of every product (F7); switching a server off revokes its sign-in client and
every token it gave out (F5).

**Gaps.** Key rotation with both keys published, and revoking refresh tokens when a membership ends (`id`'s Priority 2).
The ID token names no organisation, so each product must check membership itself: a design caveat, written in the API.

## 5. Signed webhooks: **8 / 10** (8)

**In place.** Outbound: HMAC-SHA256 over the time and body with five minutes' tolerance, secrets encrypted, no redirects
followed, the same delivery ID on a retry, a product hearing only of organisations holding it. Inbound: Sailup's
signature checked over the raw body, each report counted once. **Fixed in SEC4:** a Sailup report signed more than five
minutes ago is refused (F14).

**Gaps.** No way yet to rotate a webhook secret; endpoint addresses aren't checked against private ranges (only code or
tinker can add one today).

## 6. Blast radius: **7 / 10** (4)

**One product key leaks.** Before SEC4 it reached every module for every organisation holding its product, could send
company-paid texts under the company's name with words of its own through the sign-in code template, and could find any
account by phone or e-mail. Now: it calls only the modules granted; a company-paid code text takes only digits for the
code and minutes and one of the product's own hosts for that organisation, with the organisation's name filled in by
Atrium and no more than the sign-in codes' hourly count to one number (F1); it learns nothing of accounts it links; and it
can be cut off in one step. **The Service is taken.** It passes every gate; guarded by password and an authenticator, the
password again for keys, and its writes recorded.

**Gaps.** No second channel is told when a key is issued; the Service's reads aren't logged (the foundation's); the
per-call trail (F6).

---

## Findings

| # | Severity | Finding | State |
|---|---|---|---|
| F1 | Serious | A product key could send the sign-in code template with words of its own: unlimited, paid by the company, under its name, its content hidden from the log (`Nuntius::template`) | Fixed: digits, its own host, Atrium's name, the hourly count (`Nuntius::codeValues`; `Notify/SendTest`) |
| F2 | Serious | Adding a person by phone or e-mail joined any existing account silently, and gave the product its ID, name, shared contacts and changes (`AccountController::add`, `PersonMatcher`) | Fixed: the same answer either way; nothing readable until the person takes the place (`ProductReach`; `Api/AccountApiTest`) |
| F3 | Serious | Every key got every switched-on module (`DashboardController`) | Fixed: chosen at connecting and from the drawer (`ServerKeys::grant`) |
| F4 | Serious | One chosen key couldn't be revoked, nor a server switched off; keys never expired | Fixed: revoke, switch off, `server:revoke`, a year's life and readiness's warning |
| F5 | Lesser | A sign-in client and its refresh tokens outlived their server | Fixed with F4 (`ServerKeys::switchOff`) |
| F6 | Lesser | The per-call trail has no key, address or subject | Account reads now audited; the rest open |
| F7 | Lesser | A cross-site GET signed anyone out of `id` and every product (`OidcSignOut`) | Fixed: asked first without the account's ID token (`Accounts/OidcTest`) |
| F8 | Lesser | Nuntius's retry key was per server, not per organisation | Fixed (`message_once`; `Notify/SendTest`) |
| F9 | Lesser | The recheck answered for accounts outside the organisation | Fixed with F2 |
| F10 | Lesser | `TRUSTED_PROXIES=*` would believe a forged address and host | Fixed: the host never trusted; readiness warns on `*` |
| F11 | Lesser | Calls with a bad key weren't throttled | Fixed: held per address |
| F13 | Lesser | The API host ran people's code sign-in | Fixed: no code from that host (`Accounts/SignInTest`) |
| F14 | Lesser | Sailup's signed timestamp was never checked for age | Fixed: five minutes (`Console/ServerAccessTest`) |

(F12 in the reviewer's numbering was F5.)

## Where the earlier documents were wrong

- Plan CL, CL-9, said a compromised product could "at worst add a name". F2 showed it could learn who holds an account and
  read their details; SEC4 makes CL-9's premise true again without changing the decision (the product still decides whom
  to add).
- Plan AF-2's module scopes weren't in effect, since every key got every module (F3); they are now.
- `SECURITY_ASSESSMENT_ID.md` missed the cross-site sign-out (F7); fixed, and noted there.

## Weighted overall: **7.9 / 10** (6.2 before SEC4)

Weights follow what a breach would cost: server credentials 20%, a request naming its organisation 20%, blast radius 20%,
OIDC 15%, webhooks 15%, hosts 10%. Production-ready needs 7 or more with no critical absence; there is none.

## Open, in order

1. The invitations waiting on Home, so a person a product linked can take the place (lifts F2's closed door).
2. The per-call trail: the key and the address on each usage event (F6).
3. A second channel told when a key is issued or a server switched off.
4. Webhook secret rotation, and endpoint addresses checked against private ranges.
5. `id`'s Priority 2 (key rotation, refresh tokens revoked when a membership ends).
