diff --git a/deploy/docker-compose.fullstack.yml b/deploy/docker-compose.fullstack.yml index 1af990733..f33f3f690 100644 --- a/deploy/docker-compose.fullstack.yml +++ b/deploy/docker-compose.fullstack.yml @@ -15,7 +15,7 @@ # printf '%s' "$(openssl rand -hex 32)" > deploy/secrets/browser_session_signing_key # printf '%s' "$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')" \ # > deploy/secrets/connections_key -# chmod 0400 deploy/secrets/* +# chmod 0644 deploy/secrets/* # world-readable: non-root container users read the bind-mount # # Usage (local dev): # # 1. Generate the mounted secret files FIRST — `up` hard-fails without them: diff --git a/deploy/docker-compose.hosted-poc.yml b/deploy/docker-compose.hosted-poc.yml index 3d266a86b..8d6863bcf 100644 --- a/deploy/docker-compose.hosted-poc.yml +++ b/deploy/docker-compose.hosted-poc.yml @@ -22,4 +22,9 @@ services: environment: - AGENT_BOM_SESSION_COOKIE_SECURE=1 - AGENT_BOM_DEMO_ESTATE=1 + # Public demo opens anonymously into a read-only viewer dashboard. + # DEMO_ESTATE + NO_AUTH_ROLE=viewer only take effect once unauthenticated + # access is actually enabled — without this flag the demo falls back to the + # API-key sign-in wall and can never open anonymously as intended. + - AGENT_BOM_ALLOW_UNAUTHENTICATED_API=1 - AGENT_BOM_NO_AUTH_ROLE=viewer diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f3bbf3068..88e8d2933 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -12,7 +12,7 @@ # Credentials: file-mounted Docker secrets only (never .env passwords): # printf '%s' "$(openssl rand -hex 32)" > deploy/secrets/postgres_password # printf '%s' "$(openssl rand -hex 32)" > deploy/secrets/postgres_app_password -# chmod 0400 deploy/secrets/postgres_password deploy/secrets/postgres_app_password +# chmod 0644 deploy/secrets/postgres_password deploy/secrets/postgres_app_password # world-readable: non-root container users read the bind-mount # docker compose -f deploy/docker-compose.yml up services: diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index ea49ee101..9e71d4440 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -26,7 +26,13 @@ printf %s "$(python -c 'from cryptography.fernet import Fernet; print(Fernet.gen # printf %s "$(openssl rand -hex 32)" > deploy/secrets/trust_proxy_auth_secret # printf %s "$(openssl rand -hex 32)" > deploy/secrets/scim_bearer_token -chmod 0400 deploy/secrets/postgres_password deploy/secrets/postgres_app_password \ +# 0644 (world-readable), NOT 0400: compose bind-mounts these host files into +# /run/secrets/* preserving host perms (the mode/uid/gid long-syntax fields are +# swarm-only, ignored by compose), and the non-root container users — postgres +# UID 70 running initdb, the API app user — must read them. On a single-tenant +# self-host VM the host filesystem is the trust boundary; swarm/k8s use +# per-container secret perms instead. +chmod 0644 deploy/secrets/postgres_password deploy/secrets/postgres_app_password \ deploy/secrets/api_key deploy/secrets/audit_hmac_key \ deploy/secrets/browser_session_signing_key deploy/secrets/connections_key ``` diff --git a/deploy/supabase/postgres/init-wrapper.sh b/deploy/supabase/postgres/init-wrapper.sh index af106b944..772c12aba 100755 --- a/deploy/supabase/postgres/init-wrapper.sh +++ b/deploy/supabase/postgres/init-wrapper.sh @@ -17,7 +17,7 @@ fi if [ ! -f "${APP_PASS_FILE}" ]; then echo "ERROR: missing app role secret at ${APP_PASS_FILE}." - echo "Create deploy/secrets/postgres_app_password (chmod 0400) before compose up." + echo "Create deploy/secrets/postgres_app_password (chmod 0644) before compose up." echo "Refusing to fall back to the bootstrap/admin role." exit 1 fi diff --git a/deploy/supabase/postgres/init.sql b/deploy/supabase/postgres/init.sql index c9e433778..903bfc902 100644 --- a/deploy/supabase/postgres/init.sql +++ b/deploy/supabase/postgres/init.sql @@ -1166,8 +1166,13 @@ $$; -- LEAST PRIVILEGE: App user — DML only, no DDL (cannot CREATE/DROP/ALTER) -- ══════════════════════════════════════════════════════════════════════════════ --- Password is injected via POSTGRES_APP_PASSWORD env var in the wrapper script. --- If not set, this block is skipped and the admin user is used (dev fallback). +-- Password is injected via the init.app_password GUC by 00-init-wrapper.sh, +-- which reads /run/secrets/postgres_app_password. An empty GUC means a +-- misconfigured secret, so we RAISE EXCEPTION and abort loudly at the real +-- root cause — never limp on to create a broken passwordless app role. An +-- unset GUC (NULL) is the out-of-band path used by Alembic migrations, the +-- integration-test bootstrap, and wrapper-less local dev, where the app role +-- is provisioned separately — skip creation here instead of aborting. DO $$ DECLARE app_pass TEXT; @@ -1210,8 +1215,16 @@ BEGIN REVOKE CREATE ON SCHEMA public FROM agent_bom_app; RAISE NOTICE 'agent_bom_app user created with DML-only access'; + ELSIF app_pass = '' THEN + -- GUC set but empty: the wrapper reads a non-empty secret and errors on + -- an empty file, so an empty GUC is a misconfigured deployment — fail + -- loud rather than create a broken passwordless role. + RAISE EXCEPTION 'init.app_password is empty — /run/secrets/postgres_app_password had no value; check the secret file contents and that it is readable by the postgres user'; ELSE - RAISE NOTICE 'POSTGRES_APP_PASSWORD not set — skipping app user creation (dev mode)'; + -- Unset GUC (NULL): the app role is provisioned out of band. Normal path + -- for Alembic migrations, the integration-test bootstrap, and local dev + -- that run init.sql without the secret-injecting wrapper. + RAISE NOTICE 'init.app_password not set — skipping app user creation (provisioned by the init-wrapper in production)'; END IF; END $$; diff --git a/docs/AUTH_SSO.md b/docs/AUTH_SSO.md new file mode 100644 index 000000000..afdea5c2f --- /dev/null +++ b/docs/AUTH_SSO.md @@ -0,0 +1,132 @@ +# Single sign-on (OIDC) — 5-minute setup + +Turn on **"Sign in with Google"** (or any OIDC issuer) for the agent-bom +dashboard without hand-wiring environment variables. The guided command +collects your IdP details, validates the issuer, and emits the exact +`AGENT_BOM_OIDC_*` configuration. + +This is the onboarding path for the browser auth-code + PKCE SSO mechanism +documented in +[`ENTERPRISE_DEPLOYMENT.md`](ENTERPRISE_DEPLOYMENT.md#authentication) — it does +not change how tokens are validated. Reverse-proxy SSO +(`AGENT_BOM_TRUST_PROXY_AUTH=1`) remains the preferred posture for larger fleets; +this path is the fastest for a solo self-hoster. + +--- + +## The command + +```bash +agent-bom auth setup-oidc +``` + +Interactive on a terminal — it prompts for the provider, client ID/secret, and +your deployment's base URL. Fully flag-driven for automation: + +```bash +agent-bom auth setup-oidc \ + --non-interactive \ + --provider google \ + --client-id .apps.googleusercontent.com \ + --client-secret \ + --base-url https://abom.example.com \ + --write # write deploy/secrets/oidc.env (default path) +``` + +It prints the provider-side steps, a copy-paste env block, and (with `--write` +or an interactive confirmation) saves the block to `deploy/secrets/oidc.env`. + +> The redirect URI is always derived as `/v1/auth/oidc/callback` — the +> dashboard's OIDC callback route. It must be allowlisted at the IdP **exactly**. + +--- + +## Sign in with Google + +1. **Google Cloud Console → APIs & Services → Credentials.** +2. **Create Credentials → OAuth client ID → Application type: Web application.** +3. Add an **Authorized redirect URI**, exactly: + `https:///v1/auth/oidc/callback` +4. **Create**, then copy the **Client ID** and **Client secret**. +5. Run the command with those values (issuer is preset to + `https://accounts.google.com`): + + ```bash + agent-bom auth setup-oidc --provider google \ + --client-id .apps.googleusercontent.com \ + --client-secret \ + --base-url https:// --write + ``` + +6. Load the emitted env on the API process and restart, then open the dashboard + `/login` → **Sign in with SSO**. + +Resulting env block: + +```dotenv +AGENT_BOM_OIDC_ISSUER=https://accounts.google.com +AGENT_BOM_OIDC_CLIENT_ID=.apps.googleusercontent.com +AGENT_BOM_OIDC_CLIENT_SECRET= +AGENT_BOM_OIDC_REDIRECT_URI=https:///v1/auth/oidc/callback +AGENT_BOM_OIDC_AUDIENCE=.apps.googleusercontent.com +AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT=1 +``` + +- **`AGENT_BOM_OIDC_AUDIENCE`** defaults to the client ID — the browser ID + token's `aud` claim is the OAuth client ID. +- **`AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT=1`** is emitted for a single-tenant + self-host so SSO users resolve to the default tenant. Google emits no tenant + claim, and multi-tenant enforcement is fail-closed by default. Configure a + `--tenant-claim` instead when running multi-tenant (see below). + +### Loading the env + +- **Docker Compose:** the `deploy/secrets/oidc.env` file is written mode `0644` + so the (non-root) API container can read it when mounted as an `env_file`. + Because it may contain a client secret, restrict the `deploy/secrets/` + directory and never commit it. +- **systemd / bare process:** export the variables into the API process + environment (an `EnvironmentFile=` works with the same dotenv file). + +--- + +## Generic OIDC (Okta, Entra ID, Auth0, Keycloak) + +Use `--provider generic` and pass the issuer explicitly. The `.well-known/openid-configuration` +discovery document under the issuer is what the wizard validates. + +| IdP | Issuer example | +|------------|-------------------------------------------------------------| +| Okta | `https://.okta.com` | +| Entra ID | `https://login.microsoftonline.com//v2.0` | +| Auth0 | `https://.us.auth0.com/` | +| Keycloak | `https:///realms/` | + +```bash +agent-bom auth setup-oidc --provider generic \ + --issuer https://.okta.com \ + --client-id \ + --client-secret \ + --base-url https:// \ + --role-claim groups \ + --tenant-claim org_id \ + --write +``` + +- A **PKCE public client** (no secret) is supported — omit `--client-secret`. +- `--role-claim` maps a JWT claim to an agent-bom role (default `agent_bom_role`; + `roles`/`groups`/`permissions` arrays are also honored). +- `--tenant-claim` maps a JWT claim to a tenant. When set, the wizard does **not** + emit `AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT` — tenancy comes from the claim. + +If the issuer is unreachable when you run the command (offline/air-gapped), the +wizard prints a warning and still emits a valid env block — verify the issuer URL +by hand. + +--- + +## Reference + +- Auth mechanisms and the browser SSO route: [`ENTERPRISE_DEPLOYMENT.md`](ENTERPRISE_DEPLOYMENT.md#authentication) +- Claim mapping and tenant enforcement: `src/agent_bom/api/oidc.py` +- Compose secrets layout: [`../deploy/secrets/README.md`](../deploy/secrets/README.md) diff --git a/docs/DEPLOY_PLATFORM.md b/docs/DEPLOY_PLATFORM.md index 7c7503702..a88b87a58 100644 --- a/docs/DEPLOY_PLATFORM.md +++ b/docs/DEPLOY_PLATFORM.md @@ -74,7 +74,18 @@ make fullstack-up # == docker compose -f deploy/docker-compose.fullstack.y Prefer to write the secret files by hand? Generate all six with `openssl` / Fernet as documented in [`deploy/secrets/README.md`](../deploy/secrets/README.md), -`chmod 0400` them, then run the compose command above. +`chmod 0644` them (world-readable — compose bind-mounts preserve host perms and +the non-root postgres/API container users must read `/run/secrets/*`; the host +filesystem is the trust boundary on a single-tenant self-host VM), then run the +compose command above. + +> **Re-running a failed first `up`?** The Postgres init scripts (`init.sql`, +> app-role bootstrap) run **only against an empty data volume**. A stack that +> half-initialized (e.g. an unreadable secret aborted init) leaves a stale +> volume that init will *not* re-stamp. Wipe it before retrying: +> `docker compose -f deploy/docker-compose.fullstack.yml down && docker volume rm deploy_postgres-data` +> (use the volume name from `docker volume ls`; the compose-project prefix +> varies), then `make secrets` and bring the stack up again. Postgres and control-plane secrets are Docker secret files only — never `.env` or compose env. The API connects as `agent_bom_app` (DML-only), not the image diff --git a/docs/DEPLOY_QUICKSTART.md b/docs/DEPLOY_QUICKSTART.md index 9117fafc6..881d07654 100644 --- a/docs/DEPLOY_QUICKSTART.md +++ b/docs/DEPLOY_QUICKSTART.md @@ -241,7 +241,10 @@ printf %s "$(openssl rand -hex 32)" > deploy/secrets/api_key printf %s "$(openssl rand -hex 32)" > deploy/secrets/audit_hmac_key printf %s "$(openssl rand -hex 32)" > deploy/secrets/browser_session_signing_key printf %s "$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')" > deploy/secrets/connections_key -chmod 0400 deploy/secrets/postgres_password deploy/secrets/postgres_app_password \ +# 0644, not 0400: compose bind-mounts preserve host perms and the non-root +# postgres (UID 70) + API users must read /run/secrets/* (host FS is the +# trust boundary on a single-tenant self-host VM). +chmod 0644 deploy/secrets/postgres_password deploy/secrets/postgres_app_password \ deploy/secrets/api_key deploy/secrets/audit_hmac_key \ deploy/secrets/browser_session_signing_key deploy/secrets/connections_key scripts/deploy/install.sh platform-docker diff --git a/docs/ENTERPRISE_DEPLOYMENT.md b/docs/ENTERPRISE_DEPLOYMENT.md index 3556cb671..a50c5122f 100644 --- a/docs/ENTERPRISE_DEPLOYMENT.md +++ b/docs/ENTERPRISE_DEPLOYMENT.md @@ -204,7 +204,7 @@ Fleet trust scoring is advisory and evidence-backed. The score combines registry A SAML-only deployment is a valid, fail-closed posture: browser users authenticate against the IdP and receive a short-lived session API key, while anonymous requests are still rejected (401) by the API-key middleware, which stays installed whenever any auth path is configured. -**Dashboard OIDC auth-code + PKCE:** when the control plane has an OIDC issuer plus a confidential or public client (`AGENT_BOM_OIDC_CLIENT_ID`, `AGENT_BOM_OIDC_REDIRECT_URI`, optional `AGENT_BOM_OIDC_CLIENT_SECRET` / `AGENT_BOM_OIDC_SCOPES`), the dashboard shows **Sign in with SSO**. That CTA navigates same-origin to `GET /v1/auth/oidc/login`, completes the IdP authorize + callback with PKCE S256, and mints the usual httpOnly browser session + CSRF cookies. Role and tenant claims map through the same OIDC claim contract as bearer JWT verification. Reverse-proxy SSO remains preferred when `AGENT_BOM_TRUST_PROXY_AUTH=1` is set. Laptop-to-gateway MCP PKCE is a separate later surface — not this dashboard path. mTLS remains transport only and never substitutes for user identity. +**Dashboard OIDC auth-code + PKCE:** when the control plane has an OIDC issuer plus a confidential or public client (`AGENT_BOM_OIDC_CLIENT_ID`, `AGENT_BOM_OIDC_REDIRECT_URI`, optional `AGENT_BOM_OIDC_CLIENT_SECRET` / `AGENT_BOM_OIDC_SCOPES`), the dashboard shows **Sign in with SSO**. That CTA navigates same-origin to `GET /v1/auth/oidc/login`, completes the IdP authorize + callback with PKCE S256, and mints the usual httpOnly browser session + CSRF cookies. Role and tenant claims map through the same OIDC claim contract as bearer JWT verification. Reverse-proxy SSO remains preferred when `AGENT_BOM_TRUST_PROXY_AUTH=1` is set. Laptop-to-gateway MCP PKCE is a separate later surface — not this dashboard path. mTLS remains transport only and never substitutes for user identity. For a guided 5-minute "Sign in with Google" / OIDC walkthrough that emits this configuration for you, run `agent-bom auth setup-oidc` (see [`AUTH_SSO.md`](AUTH_SSO.md)). **Dashboard Snowflake OAuth sign-in:** Snowflake exposes a non-standard OAuth 2.0 authorization server — no OIDC discovery document, no JWKS, and no `userinfo` endpoint — so it uses a dedicated path rather than the generic OIDC flow. Configure it with the account's OAuth security integration (`AGENT_BOM_SNOWFLAKE_OAUTH_ACCOUNT_URL` = your `https://-.snowflakecomputing.com` URL, `AGENT_BOM_SNOWFLAKE_OAUTH_CLIENT_ID`, `AGENT_BOM_SNOWFLAKE_OAUTH_CLIENT_SECRET` [file-first via the secrets loader, write-only], `AGENT_BOM_SNOWFLAKE_OAUTH_REDIRECT_URI` = `https://cp.example/v1/auth/snowflake/callback`, and — if you want a role-scoped consent — `AGENT_BOM_SNOWFLAKE_OAUTH_SCOPE`). The dashboard then shows **Sign in with Snowflake**, which navigates same-origin to `GET /v1/auth/snowflake/login`, drives Snowflake's `/oauth/authorize` + `/oauth/token-request` authorization-code flow with PKCE S256, and mints the usual httpOnly session + CSRF cookies. Because Snowflake has no ID token, identity is the `username` the token endpoint returns (a missing username fails closed); role defaults to the least-privilege `AGENT_BOM_SNOWFLAKE_OAUTH_DEFAULT_ROLE` (`viewer`) and is only elevated by an explicit SCIM mapping for that user. Endpoint shapes verified against Snowflake's [Configure Snowflake OAuth for custom clients](https://docs.snowflake.com/en/user-guide/oauth-custom) docs. diff --git a/scripts/deploy/hosted_poc_preflight.py b/scripts/deploy/hosted_poc_preflight.py index 87701fba6..ce08d7f3b 100755 --- a/scripts/deploy/hosted_poc_preflight.py +++ b/scripts/deploy/hosted_poc_preflight.py @@ -197,8 +197,16 @@ def _write_secret_files(root: Path, *, force: bool) -> None: print(f"secret already exists: {secret_path}") continue secret_path.write_text(writers[name](), encoding="utf-8") - secret_path.chmod(stat.S_IRUSR) - print(f"wrote {secret_path} with mode 0400") + # World-readable (0644) on purpose: compose (non-swarm) bind-mounts + # these host files into /run/secrets/* preserving the HOST file + # permissions — the long-syntax mode/uid/gid fields are swarm-only and + # ignored by compose — so the non-root container users (postgres UID 70 + # running initdb scripts, the API app user) must be able to read the + # bind-mounted file or they crash-loop with EACCES. On a single-tenant + # self-host/demo VM the host filesystem is the trust boundary; swarm/k8s + # deployments use proper per-container secret perms instead. + secret_path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + print(f"wrote {secret_path} with mode 0644") def _compose_config(root: Path) -> str: diff --git a/site-docs/reference/cli.md b/site-docs/reference/cli.md index fdc5a2c28..dbc4fa5b8 100644 --- a/site-docs/reference/cli.md +++ b/site-docs/reference/cli.md @@ -118,6 +118,8 @@ metadata or environment defaults. | `policy` | Policy templates, application, and install-guard checks | | `firewall` | Inter-agent firewall policy validate / list / check | | `trust` | Show data access, network, auth, and storage boundaries | +| `auth` | Configure dashboard authentication — guided browser SSO / OIDC setup | +| `auth setup-oidc` | Interactive/non-interactive wizard that emits validated OIDC env config (e.g. Sign in with Google) | | `fleet` | Manage AI agent fleet discovery, lifecycle, and posture | | `fleet sync` | Discover local MCP agents and push inventory to `POST /v1/fleet/sync` | | `cost` | LLM FinOps posture — spend forecast and chargeback rollups (read-only) | diff --git a/src/agent_bom/cli/__init__.py b/src/agent_bom/cli/__init__.py index a4872c3d6..3f3c0309a 100644 --- a/src/agent_bom/cli/__init__.py +++ b/src/agent_bom/cli/__init__.py @@ -391,6 +391,13 @@ def _restore_agent_mode_env() -> None: main.add_command(identity_group) +# --------------------------------------------------------------------------- +# Auth (SSO/OIDC onboarding) group — `agent-bom auth setup-oidc` +# --------------------------------------------------------------------------- +from agent_bom.cli._auth_group import auth_group # noqa: E402 + +main.add_command(auth_group) + # --------------------------------------------------------------------------- # Ingest command group — `agent-bom ingest [hardware]` # --------------------------------------------------------------------------- diff --git a/src/agent_bom/cli/_auth_group.py b/src/agent_bom/cli/_auth_group.py new file mode 100644 index 000000000..e4c7ef6d9 --- /dev/null +++ b/src/agent_bom/cli/_auth_group.py @@ -0,0 +1,341 @@ +"""Auth command group — guided SSO / OIDC onboarding. + +This wraps the *existing* OIDC mechanism with a setup wizard. It never forks the +auth logic: it only COLLECTS, VALIDATES, and EMITS the ``AGENT_BOM_OIDC_*`` +configuration a self-hoster needs to turn on browser SSO ("Sign in with Google" +or any OIDC issuer). The bearer/verification path lives in +``agent_bom.api.oidc`` and the auth-code + PKCE browser path in +``agent_bom.api.oidc_browser`` — both untouched here. + +Usage:: + + agent-bom auth setup-oidc # interactive wizard (TTY) + agent-bom auth setup-oidc --provider google \\ + --client-id … --client-secret … --base-url https://abom.example --write +""" + +from __future__ import annotations + +import sys +from collections import OrderedDict +from pathlib import Path +from typing import Optional + +import click + +from agent_bom.cli._grouped_help import SuggestingGroup + +# The dashboard callback route is mounted under the versioned API prefix +# (``/v1/auth/oidc/callback`` in ``routes/enterprise.py``). Kept as a literal so +# the CLI import stays light (no FastAPI import); ``test_auth_setup_oidc`` asserts +# it still matches ``API_V1_PREFIX + "/auth/oidc/callback"`` so drift is caught. +OIDC_CALLBACK_PATH = "/v1/auth/oidc/callback" + +# Default env-file target. 0644 (see ``write_env_file``) because the compose +# stack mounts this as an ``env_file`` read by a non-root container UID. +DEFAULT_ENV_PATH = "deploy/secrets/oidc.env" + +_PROVIDER_ISSUERS = { + "google": "https://accounts.google.com", +} + + +class OIDCSetupError(click.ClickException): + """Raised for a user-correctable wizard configuration error.""" + + +def _stdin_is_tty() -> bool: + """Whether stdin is an interactive terminal (patch point for tests).""" + try: + return sys.stdin.isatty() + except (ValueError, OSError): + return False + + +# ── Pure config builders (unit-tested directly) ───────────────────────────── + + +def derive_redirect_uri(base_url: str) -> str: + """Derive the IdP redirect URI from the deployment base URL. + + The IdP must allowlist exactly this value; it maps to the dashboard's + auth-code callback route. + """ + base = base_url.strip().rstrip("/") + if not base: + raise OIDCSetupError("Base URL is required to derive the redirect URI.") + from urllib.parse import urlparse + + parsed = urlparse(base) + if parsed.scheme not in {"https", "http"} or not parsed.netloc: + raise OIDCSetupError(f"Base URL must be an absolute http(s) URL, got: {base_url!r}") + return f"{base}{OIDC_CALLBACK_PATH}" + + +def build_oidc_env( + *, + issuer: str, + client_id: str, + redirect_uri: str, + client_secret: Optional[str] = None, + audience: Optional[str] = None, + role_claim: Optional[str] = None, + tenant_claim: Optional[str] = None, +) -> "OrderedDict[str, str]": + """Build the ordered ``AGENT_BOM_OIDC_*`` env mapping for browser SSO. + + Encodes the two conventions verified against the mechanism: + + - **audience** defaults to ``client_id`` — the browser auth-code ID token's + ``aud`` claim is the OAuth client ID (Google and the OIDC core spec), and + ``AGENT_BOM_OIDC_AUDIENCE`` is required whenever OIDC is enabled. + - when **no tenant claim** is configured this is a single-tenant self-host, + so ``AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT=1`` is emitted; otherwise the + fail-closed multi-tenant default would block login for IdPs (e.g. Google) + that emit no tenant claim. Surfaced explicitly, never silent. + """ + issuer = (issuer or "").strip() + client_id = (client_id or "").strip() + redirect_uri = (redirect_uri or "").strip() + if not issuer: + raise OIDCSetupError("OIDC issuer is required (e.g. https://accounts.google.com).") + if not client_id: + raise OIDCSetupError("OIDC client ID is required.") + if not redirect_uri: + raise OIDCSetupError("Redirect URI is required (pass --base-url or --redirect-uri).") + + env: "OrderedDict[str, str]" = OrderedDict() + env["AGENT_BOM_OIDC_ISSUER"] = issuer + env["AGENT_BOM_OIDC_CLIENT_ID"] = client_id + secret = (client_secret or "").strip() + if secret: + env["AGENT_BOM_OIDC_CLIENT_SECRET"] = secret + env["AGENT_BOM_OIDC_REDIRECT_URI"] = redirect_uri + env["AGENT_BOM_OIDC_AUDIENCE"] = (audience or "").strip() or client_id + role = (role_claim or "").strip() + if role: + env["AGENT_BOM_OIDC_ROLE_CLAIM"] = role + tenant = (tenant_claim or "").strip() + if tenant: + env["AGENT_BOM_OIDC_TENANT_CLAIM"] = tenant + else: + env["AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT"] = "1" + return env + + +def render_env_block(env: "OrderedDict[str, str]") -> str: + """Render the env mapping as a copy-paste dotenv block (no shell wrapping).""" + lines = [ + "# agent-bom OIDC browser SSO — generated by `agent-bom auth setup-oidc`", + "# Load as a docker-compose env_file, or export into the API process env.", + ] + lines.extend(f"{key}={value}" for key, value in env.items()) + return "\n".join(lines) + "\n" + + +def write_env_file(path: str | Path, block: str) -> Path: + """Write the env block to ``path`` with mode 0644. + + 0644 (not 0400) is deliberate: the compose stack mounts this file as an + ``env_file`` and the API container runs as a non-root UID that must read it + at start; owner-only bits break that mount. Protect the *directory*, not the + file bits. The caller must confirm before writing — this never runs silently. + """ + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(block, encoding="utf-8") + target.chmod(0o644) + return target + + +def check_issuer_connectivity(issuer: str) -> dict[str, object]: + """Reuse the mechanism's discovery to confirm the issuer's endpoints resolve. + + Returns a result dict; never raises. Reuses ``oidc.discover_oidc`` (the same + ``.well-known/openid-configuration`` fetch + validation the auth path uses) + so the wizard does not fork a second discovery fetcher. An unreachable issuer + is a warning (``reachable=False``), not a failure, so an offline operator can + still emit config. + """ + from agent_bom.api.oidc import OIDCError, discover_oidc + + try: + doc = discover_oidc(issuer) + except OIDCError as exc: + return {"reachable": False, "complete": False, "warning": str(exc)} + endpoints = {key: str(doc.get(key) or "").strip() for key in ("authorization_endpoint", "token_endpoint", "jwks_uri")} + missing = [key for key, value in endpoints.items() if not value] + result: dict[str, object] = {"reachable": True, "complete": not missing, **endpoints} + if missing: + result["missing"] = missing + return result + + +# ── Provider-side steps (operator guidance) ───────────────────────────────── + + +def _provider_steps(provider: str, redirect_uri: str) -> list[str]: + if provider == "google": + return [ + "Google Cloud Console → APIs & Services → Credentials.", + "Create Credentials → OAuth client ID → Application type: Web application.", + f"Add an Authorized redirect URI, exactly: {redirect_uri}", + "Create, then copy the Client ID and Client secret shown.", + ] + return [ + "Open your IdP's app/client registration (Okta, Entra ID, Auth0, Keycloak…).", + "Create an OIDC/OAuth2 'Web application' client using the authorization-code flow.", + f"Set the redirect/callback URI exactly to: {redirect_uri}", + "Copy the issuer URL, client ID, and (for confidential clients) client secret.", + ] + + +# ── Click group + command ─────────────────────────────────────────────────── + + +@click.group("auth", cls=SuggestingGroup) +def auth_group() -> None: + """Authentication setup helpers — guided SSO / OIDC onboarding. + + Collects, validates, and emits the ``AGENT_BOM_OIDC_*`` configuration for + browser SSO. It wraps the existing OIDC mechanism (bearer verification + + auth-code/PKCE) and never stores a secret or changes token validation. + + \b + Subcommands: + setup-oidc Guided "Sign in with Google" / OIDC browser-SSO setup + """ + + +@click.command("setup-oidc") +@click.option("--provider", type=click.Choice(["google", "generic"]), default=None, help="IdP preset (google fills the issuer).") +@click.option("--issuer", default=None, help="OIDC issuer URL, e.g. https://accounts.google.com.") +@click.option("--client-id", default=None, help="OAuth client ID from the IdP.") +@click.option("--client-secret", default=None, help="OAuth client secret (omit for a PKCE public client).") +@click.option("--base-url", default=None, help="Deployment base URL; the redirect URI is derived from it.") +@click.option("--redirect-uri", default=None, help="Override the derived redirect URI (must match the IdP allowlist).") +@click.option("--audience", default=None, help="Expected JWT audience (defaults to the client ID).") +@click.option("--role-claim", default=None, help="JWT claim to map to a role (default agent_bom_role).") +@click.option("--tenant-claim", default=None, help="JWT claim to map to a tenant (omit for single-tenant self-host).") +@click.option("--write", "do_write", is_flag=True, help="Write the env block to --output without prompting.") +@click.option("--output", "-o", "output_path", default=DEFAULT_ENV_PATH, show_default=True, help="Env-file path to write.") +@click.option("--non-interactive", "--yes", "-y", "non_interactive", is_flag=True, help="Never prompt; require flags.") +def setup_oidc_cmd( + provider: Optional[str], + issuer: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + base_url: Optional[str], + redirect_uri: Optional[str], + audience: Optional[str], + role_claim: Optional[str], + tenant_claim: Optional[str], + do_write: bool, + output_path: str, + non_interactive: bool, +) -> None: + """Guided setup for browser SSO ("Sign in with Google" or any OIDC issuer). + + Prints the provider-side steps, validates the issuer's discovery document, + and emits a copy-paste ``AGENT_BOM_OIDC_*`` env block. Optionally writes it + to a file. Reuses the existing OIDC mechanism; nothing here changes token + validation or stores a secret. + """ + from rich.console import Console + + con = Console() + interactive = (not non_interactive) and _stdin_is_tty() + + if not provider: + if interactive: + provider = "google" if click.confirm("Use the Google preset?", default=True) else "generic" + else: + provider = "generic" + + # Issuer. + if not issuer: + default_issuer = _PROVIDER_ISSUERS.get(provider, "") + if interactive: + issuer = click.prompt("OIDC issuer URL", default=default_issuer or None) + else: + issuer = default_issuer + if not issuer: + raise OIDCSetupError("Missing --issuer (required for a generic provider). Re-run with --issuer or --provider google.") + + # Client ID. + if not client_id and interactive: + client_id = click.prompt("OAuth client ID") + if not client_id: + raise OIDCSetupError("Missing --client-id.") + + # Client secret (optional; never echoed). + if client_secret is None and interactive: + client_secret = click.prompt("OAuth client secret (blank = PKCE public client)", default="", hide_input=True, show_default=False) + + # Redirect URI (from base URL, or explicit override). + if not redirect_uri: + if not base_url and interactive: + base_url = click.prompt("Deployment base URL (e.g. https://abom.example.com)") + if base_url: + redirect_uri = derive_redirect_uri(base_url) + if not redirect_uri: + raise OIDCSetupError("Missing redirect URI. Pass --base-url or --redirect-uri.") + + # Audience (defaults to client ID). + if not audience and interactive: + audience = click.prompt("Expected JWT audience", default=client_id) + + env = build_oidc_env( + issuer=issuer, + client_id=client_id, + redirect_uri=redirect_uri, + client_secret=client_secret, + audience=audience, + role_claim=role_claim, + tenant_claim=tenant_claim, + ) + + # Connectivity check (warn, never fail). + con.print(f"\n [bold]OIDC setup[/bold] [dim]· provider {provider} · issuer {issuer}[/dim]") + check = check_issuer_connectivity(issuer) + if check.get("reachable") and check.get("complete"): + con.print(" [green]Issuer discovery OK[/green] [dim]· authorization, token, and JWKS endpoints resolved.[/dim]") + elif check.get("reachable"): + con.print(f" [yellow]Issuer reachable but incomplete[/yellow] [dim]· missing {', '.join(check.get('missing', []))}.[/dim]") # type: ignore[arg-type] + else: + con.print(f" [yellow]Could not reach issuer discovery[/yellow] [dim]· {check.get('warning', '')}[/dim]") + con.print(" [dim]Continuing offline — the env block below is still valid; verify the issuer URL.[/dim]") + + # Provider-side steps. + con.print("\n [bold]1. Register the client with your IdP[/bold]") + for i, step in enumerate(_provider_steps(provider, redirect_uri), start=1): + con.print(f" [cyan]{i}.[/cyan] {step}") + + # Env block (the one place the secret is shown). + con.print("\n [bold]2. Configuration[/bold] [dim]· set these on the agent-bom API process[/dim]") + if tenant_claim: + con.print(" [dim]Multi-tenant: tenant is read from the configured claim.[/dim]") + else: + con.print(" [dim]Single-tenant self-host: all SSO users share the default tenant (ALLOW_DEFAULT_TENANT=1).[/dim]") + block = render_env_block(env) + click.echo("") + click.echo(block) + + # Write (only on --write or explicit confirmation; never silent). + should_write = do_write + if not should_write and interactive: + should_write = click.confirm(f"Write this block to {output_path}?", default=False) + if should_write: + written = write_env_file(output_path, block) + con.print(f" [green]Wrote[/green] {written} [dim]· mode 0644 (compose env_file readable)[/dim]") + if "AGENT_BOM_OIDC_CLIENT_SECRET" in env: + con.print( + f" [yellow]Contains a client secret[/yellow] [dim]· restrict the {written.parent}/ directory; do not commit it.[/dim]" + ) + else: + con.print(f" [dim]Not written. Re-run with --write to save to {output_path}.[/dim]") + + con.print("\n [bold]3. Restart the API[/bold] [dim]· then open the dashboard /login → 'Sign in with SSO'.[/dim]\n") + + +auth_group.add_command(setup_oidc_cmd, "setup-oidc") diff --git a/src/agent_bom/cli/_grouped_help.py b/src/agent_bom/cli/_grouped_help.py index 68a7f06fc..9d105092c 100644 --- a/src/agent_bom/cli/_grouped_help.py +++ b/src/agent_bom/cli/_grouped_help.py @@ -39,6 +39,7 @@ "fleet", "cost", "identity", + "auth", "serve", "schedule", "remediate", diff --git a/tests/conftest.py b/tests/conftest.py index d06bebb80..35d1c9388 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,6 +72,20 @@ def _reset_enrichment_posture_state() -> None: pass +def _reset_scan_warning_state() -> None: + # Coverage / scan warnings live on threading.local. Under xdist that is + # per-worker-thread state across many tests: a prior scan can leave an + # offline_ecosystem_gap dict (no "reason") that makes a later assertion on + # consume_coverage_warnings()[0]["reason"] raise KeyError. Drain both + # channels between tests. + try: + from agent_bom.scanners.state import reset_scan_warnings + + reset_scan_warnings() + except Exception: + pass + + def _reset_resolver_state() -> None: try: import agent_bom.resolver as resolver @@ -511,6 +525,7 @@ def reset_global_test_state(): _reset_proxy_route_state() _reset_runtime_state() _reset_enrichment_posture_state() + _reset_scan_warning_state() # Snapshot auth env AFTER module-scoped setup has run (setup_module fires # before this function-scoped fixture), so module-level auth env is captured @@ -578,3 +593,4 @@ def reset_global_test_state(): _reset_proxy_route_state() _reset_runtime_state() _reset_enrichment_posture_state() + _reset_scan_warning_state() diff --git a/tests/test_auth_setup_oidc.py b/tests/test_auth_setup_oidc.py new file mode 100644 index 000000000..d750a5dca --- /dev/null +++ b/tests/test_auth_setup_oidc.py @@ -0,0 +1,298 @@ +"""Tests for the `agent-bom auth setup-oidc` onboarding wizard. + +The wizard only collects/validates/emits AGENT_BOM_OIDC_* config; it must not +fork the OIDC mechanism. Discovery is always mocked here — no network. +""" + +from __future__ import annotations + +import stat +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from agent_bom.cli import main +from agent_bom.cli._auth_group import ( + OIDC_CALLBACK_PATH, + build_oidc_env, + check_issuer_connectivity, + derive_redirect_uri, + render_env_block, +) + +_DISCOVERY_OK = { + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + "issuer": "https://accounts.google.com", +} + + +# ── callback-path alignment guard ──────────────────────────────────────────── + + +def test_callback_path_matches_versioned_route(): + from agent_bom.api.versioning import API_V1_PREFIX + + assert OIDC_CALLBACK_PATH == f"{API_V1_PREFIX}/auth/oidc/callback" + + +def test_derive_redirect_uri_appends_callback(): + assert derive_redirect_uri("https://abom.example.com/") == "https://abom.example.com/v1/auth/oidc/callback" + assert derive_redirect_uri("https://abom.example.com") == "https://abom.example.com/v1/auth/oidc/callback" + + +def test_derive_redirect_uri_rejects_non_url(): + from agent_bom.cli._auth_group import OIDCSetupError + + for bad in ("", "abom.example.com", "ftp://x"): + try: + derive_redirect_uri(bad) + except OIDCSetupError: + continue + raise AssertionError(f"expected rejection for {bad!r}") + + +# ── build_oidc_env: exact keys/values ──────────────────────────────────────── + + +def test_build_env_google_public_client_single_tenant(): + env = build_oidc_env( + issuer="https://accounts.google.com", + client_id="abc.apps.googleusercontent.com", + redirect_uri="https://abom.example.com/v1/auth/oidc/callback", + client_secret="", # PKCE public client + ) + assert env == { + "AGENT_BOM_OIDC_ISSUER": "https://accounts.google.com", + "AGENT_BOM_OIDC_CLIENT_ID": "abc.apps.googleusercontent.com", + "AGENT_BOM_OIDC_REDIRECT_URI": "https://abom.example.com/v1/auth/oidc/callback", + "AGENT_BOM_OIDC_AUDIENCE": "abc.apps.googleusercontent.com", # defaults to client_id + "AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT": "1", # single-tenant self-host + } + # order matters for readable output + assert list(env)[0] == "AGENT_BOM_OIDC_ISSUER" + + +def test_build_env_confidential_client_includes_secret(): + env = build_oidc_env( + issuer="https://accounts.google.com", + client_id="cid", + redirect_uri="https://x/v1/auth/oidc/callback", + client_secret="topsecret", + ) + assert env["AGENT_BOM_OIDC_CLIENT_SECRET"] == "topsecret" + + +def test_build_env_explicit_audience_and_role_claim(): + env = build_oidc_env( + issuer="https://idp.example", + client_id="cid", + redirect_uri="https://x/v1/auth/oidc/callback", + audience="agent-bom", + role_claim="groups", + ) + assert env["AGENT_BOM_OIDC_AUDIENCE"] == "agent-bom" + assert env["AGENT_BOM_OIDC_ROLE_CLAIM"] == "groups" + + +def test_build_env_tenant_claim_disables_default_tenant(): + env = build_oidc_env( + issuer="https://idp.example", + client_id="cid", + redirect_uri="https://x/v1/auth/oidc/callback", + tenant_claim="org_id", + ) + assert env["AGENT_BOM_OIDC_TENANT_CLAIM"] == "org_id" + assert "AGENT_BOM_OIDC_ALLOW_DEFAULT_TENANT" not in env + + +def test_build_env_requires_core_fields(): + from agent_bom.cli._auth_group import OIDCSetupError + + for kwargs in ( + {"issuer": "", "client_id": "c", "redirect_uri": "https://x/cb"}, + {"issuer": "https://i", "client_id": "", "redirect_uri": "https://x/cb"}, + {"issuer": "https://i", "client_id": "c", "redirect_uri": ""}, + ): + try: + build_oidc_env(**kwargs) # type: ignore[arg-type] + except OIDCSetupError: + continue + raise AssertionError(f"expected rejection for {kwargs}") + + +# ── connectivity check (mocked discovery, no network) ──────────────────────── + + +def test_connectivity_happy_path(): + with patch("agent_bom.api.oidc.discover_oidc", return_value=dict(_DISCOVERY_OK)) as mock: + result = check_issuer_connectivity("https://accounts.google.com") + mock.assert_called_once_with("https://accounts.google.com") + assert result["reachable"] is True + assert result["complete"] is True + assert result["token_endpoint"] == "https://oauth2.googleapis.com/token" + + +def test_connectivity_unreachable_warns_not_raises(): + from agent_bom.api.oidc import OIDCError + + with patch("agent_bom.api.oidc.discover_oidc", side_effect=OIDCError("Failed to fetch discovery")): + result = check_issuer_connectivity("https://unreachable.example") + assert result["reachable"] is False + assert result["complete"] is False + assert "Failed to fetch" in str(result["warning"]) + + +def test_connectivity_incomplete_when_endpoint_missing(): + partial = {"authorization_endpoint": "https://a", "jwks_uri": "https://j"} # no token_endpoint + with patch("agent_bom.api.oidc.discover_oidc", return_value=partial): + result = check_issuer_connectivity("https://idp.example") + assert result["reachable"] is True + assert result["complete"] is False + assert "token_endpoint" in result["missing"] # type: ignore[operator] + + +# ── render ─────────────────────────────────────────────────────────────────── + + +def test_render_env_block_is_dotenv(): + block = render_env_block(build_oidc_env(issuer="https://i", client_id="c", redirect_uri="https://x/cb")) + assert "AGENT_BOM_OIDC_ISSUER=https://i" in block + assert block.startswith("# agent-bom OIDC") + + +# ── CLI: non-interactive end to end ────────────────────────────────────────── + + +def test_cli_non_interactive_emits_env_block(): + runner = CliRunner() + with patch("agent_bom.api.oidc.discover_oidc", return_value=dict(_DISCOVERY_OK)): + result = runner.invoke( + main, + [ + "auth", + "setup-oidc", + "--non-interactive", + "--provider", + "google", + "--client-id", + "cid.apps.googleusercontent.com", + "--client-secret", + "shh", + "--base-url", + "https://abom.example.com", + ], + ) + assert result.exit_code == 0, result.output + assert "AGENT_BOM_OIDC_ISSUER=https://accounts.google.com" in result.output + assert "AGENT_BOM_OIDC_REDIRECT_URI=https://abom.example.com/v1/auth/oidc/callback" in result.output + assert "AGENT_BOM_OIDC_AUDIENCE=cid.apps.googleusercontent.com" in result.output + assert "AGENT_BOM_OIDC_CLIENT_SECRET=shh" in result.output + assert "Google Cloud Console" in result.output + # Nothing written without --write. + assert "Not written" in result.output + + +def test_cli_non_interactive_missing_client_id_errors(): + runner = CliRunner() + with patch("agent_bom.api.oidc.discover_oidc", return_value=dict(_DISCOVERY_OK)): + result = runner.invoke(main, ["auth", "setup-oidc", "--non-interactive", "--provider", "google", "--base-url", "https://x.example"]) + assert result.exit_code != 0 + assert "client-id" in result.output + + +def test_cli_generic_provider_requires_issuer(): + runner = CliRunner() + result = runner.invoke( + main, + ["auth", "setup-oidc", "--non-interactive", "--provider", "generic", "--client-id", "c", "--base-url", "https://x.example"], + ) + assert result.exit_code != 0 + assert "issuer" in result.output.lower() + + +def test_cli_write_mode_creates_0644_file(tmp_path: Path): + out = tmp_path / "secrets" / "oidc.env" + runner = CliRunner() + with patch("agent_bom.api.oidc.discover_oidc", return_value=dict(_DISCOVERY_OK)): + result = runner.invoke( + main, + [ + "auth", + "setup-oidc", + "--non-interactive", + "--provider", + "google", + "--client-id", + "cid", + "--client-secret", + "topsecret", + "--base-url", + "https://abom.example.com", + "--write", + "--output", + str(out), + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + mode = stat.S_IMODE(out.stat().st_mode) + assert mode == 0o644, oct(mode) + content = out.read_text() + assert "AGENT_BOM_OIDC_CLIENT_ID=cid" in content + assert "AGENT_BOM_OIDC_CLIENT_SECRET=topsecret" in content + assert "Contains a client secret" in result.output + + +def test_cli_offline_issuer_warns_but_still_emits(tmp_path: Path): + from agent_bom.api.oidc import OIDCError + + runner = CliRunner() + with patch("agent_bom.api.oidc.discover_oidc", side_effect=OIDCError("Failed to fetch discovery: unreachable")): + result = runner.invoke( + main, + [ + "auth", + "setup-oidc", + "--non-interactive", + "--provider", + "google", + "--client-id", + "cid", + "--base-url", + "https://abom.example.com", + ], + ) + assert result.exit_code == 0, result.output + assert "Could not reach issuer discovery" in result.output + assert "AGENT_BOM_OIDC_ISSUER=https://accounts.google.com" in result.output + + +def test_cli_interactive_prompts_and_confirms_write(tmp_path: Path): + out = tmp_path / "oidc.env" + runner = CliRunner() + # provider preset? -> y ; issuer default ; client id ; secret ; base url ; audience default ; write? -> y + stdin = "\n".join(["y", "", "cid.apps.googleusercontent.com", "s3cret", "https://abom.example.com", "", "y"]) + "\n" + with ( + patch("agent_bom.api.oidc.discover_oidc", return_value=dict(_DISCOVERY_OK)), + patch("agent_bom.cli._auth_group._stdin_is_tty", return_value=True), + ): + result = runner.invoke(main, ["auth", "setup-oidc", "--output", str(out)], input=stdin) + assert result.exit_code == 0, result.output + assert out.exists() + content = out.read_text() + assert "AGENT_BOM_OIDC_ISSUER=https://accounts.google.com" in content + assert "AGENT_BOM_OIDC_CLIENT_SECRET=s3cret" in content + assert "AGENT_BOM_OIDC_REDIRECT_URI=https://abom.example.com/v1/auth/oidc/callback" in content + + +def test_auth_group_listed_in_help(): + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "auth" in result.output + sub = runner.invoke(main, ["auth", "--help"]) + assert sub.exit_code == 0 + assert "setup-oidc" in sub.output diff --git a/tests/test_compose_secrets_healthcheck.py b/tests/test_compose_secrets_healthcheck.py index 815546724..ae8051855 100644 --- a/tests/test_compose_secrets_healthcheck.py +++ b/tests/test_compose_secrets_healthcheck.py @@ -227,9 +227,9 @@ def test_fullstack_is_loopback_only_auth_required_and_matches_runtime_user_home( assert "~/.claude:/home/abom/.claude:ro" in (api.get("volumes") or []) assert "api_key" in (api.get("secrets") or []) assert "postgres_app_password" in (api.get("secrets") or []) - assert "postgres_password" in ((data.get("secrets") or {})) - assert "postgres_app_password" in ((data.get("secrets") or {})) - assert "api_key" in ((data.get("secrets") or {})) + assert "postgres_password" in (data.get("secrets") or {}) + assert "postgres_app_password" in (data.get("secrets") or {}) + assert "api_key" in (data.get("secrets") or {}) @pytest.mark.parametrize( @@ -248,6 +248,7 @@ def test_compose_stacks_never_interpolate_control_plane_secrets(compose_name: st assert f"${{{name}" not in text, f"{compose_name} must not interpolate {name}" assert f"{name}_FILE" in text, f"{compose_name} must set {name}_FILE" + def test_hosted_poc_overlay_keeps_api_and_ui_loopback_only() -> None: path = COMPOSE_DIR / "docker-compose.hosted-poc.yml" data = yaml.safe_load(path.read_text(encoding="utf-8")) @@ -259,6 +260,9 @@ def test_hosted_poc_overlay_keeps_api_and_ui_loopback_only() -> None: assert api_env == [ "AGENT_BOM_SESSION_COOKIE_SECURE=1", "AGENT_BOM_DEMO_ESTATE=1", + # Public demo opens anonymously into a read-only viewer; DEMO_ESTATE + + # NO_AUTH_ROLE only take effect once unauthenticated access is enabled. + "AGENT_BOM_ALLOW_UNAUTHENTICATED_API=1", "AGENT_BOM_NO_AUTH_ROLE=viewer", ] @@ -281,6 +285,34 @@ def test_postgres_init_resets_app_password_guc_after_reading_it() -> None: assert reset_idx > read_idx, "the RESET of init.app_password must come after the read that uses it." +def test_postgres_init_distinguishes_empty_vs_unset_app_password() -> None: + """init.sql must never CREATE ROLE ... PASSWORD NULL (a broken passwordless + app role that later surfaces as an opaque 'password authentication failed'). + + It distinguishes two cases: + * empty GUC (misconfigured secret) -> RAISE EXCEPTION and abort loudly; + * unset GUC / NULL (Alembic migrations, integration-test bootstrap, and + wrapper-less local dev) -> skip app-role creation, since the role is + provisioned out of band there. Aborting here would break those paths.""" + init_sql = (COMPOSE_DIR / "supabase" / "postgres" / "init.sql").read_text(encoding="utf-8") + + read_idx = init_sql.find("current_setting('init.app_password'") + assert read_idx != -1 + + # Empty GUC must fail loud (never create a passwordless role). + assert "app_pass = ''" in init_sql + assert "RAISE EXCEPTION 'init.app_password is empty" in init_sql, ( + "init.sql must RAISE EXCEPTION when the app-password GUC is set but empty (misconfigured secret)." + ) + # Unset GUC (NULL) must skip cleanly so migrations / test bootstrap / dev + # that run init.sql without the wrapper are not aborted. + assert "skipping app user creation" in init_sql, ( + "init.sql must skip app-role creation (not abort) when init.app_password is unset (NULL)." + ) + # A passwordless CREATE ROLE only ever runs under a non-empty password guard. + assert "PASSWORD NULL" not in init_sql + + def test_active_docker_docs_do_not_mount_config_under_root_home() -> None: active_docs = [ ROOT / "docs" / "DEPLOYMENT.md", diff --git a/tests/test_coverage_warning.py b/tests/test_coverage_warning.py index d87f2c993..f71208cd7 100644 --- a/tests/test_coverage_warning.py +++ b/tests/test_coverage_warning.py @@ -201,7 +201,8 @@ def test_package_scan_preserves_parser_coverage_warning(tmp_path, monkeypatch): asyncio.run(scan_packages([], options=ScanOptions(offline=True))) - assert consume_coverage_warnings()[0]["reason"] == "manifest_parse_error" + warnings = consume_coverage_warnings() + assert any(w.get("reason") == "manifest_parse_error" for w in warnings), warnings def test_json_and_console_surface_coverage_warnings(): diff --git a/tests/test_hosted_poc_preflight.py b/tests/test_hosted_poc_preflight.py index c978acea1..61103a566 100644 --- a/tests/test_hosted_poc_preflight.py +++ b/tests/test_hosted_poc_preflight.py @@ -163,7 +163,11 @@ def test_preflight_can_write_secret_files(tmp_path: Path) -> None: ): path = secrets_dir / name assert len(path.read_text(encoding="utf-8")) >= 32 - assert oct(path.stat().st_mode & 0o777) == "0o400" + # 0644 (world-readable): compose bind-mounts these host files into + # /run/secrets/* preserving HOST perms, and the non-root container + # users (postgres UID 70, the API app user) must read them. See the + # generated-secret write path for the full rationale. + assert oct(path.stat().st_mode & 0o777) == "0o644" def test_secret_generation_does_not_require_hosted_url_configuration( diff --git a/ui/components/login-panel.tsx b/ui/components/login-panel.tsx index 0700385b9..ce3a34ae6 100644 --- a/ui/components/login-panel.tsx +++ b/ui/components/login-panel.tsx @@ -248,7 +248,11 @@ export function LoginPanel({ {!ssoConfigured && !proxyOrBearerHint ? (

- Setting up single sign-on? Configure browser OIDC, a reverse proxy, or an OIDC issuer in your deployment. + Setting up single sign-on? Run{" "} + + agent-bom auth setup-oidc + {" "} + for a guided “Sign in with Google” / OIDC walkthrough (see docs/AUTH_SSO.md).

) : null}