From cb5a4a22e13117dadb8bf095a06fcb7bb856e388 Mon Sep 17 00:00:00 2001 From: Mix Date: Wed, 14 Oct 2026 15:45:46 +0300 Subject: [PATCH] docs: stage 2 of the audit - roles, the guard, logs by id, import, logins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security.md - § Roles: the last-global guard is inside the transaction that deletes or demotes (write, then count); setup is one statement; a user is saved in one transaction. A log row, outbound or inbound, belongs to a domain by id; a domain added again starts with an empty log. The Inbound › Log row of the table says by id. - § Mail path, sender check: the agreement of the check and OpenDKIM is tested (the shared list of passed From: lines); the refusal does not say which other sender a line names. - Accepted risks: "a row of the inbound log belongs to a domain by name" struck (L12). guide.md - Domain export and import: the file is checked like the forms before anything is created; a login keeps its case and a case clash is refused. - The application form: a new login is stored in lower case. schema-migrations.md was updated with 0008 in its commits. Audit plan line ticked; stage 2's lines are all done. Roadmap 22/62. Co-Authored-By: Claude Opus 5.5 --- docs/audit-2026-10-12.md | 2 +- docs/guide.md | 12 ++++++++++-- docs/roadmap.md | 2 +- docs/security.md | 30 ++++++++++++++++++++++-------- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index 44457bd..e8d5f6d 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -688,7 +688,7 @@ has already run is never edited — the next file is added; the published are still in the global log. — **Opus** *Done 2026-10-14.* DMARC reports stay by name on purpose: a report is about the name in DNS, and whoever holds the name now is its reader. -- [ ] Docs: security.md § Roles (the guard) and the struck accepted risk, guide +- [x] Docs: security.md § Roles (the guard) and the struck accepted risk, guide § Domain export, schema-migrations.md. — **Sonnet** - [x] **Which key OpenDKIM signs with, seen and not reasoned** (owner, 2026-10-13, from the Not verified list of from-header-binding). The check diff --git a/docs/guide.md b/docs/guide.md index b28a2e9..210f53b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1098,7 +1098,10 @@ to a domain: who the application may send as, its client IP allow-list and its rate limit are validated and saved together, or not at all, also when the application is created. A password is shown once, on the page that answers the creation - or *New password*, and is never shown again. + or *New password*, and is never shown again. A new login is stored in lower + case - Postfix does not tell `Ops` from `ops`, so the panel does not let two + applications be told apart by case - and the page that shows the password + shows the login as it was stored. - **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting — level 2](#rate-limiting--level-2-domain-and-application)), *Export domain* @@ -1294,7 +1297,12 @@ applications' **working** SASL passwords, and configured **rate limits** instance without regenerating anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full restore (see [Full backup and restore](#full-backup-and-restore)), this works across different -hostnames/instances. *Import* is for the global role only; *export* is +hostnames/instances. The file is checked like the forms before anything is +created - the report address, every rate limit, every client address - and +refused with the form's own message, naming the field and the application. +An application login keeps the case it had (new logins are created in lower +case), and one that differs from a login of this server only in case is +refused. *Import* is for the global role only; *export* is available to any user who can access the domain, **including a user with the *domain* role** for a domain assigned to them — so that user can walk away with the domain's working SASL passwords, in the clear if they untick the encryption diff --git a/docs/roadmap.md b/docs/roadmap.md index 72fe32d..2ca9b4b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | -| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`; next stages 1+2 → `2.5.0`) | 21/62 | [audit-2026-10-12.md](audit-2026-10-12.md) | +| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`; next stages 1+2 → `2.5.0`) | 22/62 | [audit-2026-10-12.md](audit-2026-10-12.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and diff --git a/docs/security.md b/docs/security.md index ad09533..a5492e2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -137,7 +137,7 @@ assigned outbound domains and inbound domains **separately**, each list with | Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains | | Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box | | Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains | -| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by name), without the links into the filter lists | +| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by id: not those of an earlier domain of the same name), without the links into the filter lists | | Inbound › Filter lists; the inbound log's retention | yes | no — 404 | | Add or delete a domain, either direction; import a domain | yes | no | | Account, Help | yes | yes | @@ -152,7 +152,17 @@ assigned outbound domains and inbound domains **separately**, each list with (`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment in the query. Each denied path has a test for a `domain` user, including another tenant's id. -- The panel will not remove or demote the last `global` user. +- The panel will not remove or demote the last `global` user. The guard is + inside the transaction that deletes or demotes: the write comes first and + the count after it, so two administrators deleting or demoting each other + at the same moment cannot both succeed; first-user setup is one statement + that inserts only into an empty table. A user's name, password, e-mail, + role and assignments are saved in one transaction, so a failure part way + leaves no domain user with nothing assigned. +- A log row - outbound or inbound - belongs to a domain by its id, written + when the row is: the outbound row's from the application that sent it. A + domain deleted and added again starts with an empty log for whoever it is + assigned to; the rows of the deleted one are the global role's alone. - A `domain` user can export an assigned domain, and an export carries the working application passwords (encrypted unless the box is unticked): assign domains with that in mind. @@ -203,7 +213,13 @@ operator can act on it. looks at the raw value once more for any other name of OpenDKIM's `SigningTable`, so that a line its parser and OpenDKIM's could read differently is refused rather than argued about; outside quoted strings - the line may hold one `@`. What this does not promise is what a display + the line may hold one `@`. That the two agree is tested, not only argued: + the e2e sends every `From:` line the check passes - a list it shares with + the check's unit test, `internal/milter/testdata/from-lines-passed.txt` - + and wants the message signed for the application's domain and no other. + The reply to a refusal says that the line names another sender of the + server, not which: it would confirm to whoever tries names that a domain + is hosted here. What this does not promise is what a display name looks like to a person: a name of another sender written without an `@` the scan can find (a full-width dot, a space inside the name) passes, signed with the application's own key - a forged display name is as old @@ -420,11 +436,9 @@ deferred item from the roadmap. through - but no message data. The inbound journal itself is in the database, 0640. Trigger to revisit: a shared host, where the sidecar should instead be given the files through a group it shares with the panel. -- **A row of the inbound log belongs to a domain by name.** A `domain` user - sees the rows whose inbound domain has the name of one assigned to them, so - an inbound domain that is deleted and added again shows its earlier rows to - whoever it is assigned to next. Accepted: the journal is of a mail domain, - and the rows age out with the retention. +- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in + `2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see + § Roles. - **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.** The journal milter cannot observe connection-close directly through go-milter, so if a client disconnects without an explicit `Abort`, its