Allow redirect url path when user signs in #316

Closed
reda wants to merge 4 commits from feat/auth-add-next-url-redirect into master
16 changed files with 1406 additions and 15 deletions

View File

@@ -1,7 +0,0 @@
FLEET_AUTH_ISSUER_URL=
FLEET_AUTH_AUTHORIZE_URL=
FLEET_AUTH_TOKEN_URL=
FLEET_AUTH_CLIENT_ID=
FLEET_AUTH_REDIRECT_URI=
FLEET_AUTH_SCOPE=
FLEET_AUTH_TRUSTED_AUDIENCES=

25
GLOSSARY.md Normal file
View File

@@ -0,0 +1,25 @@
# Harmony Codebase Glossary
Canonical terms for learning the Harmony codebase. Terms should be added after they are understood well enough to use correctly.
## Terms
**Score**:
A declarative desired-state type that says what Harmony should achieve.
_Avoid_: Manifest, script
**Interpret**:
The execution logic that turns a Score into operations against a Topology.
_Avoid_: Handler, runner
**Topology**:
An environment view that exposes infrastructure capabilities to Scores.
_Avoid_: Provider, backend
**Capability trait bound**:
A Rust trait constraint such as `T: Topology + HelmCommand` that says which capabilities a Score or Interpret requires from a Topology.
_Avoid_: Dependency list, runtime check
**Portable Score**:
A high-level Score whose trait bounds name capabilities rather than one concrete backend, allowing multiple Topologies to satisfy the same desired state differently.
_Avoid_: Generic deployment, abstract Score

19
MISSION.md Normal file
View File

@@ -0,0 +1,19 @@
# Mission: Learn the Harmony Codebase
## Why
Learn Harmony well enough to contribute small, correct changes that preserve its architecture: typed infrastructure-as-code, Score-Topology-Interpret, and minimal implementation discipline.
## Success looks like
- Trace a change request to the right crate and owning module.
- Explain whether a change belongs in a Score, Topology capability, adapter, deploy crate, CLI, or test harness.
- Make a small change and choose a focused verification command.
- Avoid architectural regressions such as handrolled manifests outside Scores or speculative abstractions.
## Constraints
- Prefer short, code-grounded lessons over broad surveys.
- Use repository docs and source as primary material.
- Keep exercises practical for future contribution work.
## Out of scope
- Becoming expert in every provider backend at once.
- Deep fleet-agent or hardware-discovery internals until the core contribution workflow is clear.

598
OPENBAO_POLICY_MIGRATION.md Normal file
View File

