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:
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user