docs: phase 3, grants project rather than federate

Records why instance_members is an index and not the authority, why hqsync
is not part of inject, and why an invitation carries no password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:43:52 +01:00
co-authored by Claude Opus 5
parent 7b077905e2
commit 6bf288f83e
+53
View File
@@ -179,10 +179,50 @@ Signup is **account-first**: it creates an HQ account and an unverified `custome
- If sending the verification email fails, the freshly inserted `customer_user` (and account, on first signup) is rolled back rather than left stranded holding the unique index on email.
- Rate limited per client IP, plus a honeypot field.
An account is a team, not a person. `customer_users.account_role` is `owner`,
`admin` or `member` — the same three words as the control plane's roles, on
purpose. Owners and admins invite people, create instances and grant instance
access; billing is owner-only.
An invitation creates a `customer_user` with an **empty password hash**, which
cannot authenticate, and the invitee sets their own at `/accept-invite`. An
inviter-chosen password would be a shared credential to every instance that
person is later granted. `GET /auth/verify` therefore peeks before it consumes:
a token belonging to a passwordless row answers `{"needs_password":true}` and is
left unspent.
### Shared provisioning
`shared/provision` (`instance.go`, `slug.go`, `user.go`) holds the slug rules, reserved names and instance/user creation logic that both `server` and `admin/internal/cloudprov` need, so there is no longer a second copy to drift: `cloudprov.CreateInstance` calls straight into it to create a control-plane instance and its owner from a customer request.
### Grants project, they do not federate
Granting someone access to a cloud instance writes a real control-plane `users`
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
`instance_members` in admin's database is only admin's *index* of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
**Self-hosted instances are never projected into.** All three mutating member
endpoints refuse when `deployment != cloud`.
The HQ password is the single source of truth for every `hq`-sourced row.
`PUT /api/account/password` rehashes and has `cloudprov` copy the hash to every
projected row; propagation is best-effort, and `admin/internal/hqsync` compares
and repairs every 15 minutes. It is **its own package rather than a pass inside
`inject`** — `inject` writes three licence fields and nothing else, and that
narrowness is what makes admin's reach into the control plane reviewable.
The control plane refuses to change an `hq`-sourced user's role or delete it
(`services.ErrHQManaged`, 409). `web/` shows those rows read-only with a link to
the portal, but the API is the boundary; the UI is a courtesy. There is no local
password-change endpoint at all, so there is no competing writer for the hash.
---
## Auth and Orgs
@@ -279,6 +319,7 @@ GET /auth/me # who am I; 401 drives the UI's r
POST /auth/staff/login /auth/login /auth/logout
POST /auth/signup # self-hosted only; honeypot + rate limited
GET /auth/verify?token=…
POST /auth/accept-invite # an invitee sets their own password
```
Customer-session (`/api`), every instance resolved through `ownedInstance`:
@@ -290,8 +331,17 @@ POST /instances/:id/renew # Free renewal; refuses outside t
POST /instances/link · /instances/:id/relink
GET /instances/:id/license · /instances/:id/license/download
GET /subscriptions
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
PUT /account/password # propagates to every projected user
GET,POST /instances/:id/members # cloud only
PUT /instances/:id/members/:uid/role · DELETE /instances/:id/members/:uid
```
Reading is open to any signed-in member; every mutation above except
`/account/password` (which is your own) sits behind `RequireAccountRole(owner,
admin)`. `:uid` is the **`customer_users.user_id`**, not the projected
control-plane user_id — the portal never has to know that one.
Staff-session (`/api/staff`):
```
@@ -320,6 +370,8 @@ Notes that are not obvious from the structs:
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
### Migrations
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
@@ -512,6 +564,7 @@ git push origin main # server + web deploy
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
---