@@ -0,0 +1,598 @@
# OpenBao Entity Policy Migration Guide
Moving fleet device auth from Design A (JWT `deployments` claim) to Design B (OpenBao entity policies).
---
## Background: how the pieces fit together
Before touching any code, here is the data flow you need to hold in your head.
### The three systems
**Zitadel** is the identity provider. Every fleet device has a **machine user**
in Zitadel with a key file (a JSON file containing an RSA private key). The
device uses that key to mint a short-lived JWT, signed by Zitadel, proving "I
am device X."
**OpenBao** is the secret store (an open-source Vault fork). It holds
per-deployment secrets that devices need at runtime (e.g. API keys,
certificates). A device can only read secrets for the deployments it is
currently assigned to.
**Harmony operator** (`fleet/harmony-fleet-operator`) is the orchestration
process running in the cluster. It watches Kubernetes `Deployment` CRDs and
`Device` CRDs, and its job is to keep everything in sync: NATS desired-state
KV entries, OpenBao entity policies, Kubernetes status patches.
### How a device reads a secret (the auth flow)
```
Device Zitadel OpenBao
| | |
|-- sign JWT with key ---→ | |
|←-- short-lived JWT ------ |
| |
|-- POST /v1/auth/jwt/login (jwt=<token>) --------→ |
|←-- OpenBao token (scoped by entity policies) ---- |
| |
|-- GET /v1/secret/data/deployment-a/... ---------→ |
|←-- secret value --------------------------------- |
```
OpenBao verifies the JWT against Zitadel's JWKS endpoint, then looks up the
entity associated with the JWT's `sub` claim. The entity carries a policy
list. The issued OpenBao token inherits those policies.
### How deployment assignment works (the operator flow)
```
Operator
|
|-- watches k8s Deployment CR (spec.targetSelector)
|-- watches k8s Device CR (metadata.labels)
|
| When device labels match a deployment's selector:
|-- writes desired-state.<device>.<deployment> to NATS KV ← tells the agent what to run
|-- calls PUT /v1/identity/entity/name/<device> on OpenBao ← grants access to secrets
|
| When the match goes away:
|-- deletes the NATS KV entry
|-- updates entity policies (removing that deployment)
```
The two writes always happen together. A device can run a deployment task only
if the NATS KV entry exists (so it knows what to do) AND the entity has the
policy (so it can read the secrets).
---
## Why we are changing (Design A → Design B)
**Design A** encoded the device's current deployments as a `deployments: ["a", "b"]` claim inside the Zitadel JWT. OpenBao read that claim via `groups_claim=deployments` and mapped it to policies at login time. This meant:
- Every time a device was reassigned to a new deployment, it had to log out of OpenBao and back in to get a new token with updated scope.
- At 50k devices all receiving a new deployment simultaneously, that is a thundering herd hitting both Zitadel (JWT mint) and OpenBao (JWT login) at the same time.
**Design B** stores the policy list directly on the OpenBao entity. The device logs in once and gets a token. When the operator changes the assignment, it updates the entity in OpenBao. The **same** device token, on its next API call, sees the updated policy. No re-auth required.
The `deployments` JWT claim and the Zitadel action that set it are both deleted.
---
## Code map: where each concept lives
| Concept | File |
|---|---|
| OpenBao Helm deploy | `harmony/src/modules/openbao/mod.rs``OpenbaoScore` |
| OpenBao init, unseal, JWT auth config | `harmony/src/modules/openbao/setup.rs``OpenbaoSetupScore` |
| Zitadel Helm deploy + PG | `harmony/src/modules/zitadel/mod.rs``ZitadelScore` |
| Zitadel post-deploy config (machine users, apps) | `harmony/src/modules/zitadel/setup.rs``ZitadelSetupScore` |
| **[DELETE]** Zitadel action that set `deployments` claim | `harmony/src/modules/zitadel/action.rs` |
| Device secret reading (harmony_secret client) | `harmony_secret/src/store/openbao.rs``OpenbaoSecretStore` |
| Operator: deployment assignment → NATS KV | `fleet/harmony-fleet-operator/src/fleet_aggregator.rs` |
| Operator: wires up k8s CRD watch + NATS | `fleet/harmony-fleet-operator/src/main.rs` |
---
## Step 1 — Delete `action.rs`
**File:** `harmony/src/modules/zitadel/action.rs`
This file implements `ZitadelDeploymentsClaimActionScore`. A "Zitadel Action"
is a JavaScript snippet that Zitadel executes during token issuance to add
custom claims. This one read the machine user's `deployments` metadata and
injected it as a JWT claim. With Design B, OpenBao no longer reads this claim —
delete the file entirely.
```
git rm harmony/src/modules/zitadel/action.rs
```
---
## Step 2 — Remove the `action` module from `zitadel/mod.rs`
**File:** `harmony/src/modules/zitadel/mod.rs`, line 1
Remove:
```rust
pub mod action;
```
Nothing else in `mod.rs` re-exports types from `action`, so this is the only line to touch.
**Why it is a `pub mod` at all:** In Rust, modules must be explicitly declared. `pub mod action` both declares the submodule and makes it visible to crates that depend on `harmony`. Since the file is gone, the declaration must go too, or the compiler will error looking for `action.rs`.
---
## Step 3 — Strip Design A from `harmony_secret/src/store/openbao.rs`
This file is the device-side client that authenticates to OpenBao and reads secrets. It currently carries leftover Design A logic: decoding the `deployments` JWT claim to track which secrets the current token is scoped to. In Design B the scope is stored in OpenBao itself, so the device code doesn't need to know about it.
### 3a. Remove imports
Lines 3 and 7 — remove:
```rust
use base64::Engine;
use std::collections::HashSet;
```
`base64` is only used to decode the JWT body for `decode_deployments_claim`. `HashSet` is only used to hold the scope. Both go away.
### 3b. Remove `decode_deployments_claim`
Lines 3756 — remove the entire function:
```rust
fn decode_deployments_claim(jwt: &str) -> Result<HashSet<String>, SecretStoreError> {
// ... base64-decode the JWT body, parse the `deployments` array
}
```
### 3c. Remove `scope` from the `Inner` struct
Line 71 — the `Inner` struct holds the live `VaultClient` plus a cached copy of what deployments the current token can access. Remove `scope`:
```rust
// Before
struct Inner {
client: VaultClient,
scope: HashSet<String>,
}
// After
struct Inner {
client: VaultClient,
}
```
### 3d. Update `refresh_auth`
This method re-mints the Zitadel JWT and exchanges it for a new OpenBao token. Remove the scope decoding line and update the `Inner` construction:
```rust
// Remove this line:
let scope = decode_deployments_claim(&jwt)?;
// Change this:
*self.inner.lock().await = Inner { client, scope };
// To:
*self.inner.lock().await = Inner { client };
```
### 3e. Remove `cached_scope`
Lines 336338 — remove:
```rust
pub async fn cached_scope(&self) -> HashSet<String> {
self.inner.lock().await.scope.clone()
}
```
### 3f. Update `with_token_and_jwt`
Line 466 — remove `scope: HashSet::new()` from the `Inner` construction:
```rust
// Before
let inner = Mutex::new(Inner { client, scope: HashSet::new() });
// After
let inner = Mutex::new(Inner { client });
```
### 3g. Remove the tests
Remove `fn unsigned_jwt` (the test helper that constructs a fake JWT using base64) and both `decode_deployments_claim_*` test functions at lines 708750. The two remaining tests (`test_hash_url_consistency` and `test_hash_url_uniqueness`) are unrelated and stay.
---
## Step 4 — Remove `base64` from `harmony_secret/Cargo.toml`
**File:** `harmony_secret/Cargo.toml`, line 16
Remove:
```toml
base64.workspace = true
```
`base64` was a workspace dependency (version pinned in the root `Cargo.toml`). It was only used in `openbao.rs` for `decode_deployments_claim` and the test helper. After Step 3 it is unused; leaving it would cause a `cargo clippy` warning about unused deps.
---
## Step 5 — Update `OpenbaoJwtAuth` in `setup.rs`
**File:** `harmony/src/modules/openbao/setup.rs`
`OpenbaoSetupScore` handles the one-time OpenBao bootstrap: init, unseal, KV engine, userpass auth, and JWT auth. `OpenbaoJwtAuth` is the configuration struct for the JWT auth method.
### 5a. Remove `policies` from `OpenbaoJwtAuth`
```rust
// Before
pub struct OpenbaoJwtAuth {
pub oidc_discovery_url: String,
pub bound_issuer: String,
pub role_name: String,
pub bound_audiences: String,
pub user_claim: String,
pub policies: Vec<String>, // ← remove
pub ttl: String,
pub max_ttl: String,
}
```
In Design A, the JWT role itself carried policies so every device that logged in got those policies unconditionally (or via `groups_claim`). In Design B, the role grants **no static policies** — policies come from the entity. OpenBao will still attach the built-in `default` policy automatically.
### 5b. Update `configure_jwt` in `OpenbaoSetupInterpret`
Remove the two lines that format and inject `policies` into the `bao write auth/jwt/role/...` command:
```rust
// Remove:
let policies = jwt.policies.join(",");
// Remove from the &[...] slice:
&format!("policies={}", policies),
```
The `user_claim` field stays — this is what tells OpenBao which JWT field to use as the entity alias name (you'll set it to `sub`, the Zitadel machine user's client ID). OpenBao will auto-create an entity alias on first login and link it to whatever entity already has that alias name.
---
## Step 6 — Add entity management to `setup.rs`
**File:** `harmony/src/modules/openbao/setup.rs`
### How OpenBao identity entities work
OpenBao's identity engine has three concepts:
- **Entity**: a named principal with an attached policy list. Think of it as the device's identity record inside OpenBao.
- **Entity alias**: links an external identity (e.g. the `sub` value from a Zitadel JWT) to an entity. One entity can have aliases from multiple auth methods.
- **Policy**: an HCL document granting read/write access to specific KV paths. You'll have one per deployment, e.g. `deployment-a` grants `read` on `secret/data/deployment-a/*`.
When a device logs in via JWT auth, OpenBao looks up the entity alias matching `(jwt_mount_accessor, jwt_sub_value)`. The entity that alias points to defines what the token can do.
### 6a. Add `OpenbaoEntity`
```rust
#[derive(Debug, Clone, Serialize)]
pub struct OpenbaoEntity {
/// Name of the entity in OpenBao (typically the device ID or machine user name).
pub name: String,
/// The JWT `sub` value for this device — the Zitadel machine user's client ID.
/// This is what OpenBao uses as the entity alias name on the JWT auth mount.
pub alias_name: String,
/// Initial policies to attach. Usually empty at setup time; the operator
/// updates this when deployment assignment happens.
pub policies: Vec<String>,
}
```
### 6b. Add `entities` to `OpenbaoSetupScore`
```rust
pub struct OpenbaoSetupScore {
// ... existing fields ...
#[serde(default)]
pub entities: Vec<OpenbaoEntity>,
}
```
### 6c. Add `create_entities` to `OpenbaoSetupInterpret`
This method runs after `configure_jwt`. For each entity it:
1. Creates/updates the entity with its policies:
```
bao write identity/entity name=<name> policies=<csv>
```
2. Reads back the entity ID (needed to create the alias):
```
bao read -format=json identity/entity/name/<name>
```
Parse `data.id` from the JSON output.
3. Gets the JWT auth mount's accessor (a stable ID OpenBao assigns to each auth method mount):
```
bao auth list -format=json
```
Parse the `accessor` field for the `jwt/` key.
4. Creates the entity alias (idempotent — same alias on same mount accessor is an upsert):
```
bao write identity/entity-alias \
name=<alias_name> \
mount_accessor=<accessor> \
canonical_id=<entity_id>
```
Call `create_entities` in `execute()` after `configure_jwt()`.
### 6d. Add `OpenbaoEntityPoliciesScore`
This is the Score the operator runs (via `OpenbaoEntityPoliciesScore::interpret`) when a device's deployment assignment changes. It's separate from `OpenbaoSetupScore` because it is a **runtime** operation, not a one-time setup step.
```rust
#[derive(Debug, Clone, Serialize)]
pub struct OpenbaoEntityPoliciesScore {
pub instance: OpenbaoInstance,
pub entity_name: String,
pub policies: Vec<String>,
}
```
Its `Interpret::execute` runs one command:
```
bao write identity/entity/name/<entity_name> policies=<csv>
```
The `bao write identity/entity/name/...` endpoint is an upsert — it creates or updates. Passing an empty `policies` list clears all policies. This is idempotent and safe to retry.
---
## Step 7 — Export new types from `harmony/src/modules/openbao/mod.rs`
**File:** `harmony/src/modules/openbao/mod.rs`
Add to the `pub use setup::...` line:
```rust
pub use setup::{
OpenbaoEntity,
OpenbaoEntityPoliciesScore,
OpenbaoJwtAuth,
OpenbaoPolicy,
OpenbaoSetupScore,
OpenbaoUser,
cached_root_token,
};
```
---
## Step 8 — New file: `fleet/harmony-fleet-operator/src/openbao.rs`
**Why this file exists:** The operator needs to call OpenBao's HTTP API directly (not via pod exec like `OpenbaoSetupScore` does — that requires k8s exec access and is too slow for a hot reconcile loop). `reqwest` is already in the operator's `Cargo.toml`.
```rust
use anyhow::Result;
pub struct OpenBaoIdentityClient {
client: reqwest::Client,
base_url: String,
token: String,
}
impl OpenBaoIdentityClient {
pub fn new(base_url: String, token: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
token,
}
}
/// Idempotent upsert: sets the entity's policy list to exactly `policies`.
/// Passing an empty slice clears all policies (device loses all secret access).
pub async fn set_entity_policies(&self, entity: &str, policies: &[String]) -> Result<()> {
let url = format!(
"{}/v1/identity/entity/name/{}",
self.base_url.trim_end_matches('/'),
entity
);
let body = serde_json::json!({ "policies": policies });
let resp = self.client
.post(&url)
.header("X-Vault-Token", &self.token)
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("OpenBao entity update {entity} returned {status}: {text}");
}
Ok(())
}
}
```
**Note on the HTTP method:** OpenBao's identity entity endpoint accepts both `POST` and `PUT`. `POST /v1/identity/entity/name/<name>` is the create-or-update form (no entity ID required). If the entity doesn't exist yet, OpenBao creates it; if it does, it updates it.
---
## Step 9 — Update `fleet/harmony-fleet-operator/src/fleet_aggregator.rs`
This is the biggest conceptual change. Read the existing file top-to-bottom once before making changes — understanding the `FleetState` struct and `owned_targets` is key.
### How `owned_targets` works
`owned_targets: HashMap<DeploymentName, HashSet<device_id>>` is
the aggregator's in-memory truth of "which devices currently
match which deployments." It is the diff source for both the NATS
KV writes and (after this change) the OpenBao entity updates.
When a device gains a deployment:
`owned_targets[deployment].insert(device_id)` + KV put.
When a device loses a deployment:
`owned_targets[deployment].remove(device_id)` + KV delete.
To compute the full policy list for a device at any point, you
iterate over `owned_targets` and collect every deployment name
where the device appears.
### 9a. Update `run` signature
```rust
pub async fn run(
client: Client,
js: async_nats::jetstream::Context,
openbao: Option<Arc<OpenBaoIdentityClient>>,
) -> anyhow::Result<()>
```
Thread `openbao` down to the internal event handlers by adding
it as a parameter or by wrapping it in the existing
`SharedFleetState` (the latter is cleaner since you avoid
changing every function signature — add `openbao:
Option<Arc<OpenBaoIdentityClient>>` to `FleetState`).
### 9b. Add the policy helper
```rust
/// Returns the full set of OpenBao policies for `device_id` given the
/// current `owned_targets`. One policy per matched deployment, named
/// `deployment-<name>` to match the HCL policies created by OpenbaoSetupScore.
fn device_deployment_policies(state: &FleetState, device_id: &str) -> Vec<String> {
state
.owned_targets
.iter()
.filter(|(_, devices)| devices.contains(device_id))
.map(|(dn, _)| format!("deployment-{}", dn.as_str()))
.collect()
}
```
**Why compute the full set instead of add/remove:** If you add/remove incrementally you need a read-before-write (race condition) or local state that can drift. Writing the full current set is idempotent and always converges to the right answer, even after an operator restart.
### 9c. Add OpenBao sync to `on_device_upsert`
After the `match (was, now)` block that does the KV writes, add:
```rust
// After all KV writes/deletes for this device, sync entity policies.
if let Some(bao) = &openbao {
let policies = {
let guard = state.lock().await;
device_deployment_policies(&guard, &name)
};
if let Err(e) = bao.set_entity_policies(&name, &policies).await {
tracing::warn!(device = %name, error = %e, "aggregator: OpenBao entity policy sync failed");
}
}
```
The OpenBao call is **outside** the lock and **non-fatal**. Matching the existing pattern for KV write failures — log and continue, don't abort the reconcile loop.
### 9d. Add OpenBao sync to `on_deployment_delete`
After the loop that deletes KV entries for previously-owned devices, update each device's entity:
```rust
for device in &previous {
// ... existing KV delete ...
if let Some(bao) = &openbao {
let policies = {
let guard = state.lock().await;
device_deployment_policies(&guard, device)
};
if let Err(e) = bao.set_entity_policies(device, &policies).await {
tracing::warn!(device = %device, error = %e, "aggregator: OpenBao policy sync on deployment delete failed");
}
}
}
```
### 9e. Add OpenBao sync to `on_device_delete`
When a device CR is deleted (device decommissioned), clear all its policies:
```rust
if let Some(bao) = &openbao {
if let Err(e) = bao.set_entity_policies(&name, &[]).await {
tracing::warn!(device = %name, error = %e, "aggregator: OpenBao policy clear on device delete failed");
}
}
```
---
## Step 10 — Update `fleet/harmony-fleet-operator/src/main.rs`
### 10a. Add CLI args
Add to the `Cli` struct:
```rust
#[arg(long, env = "OPENBAO_URL", global = true)]
openbao_url: Option<String>,
#[arg(long, env = "OPENBAO_TOKEN", global = true)]
openbao_token: Option<String>,
```
Both are optional. If either is absent, the operator runs without OpenBao policy sync (same as today — useful for dev environments without OpenBao).
### 10b. Build the client and pass it to the aggregator
In the `run()` function:
```rust
let openbao = match (&cli.openbao_url, &cli.openbao_token) {
(Some(url), Some(token)) => {
tracing::info!(url = %url, "OpenBao identity sync enabled");
Some(Arc::new(OpenBaoIdentityClient::new(url.clone(), token.clone())))
}
_ => {
tracing::warn!("OPENBAO_URL or OPENBAO_TOKEN not set — entity policy sync disabled");
None
}
};
// Pass to the aggregator:
r = fleet_aggregator::run(client, js, openbao) => r,
```
The admin token (`OPENBAO_TOKEN`) should be mounted from a Kubernetes Secret in the operator's Pod spec — same pattern as `FLEET_OPERATOR_CREDENTIALS_TOML`. The token needs a policy that allows writes to `identity/entity/name/*`.
---
## Complete change list
| File | What changes |
|---|---|
| `harmony/src/modules/zitadel/action.rs` | **Delete** — no longer needed |
| `harmony/src/modules/zitadel/mod.rs` | Remove `pub mod action;` (1 line) |
| `harmony_secret/src/store/openbao.rs` | Remove `decode_deployments_claim`, `scope` field, `cached_scope`, base64/HashSet imports, 2 tests |
| `harmony_secret/Cargo.toml` | Remove `base64` dep (1 line) |
| `harmony/src/modules/openbao/setup.rs` | Remove `policies` from `OpenbaoJwtAuth`; add `OpenbaoEntity`, `entities` field, `create_entities()`, `OpenbaoEntityPoliciesScore` |
| `harmony/src/modules/openbao/mod.rs` | Export `OpenbaoEntity`, `OpenbaoEntityPoliciesScore` |
| `fleet/harmony-fleet-operator/src/openbao.rs` | **New** — `OpenBaoIdentityClient::set_entity_policies` |
| `fleet/harmony-fleet-operator/src/fleet_aggregator.rs` | Add openbao param, `device_deployment_policies` helper, 3 call sites |
| `fleet/harmony-fleet-operator/src/main.rs` | Add `OPENBAO_URL`/`OPENBAO_TOKEN` args; build and thread `OpenBaoIdentityClient` |
---
## Verification checklist
After all changes:
```bash
cargo check --all-targets --all-features --keep-going
cargo fmt --check
cargo clippy
cargo test
```
Manual smoke test (requires a running local stack):
1. Assign a device to `deployment-a` via the k8s CR → operator logs should show `OpenBao entity policy sync` for that device → `bao read identity/entity/name/<device>` should show `policies: ["deployment-a"]`.
2. Reassign the device to `deployment-b` → entity should update to `["deployment-b"]` without the device re-authenticating to OpenBao.
3. Remove the device CR → entity policies should be cleared.

