docs: stage 2 of the audit - roles, the guard, logs by id, import, logins
test / test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 15:45:46 +03:00
co-authored by Claude Opus 5.5
parent 5747f0b25c
commit cb5a4a22e1
4 changed files with 34 additions and 12 deletions
+1 -1
View File
@@ -688,7 +688,7 @@ has already run is never edited — the next file is added; the published
are still in the global log. — **Opus** are still in the global log. — **Opus**
*Done 2026-10-14.* DMARC reports stay by name on purpose: a report is *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. 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** § Domain export, schema-migrations.md. — **Sonnet**
- [x] **Which key OpenDKIM signs with, seen and not reasoned** (owner, - [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 2026-10-13, from the Not verified list of from-header-binding). The check
+10 -2
View File
@@ -1098,7 +1098,10 @@ to a domain:
who the application may send as, its client IP allow-list and its rate limit 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 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 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 - **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC
reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting — reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain* 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) instance without regenerating anything, so DNS (the DKIM TXT record)
doesn't need to change. Unlike a full restore (see [Full backup and doesn't need to change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different 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 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 *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 the domain's working SASL passwords, in the clear if they untick the encryption
+1 -1
View File
@@ -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) | | 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) | | 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) | | 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 **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
+22 -8
View File
@@ -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: 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 | | 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: 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 | | Inbound › Filter lists; the inbound log's retention | yes | no — 404 |
| Add or delete a domain, either direction; import a domain | yes | no | | Add or delete a domain, either direction; import a domain | yes | no |
| Account, Help | yes | yes | | 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 (`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment
in the query. Each denied path has a test for a `domain` user, including in the query. Each denied path has a test for a `domain` user, including
another tenant's id. 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 - A `domain` user can export an assigned domain, and an export carries the
working application passwords (encrypted unless the box is unticked): assign working application passwords (encrypted unless the box is unticked): assign
domains with that in mind. 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 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 `SigningTable`, so that a line its parser and OpenDKIM's could read
differently is refused rather than argued about; outside quoted strings 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 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, `@` 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 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 through - but no message data. The inbound journal itself is in the
database, 0640. Trigger to revisit: a shared host, where the sidecar should 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. 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 - ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
sees the rows whose inbound domain has the name of one assigned to them, so `2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
an inbound domain that is deleted and added again shows its earlier rows to § Roles.
whoever it is assigned to next. Accepted: the journal is of a mail domain,
and the rows age out with the retention.
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.** - **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
The journal milter cannot observe connection-close directly through The journal milter cannot observe connection-close directly through
go-milter, so if a client disconnects without an explicit `Abort`, its go-milter, so if a client disconnects without an explicit `Abort`, its