Files
harmony/examples/harmony_sso/README.md
Sylvain Tremblay 4eee06a1f2
All checks were successful
Run Check Script / check (pull_request) Successful in 2m37s
feat(example-harmony-sso): example aligned to AGENTS.md minimalism bar
Ports examples/harmony_sso/ onto current master, trimmed to the new bar:
- main.rs: drop multi-paragraph docstrings, WHAT-narration, ASCII
  section dividers; factor the duplicated OpenbaoSetupScore literal
  into openbao_setup(jwt: Option<OpenbaoJwtAuth>); add SAFETY note on
  the second unsafe set_var block
- README.md: drop doc-rotted --demo / --sso-demo sections (flags don't
  exist), drop duplicated HMAC/identity Notes section, tighten audit
  prose
- harmony_sso_plan.md unchanged (Lessons Learned are real institutional
  knowledge not derivable from code)

Same end-to-end runtime behaviour as the validated integration branch.
PR 4 of 4 splitting feat/unified-config-and-secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:44:27 -04:00

114 lines
4.6 KiB
Markdown

# Harmony SSO Example
Deploys Zitadel (identity provider) and OpenBao (secrets management) on a local k3d cluster, then round-trips two `harmony_config` structs (Secret + Standard class) through OpenBao via SSO-authenticated access.
## Prerequisites
- Docker running
- Ports 8080 and 8200 free
- `/etc/hosts` entries (or a local DNS resolver):
```
127.0.0.1 sso.harmony.local
127.0.0.1 bao.harmony.local
```
## Usage
```bash
# Deploy everything (OpenBao + Zitadel) and round-trip the example configs
cargo run -p example-harmony-sso
# OpenBao only (faster, skip Zitadel)
cargo run -p example-harmony-sso -- --skip-zitadel
# Teardown
cargo run -p example-harmony-sso -- --cleanup
```
The example sets `HARMONY_SECRET_STORE=file` and `HARMONY_SECRET_NAMESPACE=harmony-sso-example` at startup so the global `harmony_secret` manager uses the local-file backend (`~/.local/share/harmony/secrets/`). Override either in your shell to change behaviour.
## What gets deployed
| Component | Namespace | Access |
|---|---|---|
| OpenBao (standalone, file storage) | `openbao` | `http://bao.harmony.local:8200` |
| Zitadel (with CNPG PostgreSQL) | `zitadel` | `http://sso.harmony.local:8080` |
### OpenBao configuration
- **Auth methods:** userpass, JWT
- **Secrets engine:** KV v2 at `secret/`
- **Policy:** `harmony-dev` grants CRUD on `secret/data/harmony/*`
- **Userpass credentials:** `harmony` / `harmony-dev-password`
- **JWT auth:** Zitadel as OIDC provider, role `harmony-developer`
- **Unseal keys:** `~/.local/share/harmony/openbao/unseal-keys.json`
- **Audit device:** file audit at `/openbao/audit/audit.log` (see below)
## Audit log: who changed what
OpenBao's KV v2 version history shows *when* a value changed and which version, but never *who* — the caller identity isn't in KV metadata. It lives in the **audit log**.
A file audit device is enabled at `/openbao/audit/audit.log` (`auditStorage` PVC), logging every request/response with the authenticated identity. SSO mints the OpenBao token from the Zitadel id_token with `user_claim=email`, so each operation is attributed to the user.
**Applying audit config changes:** the device loads at OpenBao startup, and the helm chart's StatefulSet uses `updateStrategy: OnDelete` (a Vault/OpenBao restart needs a manual unseal — auto-rolling is intentionally disabled). So `helm upgrade` updates the server config but does *not* recreate the pod; `kubectl rollout restart` reports success but does nothing here. Delete the pod yourself and re-run to unseal:
```bash
kubectl delete pod openbao-0 -n openbao
cargo run -p example-harmony-sso # unseals the fresh pod
```
Reading the audit needs the **root token** (the `harmony-dev` policy can't touch `sys/audit`):
```bash
ROOT=$(jq -r .root_token ~/.local/share/harmony/openbao/unseal-keys.json)
# Confirm device is on:
kubectl exec -n openbao openbao-0 -- sh -c "export VAULT_TOKEN=$ROOT && bao audit list"
# Path Type Description
# file/ file n/a
```
(`bao audit list` with no token returns `403 permission denied` — that means "you didn't authenticate", not "audit is off".)
See who performed each operation. Paths and values are HMAC-hashed by default, so filter on the *identity* fields:
```bash
kubectl exec -n openbao openbao-0 -- cat /openbao/audit/audit.log | \
jq -c 'select(.type == "response" and .auth.display_name != null)
| {time, op: .request.operation,
who: .auth.display_name, role: .auth.metadata.role,
entity: .auth.entity_id}' | tail -n 15
```
SSO-authenticated operations are attributed to the user:
```
{"time":"...","op":"update","who":"jwt-admin@zitadel.sso.harmony.local","role":"harmony-developer","entity":"81ddf1d7-..."}
```
The `jwt-` prefix is the JWT auth-mount name; the rest is the `user_claim=email` value from the id_token. Setup/unseal steps run with the root token and show as `"who":"root"`. For raw paths/values, tune the audit device (`log_raw`, `hmac_accessor`) — out of scope here.
## Architecture
```
Developer CLI
|
|-- harmony_config::ConfigClient
| |-- EnvSource (HARMONY_CONFIG_* env vars)
| |-- StoreSource<OpenbaoSecretStore>
| |-- Token auth (OPENBAO_TOKEN)
| |-- Cached token validation
| |-- Zitadel OIDC device flow (RFC 8628)
| |-- Userpass fallback
|
v
k3d cluster (harmony-example)
|-- OpenBao (KV v2 secrets engine)
| |-- JWT auth -> validates Zitadel id_tokens
| |-- userpass auth -> dev credentials
|
|-- Zitadel (OpenID Connect IdP)
|-- Device authorization grant
|-- Federated login (Google, GitHub, Entra ID)
```