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
|
**Status:** agreed
|
||||||
**Version:** target bump **1.x** MINOR, given a compatible migration of the
|
**Version:** target bump **1.x** MINOR, given a compatible migration of the
|
||||||
current administrator into a global one.
|
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).
|
[inbound-relay](inbound-relay.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What this is
|
## 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
|
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.
|
nothing beyond the fact of being signed in.
|
||||||
|
|
||||||
The role grants access to **explicitly assigned domains** (one or several); the
|
Two panel roles:
|
||||||
list of domains is set by the **global administrator**. For each domain on that
|
|
||||||
list:
|
|
||||||
|
|
||||||
- that domain's applications (creation, sender mode, password regeneration,
|
| Role | Scope |
|
||||||
deletion, its own L2 limit);
|
|------|-------|
|
||||||
- the domain's DKIM/DNS status;
|
| **global** | Full panel except nothing new — same powers as today's single admin |
|
||||||
- the send log filtered to the domain — the filter already exists in the log
|
| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
|
||||||
([sendLogData](../../internal/web/handlers/handlers_monitor.go)).
|
|
||||||
|
|
||||||
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;
|
- adding and removing domains;
|
||||||
- creating domain-admin users and assigning domains to them;
|
- domain import;
|
||||||
|
- creating/editing/deleting panel users and assigning domains;
|
||||||
- `/reload`;
|
- `/reload`;
|
||||||
- the full backup (that is all of `/data` including `sasldb2`, i.e. every
|
- full backup (`/backup` — all of `/data` including every domain's `sasldb2`);
|
||||||
domain at once);
|
- mail queue (`/mail-queue*`);
|
||||||
- the queue and the `mail.log` tail — those are server-wide and not tied to a
|
- system log tail (`/system-log*`);
|
||||||
domain.
|
- 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
|
## 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
|
but limited access for the owner of one or several domains, with the list set
|
||||||
by the global administrator.)*
|
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
|
## Done when
|
||||||
|
|
||||||
- A global administrator and a domain-admin with different rights both work
|
- A global administrator and a domain-admin with different rights both work
|
||||||
|
|||||||
Reference in New Issue
Block a user