Co-authored-by: Cursor <cursoragent@cursor.com>
9.4 KiB
Plan: domain-admin (domain administrator role)
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 (done), before
inbound-relay.
What this is
Today the panel has exactly one subject: RequireAuth is a boolean gate, not a
role (web.go — the
mux.Handle("/", s.auth.RequireAuth(authed)) wrapper), and the session carries
nothing beyond the fact of being signed in.
Two panel roles:
| 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) |
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
.spdeoptional, same as today); - domain-level L2 rate limit.
What stays global-only (domain-admin gets 404 or redirect):
- adding and removing domains;
- domain import;
- creating/editing/deleting panel users and assigning domains;
/reload;- full backup (
/backup— all of/dataincluding every domain'ssasldb2); - 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
product.md puts "multiple panel users, roles" out of scope (one administrator). A second subject is a deliberate widening of the project's boundary, as inbound-relay is.
The cost is phase-sized, not patch-sized:
- a users table and their binding to domains;
- the role in the session;
- authorisation in every handler (not only on the route — today
{id}/{aid}are checked for nothing beyond existence); - reworking first-run setup and password change for several users;
- accounting for the new subject in backup and domain export.
(The earlier wording of this item — "2FA and multiple administrators" — has been replaced: 2FA is off the table, and "multiple administrators" is narrowed 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:
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; defaultrua=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):
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 ordomainIDinp.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_admindefault), multi-select domains (required when role isdomain_admin). - Edit — change password (optional), reassign domains, delete user.
- Guards: cannot delete the last
globaluser; cannot demote self todomain_adminwithout 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 viaCreateGlobalUser;AdminExists→UserExists. - Login: authenticate against
usersby 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 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 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
- Migration
0005_panel_users.sql+store/users.go(CRUD, domain bindings). - Auth: login against
users,Principalin context, setup creates global user. CanAccessDomain/CanAccessApp; hardenlookupDomain/lookupApplication.- Route guards: global-only middleware or per-handler checks.
- Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
- User management handlers + templates.
- Nav visibility + default redirect (
/). - Tests +
go build/go vet/go test; CHANGELOG[Unreleased].
Done when
- A global administrator and a domain-admin with different rights both work through the panel; the domain-admin cannot reach past the assigned domains;
- the current single admin migrates into a global one without losing access;
- backup/restore accounts for users and their bindings;
build/vet/test/image green.
Risks
- An incomplete
{id}/{aid}check in a handler — access leaking to someone else's domain; - breaking setup or backup — that would be a semver major, not 1.x.