diff --git a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md index 8c774b8..dd41306 100644 --- a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md +++ b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md @@ -11,7 +11,7 @@ Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app: 1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login. -2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org. +2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit, monitor, notification) carries an `org_id` and all queries are scoped to the caller's org. 3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it. No billing, no seat/server limits this iteration (schema leaves room). @@ -29,6 +29,7 @@ No billing, no seat/server limits this iteration (schema leaves room). | Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. | | Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. | | Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. | +| gRPC endpoint | Single shared `grpc-vantage.hostxtra.co.uk` — no per-org subdomain. Org resolved from `server_id`/token, never from host. Agent configs unchanged. | --- @@ -36,8 +37,12 @@ No billing, no seat/server limits this iteration (schema leaves room). ### `orgs` ```json -{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" } +{ "_id":"ObjectId", "org_id":"uuid", "name":"Doms Org", "slug":"doms-org", "created_at":"ISODate" } ``` +- `slug` derived from `name` at creation: lowercase, spaces/underscores → `-`, strip non `[a-z0-9-]`, collapse repeat `-`, trim leading/trailing `-`. `Doms Org` → `doms-org`. +- **Unique index on `slug`** (global). On collision append `-2`, `-3`, … or reject and ask user to pick. +- Length 3–40. Reserved slugs blocked: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, plus the bare apex. +- Slug is the DNS label → `doms-org.vantage.hostxtra.co.uk`. Treat as **immutable in v1** (rename breaks bookmarks, cookies, OIDC redirect URLs). Renaming deferred. ### `users` ```json @@ -55,7 +60,6 @@ Unique index on `email` (global — email identifies the account and its org). "_id":"ObjectId", "org_id":"uuid", "issuer":"https://id.acme.com", "client_id":"...", "client_secret_enc":"AES...", // encrypted with existing crypto.go - "redirect_url":"https://vantage.../auth/oidc/callback", "enabled": true, "updated_at":"ISODate" } ``` @@ -77,8 +81,8 @@ Unique index on `email` (global — email identifies the account and its org). - `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`. ### Per-org OIDC -- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted. -- `GET /auth/oidc/start?org=` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize. +- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted. UI shows the exact redirect URL the admin must register with their provider: `https://.vantage.hostxtra.co.uk/auth/oidc/callback`. +- `GET /auth/oidc/start` — org resolved from host (subdomain slug). Look up org's `org_oidc`, build provider on demand (cache per org). Redirect URL **derived from host** (`https:///auth/oidc/callback`), not stored. State carries `org_id`. - `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session. - If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org. @@ -95,6 +99,14 @@ Unique index on `email` (global — email identifies the account and its org). - Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC). - Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly. +### Host-based org resolution (per-org subdomain) +- Wildcard DNS `*.vantage.hostxtra.co.uk` + wildcard TLS cert (Let's Encrypt DNS-01). One record, one cert, no per-org ops. +- Middleware extracts subdomain label from `Host` header → look up `orgs.slug` → org. Cache slug→org_id. +- **Hostname is a routing/UX hint, NOT an authorization boundary.** Authorization stays session `org_id` (spec §9). If session org ≠ host org → reject (or redirect to correct host). Never trust `Host` to grant access. +- `/auth/oidc/start` reads org from host — drops the "type your org" box. +- Session cookie set on the **exact host** (`doms-org.vantage...`), not parent `.vantage...`, so cookies don't leak across orgs. +- Apex `vantage.hostxtra.co.uk` (no subdomain): serves bootstrap + login-by-email fallback; after login redirect to the user's org host. + --- ## 6. Removing global Authentik