31
RESOURCES.md Normal file
View File

@@ -0,0 +1,31 @@
# Harmony Codebase Resources
## Knowledge
- [Repository contract: `AGENTS.md`](./AGENTS.md)
Canonical contribution rules: minimalism, Score-Topology-Interpret, capability boundaries, deploy architecture, build commands.
- [Guide: Writing a Score](./docs/guides/writing-a-score.md)
Practical introduction to declaring desired state, capability trait bounds, and Interpret execution logic.
- [Guide: Writing a Topology](./docs/guides/writing-a-topology.md)
Use when learning how environments expose capabilities to Scores.
- [Guide: Adding Capabilities](./docs/guides/adding-capabilities.md)
Use when a change needs a new domain-level infrastructure capability.
- [ADR-002: Hexagonal Architecture](./docs/adr/002-hexagonal-architecture.md)
Explains the domain versus infrastructure adapter split.
- [ADR-003: Infrastructure Abstractions](./docs/adr/003-infrastructure-abstractions.md)
Use for capability naming and vendor-boundary decisions.
- [ADR-015: Higher-Order Topologies](./docs/adr/015-higher-order-topologies.md)
Use for topology composition and blanket capability forwarding.
- [ADR-023: Deploy Architecture](./docs/adr/023-deploy-architecture.md)
Canonical deploy rules: Scores over manifests, deploy crates, smoke-test ownership, topology selection.
- [Workspace manifest: `Cargo.toml`](./Cargo.toml)
Map of crates and their rough system boundaries.
## Wisdom (Communities)
- Project maintainers and code review in this repository.
Best source for contribution judgment because many rules are project-specific and enforced by review taste.
## Gaps
- No external community is recorded yet; this mission is repository-specific, so maintainers are the primary wisdom source.

