diff --git a/claude.md b/claude.md index 451ebd0..92ed5a6 100644 --- a/claude.md +++ b/claude.md @@ -362,7 +362,8 @@ password-change endpoint at all, so there is no competing writer for the hash. - **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner. - **Local auth** — email + password (bcrypt), `POST /auth/login`. -- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start` → `/auth/oidc/callback`. +- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start` → `/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control. +- **Local login** — `settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree. - **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL. - **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin. - **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `.vantage.` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s. @@ -422,7 +423,8 @@ GET /install /install.ps1 # dynamic agent install scripts GET /update /update.ps1 GET /auth/bootstrap-status POST /auth/bootstrap /auth/login /auth/logout -GET /auth/me /auth/oidc/start /auth/oidc/callback +GET /auth/me +GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret GET /api/secrets/:group/values # bearer token (ESO) ``` @@ -450,7 +452,8 @@ agent GET /agent/latest-version settings GET,PUT /settings · POST /settings/secrets-token (owner|admin) licence GET /license · POST /license (POST: self-hosted only) org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id - GET,PUT /org/oidc (owner|admin) +providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id + POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin) ``` `GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy. @@ -524,7 +527,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no ## MongoDB Collections -`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations` +`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations` Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. @@ -536,6 +539,7 @@ Notes that are not obvious from the structs: - `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history. - `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history. - `console_sessions.token_consumed_at` is set atomically to enforce one-time use. +- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed. - `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped. - `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. @@ -550,6 +554,7 @@ Admin's own database is separate and holds `accounts` · `admin_instances` · `l - `0001_default_org_backfill` - `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch) - `0003_missed_org_scopes` +- `0005_auth_providers` — copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set. Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn. diff --git a/docsite/docs/getting-started/first-login.md b/docsite/docs/getting-started/first-login.md index 5295131..43d5b11 100644 --- a/docsite/docs/getting-started/first-login.md +++ b/docsite/docs/getting-started/first-login.md @@ -66,9 +66,11 @@ Go to **Settings → Access**. Add members with a role: Settings and organisation management require `owner` or `admin`. -If you would rather not manage passwords, configure OIDC instead see -[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per -organisation, and the client secret is stored encrypted. +If you would rather not manage passwords, configure single sign-on instead: +see [Settings](../vantage/settings.md#single-sign-on). You can add more than +one identity provider; each gets its own button on the login page, and no +buttons appear at all until at least one provider is configured. Client +secrets are stored encrypted. ## Next diff --git a/docsite/docs/vantage/settings.md b/docsite/docs/vantage/settings.md index 5a95163..54894b4 100644 --- a/docsite/docs/vantage/settings.md +++ b/docsite/docs/vantage/settings.md @@ -16,6 +16,16 @@ this instance behaves" produced two half-pages and a nav entry nobody could distinguish from Settings. The old path still redirects. ::: +:::danger Upgrading breaks existing single sign-on until you re-register the callback URL +Callback URLs are now per provider instead of one shared URL for the whole +instance. If you already had single sign-on configured, it was carried +forward automatically, but its callback URL changed and **sign-in through it +will fail until you copy the new callback URL from its settings card and +register it with your identity provider**. The migrated provider's card shows +a dismissable warning as a reminder. Password sign-in is not affected by this +change, so an administrator can always sign in locally to make the update. +::: + ## Access ### Members @@ -42,20 +52,46 @@ change would be overwritten by the next sync and would leave two writers for one password hash. Manage them from [People and roles](../hq/people-and-roles.md). ::: -### Single sign-on (OIDC) +### Single sign-on -Configured per organisation: +Add as many identity providers as you need: one instance can have several at +once, each with its own name, its own button on the login page and its own +callback URL. -| Field | | -| ------------- | ---------------------------- | -| Issuer | Your provider's issuer URL | -| Client ID | | -| Client secret | Stored AES-256-GCM encrypted | +Pick a provider from the list of presets: -Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`. +| Preset | You provide | +| ---------------------- | ------------------------------------------- | +| Microsoft Entra ID | Directory (tenant) ID | +| Google Workspace | nothing further, the issuer is fixed | +| Okta | Your Okta org domain | +| GitHub | Client ID and client secret only | +| Other (OpenID Connect) | The issuer URL of your identity provider | -Local and OIDC users coexist. Keep at least one local owner: if the provider is -misconfigured or unreachable, a local account is the way back in. +Every provider also needs a **Client ID** and **Client secret**; the secret is +stored AES-256-GCM encrypted and never shown again after you save it. + +:::info GitHub requires a verified primary email +Vantage signs a person in by their email address. GitHub is asked for the +account's addresses and only accepts one that is **both** the account's +primary address **and** marked verified: an address GitHub has not confirmed +is not proof anyone controls it. +::: + +#### Callback URL + +Each provider gets its own callback URL, shown on its settings card with a +copy button. This is the address you register with the identity provider when +you set up the application on their side: each provider is registered +separately, even if you have several with the same identity provider. + +#### Turning off password sign-in + +You can disable local (email and password) sign-in once at least one provider +is enabled. Vantage refuses to save a change that would leave nobody able to +sign in, whether that change comes from the local login toggle or from +disabling the last enabled provider. Keep at least one option open until every +person who needs access can reach the new one. ## Monitoring