panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain

Stage 1, step 1. BREAKING: a 1.x database is not migrated.

Schema
  - internal/store/migrations/ holds one file, 0001_init.sql, equal to the
    2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
    admin table created and dropped, no ALTERs.
  - users.email replaces users.dmarc_report_email: the address is the
    user's own. The default DMARC report address is a user's setting
    (dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
    the instance setting that mirrored the global user's field is gone.
  - domains.dmarc_rua_user_id: a domain follows a named user's default, or
    has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
    leaves it at "no reports", never at someone else's default.
  - user_inbound_domains, users.all_domains, users.all_inbound_domains: in
    the schema for the inbound delegation of the next step.
  - users.role is 'global' / 'domain' (was 'domain_admin');
    rate_limits.allowed_ips, unused since 1.9.0, is dropped.

Opening a database
  - The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
    file that has a schema version without it (ErrForeignSchema) and one
    newer than the build. A 1.x file at user_version 10 would otherwise read
    as already migrated. The refused file is left untouched.

Code
  - Store.DomainDMARCRua is the one place a domain's report address is
    resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
    gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
    DNS re-check go through it.
  - The old templates keep working on the new model until their pages are
    restyled: the Settings form's address field is the account e-mail and
    switches the user's default between it and none; a domain's "Same as
    Settings" makes it follow the user who chose it; a new or imported
    domain follows its creator.
  - Domain export/import: a domain that follows a user exports no address.

Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.

docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.

go test ./...   (Windows)
  ok for every package except two that fail the same way before this commit:
  internal/domain  TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
  internal/logtail TestFollowTailsAndRotates (rename of an open file)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 18:31:43 +03:00
co-authored by Claude Opus 5.5
parent 53c83f690c
commit 8d1af89e1a
42 changed files with 1284 additions and 599 deletions
+25
View File
@@ -29,8 +29,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
mockups too, and its button takes the same hover, focus and press tint as mockups too, and its button takes the same hover, focus and press tint as
Account and Help (owner, 2026-10-09). Account and Help (owner, 2026-10-09).
- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own
address; the default DMARC report address is a user's setting
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
address of its own or follows a named user's default
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
and deleting that user leaves it with no report address.
`user_inbound_domains` and the per-user `all_domains` /
`all_inbound_domains` flags are in the schema for the inbound delegation
that follows. Details: `docs/schema-migrations.md`.
### Changed ### Changed
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
- Until the Account page replaces it, the old Settings form's "default report
address" field sets the account e-mail and switches the user's DMARC
default between that e-mail and none; a domain's "Same as Settings" makes
it follow the user who chose it.
- The old panel is untouched on screen but steps aside in the tree: its - The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not last old page is restyled. The 2.0 menu paths in the new shell do not
@@ -38,6 +58,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0, - panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
the component kit. the component kit.
### Removed
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
## [1.9.5] - 2026-10-09 ## [1.9.5] - 2026-10-09
CI-only cut: the release workflow no longer breaks the multi-arch image it CI-only cut: the release workflow no longer breaks the multi-arch image it
+1 -1
View File
@@ -505,7 +505,7 @@ only when both are done. Every stage-2 step ends with the evidence of
**Stage 1 — data and routes** (no template work; parallel with stage 0) **Stage 1 — data and routes** (no template work; parallel with stage 0)
- [ ] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus** - [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [ ] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus** - [ ] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus** - [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus** - [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
+8 -2
View File
@@ -39,8 +39,8 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 10/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 11/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.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): the next stage is **panel-redesign** (`2.0.0`), development convenience): the next stage is **panel-redesign** (`2.0.0`),
@@ -469,6 +469,12 @@ zero and 2.0 starts from an empty data directory. The
[plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; this [plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; this
item ships as a step of that plan, not on its own. item ships as a step of that plan, not on its own.
**Built 2026-10-09** (panel-redesign stage 1): `internal/store/migrations/`
holds one baseline, `0001_init.sql`. The gate that survives is a refusal, not
an upgrade: `store.Open` rejects a database that was not created by 2.x
(`ErrForeignSchema`, tested), so a 1.x file is never half-read — the "missed
gate" risk below. See [schema-migrations.md](schema-migrations.md).
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an **Dependencies / risks:** a decided 2.x cut (another breaking change, or an
explicit major). Squashing the current short chain is not a reason to cut 2.x on explicit major). Squashing the current short chain is not a reason to cut 2.x on
its own. A missed gate leaves a mid-chain 1.x database silently stuck. its own. A missed gate leaves a mid-chain 1.x database silently stuck.
+95 -160
View File
@@ -1,16 +1,15 @@
# SQLite schema and migrations # SQLite schema and migrations
**What this file is.** A living reference for the embedded SQLite migration chain, **What this file is.** A living reference for the embedded SQLite schema: the
historical legacy that still runs on fresh 1.x installs, and the planned 2.x 2.0 baseline, the migrations added after it, and how a database is opened.
schema squash. When you add or review a migration, update this file in the same When you add or review a migration, update this file in the same change.
change.
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by **Source of truth in code:** `internal/store/migrations/*.sql`, applied by
`internal/store/store.go` (`migrate()`). `internal/store/store.go` (`migrate()`).
**Related:** [architecture.md](architecture.md) § Persistence; **Related:** [architecture.md](architecture.md) § Persistence;
[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) § [plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility;
Documentation. [development.md](development.md) § Documentation.
--- ---
@@ -18,14 +17,35 @@ Documentation.
| Field | Value | | Field | Value |
|---|---| |---|---|
| Chain head | `user_version = 10` | | Chain head | `user_version = 1` |
| Files | `0001_init.sql` … `0010_send_log_app_login_index.sql` (10 files) | | Files | `0001_init.sql` (the 2.0 baseline) |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) | | Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory | | Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
Last updated in **[1.9.2]** (`0010_send_log_app_login_index`); **1.9.1** ---
had no schema change. The release column below records the cut a migration
shipped in. ## 2.0 starts from an empty data directory
Decided by the owner on 2026-09-21: the redesign is a breaking release, the
1.x migration chain (`0001` … `0010`, with its `admin` table created in the
first file and dropped in the fifth) is deleted from the binary, and there is
no upgrade path. The only live 1.x installation is the owner's and is set up
again on 2.0. The 1.x chain stays in git history (last present at `1.9.5`).
What that means in practice:
- A fresh `/data` gets the baseline and nothing else.
- **A 1.x database is refused, not migrated.** `store.Open` returns
`ErrForeignSchema` for a file that has a schema version but not the 2.x
`application_id`. Without that check a 1.x file at `user_version = 10`
would read as "already migrated" and the panel would start on tables it
does not know. The file is not modified. The same applies to restoring a
1.x full backup into a 2.0 container.
- A database whose version is higher than the build knows is refused too.
- Domains can still be carried by hand: a 1.x **domain export** (JSON, with
the DKIM key and applications) imports into 2.0 — the transfer format is
not the database.
--- ---
@@ -38,17 +58,12 @@ shipped in.
4. Each pending migration runs in its own transaction, then bumps `user_version`. 4. Each pending migration runs in its own transaction, then bumps `user_version`.
5. There is no down-migration; fixes ship as a new `00NN_*.sql` file. 5. There is no down-migration; fixes ship as a new `00NN_*.sql` file.
```text **Implication:** once `2.0.0` is released, `0001_init.sql` is never edited
0001_init.sql → user_version 1 again and no file is deleted, renamed or reordered — every 2.x database has
0002_sessions.sql → user_version 2 already run it. Until that release the baseline is still being written:
… later steps of the redesign and the features queued behind it (the next one
0009_application_auth_ips.sql → user_version 9 is `0002_inbound_spam_log.sql` of inbound-antispam-panel, `2.1.0`) add files
0010_send_log_app_login_index.sql → user_version 10 after it.
```
**Implication:** never delete, rename, or reorder migration files while 1.x must
stay compatible with `1.0.0`. Git history keeps old files; only a 2.x cut may
replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
--- ---
@@ -56,152 +71,72 @@ replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
| Ver | File | Shipped | Kind | Summary | | Ver | File | Shipped | Kind | Summary |
|-----|------|---------|------|---------| |-----|------|---------|------|---------|
| 1 | `0001_init.sql` | ≤ 1.0.0 | DDL | Core schema: `admin`, `settings`, `domains`, `applications`, `application_addresses`, `send_log`, `rate_limits` | | 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below |
| 2 | `0002_sessions.sql` | ≤ 1.0.0 | DDL | `sessions` (token hash, sliding idle expiry) |
| 3 | `0003_logtail_state.sql` | 0.5.0 | DDL | `logtail_state` (log-tailer read offset + fingerprint) |
| 4 | `0004_dmarc_report_email.sql` | 1.1.0 | DDL | `admin.dmarc_report_email`, `domains.dmarc_rua` |
| 5 | `0005_panel_users.sql` | 1.2.0 | DDL + data | `users`, `user_domains`; migrate single `admin` row; copy profile `dmarc_report_email` to `settings`; **DROP `admin`** |
| 6 | `0006_inbound_relay.sql` | 1.4.0 | DDL | `inbound_domains`, `inbound_transports`, `inbound_recipients` |
| 7 | `0007_rate_limit_auto.sql` | 1.6.0 | DDL | `rate_limits.mode`, `auto_multiplier`, `auto_updated_at` |
| 8 | `0008_dmarc_reports.sql` | 1.7.0 | DDL | `dmarc_reports`, `dmarc_report_records` |
| 9 | `0009_application_auth_ips.sql` | 1.9.0 | DDL + data | `applications.auth_ip_restrict`, `auth_allowed_ips`; move legacy app `rate_limits.allowed_ips` into auth columns; clear those IPs on rate limits |
| 10 | `0010_send_log_app_login_index.sql` | 1.9.2 | DDL | `idx_send_log_app_login_created_at` — app-scoped rate-limit counts and per-application send statistics were scanning the `created_at` range |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct **Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 1.x images. for operators upgrading from older 2.x images.
The feature plans that introduced schema work (`inbound-relay`,
`domain-stats-auto-ratelimit`, `dmarc-reports`) were deleted once they
shipped; what each migration was for is the table above, and the release it
came with is in [CHANGELOG.md](../CHANGELOG.md).
--- ---
## Schema after head (v10) ## Schema at head (v1)
Tables present in a fully migrated database: | Table | Role |
|-------|------|
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `dmarc_default_mode` / `dmarc_default_address` are their default DMARC report address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
| `sessions` | Panel login sessions (token hash, sliding idle expiry) |
| `domains` | Sending (outbound) domains. `dmarc_rua` / `dmarc_rua_user_id` say where aggregate reports go |
| `user_domains` | Outbound domains assigned to a `domain` user |
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
| `application_addresses` | Explicit From addresses (`list` mode) |
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
| `send_log` | Delivery journal |
| `logtail_state` | Log-tailer read position |
| `inbound_domains` | Inbound relay domains |
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
| `inbound_recipients` | Allow-list when `recipient_mode = list` |
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
| `dmarc_reports` | Parsed aggregate report summaries |
| `dmarc_report_records` | Per-source rows inside a report |
| Table | Introduced | Role | `TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
|-------|------------|------| and index of this list.
| `settings` | 0001 | Key/value panel settings (retention, profile flags) |
| `domains` | 0001 (+ `dmarc_rua` in 0004) | Sending domains |
| `applications` | 0001 (+ auth IP cols in 0009) | SASL applications per domain |
| `application_addresses` | 0001 | Explicit From addresses (`list` mode) |
| `send_log` | 0001 | Delivery journal |
| `rate_limits` | 0001 (+ auto cols in 0007) | Level-2 limits per domain/application |
| `sessions` | 0002 | Panel login sessions |
| `logtail_state` | 0003 | Log-tailer persistence |
| `users` | 0005 | Panel users (`global`, `domain_admin`) |
| `user_domains` | 0005 | Domain-admin assignments |
| `inbound_domains` | 0006 | Inbound relay domains |
| `inbound_transports` | 0006 | Upstream host/port/TLS per inbound domain |
| `inbound_recipients` | 0006 | Allow-list when `recipient_mode = list` |
| `dmarc_reports` | 0008 | Parsed aggregate report summaries |
| `dmarc_report_records` | 0008 | Per-source rows inside a report |
**Not present after v10:** `admin` (dropped in 0005). ### What changed against the last 1.x schema
| 1.x | 2.0 | Why |
|---|---|---|
| `admin` created in 0001, dropped in 0005 | never exists | the reason for the squash |
| `users.role` = `global` / `domain_admin` | `global` / `domain` | the role is a reach, not a rank — everyone in the panel is an administrator |
| `users.dmarc_report_email` | `users.email` | a user's e-mail is theirs; it is what the panel writes to, and DMARC only *may* use it |
| instance setting `dmarc_report_email` (mirror of the global user's field) | `users.dmarc_default_mode`, `users.dmarc_default_address` | the default report address is a user's setting, not the server's |
| `domains.dmarc_rua` NULL = inherit the instance default, `''` = none | `domains.dmarc_rua_user_id` set = follows that user's default; otherwise `dmarc_rua` is the address, `''` = none | a domain can have several users, so "the default" has to be someone's |
| — | `user_inbound_domains` | inbound is delegated per user, separately from outbound |
| — | `users.all_domains`, `users.all_inbound_domains` | "All" includes domains added later |
| `rate_limits.allowed_ips` (unused since 1.9.0) | gone | client IPs belong to the application (`auth_allowed_ips`) |
### Where a domain's DMARC reports go
Resolved in one place, `Store.DomainDMARCRua`:
1. `domains.dmarc_rua_user_id` is set → the domain follows that user's default
(`DMARCDefault.Resolve`): `hosted` → SelfPost's own mailbox for the domain
(no reports while ingest is off), `account` → `users.email`, `custom` →
`users.dmarc_default_address`, `none` → no reports.
2. Otherwise `domains.dmarc_rua` is the address itself; `''` means a
policy-only record.
A new domain follows whoever created it. Deleting the followed user sets
`dmarc_rua_user_id` to NULL (`ON DELETE SET NULL`), which leaves the domain at
"no reports" — never at another user's default.
--- ---
## Legacy and fresh-install artefacts ## Adding a migration
These are intentional in 1.x; they are the main motivation for 1. Add `internal/store/migrations/00NN_short_name.sql` (next number).
[schema-squash](roadmap.md#schema-squash) at 2.x. 2. Update the table in [Migration chain](#migration-chain), the snapshot at the
top, and the affected rows of [Schema at head](#schema-at-head-v1).
### `admin` table (0001 → 0005) 3. Update `TestBaselineSchema` if tables, columns or indexes change, and add a
test that opens a database at the previous version and migrates it.
On a **new** 1.x data directory the chain still: 4. A *data* migration states in a comment which 2.x versions it is written for.
1. Creates `admin` (0001),
2. Adds `admin.dmarc_report_email` (0004),
3. Copies the row into `users` and drops `admin` (0005).
Functionally harmless; confusing when reading migrations or inferring schema from
code. A 2.x baseline should define `users` directly and omit `admin`.
### Application trusted IPs (0009)
Before 1.9.0, client IPs for an application lived in `rate_limits.allowed_ips`
(`scope = application`). Migration 0009 copies non-empty values into
`applications.auth_allowed_ips`, sets `auth_ip_restrict = 1`, and clears
`rate_limits.allowed_ips` for application scope. Level-2 limits no longer carry
IP bindings; auth and rate limiting are separate concerns.
Note that the two columns do not mean the same thing: the old list was
*permissive* (those IPs got the application ceiling, every other IP fell back
to the domain limit), the new one is *restrictive* (every IP not on the list is
refused for that application). The conversion branch only fires for a database
that carried application-scope `allowed_ips` from before 1.9.0.
### Profile DMARC email (0004 → 0005)
`0004` adds `admin.dmarc_report_email`. `0005` copies it into the migrated global
`users` row and into `settings` key `dmarc_report_email`. Per-domain overrides
remain on `domains.dmarc_rua`.
### Documentation references to migration numbers
Other docs cite migrations by number (e.g. architecture § sessions → `0002`).
After a 2.x squash, those references remain valid for **upgrade history** and
git; fresh 2.x installs only run the baseline plus post-2.x files.
---
## Rules for 1.x changes
1. **Add** the next `00NN_descriptive_name.sql`; do not edit shipped migrations.
2. **Bump this file:** snapshot table, chain row, schema table if needed.
3. **CHANGELOG** under the release that ships the migration.
4. **Operator impact:** if upgrade behaviour matters, note it in [guide.md](guide.md).
5. **Backup manifest** is separate: restore requires matching binary version
([architecture.md](architecture.md) § Persistence); it does not replace
running pending SQLite migrations.
### Checklist for a new migration
- [ ] File name is next integer, zero-padded four digits.
- [ ] SQL is idempotent in spirit (runs once per DB; guard with schema state, not
“IF NOT EXISTS” everywhere unless needed).
- [ ] Data migrations handle empty/partial state (e.g. no `admin` row on re-run is
impossible; mid-upgrade failure is recovered by re-running the same file
only if the transaction failed before `user_version` bump).
- [ ] Row added to [Migration chain](#migration-chain) and [Current snapshot](#current-snapshot).
- [ ] `go test ./...` (store and dependents).
---
## Planned 2.x squash
Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x
on its own — only bundled with another breaking change or an explicit major.
**Goal:** embed one baseline SQL file equal to the schema at whatever the 1.x
chain head is when the squash ships (`v10` today), instead of the full 1.x
chain. Fresh 2.x `/data` directories skip create-then-drop `admin`.
**Upgrade gate (required when squash ships):**
| `user_version` | 2.x behaviour |
|---|---|
| `0` (empty DB) | Apply baseline; set `user_version` to new chain head |
| `>= N` (fully migrated 1.x) | Skip; schema already matches baseline |
| `1`…`N-1` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x |
`N` is the chain head at cut time (`10` today).
**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that
2.x will not open an unfinished 1.x database.
---
## Restore vs migrate
| Mechanism | What it checks |
|-----------|----------------|
| SQLite `user_version` | Which embedded migrations have run on this `selfpost.db` |
| Backup `manifest.json` | Binary/image version after full restore |
An operator can have a matching manifest after restore but still need migrations
if they restored an older DB snapshot with a newer binary — normal `migrate()`
applies pending files. The 2.x gate adds a **refusal** for half-upgraded 1.x DBs
when the old chain is no longer embedded.
-1
View File
@@ -205,7 +205,6 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error { func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeApp rl.Scope = store.RateLimitScopeApp
rl.RefID = appID rl.RefID = appID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl) return s.store.SetRateLimit(rl)
} }
+24 -20
View File
@@ -54,10 +54,6 @@ func (s *Service) AllowedRecipients() ([]string, error) {
if !s.enabled || s.hostname == "" { if !s.enabled || s.hostname == "" {
return nil, nil return nil, nil
} }
profile, err := s.store.GlobalDMARCReportEmail()
if err != nil {
return nil, err
}
seen := make(map[string]bool) seen := make(map[string]bool)
var out []string var out []string
add := func(addr string) { add := func(addr string) {
@@ -68,8 +64,24 @@ func (s *Service) AllowedRecipients() ([]string, error) {
seen[addr] = true seen[addr] = true
out = append(out, addr) out = append(out, addr)
} }
add(profile) // A user's own default that points at this host is accepted even before a
if profile == "" { // domain follows it, so the address can be published first. With no such
// default anywhere, the instance's generic mailbox stays open — the address
// the guide tells a fresh install to publish.
users, err := s.store.ListUsers()
if err != nil {
return nil, err
}
anyDefault := false
for _, u := range users {
if def := u.DMARCDefault(); def.Mode == store.DMARCDefaultAccount || def.Mode == store.DMARCDefaultCustom {
if addr := def.Resolve(""); addr != "" {
anyDefault = true
add(addr)
}
}
}
if !anyDefault {
add(DefaultHostedReportAddress(s.hostname)) add(DefaultHostedReportAddress(s.hostname))
} }
domains, err := s.store.ListDomains() domains, err := s.store.ListDomains()
@@ -77,21 +89,13 @@ func (s *Service) AllowedRecipients() ([]string, error) {
return nil, err return nil, err
} }
for _, d := range domains { for _, d := range domains {
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile) // add keeps only addresses on this host, so a domain reporting elsewhere
if rua == "" { // (or nowhere) opens nothing here.
continue rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name))
} if err != nil {
if IsHostedOnHostname(rua, s.hostname) { return nil, err
add(rua)
continue
}
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
continue
}
hosted := HostedReportAddress(s.hostname, d.Name)
if strings.EqualFold(rua, hosted) {
add(rua)
} }
add(rua)
} }
sort.Strings(out) sort.Strings(out)
return out, nil return out, nil
-10
View File
@@ -1,7 +1,6 @@
package dnscheck package dnscheck
import ( import (
"database/sql"
"net" "net"
"strings" "strings"
) )
@@ -48,15 +47,6 @@ func SPFExample(hostname string, serverIPs []string) string {
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all" return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
} }
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
// override wins, then the administrator profile, then policy-only (empty).
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
if domainRua.Valid {
return domainRua.String
}
return profileEmail
}
// EmailDomain returns the lower-case domain part of addr, or "" when invalid. // EmailDomain returns the lower-case domain part of addr, or "" when invalid.
func EmailDomain(addr string) string { func EmailDomain(addr string) string {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
-16
View File
@@ -2,7 +2,6 @@ package dnscheck
import ( import (
"context" "context"
"database/sql"
"strings" "strings"
"testing" "testing"
@@ -41,21 +40,6 @@ func TestDMARCExample(t *testing.T) {
} }
} }
func TestResolveDMARCRua(t *testing.T) {
inherit := sql.NullString{}
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
t.Errorf("inherit profile = %q", got)
}
none := sql.NullString{Valid: true}
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
t.Errorf("explicit none = %q", got)
}
custom := sql.NullString{Valid: true, String: "x@y.com"}
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
t.Errorf("custom = %q", got)
}
}
func TestExternalReportAuth(t *testing.T) { func TestExternalReportAuth(t *testing.T) {
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com") name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" { if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
-2
View File
@@ -146,11 +146,9 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
// SaveRateLimit stores the domain-level rate limit. The caller has validated the // SaveRateLimit stores the domain-level rate limit. The caller has validated the
// numbers (security.md); the milter reads the row live, so no reload is needed. // numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error { func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeDomain rl.Scope = store.RateLimitScopeDomain
rl.RefID = domainID rl.RefID = domainID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl) return s.store.SetRateLimit(rl)
} }
+8 -7
View File
@@ -1,7 +1,6 @@
package domain package domain
import ( import (
"database/sql"
"fmt" "fmt"
"github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/buildinfo"
@@ -24,7 +23,7 @@ type DomainExport struct {
Domain string `json:"domain"` Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"` DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none) DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = follows a user's default; set = the domain's own address ("" = none)
RateLimit *RateLimitExport `json:"rate_limit,omitempty"` RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Applications []AppExport `json:"applications"` Applications []AppExport `json:"applications"`
} }
@@ -74,9 +73,11 @@ func (s *Service) Export(id int64) (DomainExport, error) {
DKIMPrivateKey: string(pem), DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)), Applications: make([]AppExport, 0, len(apps)),
} }
if d.DMARCRua.Valid { // A domain that follows a user's default exports no address: users do not
s := d.DMARCRua.String // travel with a domain, and whoever imports it becomes the one it follows.
exp.DMARCRua = &s if !d.DMARCRuaUserID.Valid {
rua := d.DMARCRua
exp.DMARCRua = &rua
} }
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id) rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
if err != nil { if err != nil {
@@ -147,11 +148,11 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
} }
if exp.DMARCRua != nil { if exp.DMARCRua != nil {
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil { if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, err return store.Domain{}, err
} }
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua} d.DMARCRua = *exp.DMARCRua
} }
if exp.RateLimit != nil { if exp.RateLimit != nil {
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil { if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
+3 -4
View File
@@ -1,7 +1,6 @@
package domain package domain
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"testing" "testing"
@@ -73,7 +72,7 @@ func TestExportImportRoundTrip(t *testing.T) {
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil { if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err) t.Fatalf("add alerts: %v", err)
} }
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil { if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
t.Fatalf("set dmarc rua: %v", err) t.Fatalf("set dmarc rua: %v", err)
} }
@@ -111,8 +110,8 @@ func TestExportImportRoundTrip(t *testing.T) {
if got.Name != "example.com" || got.DKIMSelector != "selfpost" { if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got) t.Errorf("imported domain = %+v", got)
} }
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" { if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" {
t.Errorf("imported dmarc rua = %+v", got.DMARCRua) t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid)
} }
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" { if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
t.Errorf("exported dmarc rua = %v", exp.DMARCRua) t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
+2 -3
View File
@@ -292,8 +292,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
}, },
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
} }
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row // Domain ceilings apply to every client IP.
// are ignored.
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp) t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
} }
@@ -362,7 +361,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
func TestRateLimitInactiveWithoutCeiling(t *testing.T) { func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window store.RateLimitScopeDomain + "|example.com": {}, // no max/window
}, },
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
} }
+44 -12
View File
@@ -25,9 +25,13 @@ type Domain struct {
ID int64 ID int64
Name string Name string
DKIMSelector string DKIMSelector string
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports // Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
CreatedAt time.Time // follows that user's default (DMARCDefault) and DMARCRua is ignored;
AppCount int // otherwise DMARCRua is the address itself, "" meaning no reports.
DMARCRua string
DMARCRuaUserID sql.NullInt64
CreatedAt time.Time
AppCount int
} }
// AddDomain inserts a new sending domain. The caller is responsible for having // AddDomain inserts a new sending domain. The caller is responsible for having
@@ -56,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
// ordered by name. // ordered by name.
func (s *Store) ListDomains() ([]Domain, error) { func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
ORDER BY d.name`) ORDER BY d.name`)
@@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
// ordered by name. // ordered by name.
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) { func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
INNER JOIN user_domains ud ON ud.domain_id = d.id INNER JOIN user_domains ud ON ud.domain_id = d.id
@@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
// ErrDomainNotFound. // ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) { func (s *Store) GetDomain(id int64) (Domain, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
WHERE d.id = ?`, id) WHERE d.id = ?`, id)
@@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) {
d Domain d Domain
createdAt string createdAt string
) )
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil { if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil {
return Domain{}, err return Domain{}, err
} }
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
@@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool {
return false return false
} }
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination. // SetDomainDMARCAddress gives the domain a report address of its own; "" means
// NULL means inherit the administrator profile; Valid with an empty string means // no aggregate reports. The domain stops following anyone's default.
// policy-only with no aggregate reports for this domain. func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error { return s.updateDomainDMARC(id, rua, sql.NullInt64{})
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id) }
// SetDomainDMARCUser makes the domain follow userID's default report address
// (DMARCDefault). If that user is later deleted the domain falls back to no
// reports rather than to someone else's default.
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
}
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
if err != nil { if err != nil {
return fmt.Errorf("update domain dmarc rua: %w", err) return fmt.Errorf("update domain dmarc rua: %w", err)
} }
@@ -184,6 +198,24 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
return nil return nil
} }
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
// address, or the default of the user it follows. hosted is the SelfPost-hosted
// mailbox for this domain, "" when report ingest is off (the store does not
// know the server's hostname). The result is "" for a policy-only record.
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
if !d.DMARCRuaUserID.Valid {
return d.DMARCRua, nil
}
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
if errors.Is(err, ErrUserNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return def.Resolve(hosted), nil
}
// DomainExists reports whether a sending domain with this name is configured. // DomainExists reports whether a sending domain with this name is configured.
// Used by the DMARC ingest path to refuse reports for domains this relay does // Used by the DMARC ingest path to refuse reports for domains this relay does
// not send for (security.md: report XML is attacker-supplied input). // not send for (security.md: report XML is attacker-supplied input).
+116 -20
View File
@@ -1,11 +1,15 @@
package store package store
import ( import (
"database/sql" "errors"
"testing" "testing"
) )
func TestDomainDMARCRua(t *testing.T) { const hostedForTest = "dmarc+example.com@mail.example.org"
// A domain either has a report address of its own or follows a user's default;
// the two never mix, and a fresh domain has neither.
func TestDomainDMARCAddressAndUser(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
d, err := st.AddDomain("example.com", "sel") d, err := st.AddDomain("example.com", "sel")
if err != nil { if err != nil {
@@ -15,31 +19,123 @@ func TestDomainDMARCRua(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetDomain: %v", err) t.Fatalf("GetDomain: %v", err)
} }
if got.DMARCRua.Valid { if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
t.Fatal("new domain should inherit profile") t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
} }
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil { if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil {
t.Fatalf("UpdateDomainDMARCRua custom: %v", err) t.Fatalf("SetDomainDMARCAddress: %v", err)
}
got, err = st.GetDomain(d.ID)
if err != nil || got.DMARCRua.String != "reports@hub.com" {
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
}
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
} }
got, _ = st.GetDomain(d.ID) got, _ = st.GetDomain(d.ID)
if !got.DMARCRua.Valid || got.DMARCRua.String != "" { if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" {
t.Fatalf("none = %+v", got.DMARCRua) t.Fatalf("own address resolves to %q, %v", rua, err)
} }
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil { if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err) t.Fatalf("CreateGlobalUser: %v", err)
}
u, _ := st.GetUserByUsername("admin")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatalf("SetDomainDMARCUser: %v", err)
} }
got, _ = st.GetDomain(d.ID) got, _ = st.GetDomain(d.ID)
if got.DMARCRua.Valid { if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID {
t.Fatalf("inherit = %+v", got.DMARCRua) t.Fatalf("following a user left %q / %v; the own address must be dropped", got.DMARCRua, got.DMARCRuaUserID)
}
if err := st.SetDomainDMARCAddress(d.ID, ""); err != nil {
t.Fatalf("SetDomainDMARCAddress none: %v", err)
}
got, _ = st.GetDomain(d.ID)
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
t.Fatalf("none = %q / %v", got.DMARCRua, got.DMARCRuaUserID)
}
if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("unknown domain = %v, want ErrDomainNotFound", err)
}
}
// A domain that follows a user resolves to whatever that user's default says
// today — every mode — and changes with it.
func TestDomainFollowsUserDefault(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, _ := st.GetUserByUsername("admin")
if err := st.UpdateUser(u.ID, "admin", "hash", "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
d, _ := st.AddDomain("example.com", "sel")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatalf("SetDomainDMARCUser: %v", err)
}
d, _ = st.GetDomain(d.ID)
for _, tc := range []struct {
mode, address string
hosted string
want string
}{
{DMARCDefaultNone, "", hostedForTest, ""},
{DMARCDefaultAccount, "", hostedForTest, "mix@example.org"},
{DMARCDefaultCustom, "dmarc@hub.example", hostedForTest, "dmarc@hub.example"},
{DMARCDefaultHosted, "", hostedForTest, hostedForTest},
// Ingest switched off: a hosted default has no mailbox to point at.
{DMARCDefaultHosted, "", "", ""},
// The custom address belongs to the custom mode only.
{DMARCDefaultAccount, "ignored@hub.example", hostedForTest, "mix@example.org"},
} {
if err := st.SetDMARCDefault(u.ID, tc.mode, tc.address); err != nil {
t.Fatalf("SetDMARCDefault(%s): %v", tc.mode, err)
}
got, err := st.DomainDMARCRua(d, tc.hosted)
if err != nil || got != tc.want {
t.Errorf("mode %s, hosted %q: resolved %q, %v; want %q", tc.mode, tc.hosted, got, err, tc.want)
}
}
def, err := st.GetDMARCDefault(u.ID)
if err != nil || def.Address != "" || def.Username != "admin" {
t.Errorf("default after a non-custom mode = %+v, %v; the custom address must be cleared", def, err)
}
if err := st.SetDMARCDefault(u.ID, "profile", ""); err == nil {
t.Error("an unknown mode was stored")
}
if err := st.SetDMARCDefault(u.ID+100, DMARCDefaultNone, ""); !errors.Is(err, ErrUserNotFound) {
t.Errorf("unknown user = %v, want ErrUserNotFound", err)
}
}
// Deleting the user a domain follows must leave the domain with no report
// address — not with another user's default, and not with a dangling id.
func TestDeletingTheFollowedUserLeavesNoReports(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
d, _ := st.AddDomain("example.com", "sel")
id, err := st.CreateUser("second", "hash", RoleGlobal, nil)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
t.Fatalf("SetDMARCDefault: %v", err)
}
if err := st.SetDomainDMARCUser(d.ID, id); err != nil {
t.Fatalf("SetDomainDMARCUser: %v", err)
}
if err := st.DeleteUser(id); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
got, err := st.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
}
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no reports", rua, err)
} }
} }
+187 -33
View File
@@ -1,39 +1,103 @@
-- Initial SelfPost schema. One SQLite file under /data holds the whole panel -- SelfPost 2.0 baseline schema. One SQLite file under /data holds the whole
-- state so a single directory backup/restore is sufficient. -- panel state, so a single directory backup/restore is sufficient.
--
-- This file IS the 2.0 schema: the 1.x chain (0001..0010, with its admin table
-- created and dropped again) was deleted, not squashed with an upgrade path —
-- 2.0 starts from an empty data directory (docs/schema-migrations.md). Later
-- changes ship as 0002_*.sql and onwards; this file is never edited again once
-- 2.0.0 is released.
-- Single administrator account. Exactly one row is allowed; the presence of -- Marks the file as a 2.x SelfPost database ("SP20"). store.Open refuses a
-- that row is what marks primary setup as complete, which is why the /setup -- database that has a schema version but not this mark, which is what a 1.x
-- route disappears once it exists. -- file looks like: its user_version would otherwise read as "already migrated".
CREATE TABLE admin ( PRAGMA application_id = 1397764656;
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Free-form key/value panel settings (retention overrides, misc flags). -- Free-form key/value settings of the instance: what is true whoever is signed
-- in (send-log retention). Nothing that belongs to a user lives here.
CREATE TABLE settings ( CREATE TABLE settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
-- Sending domains managed through the panel. DKIM keys themselves live on disk -- Panel logins (not application SASL accounts). The presence of a row is what
-- under /data; this row records the selector and metadata. -- marks primary setup as complete, which is why /setup disappears once one
-- exists. role is a reach, not a rank: 'global' sees the whole instance,
-- 'domain' only what is assigned below.
--
-- email is the user's own address — what the panel writes to. The DMARC
-- default is a separate choice that may use it: dmarc_default_mode says where
-- aggregate reports go for a domain that follows this user
-- (domains.dmarc_rua_user_id): 'hosted' (SelfPost's own mailbox for that
-- domain), 'account' (email above), 'custom' (dmarc_default_address) or 'none'.
--
-- all_domains / all_inbound_domains widen a 'domain' user's list to every
-- domain of that direction, including the ones added later; with a flag set
-- the rows of the matching assignment table are ignored.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain')),
email TEXT NOT NULL DEFAULT '',
dmarc_default_mode TEXT NOT NULL DEFAULT 'none'
CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')),
dmarc_default_address TEXT NOT NULL DEFAULT '',
all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)),
all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)),
created_at TEXT NOT NULL
);
-- Panel login sessions. Persisted so a login survives a container restart or
-- redeploy; only the SHA-256 of the session token is stored, never the token
-- itself, so a stolen database file cannot be used to sign in. expires_at
-- implements the sliding idle timeout: it is pushed forward on activity rather
-- than being fixed at creation time.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
-- Sending (outbound) domains. DKIM keys themselves live on disk under /data;
-- this row records the selector and metadata.
--
-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id
-- set, the domain follows that user's default and dmarc_rua is ignored;
-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting
-- the user leaves the domain at "none" rather than silently handing its
-- reports to someone else.
CREATE TABLE domains ( CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
dkim_selector TEXT NOT NULL, dkim_selector TEXT NOT NULL,
created_at TEXT NOT NULL dmarc_rua TEXT NOT NULL DEFAULT '',
dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
-- Outbound domains assigned to a 'domain' user.
CREATE TABLE user_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, domain_id)
); );
-- Applications bound to a domain. address_mode is either the domain wildcard or -- Applications bound to a domain. address_mode is either the domain wildcard or
-- an explicit address list; the SASL login is globally unique. -- an explicit address list; the SASL login is globally unique.
--
-- auth_ip_restrict / auth_allowed_ips restrict which client IPs may submit as
-- the application at all (refused otherwise). That is independent of the
-- level-2 rate limits below.
CREATE TABLE applications ( CREATE TABLE applications (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
login TEXT NOT NULL UNIQUE, login TEXT NOT NULL UNIQUE,
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')), address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
created_at TEXT NOT NULL auth_ip_restrict INTEGER NOT NULL DEFAULT 0,
auth_allowed_ips TEXT,
created_at TEXT NOT NULL
); );
-- Explicit sender addresses for applications in 'list' mode. Each address must -- Explicit sender addresses for applications in 'list' mode. Each address must
@@ -45,6 +109,20 @@ CREATE TABLE application_addresses (
UNIQUE (application_id, address) UNIQUE (application_id, address)
); );
-- Level-2 rate limits per domain or application. In 'auto' mode max_messages
-- is derived from 30-day send statistics times auto_multiplier.
CREATE TABLE rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
ref_id INTEGER NOT NULL,
max_messages INTEGER,
window_seconds INTEGER,
mode TEXT NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'auto')),
auto_multiplier REAL,
auto_updated_at TEXT,
UNIQUE (scope, ref_id)
);
-- Structured send log. One row per (queue-id, recipient); the log-tailer -- Structured send log. One row per (queue-id, recipient); the log-tailer
-- advances status from queued to a final state. -- advances status from queued to a final state.
CREATE TABLE send_log ( CREATE TABLE send_log (
@@ -62,15 +140,91 @@ CREATE TABLE send_log (
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id); CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
CREATE INDEX idx_send_log_domain ON send_log (domain); CREATE INDEX idx_send_log_domain ON send_log (domain);
CREATE INDEX idx_send_log_created_at ON send_log (created_at); CREATE INDEX idx_send_log_created_at ON send_log (created_at);
-- The journal milter counts messages for a level-2 limit on every MAIL FROM,
-- and the domain page computes 30-day statistics per application on every
-- render: equality on app_login, range on created_at.
CREATE INDEX idx_send_log_app_login_created_at ON send_log (app_login, created_at);
-- Differentiated rate limits per domain/application. Both the IP binding and -- Log-tailer read position. Without it the tailer starts at end-of-file on
-- the message limit are optional. -- every start, so delivery lines written while the panel was down are never
CREATE TABLE rate_limits ( -- parsed and their send-log rows stay "queued" forever. One row per followed
id INTEGER PRIMARY KEY AUTOINCREMENT, -- path; fingerprint identifies the file the offset belongs to (the head bytes
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')), -- of the log), so a rotated or recreated mail.log is detected across a restart,
ref_id INTEGER NOT NULL, -- where os.SameFile cannot help.
allowed_ips TEXT, CREATE TABLE logtail_state (
max_messages INTEGER, path TEXT PRIMARY KEY,
window_seconds INTEGER, fingerprint TEXT NOT NULL,
UNIQUE (scope, ref_id) read_offset INTEGER NOT NULL,
updated_at TEXT NOT NULL
); );
-- Optional inbound relay (backup-MX / forwarder). A separate entity from the
-- sending domains, even under the same name: these rows exist while
-- INBOUND_RELAY_ENABLE is false, but the listener, the Postfix maps and the
-- panel pages are generated only when that flag is on.
CREATE TABLE inbound_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
created_at TEXT NOT NULL
);
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
CREATE TABLE inbound_transports (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
);
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
CREATE TABLE inbound_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (inbound_domain_id, address)
);
-- Inbound domains assigned to a 'domain' user — granted separately from the
-- outbound ones above; the same name on both lists is two assignments.
CREATE TABLE user_inbound_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, inbound_domain_id)
);
-- DMARC aggregate report summaries. One row per parsed report; per-source rows
-- hang off it. Forensic (ruf=) payloads are not stored.
CREATE TABLE dmarc_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
reporter TEXT NOT NULL,
report_id TEXT NOT NULL,
period_begin TEXT NOT NULL,
period_end TEXT NOT NULL,
received_at TEXT NOT NULL,
contact_email TEXT NOT NULL DEFAULT '',
policy_p TEXT NOT NULL DEFAULT '',
policy_sp TEXT NOT NULL DEFAULT '',
policy_pct INTEGER NOT NULL DEFAULT 100,
policy_adkim TEXT NOT NULL DEFAULT '',
policy_aspf TEXT NOT NULL DEFAULT '',
pass_count INTEGER NOT NULL DEFAULT 0,
fail_count INTEGER NOT NULL DEFAULT 0,
recipient TEXT NOT NULL DEFAULT '',
UNIQUE (reporter, report_id, domain)
);
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
CREATE TABLE dmarc_report_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
source_ip TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL,
disposition TEXT NOT NULL DEFAULT '',
spf_result TEXT NOT NULL DEFAULT '',
dkim_result TEXT NOT NULL DEFAULT '',
header_from TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
@@ -1,12 +0,0 @@
-- Panel login sessions. Persisted so a login survives a container restart or
-- redeploy; only the SHA-256 of the session token is stored, never the token
-- itself, so a stolen database file cannot be used to sign in. expires_at
-- implements the sliding idle timeout: it is pushed forward on activity rather
-- than being fixed at creation time.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
@@ -1,12 +0,0 @@
-- Log-tailer read position. Without it the tailer starts at end-of-file on
-- every start, so delivery lines written while the panel was down are never
-- parsed and their send-log rows stay "queued" forever. One row per followed
-- path; fingerprint identifies the file the offset belongs to (the head bytes
-- of the log), so a rotated or recreated mail.log is detected across a restart,
-- where os.SameFile cannot help.
CREATE TABLE logtail_state (
path TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
read_offset INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
@@ -1,5 +0,0 @@
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
@@ -1,26 +0,0 @@
-- Panel users and domain-admin assignments. Migrates the single admin row into
-- a global user; drops the admin table.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
dmarc_report_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE user_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, domain_id)
);
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
SELECT username, password_hash, 'global', dmarc_report_email, created_at
FROM admin WHERE id = 1;
INSERT OR REPLACE INTO settings (key, value)
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
DROP TABLE admin;
@@ -1,26 +0,0 @@
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
-- listener, Postfix maps and panel UI are generated only when that flag is on.
CREATE TABLE inbound_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
created_at TEXT NOT NULL
);
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
CREATE TABLE inbound_transports (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
);
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
CREATE TABLE inbound_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (inbound_domain_id, address)
);
@@ -1,7 +0,0 @@
-- Level-2 rate limit auto mode: operator sets a multiplier; max_messages is
-- derived from 30-day send stats (plan domain-stats-auto-ratelimit).
ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'
CHECK (mode IN ('manual', 'auto'));
ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL;
ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT;
@@ -1,38 +0,0 @@
-- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed
-- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored.
CREATE TABLE dmarc_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
reporter TEXT NOT NULL,
report_id TEXT NOT NULL,
period_begin TEXT NOT NULL,
period_end TEXT NOT NULL,
received_at TEXT NOT NULL,
contact_email TEXT NOT NULL DEFAULT '',
policy_p TEXT NOT NULL DEFAULT '',
policy_sp TEXT NOT NULL DEFAULT '',
policy_pct INTEGER NOT NULL DEFAULT 100,
policy_adkim TEXT NOT NULL DEFAULT '',
policy_aspf TEXT NOT NULL DEFAULT '',
pass_count INTEGER NOT NULL DEFAULT 0,
fail_count INTEGER NOT NULL DEFAULT 0,
recipient TEXT NOT NULL DEFAULT '',
UNIQUE (reporter, report_id, domain)
);
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
CREATE TABLE dmarc_report_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
source_ip TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL,
disposition TEXT NOT NULL DEFAULT '',
spf_result TEXT NOT NULL DEFAULT '',
dkim_result TEXT NOT NULL DEFAULT '',
header_from TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
@@ -1,22 +0,0 @@
-- Client IP restriction for application SASL authorization (independent of
-- level-2 rate limits). When enabled, only listed IPs may submit mail as the
-- application; when disabled, any client IP is allowed.
ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0;
ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT;
-- Migrate trusted IPs from application rate_limits into auth_allowed_ips.
UPDATE applications
SET auth_ip_restrict = 1,
auth_allowed_ips = (
SELECT allowed_ips FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
)
WHERE EXISTS (
SELECT 1 FROM rate_limits
WHERE scope = 'application' AND ref_id = applications.id
AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != ''
);
UPDATE rate_limits SET allowed_ips = NULL
WHERE scope = 'application' AND allowed_ips IS NOT NULL;
@@ -1,9 +0,0 @@
-- Send-log lookups by application login run on two hot paths: the journal
-- milter counts messages for a level-2 rate limit on every MAIL FROM, and the
-- domain page computes 30-day statistics per application on every render.
-- 0001 indexed queue_id, domain and created_at, but not app_login, so both
-- fell back to scanning the created_at range. The composite matches the shape
-- of those queries (equality on app_login, range on created_at).
CREATE INDEX IF NOT EXISTS idx_send_log_app_login_created_at
ON send_log (app_login, created_at);
+2 -4
View File
@@ -43,7 +43,7 @@ func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1
func (s *Store) listAutoRateLimits() ([]RateLimit, error) { func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at `SELECT scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
FROM rate_limits WHERE mode = ?`, RateLimitModeAuto, FROM rate_limits WHERE mode = ?`, RateLimitModeAuto,
) )
if err != nil { if err != nil {
@@ -55,18 +55,16 @@ func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
for rows.Next() { for rows.Next() {
var scope string var scope string
var refID int64 var refID int64
var ips sql.NullString
var maxMsgs, windowSecs sql.NullInt64 var maxMsgs, windowSecs sql.NullInt64
var mode sql.NullString var mode sql.NullString
var autoMult sql.NullFloat64 var autoMult sql.NullFloat64
var autoUpdated sql.NullString var autoUpdated sql.NullString
if err := rows.Scan(&scope, &refID, &ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { if err := rows.Scan(&scope, &refID, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
return nil, fmt.Errorf("scan auto rate limit: %w", err) return nil, fmt.Errorf("scan auto rate limit: %w", err)
} }
rl := RateLimit{ rl := RateLimit{
Scope: scope, Scope: scope,
RefID: refID, RefID: refID,
AllowedIPs: splitIPs(ips.String),
MaxMessages: int(maxMsgs.Int64), MaxMessages: int(maxMsgs.Int64),
WindowSeconds: int(windowSecs.Int64), WindowSeconds: int(windowSecs.Int64),
Mode: mode.String, Mode: mode.String,
+8 -12
View File
@@ -41,7 +41,6 @@ const (
type RateLimit struct { type RateLimit struct {
Scope string Scope string
RefID int64 RefID int64
AllowedIPs []string // legacy column; unused for new rows
MaxMessages int MaxMessages int
WindowSeconds int WindowSeconds int
Mode string // manual | auto Mode string // manual | auto
@@ -63,7 +62,7 @@ func (r RateLimit) Active() bool {
// its id, for the panel's edit form. ok is false when none is configured. // its id, for the panel's edit form. ok is false when none is configured.
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) { func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
row := s.db.QueryRow( row := s.db.QueryRow(
`SELECT allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at `SELECT max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID) FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
rl, err := scanRateLimit(row) rl, err := scanRateLimit(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -93,16 +92,15 @@ func (s *Store) SetRateLimit(rl RateLimit) error {
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339) autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at) `INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scope, ref_id) DO UPDATE SET ON CONFLICT(scope, ref_id) DO UPDATE SET
allowed_ips = excluded.allowed_ips,
max_messages = excluded.max_messages, max_messages = excluded.max_messages,
window_seconds = excluded.window_seconds, window_seconds = excluded.window_seconds,
mode = excluded.mode, mode = excluded.mode,
auto_multiplier = excluded.auto_multiplier, auto_multiplier = excluded.auto_multiplier,
auto_updated_at = excluded.auto_updated_at`, auto_updated_at = excluded.auto_updated_at`,
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds, rl.Scope, rl.RefID, rl.MaxMessages, rl.WindowSeconds,
mode, autoMult, autoUpdated, mode, autoMult, autoUpdated,
) )
if err != nil { if err != nil {
@@ -144,11 +142,11 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
var query string var query string
switch scope { switch scope {
case RateLimitScopeDomain: case RateLimitScopeDomain:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
WHERE rl.scope = 'domain' AND d.name = ?` WHERE rl.scope = 'domain' AND d.name = ?`
case RateLimitScopeApp: case RateLimitScopeApp:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
WHERE rl.scope = 'application' AND a.login = ?` WHERE rl.scope = 'application' AND a.login = ?`
default: default:
@@ -196,23 +194,21 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
return n, nil return n, nil
} }
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns // scanRateLimit reads the stored columns, tolerating NULL numeric columns
// by leaving the corresponding field zero, which makes the limit inert via // by leaving the corresponding field zero, which makes the limit inert via
// Active() until max and window are both set. // Active() until max and window are both set.
func scanRateLimit(r scanRow) (RateLimit, error) { func scanRateLimit(r scanRow) (RateLimit, error) {
var ( var (
ips sql.NullString
maxMsgs sql.NullInt64 maxMsgs sql.NullInt64
windowSecs sql.NullInt64 windowSecs sql.NullInt64
mode sql.NullString mode sql.NullString
autoMult sql.NullFloat64 autoMult sql.NullFloat64
autoUpdated sql.NullString autoUpdated sql.NullString
) )
if err := r.Scan(&ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { if err := r.Scan(&maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
return RateLimit{}, err return RateLimit{}, err
} }
rl := RateLimit{ rl := RateLimit{
AllowedIPs: splitIPs(ips.String),
MaxMessages: int(maxMsgs.Int64), MaxMessages: int(maxMsgs.Int64),
WindowSeconds: int(windowSecs.Int64), WindowSeconds: int(windowSecs.Int64),
Mode: mode.String, Mode: mode.String,
+10 -13
View File
@@ -20,7 +20,6 @@ func TestRateLimitSetGetDelete(t *testing.T) {
want := RateLimit{ want := RateLimit{
Scope: RateLimitScopeDomain, Scope: RateLimitScopeDomain,
RefID: d.ID, RefID: d.ID,
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
MaxMessages: 100, MaxMessages: 100,
WindowSeconds: 3600, WindowSeconds: 3600,
} }
@@ -32,19 +31,17 @@ func TestRateLimitSetGetDelete(t *testing.T) {
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err) t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
} }
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 || if got.MaxMessages != 100 || got.WindowSeconds != 3600 {
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
t.Fatalf("roundtrip mismatch: %+v", got) t.Fatalf("roundtrip mismatch: %+v", got)
} }
// Upsert replaces in place (UNIQUE(scope, ref_id)). // Upsert replaces in place (UNIQUE(scope, ref_id)).
want.MaxMessages = 5 want.MaxMessages = 5
want.AllowedIPs = []string{"198.51.100.9"}
if err := st.SetRateLimit(want); err != nil { if err := st.SetRateLimit(want); err != nil {
t.Fatalf("SetRateLimit upsert: %v", err) t.Fatalf("SetRateLimit upsert: %v", err)
} }
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID) got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" { if got.MaxMessages != 5 || got.WindowSeconds != 3600 {
t.Fatalf("upsert did not replace: %+v", got) t.Fatalf("upsert did not replace: %+v", got)
} }
@@ -64,10 +61,10 @@ func TestRateLimitByNameAndLogin(t *testing.T) {
t.Fatalf("AddApplication: %v", err) t.Fatalf("AddApplication: %v", err)
} }
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil { if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60}); err != nil {
t.Fatalf("set domain limit: %v", err) t.Fatalf("set domain limit: %v", err)
} }
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil { if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60}); err != nil {
t.Fatalf("set app limit: %v", err) t.Fatalf("set app limit: %v", err)
} }
@@ -131,9 +128,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil) a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
other, _ := st.AddDomain("other.example", "selfpost") other, _ := st.AddDomain("other.example", "selfpost")
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}) _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}) _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60}) _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, MaxMessages: 1, WindowSeconds: 60})
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil { if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
t.Fatalf("DeleteRateLimitsForDomain: %v", err) t.Fatalf("DeleteRateLimitsForDomain: %v", err)
@@ -153,9 +150,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
func TestRateLimitActive(t *testing.T) { func TestRateLimitActive(t *testing.T) {
inactive := []RateLimit{ inactive := []RateLimit{
{}, {},
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling {Scope: RateLimitScopeDomain}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window {Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
} }
for i, rl := range inactive { for i, rl := range inactive {
if rl.Active() { if rl.Active() {
+291
View File
@@ -0,0 +1,291 @@
package store
import (
"database/sql"
"errors"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not
// carried along (docs/schema-migrations.md).
func TestBaselineIsTheOnlyMigration(t *testing.T) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
t.Fatalf("read migrations: %v", err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
if len(names) == 0 || names[0] != "0001_init.sql" {
t.Fatalf("migrations = %v, want the chain to start at 0001_init.sql", names)
}
baseline, _ := migrationsFS.ReadFile("migrations/0001_init.sql")
for _, gone := range []string{"CREATE TABLE admin", "DROP TABLE", "ALTER TABLE", "dmarc_report_email", "allowed_ips ", "domain_admin"} {
if strings.Contains(string(baseline), gone) {
t.Errorf("the baseline still contains %q — a leftover of the 1.x chain", gone)
}
}
}
// The schema a fresh data directory gets, table by table and column by
// column. A change here is a schema change: it ships as a new migration file
// and a row in docs/schema-migrations.md, never as an edit of the baseline
// after 2.0.0.
func TestBaselineSchema(t *testing.T) {
st := openTestStore(t)
want := map[string]string{
"settings": "key value",
"users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at",
"sessions": "token_hash username created_at expires_at",
"domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at",
"user_domains": "user_id domain_id",
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
"application_addresses": "id application_id address",
"rate_limits": "id scope ref_id max_messages window_seconds mode auto_multiplier auto_updated_at",
"send_log": "id queue_id domain app_login from_addr to_addr subject status created_at updated_at",
"logtail_state": "path fingerprint read_offset updated_at",
"inbound_domains": "id name recipient_mode created_at",
"inbound_transports": "inbound_domain_id host port tls_mode",
"inbound_recipients": "id inbound_domain_id address",
"user_inbound_domains": "user_id inbound_domain_id",
"dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient",
"dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from",
}
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
t.Fatalf("list tables: %v", err)
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
tables = append(tables, name)
}
rows.Close()
for _, name := range tables {
cols, ok := want[name]
if !ok {
t.Errorf("unexpected table %q", name)
continue
}
if got := strings.Join(tableColumns(t, st, name), " "); got != cols {
t.Errorf("table %s has columns\n %s\nwant\n %s", name, got, cols)
}
delete(want, name)
}
for name := range want {
t.Errorf("table %q is missing", name)
}
var indexes []string
rows, err = st.db.Query("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
t.Fatalf("list indexes: %v", err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
indexes = append(indexes, name)
}
rows.Close()
wantIdx := []string{
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_queue_id",
"idx_sessions_expires_at",
}
sort.Strings(wantIdx)
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
t.Errorf("indexes = %v\nwant %v", indexes, wantIdx)
}
var version, appID int64
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 1 {
t.Errorf("user_version = %d, %v; want 1", version, err)
}
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
}
}
func tableColumns(t *testing.T, st *Store, table string) []string {
t.Helper()
rows, err := st.db.Query("SELECT name FROM pragma_table_info(?) ORDER BY cid", table)
if err != nil {
t.Fatalf("columns of %s: %v", table, err)
}
defer rows.Close()
var cols []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
cols = append(cols, name)
}
return cols
}
// Reopening a 2.x database applies nothing twice and keeps its data.
func TestReopenKeepsTheDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
st, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
st.Close()
st, err = Open(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer st.Close()
if ok, err := st.UserExists(); err != nil || !ok {
t.Fatalf("after reopen: user exists = %v, %v", ok, err)
}
}
// A 1.x data directory must be refused, loudly. Its user_version (10 at the
// last 1.x release) would otherwise read as "the baseline is already applied",
// and the panel would start on tables it does not know.
func TestOpenRefusesA1xDatabase(t *testing.T) {
for _, version := range []string{"1", "10"} {
path := filepath.Join(t.TempDir(), "old.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("create old db: %v", err)
}
for _, stmt := range []string{
"CREATE TABLE admin (id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)",
"INSERT INTO admin VALUES (1, 'admin', 'hash', '2026-01-01T00:00:00Z')",
"PRAGMA user_version = " + version,
} {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("%s: %v", stmt, err)
}
}
db.Close()
st, err := Open(path)
if err == nil {
st.Close()
t.Fatalf("a 1.x database at version %s was opened", version)
}
if !errors.Is(err, ErrForeignSchema) {
t.Fatalf("version %s: error = %v, want ErrForeignSchema", version, err)
}
// Refusing must not have touched the file: the operator still has
// their 1.x data to go back to.
db, _ = sql.Open("sqlite", "file:"+path)
var n int
if err := db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil || n != 1 {
t.Errorf("version %s: the refused database was altered (admin rows = %d, %v)", version, n, err)
}
var tables int
_ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&tables)
if tables != 1 {
t.Errorf("version %s: the refused database now has %d tables", version, tables)
}
db.Close()
}
}
// A database written by a newer build is not guessed at either.
func TestOpenRefusesANewerSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
st, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := st.db.Exec("PRAGMA user_version = 99"); err != nil {
t.Fatal(err)
}
st.Close()
if st, err := Open(path); err == nil {
st.Close()
t.Fatal("a database at schema version 99 was opened")
}
}
// The checks the baseline puts on a user row, and what the new assignment
// table does when either side goes away.
func TestBaselineConstraints(t *testing.T) {
st := openTestStore(t)
insert := func(role, mode string, all int) error {
_, err := st.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')",
role+mode, role, mode, all)
return err
}
if err := insert("global", "none", 0); err != nil {
t.Fatalf("a plain global user: %v", err)
}
if err := insert("domain", "hosted", 1); err != nil {
t.Fatalf("a domain user with All: %v", err)
}
if err := insert("domain_admin", "none", 0); err == nil {
t.Error("the 1.x role name domain_admin was accepted")
}
if err := insert("global", "profile", 0); err == nil {
t.Error("an unknown dmarc_default_mode was accepted")
}
if err := insert("domain", "custom", 2); err == nil {
t.Error("all_domains = 2 was accepted")
}
u, err := st.GetUserByUsername("domainhosted")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if !u.AllDomains || u.AllInboundDomains || u.DMARCDefaultMode != DMARCDefaultHosted {
t.Errorf("flags read back as %+v", u)
}
in, err := st.AddInboundDomain("in.example.com")
if err != nil {
t.Fatalf("AddInboundDomain: %v", err)
}
other, err := st.AddInboundDomain("other.example.com")
if err != nil {
t.Fatalf("AddInboundDomain: %v", err)
}
for _, id := range []int64{in.ID, other.ID} {
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, id); err != nil {
t.Fatalf("assign inbound domain: %v", err)
}
}
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, in.ID+100); err == nil {
t.Error("an assignment to a missing inbound domain was accepted")
}
u, _ = st.GetUser(u.ID)
if len(u.InboundDomainIDs) != 2 || len(u.DomainIDs) != 0 {
t.Fatalf("assignments = inbound %v, outbound %v; the two lists are separate", u.InboundDomainIDs, u.DomainIDs)
}
if err := st.DeleteInboundDomain(in.ID); err != nil {
t.Fatalf("DeleteInboundDomain: %v", err)
}
u, _ = st.GetUser(u.ID)
if len(u.InboundDomainIDs) != 1 || u.InboundDomainIDs[0] != other.ID {
t.Errorf("after deleting an inbound domain its assignment stayed: %v", u.InboundDomainIDs)
}
if err := st.DeleteUser(u.ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
var n int
if err := st.db.QueryRow("SELECT COUNT(*) FROM user_inbound_domains").Scan(&n); err != nil || n != 0 {
t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err)
}
}
+26
View File
@@ -8,6 +8,7 @@ package store
import ( import (
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"sort" "sort"
@@ -50,6 +51,14 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
// applicationID marks a database file as a 2.x SelfPost one ("SP20"). The
// baseline migration sets it.
const applicationID = 0x53503230
// ErrForeignSchema is returned by Open for a database that has a schema but
// was not created by SelfPost 2.x — in practice a 1.x data directory.
var ErrForeignSchema = errors.New("database was not created by SelfPost 2.x; 2.0 starts from an empty data directory (docs/schema-migrations.md)")
// migrate applies embedded migrations in filename order, tracking progress via // migrate applies embedded migrations in filename order, tracking progress via
// SQLite's PRAGMA user_version so each migration runs at most once. // SQLite's PRAGMA user_version so each migration runs at most once.
func (s *Store) migrate() error { func (s *Store) migrate() error {
@@ -70,6 +79,23 @@ func (s *Store) migrate() error {
return fmt.Errorf("read schema version: %w", err) return fmt.Errorf("read schema version: %w", err)
} }
// A 1.x file carries a user_version from the deleted 1.x chain. Counting it
// against today's files would skip the baseline and leave the panel running
// on a schema it does not know, so such a file is refused outright; nothing
// is migrated from 1.x (docs/schema-migrations.md).
if version > 0 {
var appID int64
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
return fmt.Errorf("read application id: %w", err)
}
if appID != applicationID {
return fmt.Errorf("schema version %d: %w", version, ErrForeignSchema)
}
}
if version > len(names) {
return fmt.Errorf("database schema version %d is newer than this build knows (%d)", version, len(names))
}
for i, name := range names { for i, name := range names {
target := i + 1 target := i + 1
if target <= version { if target <= version {
+166 -64
View File
@@ -22,20 +22,95 @@ var ErrLastGlobal = errors.New("cannot remove last global administrator")
// Role identifies a panel user's access level. // Role identifies a panel user's access level.
type Role string type Role string
// The role is a reach, not a rank: everyone in the panel is an administrator,
// of the whole instance or of the domains assigned to them.
const ( const (
RoleGlobal Role = "global" RoleGlobal Role = "global"
RoleDomainAdmin Role = "domain_admin" RoleDomain Role = "domain"
) )
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
const (
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
DMARCDefaultAccount = "account" // the user's account e-mail
DMARCDefaultCustom = "custom" // DMARCDefault.Address
DMARCDefaultNone = "none" // no aggregate reports
)
// DMARCDefault is a user's default report address: the choice a domain takes
// over when it follows that user (Domain.DMARCRuaUserID).
type DMARCDefault struct {
UserID int64
Username string
Mode string
Address string // for DMARCDefaultCustom
Email string // the account e-mail, for DMARCDefaultAccount
}
// Resolve returns the address the default stands for on one domain. hosted is
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
// a hosted default then resolves to no reports.
func (d DMARCDefault) Resolve(hosted string) string {
switch d.Mode {
case DMARCDefaultHosted:
return hosted
case DMARCDefaultAccount:
return d.Email
case DMARCDefaultCustom:
return d.Address
}
return ""
}
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
func ValidDMARCDefaultMode(mode string) bool {
switch mode {
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
return true
}
return false
}
// User is a panel login (not an application SASL account). // User is a panel login (not an application SASL account).
type User struct { type User struct {
ID int64 ID int64
Username string Username string
PasswordHash string PasswordHash string
Role Role Role Role
DMARCReportEmail string // Email is the user's own address — what the panel writes to. DMARC may
CreatedAt time.Time // use it (DMARCDefaultAccount) but it is not a DMARC field.
DomainIDs []int64 Email string
DMARCDefaultMode string
DMARCDefaultAddress string
// AllDomains / AllInboundDomains widen a domain user's list to every
// domain of that direction, including those added later; the id lists
// are then ignored.
AllDomains bool
AllInboundDomains bool
CreatedAt time.Time
DomainIDs []int64
InboundDomainIDs []int64
}
// DMARCDefault returns the user's default report address choice.
func (u User) DMARCDefault() DMARCDefault {
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
}
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
func scanUser(r scanRow) (User, error) {
var (
u User
createdAt string
)
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress,
&u.AllDomains, &u.AllInboundDomains, &createdAt)
if err != nil {
return User{}, err
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return u, nil
} }
// UserExists reports whether any panel user exists (setup complete). // UserExists reports whether any panel user exists (setup complete).
@@ -58,7 +133,7 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
} }
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec( _, err = s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
username, passwordHash, RoleGlobal, now, username, passwordHash, RoleGlobal, now,
) )
if err != nil { if err != nil {
@@ -69,23 +144,17 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
// GetUserByUsername returns a user with domain assignments loaded. // GetUserByUsername returns a user with domain assignments loaded.
func (s *Store) GetUserByUsername(username string) (User, error) { func (s *Store) GetUserByUsername(username string) (User, error) {
var ( u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username))
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound return User{}, ErrUserNotFound
} }
if err != nil { if err != nil {
return User{}, fmt.Errorf("get user by username: %w", err) return User{}, fmt.Errorf("get user by username: %w", err)
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
u.DomainIDs, err = s.listUserDomainIDs(u.ID) return User{}, err
if err != nil { }
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
return User{}, err return User{}, err
} }
return u, nil return u, nil
@@ -93,47 +162,35 @@ func (s *Store) GetUserByUsername(username string) (User, error) {
// GetUser returns a user by id with domain assignments. // GetUser returns a user by id with domain assignments.
func (s *Store) GetUser(id int64) (User, error) { func (s *Store) GetUser(id int64) (User, error) {
var ( u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id))
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
id,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound return User{}, ErrUserNotFound
} }
if err != nil { if err != nil {
return User{}, fmt.Errorf("get user: %w", err) return User{}, fmt.Errorf("get user: %w", err)
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
u.DomainIDs, err = s.listUserDomainIDs(u.ID) return User{}, err
if err != nil { }
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
return User{}, err return User{}, err
} }
return u, nil return u, nil
} }
// ListUsers returns every panel user without domain ids. // ListUsers returns every panel user without the assigned domain ids.
func (s *Store) ListUsers() ([]User, error) { func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query( rows, err := s.db.Query("SELECT " + userColumns + " FROM users ORDER BY id")
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
)
if err != nil { if err != nil {
return nil, fmt.Errorf("list users: %w", err) return nil, fmt.Errorf("list users: %w", err)
} }
defer rows.Close() defer rows.Close()
var users []User var users []User
for rows.Next() { for rows.Next() {
var ( u, err := scanUser(rows)
u User if err != nil {
createdAt string
)
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
return nil, fmt.Errorf("list users scan: %w", err) return nil, fmt.Errorf("list users scan: %w", err)
} }
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
users = append(users, u) users = append(users, u)
} }
return users, rows.Err() return users, rows.Err()
@@ -177,12 +234,12 @@ func (s *Store) CountGlobalUsers() (int, error) {
// CreateUser inserts a panel user and optional domain assignments. // CreateUser inserts a panel user and optional domain assignments.
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) { func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
if role == RoleDomainAdmin && len(domainIDs) == 0 { if role == RoleDomain && len(domainIDs) == 0 {
return 0, fmt.Errorf("create user: domain_admin requires domains") return 0, fmt.Errorf("create user: the domain role requires domains")
} }
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec( res, err := s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
username, passwordHash, role, now, username, passwordHash, role, now,
) )
if err != nil { if err != nil {
@@ -195,7 +252,7 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
if err != nil { if err != nil {
return 0, fmt.Errorf("create user id: %w", err) return 0, fmt.Errorf("create user id: %w", err)
} }
if role == RoleDomainAdmin { if role == RoleDomain {
if err := s.setUserDomains(id, domainIDs); err != nil { if err := s.setUserDomains(id, domainIDs); err != nil {
return 0, err return 0, err
} }
@@ -203,15 +260,11 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
return id, nil return id, nil
} }
// UpdateUser replaces username, password hash, and DMARC email for a user. // UpdateUser replaces a user's username, password hash and account e-mail.
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error { func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
u, err := s.GetUser(id)
if err != nil {
return err
}
res, err := s.db.Exec( res, err := s.db.Exec(
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?", "UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
username, passwordHash, dmarcReportEmail, id, username, passwordHash, email, id,
) )
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -226,14 +279,50 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail st
if n == 0 { if n == 0 {
return ErrUserNotFound return ErrUserNotFound
} }
if u.Role == RoleGlobal { return nil
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil { }
return err
} // SetDMARCDefault stores the user's default report address choice. address is
// kept only for DMARCDefaultCustom. Every domain that follows this user
// resolves to the new choice from the next read on.
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
if !ValidDMARCDefaultMode(mode) {
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
}
if mode != DMARCDefaultCustom {
address = ""
}
res, err := s.db.Exec(
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
if n == 0 {
return ErrUserNotFound
} }
return nil return nil
} }
// GetDMARCDefault returns a user's default report address choice, or
// ErrUserNotFound.
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
d := DMARCDefault{UserID: userID}
err := s.db.QueryRow(
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
if errors.Is(err, sql.ErrNoRows) {
return DMARCDefault{}, ErrUserNotFound
}
if err != nil {
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
}
return d, nil
}
// SetUserRole updates a user's role. // SetUserRole updates a user's role.
func (s *Store) SetUserRole(userID int64, role Role) error { func (s *Store) SetUserRole(userID int64, role Role) error {
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID) res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
@@ -259,14 +348,14 @@ func (s *Store) ClearUserDomains(userID int64) error {
return nil return nil
} }
// SetUserDomains replaces domain assignments for a domain_admin user. // SetUserDomains replaces the outbound domain assignments of a domain user.
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error { func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
u, err := s.GetUser(userID) u, err := s.GetUser(userID)
if err != nil { if err != nil {
return err return err
} }
if u.Role != RoleDomainAdmin { if u.Role != RoleDomain {
return fmt.Errorf("set user domains: user is not domain_admin") return fmt.Errorf("set user domains: user does not have the domain role")
} }
if len(domainIDs) == 0 { if len(domainIDs) == 0 {
return fmt.Errorf("set user domains: at least one domain required") return fmt.Errorf("set user domains: at least one domain required")
@@ -303,9 +392,22 @@ func (s *Store) DeleteUser(id int64) error {
return nil return nil
} }
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode. func (s *Store) listUserInboundDomainIDs(userID int64) ([]int64, error) {
func (s *Store) GlobalDMARCReportEmail() (string, error) { rows, err := s.db.Query(
return s.GetSetting("dmarc_report_email") "SELECT inbound_domain_id FROM user_inbound_domains WHERE user_id = ? ORDER BY inbound_domain_id", userID)
if err != nil {
return nil, fmt.Errorf("list user inbound domains: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("list user inbound domains scan: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
} }
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) { func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
+10 -9
View File
@@ -23,19 +23,20 @@ func TestUpdateUser(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetUser: %v", err) t.Fatalf("GetUser: %v", err)
} }
if got.DMARCReportEmail != "reports@hub.example" { if got.Email != "reports@hub.example" {
t.Fatalf("dmarc email = %q", got.DMARCReportEmail) t.Fatalf("email = %q", got.Email)
} }
email, err := st.GlobalDMARCReportEmail() // The account e-mail is the user's own: saving it does not make it anyone's
if err != nil { // report address, and nothing is mirrored into the instance settings.
t.Fatalf("GlobalDMARCReportEmail: %v", err) if got.DMARCDefaultMode != DMARCDefaultNone {
t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone)
} }
if email != "reports@hub.example" { if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
t.Fatalf("settings dmarc = %q", email) t.Fatalf("the e-mail leaked into the instance settings: %q", v)
} }
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil { if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
t.Fatalf("clear dmarc email: %v", err) t.Fatalf("clear e-mail: %v", err)
} }
got, err = st.GetUser(u.ID) got, err = st.GetUser(u.ID)
if err != nil { if err != nil {
@@ -73,7 +74,7 @@ func TestCreateDomainAdminUser(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("AddDomain: %v", err) t.Fatalf("AddDomain: %v", err)
} }
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID}) id, err := st.CreateUser("domainop", "hash2", RoleDomain, []int64{d.ID})
if err != nil { if err != nil {
t.Fatalf("CreateUser: %v", err) t.Fatalf("CreateUser: %v", err)
} }
+2 -2
View File
@@ -18,8 +18,8 @@ const (
type Role = store.Role type Role = store.Role
const ( const (
RoleGlobal = store.RoleGlobal RoleGlobal = store.RoleGlobal
RoleDomainAdmin = store.RoleDomainAdmin RoleDomain = store.RoleDomain
) )
// Principal is the authenticated panel user attached to a request. // Principal is the authenticated panel user attached to a request.
+17
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
) )
@@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
return h.store.ListDomainsForUser(p.ID) return h.store.ListDomainsForUser(p.ID)
} }
// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending
// domain, "" when report ingest is off on this server.
func (h *Handlers) hostedDMARCAddress(domainName string) string {
if h.dmarc == nil || !h.cfg.DMARCEnabled {
return ""
}
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
}
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
// its own address, or the default of the user it follows. "" is a policy-only
// record.
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
}
func domainNameSet(domains []store.Domain) map[string]bool { func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains)) m := make(map[string]bool, len(domains))
for _, d := range domains { for _, d := range domains {
+27 -19
View File
@@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// What DNS actually publishes for the domain today, checked against the key // What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page // this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing. // after a form post costs nothing.
profileEmail, err := h.store.GlobalDMARCReportEmail() reportEmail, err := h.domainReportAddress(d)
if err != nil { if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err) logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) dns, srv := h.domainDNS(d, record, reportEmail, false)
dns, srv := h.domainDNS(d, record, profileEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
hostedAddr := h.hostedDMARCAddress(d.Name)
// The form's "inherit" is a domain that follows a user's default. What it
// offers next to that choice is the address following would give: the
// followed user's when the domain already follows one, otherwise the
// signed-in user's own — choosing it makes the domain follow them.
dmarcMode := "inherit" dmarcMode := "inherit"
dmarcCustom := "" dmarcCustom := ""
hostedAddr := "" profileEmail := reportEmail
if h.dmarc != nil && h.cfg.DMARCEnabled { if !d.DMARCRuaUserID.Valid {
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) profileEmail = ""
} if p, ok := h.principal(r); ok {
if d.DMARCRua.Valid { if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
if d.DMARCRua.String == "" { profileEmail = def.Resolve(hostedAddr)
}
}
switch {
case d.DMARCRua == "":
dmarcMode = "none" dmarcMode = "none"
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) { case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
dmarcMode = "hosted" dmarcMode = "hosted"
} else { default:
dmarcMode = "custom" dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String dmarcCustom = d.DMARCRua
} }
} }
dmarcSource := "policy" dmarcSource := "policy"
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
dmarcSource = "custom" dmarcSource = "custom"
case dmarcMode == "none": case dmarcMode == "none":
dmarcSource = "none" dmarcSource = "none"
case profileEmail != "": case reportEmail != "":
dmarcSource = "settings" dmarcSource = "settings"
} }
@@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// and no extra environment variable is needed. That server result is returned // and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same // alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button. // addresses. force bypasses the cache, for the Re-check button.
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false) srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{ return h.dns.Domain(dnscheck.Query{
Name: d.Name, Name: d.Name,
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
ExpectedDKIM: record.Value, ExpectedDKIM: record.Value,
Hostname: srv.Hostname, Hostname: srv.Hostname,
ServerIPs: srv.IPs, ServerIPs: srv.IPs,
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail), DMARCReportEmail: reportEmail,
}, force), srv }, force), srv
} }
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
profileEmail, err := h.store.GlobalDMARCReportEmail() reportEmail, err := h.domainReportAddress(d)
if err != nil { if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err) logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.domainDNS(d, record, profileEmail, true) h.domainDNS(d, record, reportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
} }
+7
View File
@@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.renderBackupPage(w, r, status, msg) h.renderBackupPage(w, r, status, msg)
return return
} }
// An export without an address of its own followed someone's default on the
// server it came from; here it follows whoever imports it.
if exp.DMARCRua == nil {
if p, ok := h.principal(r); ok {
h.followCreator(d.ID, p.ID)
}
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
} }
+14 -8
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return return
} }
var rua sql.NullString // "inherit" makes the domain follow the default of the user who chose it;
// every other mode gives the domain an address of its own ("" = none).
follow := false
rua := ""
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) { switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
case "inherit": case "inherit":
rua = sql.NullString{} follow = true
case "none": case "none":
rua = sql.NullString{Valid: true, String: ""}
case "custom": case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email")) email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil { if err := validate.Email(email); err != nil {
@@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return return
} }
rua = sql.NullString{Valid: true, String: email} rua = email
case "hosted": case "hosted":
if h.dmarc == nil || !h.dmarc.Enabled() { if h.dmarc == nil || !h.dmarc.Enabled() {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
return return
} }
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
rua = sql.NullString{Valid: true, String: addr}
default: default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return return
} }
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil { var err error
if p, ok := h.principal(r); follow && ok {
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
} else {
err = h.store.SetDomainDMARCAddress(d.ID, rua)
}
if err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err) logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return return
@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// postFormAs sends a form to a handler as the given principal.
func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
h(rec, auth.RequestWithPrincipal(req, p))
return rec
}
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
// follows the default of the user who chose it; every other choice gives the
// domain an address of its own and stops it following anyone.
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
h, _ := settingsServer(t)
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
admin, err := h.store.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatalf("SetDMARCDefault: %v", err)
}
save := func(values url.Values) store.Domain {
t.Helper()
rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
return got
}
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
}
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
}
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
got = save(url.Values{"dmarc_rua_mode": {"none"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
}
// Until the Account page replaces the old Settings form, its single address
// field is the account e-mail and switches the user's DMARC default between
// "my account e-mail" and "none". Nothing is written to the instance settings.
func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
h, password := settingsServer(t)
submit := func(email string) store.User {
t.Helper()
rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
"username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
}
u, err := h.store.GetUser(globalPrincipal.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
return u
}
u := submit("mix@example.org")
if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
t.Fatalf("the address was mirrored into the instance settings: %q", v)
}
u = submit("")
if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
}
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
h.followCreator(d.ID, globalPrincipal.ID)
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
}
// The creator's default is "none" until they choose one, so the new
// domain publishes a policy-only record — as a fresh 1.x install did.
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
}
}
+25 -6
View File
@@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
} }
func (h *Handlers) domainRows(domains []store.Domain) []domainRow { func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
profileEmail = email
}
rows := make([]domainRow, len(domains)) rows := make([]domainRow, len(domains))
var wg sync.WaitGroup var wg sync.WaitGroup
for i, d := range domains { for i, d := range domains {
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err) logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return return
} }
dns, _ := h.domainDNS(d, record, profileEmail, false) reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dns.Overall rows[i].DNS = dns.Overall
}() }()
} }
@@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string {
// OpenDKIM reload), and redirects to the domain's page so the DNS record to // OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md). // publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok { p, ok := h.requireGlobal(w, r)
if !ok {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw) "Could not add the domain. Please check the logs and try again.", raw)
return return
} }
h.followCreator(d.ID, p.ID)
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
} }
// followCreator makes a new domain take its DMARC report address from the
// default of whoever created it (plan § Account e-mail). The domain exists
// either way: a failure here leaves it with no report address, which its page
// shows and its DMARC form can change.
func (h *Handlers) followCreator(domainID, userID int64) {
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", domainID, err)
}
}
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed. // HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok { if _, ok := h.requireGlobal(w, r); !ok {
@@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
if err != nil { if err != nil {
t.Fatalf("add placeholder domain: %v", err) t.Fatalf("add placeholder domain: %v", err)
} }
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID}) id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID})
if err != nil { if err != nil {
t.Fatalf("create domain admin %s: %v", username, err) t.Fatalf("create domain admin %s: %v", username, err)
} }
@@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
} }
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs} return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
} }
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs) id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs)
if err != nil { if err != nil {
t.Fatalf("create domain admin %s: %v", username, err) t.Fatalf("create domain admin %s: %v", username, err)
} }
+20 -3
View File
@@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal()) h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
case http.MethodPost: case http.MethodPost:
h.submitSettings(w, r) h.submitSettings(w, r)
default: default:
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
formRetention := currentRetention formRetention := currentRetention
if !p.IsGlobal() { if !p.IsGlobal() {
dmarcEmail = user.DMARCReportEmail dmarcEmail = user.Email
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" { } else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
parsed, err := strconv.Atoi(raw) parsed, err := strconv.Atoi(raw)
if err != nil { if err != nil {
@@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
} }
} }
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail emailChanging := p.IsGlobal() && dmarcEmail != user.Email
retentionChanging := false retentionChanging := false
if p.IsGlobal() && formRetention != currentRetention { if p.IsGlobal() && formRetention != currentRetention {
@@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
} }
} }
// Until the Account page replaces this form (stage 2), its one "default
// report address" field stands for both halves of the 2.0 model: it is the
// account e-mail, and the user's DMARC default is "my account e-mail" while
// it is filled and "none" once it is cleared.
if emailChanging {
mode := store.DMARCDefaultNone
if dmarcEmail != "" {
mode = store.DMARCDefaultAccount
}
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
logf("panel: settings: set dmarc default failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if retentionChanging { if retentionChanging {
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil { if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
logf("panel: settings: set send-log retention failed: %v", err) logf("panel: settings: set send-log retention failed: %v", err)
+8 -8
View File
@@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)}) h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
case http.MethodPost: case http.MethodPost:
h.submitUserCreate(w, r) h.submitUserCreate(w, r)
default: default:
@@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return return
} }
if role != store.RoleGlobal && role != store.RoleDomainAdmin { if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return return
} }
if role == store.RoleDomainAdmin && len(domainIDs) == 0 { if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return return
} }
@@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected}) h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return return
} }
if role != store.RoleGlobal && role != store.RoleDomainAdmin { if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected}) h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return return
} }
if role == store.RoleDomainAdmin && len(domainIDs) == 0 { if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected}) h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return return
} }
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { if u.Role == store.RoleGlobal && role == store.RoleDomain {
n, err := h.store.CountGlobalUsers() n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 { if err != nil || n <= 1 {
msg := "Cannot demote the last global administrator." msg := "Cannot demote the last global administrator."
@@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
hash = string(newHash) hash = string(newHash)
} }
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil { if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
if errors.Is(err, store.ErrUserExists) { if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected}) h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return return
@@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
} }
} }
if role == store.RoleDomainAdmin { if role == store.RoleDomain {
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil { if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
logf("panel: set user domains: %v", err) logf("panel: set user domains: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected}) h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
+1 -1
View File
@@ -16,7 +16,7 @@
<label for="role">Role</label> <label for="role">Role</label>
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}} {{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}> <select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option> <option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option> <option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select> </select>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}} {{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}