diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md index 69c50e2..3e62e87 100644 --- a/docs/plans/domain-admin.md +++ b/docs/plans/domain-admin.md @@ -3,37 +3,49 @@ **Status:** agreed **Version:** target bump **1.x** MINOR, given a compatible migration of the current administrator into a global one. -**Order:** recommended after [web-split](web-split.md), before +**Order:** recommended after [web-split](web-split.md) (done), before [inbound-relay](inbound-relay.md). --- ## What this is -Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a +Today the panel has exactly one subject: `RequireAuth` is a boolean gate, not a role ([web.go](../../internal/web/web.go) — the -`mux.Handle("/", s.requireAuth(authed))` wrapper), and the session carries +`mux.Handle("/", s.auth.RequireAuth(authed))` wrapper), and the session carries nothing beyond the fact of being signed in. -The role grants access to **explicitly assigned domains** (one or several); the -list of domains is set by the **global administrator**. For each domain on that -list: +Two panel roles: -- that domain's applications (creation, sender mode, password regeneration, - deletion, its own L2 limit); -- the domain's DKIM/DNS status; -- the send log filtered to the domain — the filter already exists in the log - ([sendLogData](../../internal/web/handlers/handlers_monitor.go)). +| Role | Scope | +|------|-------| +| **global** | Full panel except nothing new — same powers as today's single admin | +| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) | -What stays outside the role is what is global by nature: +For each assigned domain, a domain-admin can: + +- applications (create, sender mode, password regeneration, delete, L2 limit); +- DKIM/DNS status and recheck; +- per-domain DMARC `rua=` (inherit / none / custom) — full control on the + domain page; +- send log filtered to assigned domains; +- domain export (encrypted `.spde` optional, same as today); +- domain-level L2 rate limit. + +What stays **global-only** (domain-admin gets 404 or redirect): - adding and removing domains; -- creating domain-admin users and assigning domains to them; +- domain import; +- creating/editing/deleting panel users and assigning domains; - `/reload`; -- the full backup (that is all of `/data` including `sasldb2`, i.e. every - domain at once); -- the queue and the `mail.log` tail — those are server-wide and not tied to a - domain. +- full backup (`/backup` — all of `/data` including every domain's `sasldb2`); +- mail queue (`/mail-queue*`); +- system log tail (`/system-log*`); +- status page (`/status*`) — server-wide health, queue summary, reload, DNS + recheck of the **hostname**; same treatment as queue and system log. + +Domain-admin **self-service** on `/account`: username and password only (not +global DMARC report email). ## Why this extends v1.0 @@ -56,6 +68,181 @@ to one specific role, because what is needed is not a second all-powerful admin but limited access for the owner of one or several domains, with the list set by the global administrator.)* +--- + +## Schema and migration + +**New migration** `0005_panel_users.sql`: + +```sql +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) +); + +-- Migrate existing administrator → global user (idempotent guard via admin count). +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; + +DROP TABLE admin; +``` + +**Sessions:** keep `sessions.username` (no schema change). On login and +`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session +after username change behaves as today (`Lookup` fails → redirect login). + +**Backup/restore:** full backup already snapshots `selfpost.db` via +`VACUUM INTO`; users and bindings restore with the DB. No manifest format change +required (same `selfpost-full-backup`). + +**DMARC two levels:** + +- **Global** `users.dmarc_report_email` — only on global user's `/account`; + default `rua=` when a domain uses *inherit*. +- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page + (existing handler); domain-admin never sees the global default field. + +--- + +## Principal model + +Request context carries a `Principal` (in `internal/web/auth`): + +```go +type Role string // "global" | "domain_admin" + +type Principal struct { + ID int64 + Username string + Role Role + Domains []int64 // assigned domain IDs; empty for global (meaning "all") +} +``` + +Helpers: + +- `CurrentPrincipal(r)` — from context; +- `IsGlobal(p)` — `p.Role == "global"`; +- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`; +- `CanAccessApp(p, app)` — `CanAccessDomain(p, app.DomainID)`. + +`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after +existence check; return 404 (not 403) to avoid leaking IDs. + +--- + +## Route matrix + +| Method | Path | global | domain_admin | +|--------|------|--------|--------------| +| GET | `/` | → `/status` | → `/domains` | +| GET | `/status`, `/status/fragment` | yes | **no** (404) | +| POST | `/status/recheck` | yes | **no** | +| GET | `/domains` | all domains | assigned only | +| POST | `/domains` | yes | **no** | +| POST | `/domains/import` | yes | **no** | +| GET | `/domains/{id}` | yes | assigned | +| POST | `/domains/{id}/dns-recheck` | yes | assigned | +| GET/POST | `/domains/{id}/delete` | yes | **no** | +| POST | `/domains/{id}/applications` | yes | assigned | +| POST | `/domains/{id}/ratelimit` | yes | assigned | +| POST | `/domains/{id}/dmarc` | yes | assigned | +| POST | `/domains/{id}/export` | yes | assigned | +| POST | `/applications/{aid}/*` | yes | if app in assigned domain | +| POST | `/reload` | yes | **no** | +| GET/POST | `/account` | username, password, global DMARC email | username, password only | +| GET/POST | `/backup` | yes | **no** | +| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains | +| GET | `/mail-queue*` | yes | **no** | +| GET | `/system-log*` | yes | **no** | +| GET | `/users` | list users | **no** | +| GET/POST | `/users/new` | create user | **no** | +| GET/POST | `/users/{uid}` | edit/delete user | **no** | +| POST | `/logout` | yes | yes | + +**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned +domain set; dropdowns list only assigned domains/apps; reject `domain` query +param outside assignment; `HandleDelivery` checks log row's `domain` field. + +--- + +## User management UI (global only) + +New routes under `/users`: + +- **List** — username, role, assigned domain names (or "all" for global). +- **Create** — username, password, role (`domain_admin` default), multi-select + domains (required when role is `domain_admin`). +- **Edit** — change password (optional), reassign domains, delete user. +- **Guards:** cannot delete the last `global` user; cannot demote self to + `domain_admin` without another global user; domain-admin role cannot access + these routes. + +Templates: `users.html`, `user_form.html`; nav link visible only for global +users. + +--- + +## Auth / setup / sessions + +- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global** + user via `CreateGlobalUser`; `AdminExists` → `UserExists`. +- **Login:** authenticate against `users` by username + bcrypt. +- **Password change:** per-user `UpdateUser`; domain-admin cannot change + another user's password. +- **Session rename / destroy others:** unchanged behaviour keyed by username. + +--- + +## Navigation + +[layout.html](../../internal/web/view/templates/layout.html) `nav` template: + +- **global:** all items today (status, domains, deliveries, mail queue, system + log, backup, settings) + **Users**. +- **domain_admin:** domains, deliveries, settings only. + +Pass `IsGlobal` (or `Principal`) into every rendered page. + +--- + +## Security + +- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR); + note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in + this phase. +- **Export encryption:** optional password on domain export remains; full backup + encryption trigger ("second administrator") is satisfied by domain-admin + existing — no change required. +- **Authorization tests:** table-driven tests for global vs domain-admin on + representative handlers; explicit `{aid}` cross-domain mutation blocked. + +--- + +## Implementation order + +1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings). +2. Auth: login against `users`, `Principal` in context, setup creates global user. +3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`. +4. Route guards: global-only middleware or per-handler checks. +5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source. +6. User management handlers + templates. +7. Nav visibility + default redirect (`/`). +8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`. + +--- + ## Done when - A global administrator and a domain-admin with different rights both work