Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/docker-compose.fullstack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions deploy/docker-compose.hosted-poc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion deploy/secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion deploy/supabase/postgres/init-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions deploy/supabase/postgres/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
$$;
Expand Down
132 changes: 132 additions & 0 deletions docs/AUTH_SSO.md
Original file line number Diff line number Diff line change
@@ -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 <client-id>.apps.googleusercontent.com \
--client-secret <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 `<base-url>/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://<your-host>/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 <id>.apps.googleusercontent.com \
--client-secret <secret> \
--base-url https://<your-host> --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=<id>.apps.googleusercontent.com
AGENT_BOM_OIDC_CLIENT_SECRET=<secret>
AGENT_BOM_OIDC_REDIRECT_URI=https://<your-host>/v1/auth/oidc/callback
AGENT_BOM_OIDC_AUDIENCE=<id>.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://<org>.okta.com` |
| Entra ID | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
| Auth0 | `https://<tenant>.us.auth0.com/` |
| Keycloak | `https://<host>/realms/<realm>` |

```bash
agent-bom auth setup-oidc --provider generic \
--issuer https://<org>.okta.com \
--client-id <client-id> \
--client-secret <client-secret> \
--base-url https://<your-host> \
--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)
13 changes: 12 additions & 1 deletion docs/DEPLOY_PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/DEPLOY_QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ENTERPRISE_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<org>-<account>.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.

Expand Down
12 changes: 10 additions & 2 deletions scripts/deploy/hosted_poc_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions site-docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
7 changes: 7 additions & 0 deletions src/agent_bom/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading