docs: expand domain-admin plan with schema, routes, and UI spec
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+204
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user