View File

@@ -759,7 +759,7 @@ pub async fn mint_access_token(
access_token: String,
}
let tr: TokenResponse = resp.json().await.context("parse token response")?;
if std::env::var("FLEET_AUTH_CALLOUT_DEBUG_TOKENS").is_ok()
if std::env::var("HARMONY_SSO_CALLOUT_DEBUG_TOKENS").is_ok()
&& let Some(payload_b64) = tr.access_token.split('.').nth(1)
{
use base64::Engine;

View File

@@ -0,0 +1,598 @@
# OpenBao Entity Policy Migration Guide
Moving fleet device auth from Design A (JWT `deployments` claim) to Design B (OpenBao entity policies).
---
## Background: how the pieces fit together
Before touching any code, here is the data flow you need to hold in your head.
### The three systems
**Zitadel** is the identity provider. Every fleet device has a **machine user**
in Zitadel with a key file (a JSON file containing an RSA private key). The
device uses that key to mint a short-lived JWT, signed by Zitadel, proving "I
am device X."
**OpenBao** is the secret store (an open-source Vault fork). It holds
per-deployment secrets that devices need at runtime (e.g. API keys,
certificates). A device can only read secrets for the deployments it is
currently assigned to.
**Harmony operator** (`fleet/harmony-fleet-operator`) is the orchestration
process running in the cluster. It watches Kubernetes `Deployment` CRDs and
`Device` CRDs, and its job is to keep everything in sync: NATS desired-state
KV entries, OpenBao entity policies, Kubernetes status patches.
### How a device reads a secret (the auth flow)
```
Device Zitadel OpenBao
| | |
|-- sign JWT with key ---→ | |
|←-- short-lived JWT ------ |
| |
|-- POST /v1/auth/jwt/login (jwt=<token>) --------→ |
|←-- OpenBao token (scoped by entity policies) ---- |
| |
|-- GET /v1/secret/data/deployment-a/... ---------→ |
|←-- secret value --------------------------------- |
```
OpenBao verifies the JWT against Zitadel's JWKS endpoint, then looks up the
entity associated with the JWT's `sub` claim. The entity carries a policy
list. The issued OpenBao token inherits those policies.
### How deployment assignment works (the operator flow)
```
Operator
|
|-- watches k8s Deployment CR (spec.targetSelector)
|-- watches k8s Device CR (metadata.labels)
|
| When device labels match a deployment's selector:
|-- writes desired-state.<device>.<deployment> to NATS KV ← tells the agent what to run
|-- calls PUT /v1/identity/entity/name/<device> on OpenBao ← grants access to secrets
|
| When the match goes away:
|-- deletes the NATS KV entry
|-- updates entity policies (removing that deployment)
```
The two writes always happen together. A device can run a deployment task only
if the NATS KV entry exists (so it knows what to do) AND the entity has the
policy (so it can read the secrets).
---
## Why we are changing (Design A → Design B)
**Design A** encoded the device's current deployments as a `deployments: ["a", "b"]` claim inside the Zitadel JWT. OpenBao read that claim via `groups_claim=deployments` and mapped it to policies at login time. This meant:
- Every time a device was reassigned to a new deployment, it had to log out of OpenBao and back in to get a new token with updated scope.
- At 50k devices all receiving a new deployment simultaneously, that is a thundering herd hitting both Zitadel (JWT mint) and OpenBao (JWT login) at the same time.
**Design B** stores the policy list directly on the OpenBao entity. The device logs in once and gets a token. When the operator changes the assignment, it updates the entity in OpenBao. The **same** device token, on its next API call, sees the updated policy. No re-auth required.
The `deployments` JWT claim and the Zitadel action that set it are both deleted.
---
## Code map: where each concept lives
| Concept | File |
|---|---|
| OpenBao Helm deploy | `harmony/src/modules/openbao/mod.rs``OpenbaoScore` |
| OpenBao init, unseal, JWT auth config | `harmony/src/modules/openbao/setup.rs``OpenbaoSetupScore` |
| Zitadel Helm deploy + PG | `harmony/src/modules/zitadel/mod.rs``ZitadelScore` |
| Zitadel post-deploy config (machine users, apps) | `harmony/src/modules/zitadel/setup.rs``ZitadelSetupScore` |
| **[DELETE]** Zitadel action that set `deployments` claim | `harmony/src/modules/zitadel/action.rs` |
| Device secret reading (harmony_secret client) | `harmony_secret/src/store/openbao.rs``OpenbaoSecretStore` |
| Operator: deployment assignment → NATS KV | `fleet/harmony-fleet-operator/src/fleet_aggregator.rs` |
| Operator: wires up k8s CRD watch + NATS | `fleet/harmony-fleet-operator/src/main.rs` |
---
## Step 1 — Delete `action.rs`
**File:** `harmony/src/modules/zitadel/action.rs`
This file implements `ZitadelDeploymentsClaimActionScore`. A "Zitadel Action"
is a JavaScript snippet that Zitadel executes during token issuance to add
custom claims. This one read the machine user's `deployments` metadata and
injected it as a JWT claim. With Design B, OpenBao no longer reads this claim —
delete the file entirely.
```
git rm harmony/src/modules/zitadel/action.rs
```
---
## Step 2 — Remove the `action` module from `zitadel/mod.rs`
**File:** `harmony/src/modules/zitadel/mod.rs`, line 1
Remove:
```rust
pub mod action;
```
Nothing else in `mod.rs` re-exports types from `action`, so this is the only line to touch.
**Why it is a `pub mod` at all:** In Rust, modules must be explicitly declared. `pub mod action` both declares the submodule and makes it visible to crates that depend on `harmony`. Since the file is gone, the declaration must go too, or the compiler will error looking for `action.rs`.
---
## Step 3 — Strip Design A from `harmony_secret/src/store/openbao.rs`
This file is the device-side client that authenticates to OpenBao and reads secrets. It currently carries leftover Design A logic: decoding the `deployments` JWT claim to track which secrets the current token is scoped to. In Design B the scope is stored in OpenBao itself, so the device code doesn't need to know about it.
### 3a. Remove imports
Lines 3 and 7 — remove:
```rust
use base64::Engine;
use std::collections::HashSet;
```
`base64` is only used to decode the JWT body for `decode_deployments_claim`. `HashSet` is only used to hold the scope. Both go away.
### 3b. Remove `decode_deployments_claim`
Lines 3756 — remove the entire function:
```rust
fn decode_deployments_claim(jwt: &str) -> Result<HashSet<String>, SecretStoreError> {
// ... base64-decode the JWT body, parse the `deployments` array
}
```
### 3c. Remove `scope` from the `Inner` struct
Line 71 — the `Inner` struct holds the live `VaultClient` plus a cached copy of what deployments the current token can access. Remove `scope`:
```rust
// Before
struct Inner {
client: VaultClient,
scope: HashSet<String>,
}
// After
struct Inner {
client: VaultClient,
}
```
### 3d. Update `refresh_auth`
This method re-mints the Zitadel JWT and exchanges it for a new OpenBao token. Remove the scope decoding line and update the `Inner` construction:
```rust
// Remove this line:
let scope = decode_deployments_claim(&jwt)?;
// Change this:
*self.inner.lock().await = Inner { client, scope };
// To:
*self.inner.lock().await = Inner { client };
```
### 3e. Remove `cached_scope`
Lines 336338 — remove:
```rust
pub async fn cached_scope(&self) -> HashSet<String> {
self.inner.lock().await.scope.clone()
}
```
### 3f. Update `with_token_and_jwt`
Line 466 — remove `scope: HashSet::new()` from the `Inner` construction:
```rust
// Before
let inner = Mutex::new(Inner { client, scope: HashSet::new() });
// After
let inner = Mutex::new(Inner { client });
```
### 3g. Remove the tests
Remove `fn unsigned_jwt` (the test helper that constructs a fake JWT using base64) and both `decode_deployments_claim_*` test functions at lines 708750. The two remaining tests (`test_hash_url_consistency` and `test_hash_url_uniqueness`) are unrelated and stay.
---
## Step 4 — Remove `base64` from `harmony_secret/Cargo.toml`
**File:** `harmony_secret/Cargo.toml`, line 16
Remove:
```toml
base64.workspace = true
```
`base64` was a workspace dependency (version pinned in the root `Cargo.toml`). It was only used in `openbao.rs` for `decode_deployments_claim` and the test helper. After Step 3 it is unused; leaving it would cause a `cargo clippy` warning about unused deps.
---
## Step 5 — Update `OpenbaoJwtAuth` in `setup.rs`
**File:** `harmony/src/modules/openbao/setup.rs`
`OpenbaoSetupScore` handles the one-time OpenBao bootstrap: init, unseal, KV engine, userpass auth, and JWT auth. `OpenbaoJwtAuth` is the configuration struct for the JWT auth method.
### 5a. Remove `policies` from `OpenbaoJwtAuth`
```rust
// Before
pub struct OpenbaoJwtAuth {
pub oidc_discovery_url: String,
pub bound_issuer: String,
pub role_name: String,
pub bound_audiences: String,
pub user_claim: String,
pub policies: Vec<String>, // ← remove
pub ttl: String,
pub max_ttl: String,
}
```
In Design A, the JWT role itself carried policies so every device that logged in got those policies unconditionally (or via `groups_claim`). In Design B, the role grants **no static policies** — policies come from the entity. OpenBao will still attach the built-in `default` policy automatically.
### 5b. Update `configure_jwt` in `OpenbaoSetupInterpret`
Remove the two lines that format and inject `policies` into the `bao write auth/jwt/role/...` command:
```rust
// Remove:
let policies = jwt.policies.join(",");
// Remove from the &[...] slice:
&format!("policies={}", policies),
```
The `user_claim` field stays — this is what tells OpenBao which JWT field to use as the entity alias name (you'll set it to `sub`, the Zitadel machine user's client ID). OpenBao will auto-create an entity alias on first login and link it to whatever entity already has that alias name.
---
## Step 6 — Add entity management to `setup.rs`
**File:** `harmony/src/modules/openbao/setup.rs`
### How OpenBao identity entities work
OpenBao's identity engine has three concepts:
- **Entity**: a named principal with an attached policy list. Think of it as the device's identity record inside OpenBao.
- **Entity alias**: links an external identity (e.g. the `sub` value from a Zitadel JWT) to an entity. One entity can have aliases from multiple auth methods.
- **Policy**: an HCL document granting read/write access to specific KV paths. You'll have one per deployment, e.g. `deployment-a` grants `read` on `secret/data/deployment-a/*`.
When a device logs in via JWT auth, OpenBao looks up the entity alias matching `(jwt_mount_accessor, jwt_sub_value)`. The entity that alias points to defines what the token can do.
### 6a. Add `OpenbaoEntity`
```rust
#[derive(Debug, Clone, Serialize)]
pub struct OpenbaoEntity {
/// Name of the entity in OpenBao (typically the device ID or machine user name).
pub name: String,
/// The JWT `sub` value for this device — the Zitadel machine user's client ID.
/// This is what OpenBao uses as the entity alias name on the JWT auth mount.
pub alias_name: String,
/// Initial policies to attach. Usually empty at setup time; the operator
/// updates this when deployment assignment happens.
pub policies: Vec<String>,
}
```
### 6b. Add `entities` to `OpenbaoSetupScore`
```rust
pub struct OpenbaoSetupScore {
// ... existing fields ...
#[serde(default)]
pub entities: Vec<OpenbaoEntity>,
}
```
### 6c. Add `create_entities` to `OpenbaoSetupInterpret`
This method runs after `configure_jwt`. For each entity it:
1. Creates/updates the entity with its policies:
```
bao write identity/entity name=<name> policies=<csv>
```
2. Reads back the entity ID (needed to create the alias):
```
bao read -format=json identity/entity/name/<name>
```
Parse `data.id` from the JSON output.
3. Gets the JWT auth mount's accessor (a stable ID OpenBao assigns to each auth method mount):
```
bao auth list -format=json
```
Parse the `accessor` field for the `jwt/` key.
4. Creates the entity alias (idempotent — same alias on same mount accessor is an upsert):
```
bao write identity/entity-alias \
name=<alias_name> \
mount_accessor=<accessor> \
canonical_id=<entity_id>
```
Call `create_entities` in `execute()` after `configure_jwt()`.
### 6d. Add `OpenbaoEntityPoliciesScore`
This is the Score the operator runs (via `OpenbaoEntityPoliciesScore::interpret`) when a device's deployment assignment changes. It's separate from `OpenbaoSetupScore` because it is a **runtime** operation, not a one-time setup step.
```rust
#[derive(Debug, Clone, Serialize)]
pub struct OpenbaoEntityPoliciesScore {
pub instance: OpenbaoInstance,
pub entity_name: String,
pub policies: Vec<String>,
}
```
Its `Interpret::execute` runs one command:
```
bao write identity/entity/name/<entity_name> policies=<csv>
```
The `bao write identity/entity/name/...` endpoint is an upsert — it creates or updates. Passing an empty `policies` list clears all policies. This is idempotent and safe to retry.
---
## Step 7 — Export new types from `harmony/src/modules/openbao/mod.rs`
**File:** `harmony/src/modules/openbao/mod.rs`
Add to the `pub use setup::...` line:
```rust
pub use setup::{
OpenbaoEntity,
OpenbaoEntityPoliciesScore,
OpenbaoJwtAuth,
OpenbaoPolicy,
OpenbaoSetupScore,
OpenbaoUser,
cached_root_token,
};
```
---
## Step 8 — New file: `fleet/harmony-fleet-operator/src/openbao.rs`
**Why this file exists:** The operator needs to call OpenBao's HTTP API directly (not via pod exec like `OpenbaoSetupScore` does — that requires k8s exec access and is too slow for a hot reconcile loop). `reqwest` is already in the operator's `Cargo.toml`.
```rust
use anyhow::Result;
pub struct OpenBaoIdentityClient {
client: reqwest::Client,
base_url: String,
token: String,
}
impl OpenBaoIdentityClient {
pub fn new(base_url: String, token: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
token,
}
}
/// Idempotent upsert: sets the entity's policy list to exactly `policies`.
/// Passing an empty slice clears all policies (device loses all secret access).
pub async fn set_entity_policies(&self, entity: &str, policies: &[String]) -> Result<()> {
let url = format!(
"{}/v1/identity/entity/name/{}",
self.base_url.trim_end_matches('/'),
entity
);
let body = serde_json::json!({ "policies": policies });
let resp = self.client
.post(&url)
.header("X-Vault-Token", &self.token)
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("OpenBao entity update {entity} returned {status}: {text}");
}
Ok(())
}
}
```
**Note on the HTTP method:** OpenBao's identity entity endpoint accepts both `POST` and `PUT`. `POST /v1/identity/entity/name/<name>` is the create-or-update form (no entity ID required). If the entity doesn't exist yet, OpenBao creates it; if it does, it updates it.
---
## Step 9 — Update `fleet/harmony-fleet-operator/src/fleet_aggregator.rs`
This is the biggest conceptual change. Read the existing file top-to-bottom once before making changes — understanding the `FleetState` struct and `owned_targets` is key.
### How `owned_targets` works
`owned_targets: HashMap<DeploymentName, HashSet<device_id>>` is
the aggregator's in-memory truth of "which devices currently
match which deployments." It is the diff source for both the NATS
KV writes and (after this change) the OpenBao entity updates.
When a device gains a deployment:
`owned_targets[deployment].insert(device_id)` + KV put.
When a device loses a deployment:
`owned_targets[deployment].remove(device_id)` + KV delete.
To compute the full policy list for a device at any point, you
iterate over `owned_targets` and collect every deployment name
where the device appears.
### 9a. Update `run` signature
```rust
pub async fn run(
client: Client,
js: async_nats::jetstream::Context,
openbao: Option<Arc<OpenBaoIdentityClient>>,
) -> anyhow::Result<()>
```
Thread `openbao` down to the internal event handlers by adding
it as a parameter or by wrapping it in the existing
`SharedFleetState` (the latter is cleaner since you avoid
changing every function signature — add `openbao:
Option<Arc<OpenBaoIdentityClient>>` to `FleetState`).
### 9b. Add the policy helper
```rust
/// Returns the full set of OpenBao policies for `device_id` given the
/// current `owned_targets`. One policy per matched deployment, named
/// `deployment-<name>` to match the HCL policies created by OpenbaoSetupScore.
fn device_deployment_policies(state: &FleetState, device_id: &str) -> Vec<String> {
state
.owned_targets
.iter()
.filter(|(_, devices)| devices.contains(device_id))
.map(|(dn, _)| format!("deployment-{}", dn.as_str()))
.collect()
}
```
**Why compute the full set instead of add/remove:** If you add/remove incrementally you need a read-before-write (race condition) or local state that can drift. Writing the full current set is idempotent and always converges to the right answer, even after an operator restart.
### 9c. Add OpenBao sync to `on_device_upsert`
After the `match (was, now)` block that does the KV writes, add:
```rust
// After all KV writes/deletes for this device, sync entity policies.
if let Some(bao) = &openbao {
let policies = {
let guard = state.lock().await;
device_deployment_policies(&guard, &name)
};
if let Err(e) = bao.set_entity_policies(&name, &policies).await {
tracing::warn!(device = %name, error = %e, "aggregator: OpenBao entity policy sync failed");
}
}
```
The OpenBao call is **outside** the lock and **non-fatal**. Matching the existing pattern for KV write failures — log and continue, don't abort the reconcile loop.
### 9d. Add OpenBao sync to `on_deployment_delete`
After the loop that deletes KV entries for previously-owned devices, update each device's entity:
```rust
for device in &previous {
// ... existing KV delete ...
if let Some(bao) = &openbao {
let policies = {
let guard = state.lock().await;
device_deployment_policies(&guard, device)
};
if let Err(e) = bao.set_entity_policies(device, &policies).await {
tracing::warn!(device = %device, error = %e, "aggregator: OpenBao policy sync on deployment delete failed");
}
}
}
```
### 9e. Add OpenBao sync to `on_device_delete`
When a device CR is deleted (device decommissioned), clear all its policies:
```rust
if let Some(bao) = &openbao {
if let Err(e) = bao.set_entity_policies(&name, &[]).await {
tracing::warn!(device = %name, error = %e, "aggregator: OpenBao policy clear on device delete failed");
}
}
```
---
## Step 10 — Update `fleet/harmony-fleet-operator/src/main.rs`
### 10a. Add CLI args
Add to the `Cli` struct:
```rust
#[arg(long, env = "OPENBAO_URL", global = true)]
openbao_url: Option<String>,
#[arg(long, env = "OPENBAO_TOKEN", global = true)]
openbao_token: Option<String>,
```
Both are optional. If either is absent, the operator runs without OpenBao policy sync (same as today — useful for dev environments without OpenBao).
### 10b. Build the client and pass it to the aggregator
In the `run()` function:
```rust
let openbao = match (&cli.openbao_url, &cli.openbao_token) {
(Some(url), Some(token)) => {
tracing::info!(url = %url, "OpenBao identity sync enabled");
Some(Arc::new(OpenBaoIdentityClient::new(url.clone(), token.clone())))
}
_ => {
tracing::warn!("OPENBAO_URL or OPENBAO_TOKEN not set — entity policy sync disabled");
None
}
};
// Pass to the aggregator:
r = fleet_aggregator::run(client, js, openbao) => r,
```
The admin token (`OPENBAO_TOKEN`) should be mounted from a Kubernetes Secret in the operator's Pod spec — same pattern as `FLEET_OPERATOR_CREDENTIALS_TOML`. The token needs a policy that allows writes to `identity/entity/name/*`.
---
## Complete change list
| File | What changes |
|---|---|
| `harmony/src/modules/zitadel/action.rs` | **Delete** — no longer needed |
| `harmony/src/modules/zitadel/mod.rs` | Remove `pub mod action;` (1 line) |
| `harmony_secret/src/store/openbao.rs` | Remove `decode_deployments_claim`, `scope` field, `cached_scope`, base64/HashSet imports, 2 tests |
| `harmony_secret/Cargo.toml` | Remove `base64` dep (1 line) |
| `harmony/src/modules/openbao/setup.rs` | Remove `policies` from `OpenbaoJwtAuth`; add `OpenbaoEntity`, `entities` field, `create_entities()`, `OpenbaoEntityPoliciesScore` |
| `harmony/src/modules/openbao/mod.rs` | Export `OpenbaoEntity`, `OpenbaoEntityPoliciesScore` |
| `fleet/harmony-fleet-operator/src/openbao.rs` | **New** — `OpenBaoIdentityClient::set_entity_policies` |
| `fleet/harmony-fleet-operator/src/fleet_aggregator.rs` | Add openbao param, `device_deployment_policies` helper, 3 call sites |
| `fleet/harmony-fleet-operator/src/main.rs` | Add `OPENBAO_URL`/`OPENBAO_TOKEN` args; build and thread `OpenBaoIdentityClient` |
---
## Verification checklist
After all changes:
```bash
cargo check --all-targets --all-features --keep-going
cargo fmt --check
cargo clippy
cargo test
```
Manual smoke test (requires a running local stack):
1. Assign a device to `deployment-a` via the k8s CR → operator logs should show `OpenBao entity policy sync` for that device → `bao read identity/entity/name/<device>` should show `policies: ["deployment-a"]`.
2. Reassign the device to `deployment-b` → entity should update to `["deployment-b"]` without the device re-authenticating to OpenBao.
3. Remove the device CR → entity policies should be cleared.

View File

@@ -1,5 +1,13 @@
#!/bin/bash
export BASE_URL=http://localhost:18080
export HARMONY_SSO_ZITADEL_BASE=https://sso-stg.cb1.nationtech.io
export HARMONY_SSO_CLIENT_ID=372626218874372917
export HARMONY_SSO_SCOPE="openid profile email"
export HARMONY_SSO_LOGOUT_REDIRECT_URI="http://localhost:18080/"
export HARMONY_COOKIE_KEY_B64=6eKVpj88jwIcmaJajPfohdaIXhSPlfYCrHaOfymTcIWBAIadvhg7NHpMo5vPSMy90vac3cq2liWe1naSgkbaYg==
export HARMONY_SSO_TRUSTED_AUDIENCES=371639797493596981,371683318111994677,372626218874372917,371639797157987125
export BASE_URL=http://localhost:18080
# The operator reads its auth config via ConfigClient — one typed value
# each (EnvSource keys a Config struct under HARMONY_CONFIG_<TypeName>),
# not a fistful of FLEET_AUTH_* vars. base_url is localhost for this local

View File

@@ -9,8 +9,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::config::ZitadelAuthConfig;
use crate::jwks::JwksCache;
use crate::login::{
AuthCallbackQuery, RawAuthCallbackQuery, TokenResponse, build_login_attempt, build_logout_url,
exchange_code_for_token, jwt_exp, validate_callback_state,
AuthCallbackQuery, LoginQuery, RawAuthCallbackQuery, TokenResponse, build_login_attempt,
build_logout_url, exchange_code_for_token, jwt_exp, valid_next, validate_callback_state,
};
use crate::session::LoginAttemptCookie;
@@ -23,8 +23,9 @@ pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session";
pub async fn login_handler(
jar: PrivateCookieJar,
State(config): State<ZitadelAuthConfig>,
Query(query): Query<LoginQuery>,
) -> Response {
match build_login_response(jar, &config) {
match build_login_response(jar, &config, query) {
Ok(r) => r.into_response(),
Err(e) => auth_error_response(e),
}
@@ -33,9 +34,16 @@ pub async fn login_handler(
fn build_login_response(
jar: PrivateCookieJar,
config: &ZitadelAuthConfig,
query: LoginQuery,
) -> Result<impl IntoResponse> {
let attempt = build_login_attempt(config)?;
let cookie_payload = LoginAttemptCookie::from(&attempt);
let mut cookie_payload = LoginAttemptCookie::from(&attempt);
cookie_payload.next = Some(
query
.next
.filter(|next| valid_next(next))
.unwrap_or_else(|| "/".to_string()),
);
let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?);
let mut builder = Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value))
@@ -113,7 +121,12 @@ async fn build_callback_response(
}
let session_jar = session_jar.add(session_cookie(&tokens, config));
Ok((jar, session_jar, Redirect::to("/")).into_response())
let next = attempt
.next
.as_deref()
.filter(|next| valid_next(next))
.unwrap_or("/");
Ok((jar, session_jar, Redirect::to(next)).into_response())
}
AuthCallbackQuery::Failure {
error,

View File

@@ -40,6 +40,28 @@ impl ZitadelAuthConfig {
}
}
pub const ZITADEL_BASE_ENV: &str = "HARMONY_SSO_ZITADEL_BASE";
pub const BASE_URL_ENV: &str = "BASE_URL";
pub const CLIENT_ID_ENV: &str = "HARMONY_SSO_CLIENT_ID";
pub const SCOPE_ENV: &str = "HARMONY_SSO_SCOPE";
pub const TRUSTED_AUDIENCES_ENV: &str = "HARMONY_SSO_TRUSTED_AUDIENCES";
pub const LOGOUT_REDIRECT_URI_ENV: &str = "HARMONY_SSO_LOGOUT_REDIRECT_URI";
pub const COOKIE_KEY_ENV: &str = "HARMONY_COOKIE_KEY_B64";
pub fn config_from_env() -> ZitadelAuthConfig {
ZitadelAuthConfig {
zitadel_base: required_env(ZITADEL_BASE_ENV),
base_url: required_env(BASE_URL_ENV),
client_id: required_env(CLIENT_ID_ENV),
scope: required_env(SCOPE_ENV),
trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV)
.split(',')
.map(str::to_string)
.collect(),
logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV),
}
}
/// Operator session-cookie signing key: standard-base64 of ≥64 random
/// bytes. Secret-class, so it resolves from OpenBao / a k8s Secret —
/// never cleartext config. Whoever holds it can forge sessions.
@@ -64,3 +86,7 @@ impl OperatorCookieKey {
Ok(axum_extra::extract::cookie::Key::from(&bytes))
}
}
fn required_env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}"))
}

