diff --git a/claude.md b/claude.md index e551013..4aabcaa 100644 --- a/claude.md +++ b/claude.md @@ -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. | ---