View File

@@ -10,9 +10,9 @@ pub use config::{OperatorCookieKey, ZitadelAuthConfig};
pub use jwks::JwksCache;
pub use login::{
AuthCallbackQuery, LoginAttempt, RawAuthCallbackQuery, TokenResponse, ValidatedUser,
AuthCallbackQuery, LoginAttempt, LoginQuery, RawAuthCallbackQuery, TokenResponse, ValidatedUser,
build_login_attempt, build_logout_url, exchange_code_for_token, jwt_exp,
validate_callback_state, validate_id_token,
valid_next, validate_callback_state, validate_id_token,
};
pub use session::{LoginAttemptCookie, VerifiedSession};

View File

@@ -37,6 +37,11 @@ pub struct TokenResponse {
pub expires_in: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
pub next: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawAuthCallbackQuery {
pub code: Option<String>,
@@ -63,10 +68,15 @@ impl From<&LoginAttempt> for LoginAttemptCookie {
state: attempt.state.clone(),
pkce_code_verifier: attempt.pkce_code_verifier.clone(),
nonce: attempt.nonce.clone(),
next: None,
}
}
}
pub fn valid_next(next: &str) -> bool {
next.starts_with('/') && !next.starts_with("//") && !next.chars().any(char::is_control)
}
impl TryFrom<RawAuthCallbackQuery> for AuthCallbackQuery {
type Error = anyhow::Error;

View File

@@ -18,4 +18,5 @@ pub struct LoginAttemptCookie {
pub state: String,
pub pkce_code_verifier: String,
pub nonce: String,
pub next: Option<String>,
}

View File

@@ -0,0 +1,3 @@
# Score, Interpret, and capability bound identified
The user correctly identified `HelmChartScore`, `HelmChartInterpret`, and `T: Topology + HelmCommand` as the capability trait bound in `harmony/src/modules/helm/chart.rs`. This establishes enough understanding to move from naming the parts to deciding which layer owns a change.

View File

@@ -0,0 +1,3 @@
# Portable Score versus topology-specific implementation
The user correctly inferred that an AWS RDS-style PostgreSQL backend should implement the `PostgreSQL` capability for a new topology, rather than changing `PostgreSQLScore` or the existing Kubernetes-specific Score. This shows the core ownership rule: high-level Scores remain portable, while concrete Topologies satisfy capabilities differently.

View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Harmony Contributor Map</title>
<style>
:root { color-scheme: dark; --bg:#101318; --panel:#171b22; --ink:#e8edf3; --muted:#aab4c0; --line:#2a3240; --accent:#8fd3ff; --warn:#ffd28f; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left, #1d2a3a, var(--bg) 42rem); color:var(--ink); line-height:1.55; }
main { max-width: 980px; margin: 0 auto; padding: 48px 22px 72px; }
h1 { font-size: clamp(2rem, 6vw, 4.5rem); line-height:.95; letter-spacing:-.06em; margin:0 0 18px; }
h2 { margin-top: 34px; letter-spacing:-.03em; }
a { color: var(--accent); }
.lede { color:var(--muted); font-size:1.15rem; max-width: 760px; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:14px; margin:22px 0; }
.card, .try { background: color-mix(in srgb, var(--panel), transparent 6%); border:1px solid var(--line); border-radius:18px; padding:18px; box-shadow:0 18px 50px #0005; }
.card b { display:block; color:#fff; margin-bottom:6px; }
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color:#d7ecff; font-size:.92rem; }
.flow { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin:18px 0; }
.pill { border:1px solid var(--line); border-radius:999px; padding:8px 12px; background:#111821; }
.arrow { color:var(--muted); }
.try { border-color:#4a3d24; background:#211b12; }
.try b { color:var(--warn); }
code { background:#0c1117; border:1px solid var(--line); border-radius:6px; padding:.1rem .35rem; }
</style>
</head>
<body>
<main>
<h1>Harmony Contributor Map</h1>
<p class="lede">Your first job as a contributor is not to memorize every crate. It is to decide where a change belongs without violating the repo contract in <a href="../AGENTS.md">AGENTS.md</a>.</p>
<h2>The Core Loop</h2>
<div class="flow" aria-label="Score Topology Interpret flow">
<span class="pill">Score: desired state</span><span class="arrow"></span>
<span class="pill">Interpret: execution logic</span><span class="arrow"></span>
<span class="pill">Topology: available capabilities</span><span class="arrow"></span>
<span class="pill">Outcome: result</span>
</div>
<p>Source anchors: <a href="../harmony/src/domain/score.rs"><code>score.rs</code></a>, <a href="../harmony/src/domain/interpret/mod.rs"><code>interpret/mod.rs</code></a>, and <a href="../harmony/src/domain/topology/mod.rs"><code>topology/mod.rs</code></a>. The important contribution rule: a Score declares what it needs through trait bounds, so missing infrastructure support becomes a compile-time problem.</p>
<h2>Where Changes Usually Belong</h2>
<section class="grid">
<div class="card"><b>New desired infrastructure state</b><span class="path">harmony/src/modules/**</span><br>Create or adjust a Score. Keep operational complexity inside the Score.</div>
<div class="card"><b>Environment capability</b><span class="path">harmony/src/domain/topology/**</span><br>Add or adjust a capability only if it is an industry concept, not a vendor wrapper.</div>
<div class="card"><b>Vendor or protocol implementation</b><span class="path">harmony/src/infra/**</span><br>Adapters talk to external systems. The domain should not depend on them.</div>
<div class="card"><b>App-specific deployment</b><span class="path">*-deploy crates</span><br>ADR-023 says deploy logic for an app lives beside that app, not in core.</div>
<div class="card"><b>CLI behavior</b><span class="path">harmony_cli</span><br>The CLI should compose Scores; it should not handroll deploy manifests.</div>
<div class="card"><b>Local Kubernetes runtime</b><span class="path">k3d</span><br>Use when the change is about provisioning or connecting to local K3D.</div>
</section>
<h2>Contribution Red Flags</h2>
<p>If a test harness, example, or CLI builds raw Kubernetes <code>Deployment</code>, <code>Service</code>, or <code>ConfigMap</code> structs, ADR-023 treats that as a missing Score. If a capability is named after a product, it is probably in the wrong layer. If a helper exists for one call site and makes the code longer, it probably violates the repo contract.</p>
<div class="try">
<b>Try this</b>
<p>Open <code>harmony/src/modules/helm/chart.rs</code>. Find the Score, its Interpret, and the capability trait bound. Then answer: if Helm command construction changes, should that change live in the Score, the Interpret, or the Topology capability implementation?</p>
</div>
<h2>Primary References</h2>
<p><a href="../docs/guides/writing-a-score.md">Writing a Score</a>, <a href="../docs/guides/writing-a-topology.md">Writing a Topology</a>, <a href="../docs/guides/adding-capabilities.md">Adding Capabilities</a>, <a href="../docs/adr/002-hexagonal-architecture.md">ADR-002</a>, and <a href="../docs/adr/023-deploy-architecture.md">ADR-023</a>.</p>
</main>
</body>
</html>