feat/declarative-application-deployment #345

Merged
johnride merged 34 commits from feat/declarative-application-deployment into master 2026-08-07 13:11:46 +00:00
50 changed files with 11482 additions and 1960 deletions

View File

@@ -93,6 +93,27 @@ The opposite failure is one function or type owning unrelated reasons to
change. Split by concern when the parts have different callers, lifecycles, change. Split by concern when the parts have different callers, lifecycles,
trust boundaries, or tests. Do not split merely to shorten a function. trust boundaries, or tests. Do not split merely to shorten a function.
### Misplaced knowledge
Architecture follows information ownership, not call-site convenience. Put
each fact in the layer whose reason to change it:
- domain and policy code state intent and invariants;
- adapters own provider syntax, protocol details, paths, and storage layout;
- composition roots select and wire abstractions without recreating backend
construction or deriving provider-specific coordinates;
- neutral value-type crates contain shared vocabulary, not constants belonging
to one backend.
Ask which module should change when the provider, path layout, authorization
syntax, or source chain changes. That module owns the knowledge. Warning signs
include raw HCL, SQL, manifests, or provider paths in policy code; storage
constants in generic types; the same authenticated adapter constructed twice
for different views; and application code rebuilding a source chain already
owned by infrastructure. Move the mechanism to the deepest existing owner and
expose the smallest operation or view the caller needs. Keep product choices in
the application layer; moving everything downward is also misplaced ownership.
### Duplicate knowledge ### Duplicate knowledge
Two copies of the same condition, default, mapping, or deployment recipe in one Two copies of the same condition, default, mapping, or deployment recipe in one
@@ -148,6 +169,7 @@ After tests pass, inspect only the diff and ask:
5. Did test setup reimplement production behavior? 5. Did test setup reimplement production behavior?
6. Can control flow become linear and local? 6. Can control flow become linear and local?
7. Does every changed file need to be in this PR? 7. Does every changed file need to be in this PR?
8. Does each fact live where its reason to change lives?
Rewrite when the answer exposes accidental complexity. Run verification again. Rewrite when the answer exposes accidental complexity. Run verification again.

1930
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
# Harmony Auth CLI # Harmony Auth CLI
> **Status: read-only commands implemented.** Mutation commands follow the > **Status:** read-only inspection and ADR-027 tenant identity provisioning are
> ADR-027 group migration. > implemented. Identity grant and revoke commands remain pending.
`harmony-auth` inspects and manages the relationship between Zitadel `harmony-auth` inspects access and provisions tenants across Zitadel, OpenBao,
identities and OpenBao access. It presents tenants, identities, and Harmony and Kubernetes. It presents tenants, identities, and Harmony permissions first.
permissions first. JWT roles, policy names, subject claims, and HCL remain JWT roles, policy names, subject claims, and HCL remain available through
available through advanced output. advanced output.
The CLI is the preferred interface while the web UI matures. Both interfaces The CLI is the preferred interface while the web UI matures. Both interfaces
use the same `harmony_auth` operations and return the same effective access. use the same `harmony_auth` operations and return the same effective access.
@@ -33,43 +33,61 @@ shown as imported access. The CLI does not rename, rewrite, or hide them.
```text ```text
harmony-auth harmony-auth
├── context configure
├── connection check ├── connection check
├── identity list ├── identity list
├── identity show <subject-id> ├── identity show <subject-id>
├── tenant list ├── tenant list
├── tenant create <tenant>
├── tenant deployer create <tenant> <account>
└── tenant show <tenant> └── tenant show <tenant>
``` ```
There are no flat aliases. `harmony-auth list` is not valid. There are no flat aliases. `harmony-auth list` is not valid.
## Connection and credentials ## Context and credentials
Every command except `--help` and `--version` requires: Every command selects one named environment through `--context` or
`HARMONY_CONTEXT`:
| Flag | Environment | Meaning |
|---|---|---|
| `--zitadel-url` | `ZITADEL_URL` | Zitadel base URL |
| `--openbao-url` | `OPENBAO_URL` | OpenBao base URL |
| none | `ZITADEL_PAT` | Zitadel service-account PAT |
| none | `OPENBAO_TOKEN` | Temporary OpenBao administrator token |
Secrets are environment-only because command-line arguments remain in shell
history and may be visible in the process list. Secret values never appear in
help output, normal output, JSON, or logs.
Example:
```sh ```sh
export ZITADEL_URL=https://sso.example.com harmony-auth connection check --context prod
export ZITADEL_PAT=... ```
export OPENBAO_URL=https://secrets.example.com
export OPENBAO_TOKEN=...
Configure the context once:
```sh
harmony-auth context configure --context prod
```
The guided setup collects the complete context in one session:
| Field | Meaning |
|---|---|
| `zitadel_url` | Zitadel issuer and Management API URL |
| `zitadel_pat` | Zitadel administrator PAT |
| `zitadel_project` | Existing Zitadel project that contains tenant roles |
| `openbao_url` | OpenBao API URL |
| `openbao_token` | OpenBao administrator token |
| `kubeconfig` | Administrator kubeconfig path |
| `kube_context` | Exact administrator kube context |
Harmony Config stores the profile at
`<config-dir>/contexts/<context>/HarmonyAuthContext.json`. The file is mode
`0600`; PAT and token prompts are masked. Each answer is saved immediately, so
rerunning `context configure` after an interruption resumes at the first missing
field. Commands either load the complete profile or report the context as
unconfigured. They do not fail through a sequence of missing provider
environment variables.
`HARMONY_CONTEXT` can set a shell's default context:
```sh
export HARMONY_CONTEXT=prod
harmony-auth connection check harmony-auth connection check
``` ```
The CLI does not persist profiles or credentials. Browser profile storage and Secret values never appear in help output, normal output, JSON, or logs.
session credential refresh remain web UI concerns.
`connection check` attempts both backends even when one fails. It reports each `connection check` attempts both backends even when one fails. It reports each
status without printing provider response bodies: status without printing provider response bodies:
@@ -222,6 +240,126 @@ manual migration.
## Tenant commands ## Tenant commands
### Create a tenant
`tenant create` configures owner access, secret access, resource limits,
network isolation, Harbor push credentials, and namespace-scoped deployment
credentials. It runs `TenantScore` and `TenantCredentialScore` against an
explicit administrator kube context.
```sh
harmony-auth tenant create acme \
--context prod \
--owner acme-admin
```
Missing resource limits are prompted with defaults. Each answer is saved
immediately in the selected context's tenant draft, so an interrupted or
plan-only run resumes without repeating completed prompts. Later plans print
the saved limits. Applied values are stored in the authoritative tenant
definition under `harmony_auth/data/tenants/<tenant>`. Use flags such as
`--cpu-limit-cores` for unattended use.
Interactive applied runs request any missing Harbor push and pull credentials.
For unattended use, set `HARBOR_USERNAME`, `HARBOR_TOKEN`,
`HARBOR_PULL_USERNAME`, and `HARBOR_PULL_TOKEN`; tokens have no command-line
flags because process arguments are not secret-safe. Supplying either half of
one credential pair replaces that pair and requires both values. Harmony stores them at
`<OPENBAO_KV_MOUNT>/data/<tenant>/RegistryCredentials` and
`<OPENBAO_KV_MOUNT>/data/<tenant>/RegistryPullCredentials`. K8sAnywhere
`Application` deploys use the pull credentials to reconcile the context's
Kubernetes image pull Secret. Harbor must enforce the robot's pull-only scope.
Harbor SSO will replace the stored push credentials when registry federation is
available.
The selected Harmony context supplies the default kubeconfig path and kube
context. Command flags override those defaults for the current invocation;
local paths are not stored in the tenant definition. Owner usernames are bound
to the namespace deployer Role until OKD group claims are available.
For an OKD OpenID provider that maps Zitadel's `preferred_username` to the
OpenShift username, values passed through `--owner` must be OpenShift usernames
such as `acme-admin`, not email addresses unless the username itself is an
email.
The command only prints a plan unless `--apply` is set. Owner additions are
additive. Omitting an existing owner does not revoke access; identity revocation
remains a separate pending command.
Tenant creation does not grant viewer access. The tenant's secret subtree also
contains namespace deployment credentials, so viewer access remains disabled
until those credentials are separated.
The shared identity and secret-login baseline must exist before tenant
creation. Operators can override its defaults through hidden environment
configuration:
| Environment | Default | Meaning |
|---|---|---|
| `ZITADEL_ORG_ID` | PAT organization | Zitadel organization containing the project |
| `HARMONY_GROUPS_ACTION` | `harmonyGroupsClaim` | Action that adds tenant roles to tokens |
| `OPENBAO_KV_MOUNT` | `secret` | Tenant secret mount |
| `OPENBAO_JWT_AUTH_MOUNT` | `jwt` | Zitadel-backed OpenBao auth mount |
| `OPENBAO_JWT_ROLE` | auto-discovered | Shared login role; set only when more than one compatible role exists |
Use `--step-by-step` with `--apply` to pause after each completed component:
```sh
harmony-auth tenant create acme \
--context prod \
--owner acme-admin \
--apply \
--step-by-step
```
The command pauses after baseline validation, tenant permissions, owner access,
secret access, stored tenant state, each Harbor credential pair, and between
the Kubernetes tenant and credential Scores. Each prompt names the operation
it will run next, including the target Zitadel project, OpenBao path, namespace,
and kube context where applicable. All completed operations are logged at
`INFO`, including detail between checkpoints. The administrator can test from
another terminal before approving the next operation. Declining stops safely;
rerunning the same command continues through idempotent operations. Interactive
mode cannot be combined with `--json` and requires a terminal.
### Create a CI deployer
CI deployer creation is separate from tenant creation:
```sh
harmony-auth tenant deployer create acme acme-ci \
--context prod \
--credentials ./acme-ci-key.json
```
This prints a plan and verifies that the tenant exists, the shared baseline is
valid, and the account name is unused. Add `--apply` to create the account,
grant tenant deployer access, and write its one-time credentials:
```sh
harmony-auth tenant deployer create acme acme-ci \
--context prod \
--credentials ./acme-ci-key.json \
--apply
```
The credentials file is created with mode `0600` and is never overwritten. The
command fails without changing access if the account already exists. If writing
the credentials fails after account creation, it deletes the new account; the
error reports if that cleanup also fails.
The deployer can read tenant inputs under
`<OPENBAO_KV_MOUNT>/data/<tenant>/*`. Harmony-generated durable state is kept
separately under
`<OPENBAO_KV_MOUNT>/data/<tenant>/harmony-state/*`, where the deployer can
create, read, and update values. It cannot write input credentials or other
tenant secrets.
Before deploying an updated Harmony application into an existing tenant, rerun
`tenant create --apply` with the updated `harmony-auth` binary. Policy
reconciliation must grant the state subpath before the application migrates any
legacy generated values from the tenant root.
### List tenants ### List tenants
```sh ```sh
@@ -275,10 +413,13 @@ The first release uses these result shapes:
| Command | `result` fields | | Command | `result` fields |
|---|---| |---|---|
| `context configure` | `context`, `path` |
| `connection check` | `zitadel: { connected }`, `openbao: { connected }` | | `connection check` | `zitadel: { connected }`, `openbao: { connected }` |
| `identity list` | `identities: [{ identity, access }]` | | `identity list` | `identities: [{ identity, access }]` |
| `identity show` | `identity`, `access` | | `identity show` | `identity`, `access` |
| `tenant list` | `tenants: [{ scope, humans, services }]` | | `tenant list` | `tenants: [{ scope, humans, services }]` |
| `tenant create` | `tenant`, `applied` |
| `tenant deployer create` | `tenant`, `account`, `credentials`, `applied` |
| `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` | | `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` |
`identity` contains `subject_id`, `kind`, `display_name`, `login_name`, `identity` contains `subject_id`, `kind`, `display_name`, `login_name`,
@@ -358,6 +499,7 @@ harmony_auth_cli ─┘
`harmony_auth_cli` owns: `harmony_auth_cli` owns:
- Clap arguments and environment mapping - Clap arguments and environment mapping
- named Harmony context profile loading
- terminal and JSON rendering - terminal and JSON rendering
- binary exit codes and logging setup - binary exit codes and logging setup
@@ -369,8 +511,12 @@ tenants, reconcile JWT roles, or implement permission rules.
- Authorization discovers existing per-subject JWT roles as imported access. - Authorization discovers existing per-subject JWT roles as imported access.
- Grant and revoke wait for Zitadel role and OpenBao external-group operations. - Grant and revoke wait for Zitadel role and OpenBao external-group operations.
- The CLI does not create Zitadel identities. - Tenant deployer creation can create a Zitadel machine identity; general
identity creation is not implemented.
- The CLI does not provide a generic OpenBao policy editor. - The CLI does not provide a generic OpenBao policy editor.
- OKD group-to-RBAC mapping, read-only viewer RBAC, short-lived Kubernetes
credentials, and WireGuard identity linking remain deferred.
- Tenant administrators are not yet authenticated as constrained actors; the - Tenant administrators are not yet authenticated as constrained actors; the
supplied OpenBao token determines backend authority. supplied OpenBao token determines backend authority.
- The CLI does not store profiles or credentials. - Context profiles are local mode-`0600` files; credential refresh and remote
profile synchronization are not implemented.

View File

@@ -13,6 +13,7 @@ pub fn fleet_context() -> anyhow::Result<Context> {
repository: oci_repository!("customer/fleet"), repository: oci_repository!("customer/fleet"),
domain: domain!("fleet.example.com"), domain: domain!("fleet.example.com"),
image_pull_secret: None, image_pull_secret: None,
object_storage_endpoint: None,
access: tenant_access("fleet-deployer")?, access: tenant_access("fleet-deployer")?,
}), }),
}) })

View File

@@ -127,6 +127,7 @@ async fn main() -> anyhow::Result<()> {
repository: "apps".parse()?, repository: "apps".parse()?,
domain: "example.com".parse()?, domain: "example.com".parse()?,
image_pull_secret: None, image_pull_secret: None,
object_storage_endpoint: None,
access: OpenBaoClusterAccess { access: OpenBaoClusterAccess {
namespace: "platform/example-app".parse()?, namespace: "platform/example-app".parse()?,
url: "https://secrets.example.com".parse()?, url: "https://secrets.example.com".parse()?,

View File

@@ -278,6 +278,8 @@ impl HarmonyApp<K8sAnywhereTopology> for FleetTenantProvisionApp {
let source = harmony_config::openbao_source( let source = harmony_config::openbao_source(
store.namespace.as_ref(), store.namespace.as_ref(),
Some(store.url.to_string()), Some(store.url.to_string()),
None,
None,
Some(store.zitadel_url.to_string()), Some(store.zitadel_url.to_string()),
Some(store.zitadel_audience.to_string()), Some(store.zitadel_audience.to_string()),
Some(store.role.to_string()), Some(store.role.to_string()),
@@ -450,6 +452,7 @@ mod tests {
repository: "harmony".parse().unwrap(), repository: "harmony".parse().unwrap(),
domain: "fleet.example.com".parse().unwrap(), domain: "fleet.example.com".parse().unwrap(),
image_pull_secret: None, image_pull_secret: None,
object_storage_endpoint: None,
access: OpenBaoClusterAccess { access: OpenBaoClusterAccess {
namespace: "customer/fleet".parse().unwrap(), namespace: "customer/fleet".parse().unwrap(),
url: "https://secrets.example.com".parse().unwrap(), url: "https://secrets.example.com".parse().unwrap(),

View File

@@ -152,6 +152,18 @@ impl K8sClient {
.await .await
} }
/// Server-side apply without serializing the resource into logs. Use for
/// generated credential objects whose `Debug`/`Serialize` output contains
/// secret material.
pub async fn apply_redacted<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
self.apply_with_strategy_inner(resource, namespace, WriteMode::CreateOrUpdate, true)
.await
}
/// POST only — returns an error if the resource already exists. /// POST only — returns an error if the resource already exists.
pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error> pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
where where
@@ -178,6 +190,21 @@ impl K8sClient {
namespace: Option<&str>, namespace: Option<&str>,
write_mode: WriteMode, write_mode: WriteMode,
) -> Result<K, Error> ) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
self.apply_with_strategy_inner(resource, namespace, write_mode, false)
.await
}
async fn apply_with_strategy_inner<K>(
&self,
resource: &K,
namespace: Option<&str>,
write_mode: WriteMode,
redact: bool,
) -> Result<K, Error>
where where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize, K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default, <K as Resource>::DynamicType: Default,
@@ -187,7 +214,9 @@ impl K8sClient {
resource.meta().name, resource.meta().name,
namespace namespace
); );
trace!("{:#}", serde_json::to_value(resource).unwrap_or_default()); if !redact {
trace!("{:#}", serde_json::to_value(resource).unwrap_or_default());
}
let dyntype = K::DynamicType::default(); let dyntype = K::DynamicType::default();
let gvk = GroupVersionKind { let gvk = GroupVersionKind {
@@ -219,6 +248,10 @@ impl K8sClient {
.expect("Kubernetes resource must have a name"); .expect("Kubernetes resource must have a name");
if self.dry_run { if self.dry_run {
if redact {
debug!("Dry-run payload for secret resource '{name}' is redacted");
return Ok(resource.clone());
}
show_dry_run(&api, name, resource).await?; show_dry_run(&api, name, resource).await?;
return Ok(resource.clone()); return Ok(resource.clone());
} }

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use kube::config::{KubeConfigOptions, Kubeconfig}; use kube::config::{KubeConfigOptions, Kubeconfig};
use kube::{Client, Config, Discovery, Error}; use kube::{Client, Config, Discovery, Error};
use log::error; use log::{error, info};
use serde::Serialize; use serde::Serialize;
use tokio::sync::{OnceCell, RwLock}; use tokio::sync::{OnceCell, RwLock};
@@ -61,6 +61,22 @@ impl ClusterConnection {
tls_verified: cluster.insecure_skip_tls_verify != Some(true), tls_verified: cluster.insecure_skip_tls_verify != Some(true),
}) })
} }
fn from_config(name: String, config: &Config) -> Result<Self, String> {
let server = safe_endpoint(&config.cluster_url.to_string(), "cluster server", true)?;
let proxy_url = config
.proxy_url
.as_ref()
.map(|url| safe_endpoint(&url.to_string(), "cluster proxy", false))
.transpose()?;
Ok(Self {
name,
server,
tls_server_name: config.tls_server_name.clone(),
proxy_url,
tls_verified: !config.accept_invalid_certs,
})
}
} }
fn safe_endpoint(endpoint: &str, name: &str, require_https: bool) -> Result<String, String> { fn safe_endpoint(endpoint: &str, name: &str, require_https: bool) -> Result<String, String> {
@@ -121,6 +137,21 @@ impl std::fmt::Debug for K8sClient {
} }
impl K8sClient { impl K8sClient {
pub fn validate_kubeconfig_context(
path: &str,
context: String,
) -> Result<ClusterConnection, String> {
let kubeconfig = Kubeconfig::read_from(path)
.map_err(|error| format!("failed to load kubeconfig from {path}: {error}"))?;
ClusterConnection::from_kubeconfig(
&kubeconfig,
&KubeConfigOptions {
context: Some(context),
..Default::default()
},
)
}
/// Create a client, reading `DRY_RUN` from the environment. /// Create a client, reading `DRY_RUN` from the environment.
pub fn new(client: Client) -> Self { pub fn new(client: Client) -> Self {
Self { Self {
@@ -174,7 +205,13 @@ impl K8sClient {
return None; return None;
} }
}; };
let connection = ClusterConnection::from_kubeconfig(&k, opts) let context = opts
.context
.clone()
.or_else(|| k.current_context.clone())
.unwrap_or_default();
let cluster_name = ClusterConnection::from_kubeconfig(&k, opts)
.map(|connection| connection.name)
.map_err(|error| error!("Tenant credentials unavailable for {path}: {error}")) .map_err(|error| error!("Tenant credentials unavailable for {path}: {error}"))
.ok(); .ok();
let config = match Config::from_custom_kubeconfig(k, opts).await { let config = match Config::from_custom_kubeconfig(k, opts).await {
@@ -184,6 +221,17 @@ impl K8sClient {
return None; return None;
} }
}; };
let connection = cluster_name.and_then(|name| {
ClusterConnection::from_config(name, &config)
.map_err(|error| error!("Tenant credentials unavailable for {path}: {error}"))
.ok()
});
if let Some(connection) = &connection {
info!(
"Loaded Kubernetes context '{context}' for cluster '{}' at '{}'",
connection.name, connection.server
);
}
let client = match Client::try_from(config) { let client = match Client::try_from(config) {
Ok(client) => client, Ok(client) => client,
Err(error) => { Err(error) => {
@@ -236,6 +284,54 @@ users:
); );
} }
#[tokio::test]
async fn resolved_connection_uses_explicit_context_in_multi_cluster_config() {
let kubeconfig: Kubeconfig = serde_yaml::from_str(
r#"
current-context: cluster-a-admin
contexts:
- name: cluster-a-admin
context: { cluster: cluster-a, user: cluster-a-admin }
- name: cluster-b-admin
context: { cluster: cluster-b, user: cluster-b-admin }
clusters:
- name: cluster-a
cluster: { server: "https://192.0.2.10:6443" }
- name: cluster-b
cluster:
server: https://api.cluster-b.example.com:6443
tls-server-name: api.cluster-b.example.com
users:
- name: cluster-a-admin
user: { token: cluster-a-secret }
- name: cluster-b-admin
user: { token: cluster-b-secret }
"#,
)
.unwrap();
let options = KubeConfigOptions {
context: Some("cluster-b-admin".into()),
..Default::default()
};
let name = ClusterConnection::from_kubeconfig(&kubeconfig, &options)
.unwrap()
.name;
let config = Config::from_custom_kubeconfig(kubeconfig, &options)
.await
.unwrap();
assert_eq!(
ClusterConnection::from_config(name, &config).unwrap(),
ClusterConnection {
name: "cluster-b".into(),
server: "https://api.cluster-b.example.com:6443/".into(),
tls_server_name: Some("api.cluster-b.example.com".into()),
proxy_url: None,
tls_verified: true,
}
);
}
#[test] #[test]
fn connection_records_insecure_tls() { fn connection_records_insecure_tls() {
let kubeconfig: Kubeconfig = serde_yaml::from_str( let kubeconfig: Kubeconfig = serde_yaml::from_str(

View File

@@ -10,8 +10,6 @@ use kube::{
Error, Resource, Error, Resource,
api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList}, api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList},
core::ErrorResponse, core::ErrorResponse,
runtime::conditions,
runtime::wait::await_condition,
}; };
use log::{debug, info}; use log::{debug, info};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@@ -276,15 +274,25 @@ impl K8sClient {
namespace: Option<&str>, namespace: Option<&str>,
timeout: Option<Duration>, timeout: Option<Duration>,
) -> Result<(), String> { ) -> Result<(), String> {
let api: Api<Deployment> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
let timeout = timeout.unwrap_or(Duration::from_secs(120)); let timeout = timeout.unwrap_or(Duration::from_secs(120));
let establish = await_condition(api, name, conditions::is_deployment_completed()); match tokio::time::timeout(timeout, async {
match tokio::time::timeout(timeout, establish).await { loop {
Ok(Ok(_)) => Ok(()), match self.get_resource::<Deployment>(name, namespace).await {
Ok(Err(error)) => Err(format!("Failed waiting for deployment {name}: {error}")), Ok(Some(deployment)) if deployment_rollout_complete(&deployment) => {
return Ok(());
}
Ok(_) => {}
// Transient API blips (watch/stream drops) — keep polling.
Err(error) => {
debug!("waiting for deployment {name}: {error}");
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
})
.await
{
Ok(result) => result,
Err(_) => Err(format!( Err(_) => Err(format!(
"Timed out after {}s waiting for deployment {name}", "Timed out after {}s waiting for deployment {name}",
timeout.as_secs() timeout.as_secs()
@@ -522,3 +530,22 @@ impl K8sClient {
} }
} }
} }
/// Same criteria as `kubectl rollout status` / kube-rs `is_deployment_completed`.
fn deployment_rollout_complete(deployment: &Deployment) -> bool {
let desired = deployment
.spec
.as_ref()
.and_then(|spec| spec.replicas)
.unwrap_or(1);
let Some(status) = deployment.status.as_ref() else {
return false;
};
let observed = status.observed_generation.unwrap_or(0);
let generation = deployment.metadata.generation.unwrap_or(0);
observed >= generation
&& status.updated_replicas.unwrap_or(0) >= desired
&& status.ready_replicas.unwrap_or(0) >= desired
&& status.available_replicas.unwrap_or(0) >= desired
&& status.replicas.unwrap_or(0) <= desired
}

View File

@@ -89,6 +89,8 @@ walkdir = "2.5.0"
harmony_inventory_agent = { path = "../harmony_inventory_agent" } harmony_inventory_agent = { path = "../harmony_inventory_agent" }
harmony_secret_derive = { path = "../harmony_secret_derive" } harmony_secret_derive = { path = "../harmony_secret_derive" }
harmony_secret = { path = "../harmony_secret" } harmony_secret = { path = "../harmony_secret" }
harmony_zitadel_auth = { path = "../harmony_zitadel_auth" }
hmac = "0.12"
askama.workspace = true askama.workspace = true
sha2 = "0.10" sha2 = "0.10"
sqlx.workspace = true sqlx.workspace = true

View File

@@ -114,9 +114,9 @@ impl K8sTenantManager {
}, },
"spec": { "spec": {
"hard": { "hard": {
"limits.cpu": format!("{:.0}",config.resource_limits.cpu_limit_cores), "limits.cpu": config.resource_limits.cpu_limit_cores.to_string(),
"limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb), "limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb),
"requests.cpu": format!("{:.0}",config.resource_limits.cpu_request_cores), "requests.cpu": config.resource_limits.cpu_request_cores.to_string(),
"requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb), "requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb),
"requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb), "requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb),
"pods": "20", "pods": "20",

View File

@@ -261,6 +261,10 @@ impl<T: Topology + HelmCommand> Interpret<T> for HelmChartInterpret {
&self.score.chart_name, &self.score.chart_name,
"--namespace", "--namespace",
&ns, &ns,
// Charts (e.g. Zitadel) $ref remote k8s JSON schemas; that
// fetch is not a trust boundary we own and fails offline /
// on restricted egress. Scores own values correctness.
"--skip-schema-validation",
]); ]);
if self.score.create_namespace { if self.score.create_namespace {

View File

@@ -63,6 +63,7 @@ pub struct PostgreSQLConfig {
pub cluster_name: String, pub cluster_name: String,
pub instances: u32, pub instances: u32,
pub storage_size: StorageSize, pub storage_size: StorageSize,
pub version: Option<String>,
pub role: PostgreSQLClusterRole, pub role: PostgreSQLClusterRole,
/// **Note :** on OpenShfit based clusters, the namespace `default` has security /// **Note :** on OpenShfit based clusters, the namespace `default` has security
/// settings incompatible with the default CNPG behavior. /// settings incompatible with the default CNPG behavior.
@@ -90,6 +91,7 @@ impl Default for PostgreSQLConfig {
role: PostgreSQLClusterRole::Primary, role: PostgreSQLClusterRole::Primary,
namespace: "harmony".to_string(), namespace: "harmony".to_string(),
wait_for_ready: true, wait_for_ready: true,
version: None,
} }
} }
} }

View File

@@ -30,6 +30,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
role: PostgreSQLClusterRole::Primary, role: PostgreSQLClusterRole::Primary,
namespace: config.namespace.clone(), namespace: config.namespace.clone(),
wait_for_ready: config.wait_for_ready, wait_for_ready: config.wait_for_ready,
version: config.version.clone(),
}; };
info!( info!(
@@ -146,6 +147,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
role: PostgreSQLClusterRole::Replica(replica_cluster_config), role: PostgreSQLClusterRole::Replica(replica_cluster_config),
namespace: config.namespace.clone(), namespace: config.namespace.clone(),
wait_for_ready: config.wait_for_ready, wait_for_ready: config.wait_for_ready,
version: config.version.clone(),
}; };
info!( info!(

View File

@@ -6,6 +6,8 @@ pub use score_connect::*;
pub use score_k8s::*; pub use score_k8s::*;
mod score_public; mod score_public;
pub use score_public::*; pub use score_public::*;
mod score_debug_route;
pub use score_debug_route::*;
pub mod failover; pub mod failover;
mod operator; mod operator;

View File

@@ -0,0 +1,194 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use harmony_types::id::Id;
use k8s_openapi::api::core::v1::{Service, ServicePort, ServiceSpec};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
use log::{info, warn};
use serde::Serialize;
use crate::data::Version;
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::modules::k8s::resource::K8sResourceScore;
use crate::modules::okd::crd::route::Route;
use crate::score::Score;
use crate::topology::{K8sclient, Topology};
/// VPN/debug expose for a CNPG primary via a companion Service.
///
/// **Why not an OKD TLS passthrough Route?** PostgreSQL before 17 does TLS only after the
/// PostgreSQL `SSLRequest` startup packet, not with a TLS ClientHello first. The OpenShift
/// router needs ClientHello+SNI for passthrough. PG 17 adds `sslnegotiation=direct`; we pin
/// PG 16 for Flyway/Quarkus, so typical clients (pgAdmin, stock libpq) cannot use a passthrough
/// Route. A NodePort speaks plain TCP to the pod; the client then does normal PG SSL to CNPG.
///
/// **Toggle in OKD console:** edit Service `{cluster}-rw-debug`:
/// - `spec.type: ClusterIP` — off (default on first create)
/// - `spec.type: NodePort` — on (any node/VPN IP + `nodePort`, `sslmode=require`)
///
/// Ships preserve live `type` and `nodePort`.
#[derive(Debug, Clone, Serialize)]
pub struct PostgresDebugRouteScore {
pub namespace: String,
pub cluster_name: String,
}
impl PostgresDebugRouteScore {
pub fn new(namespace: impl Into<String>, cluster_name: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
cluster_name: cluster_name.into(),
}
}
fn service_name(&self) -> String {
format!("{}-rw-debug", self.cluster_name)
}
}
impl<T: Topology + K8sclient + 'static> Score<T> for PostgresDebugRouteScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(PostgresDebugRouteInterpret {
score: self.clone(),
})
}
fn name(&self) -> String {
format!(
"PostgresDebugRouteScore({}/{})",
self.namespace, self.cluster_name
)
}
}
#[derive(Debug, Clone)]
struct PostgresDebugRouteInterpret {
score: PostgresDebugRouteScore,
}
#[async_trait]
impl<T: Topology + K8sclient> Interpret<T> for PostgresDebugRouteInterpret {
async fn execute(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
let name = self.score.service_name();
let client = topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?;
// Best-effort cleanup of the earlier passthrough Route (same name).
match client
.delete_resource::<Route>(&name, Some(&self.score.namespace))
.await
{
Ok(()) => {}
Err(e) => warn!(
"could not delete legacy DB debug Route {}/{}: {e}",
self.score.namespace, name
),
}
let (svc_type, node_port) = match client
.get_resource::<Service>(&name, Some(&self.score.namespace))
.await
{
Ok(Some(existing)) => {
let t = existing
.spec
.as_ref()
.and_then(|s| s.type_.clone())
.unwrap_or_else(|| "ClusterIP".into());
let np = existing
.spec
.as_ref()
.and_then(|s| s.ports.as_ref())
.and_then(|p| p.first())
.and_then(|p| p.node_port);
(t, np)
}
Ok(None) => ("ClusterIP".into(), None),
Err(e) => {
return Err(InterpretError::new(format!(
"get existing DB debug service: {e}"
)));
}
};
info!(
"DB debug service '{}/{}' type={} nodePort={:?} (ClusterIP=off NodePort=on)",
self.score.namespace, name, svc_type, node_port
);
let mut annotations = BTreeMap::new();
annotations.insert(
"harmony.nationtech.io/db-expose".into(),
"Set spec.type to NodePort to enable VPN/debug (node IP + nodePort, sslmode=require). ClusterIP disables. Ships preserve type/nodePort. PG16 cannot use TLS-passthrough Routes (needs sslnegotiation=direct from PG17+).".into(),
);
let mut port = ServicePort {
name: Some("postgres".into()),
port: 5432,
protocol: Some("TCP".into()),
target_port: Some(IntOrString::Int(5432)),
..Default::default()
};
if svc_type == "NodePort"
&& let Some(np) = node_port
{
port.node_port = Some(np);
}
let service = Service {
metadata: ObjectMeta {
name: Some(name.clone()),
namespace: Some(self.score.namespace.clone()),
annotations: Some(annotations),
labels: Some(BTreeMap::from([
("cnpg.io/cluster".into(), self.score.cluster_name.clone()),
("harmony.nationtech.io/role".into(), "db-debug".into()),
])),
..ObjectMeta::default()
},
spec: Some(ServiceSpec {
type_: Some(svc_type.clone()),
selector: Some(BTreeMap::from([
("cnpg.io/cluster".into(), self.score.cluster_name.clone()),
("cnpg.io/instanceRole".into(), "primary".into()),
])),
ports: Some(vec![port]),
..Default::default()
}),
..Default::default()
};
K8sResourceScore::single(service, Some(self.score.namespace.clone()))
.create_interpret()
.execute(inventory, topology)
.await?;
Ok(Outcome::success(format!(
"DB debug service '{name}' type={svc_type} (edit Service type ClusterIP/NodePort to toggle)"
)))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("PostgresDebugRouteInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
}

View File

@@ -66,6 +66,11 @@ impl K8sPostgreSQLScore {
self self
} }
pub fn version(mut self, version: impl Into<String>) -> Self {
self.config.version = Some(version.into());
self
}
pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef { pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef {
PostgreSQLRootAccountRef { PostgreSQLRootAccountRef {
host: format!( host: format!(
@@ -298,6 +303,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
let spec = ClusterSpec { let spec = ClusterSpec {
instances: self.config.instances, instances: self.config.instances,
image_name: cnpg_image_name(self.config.version.as_deref()),
storage: Storage { storage: Storage {
size: self.config.storage_size.to_string(), size: self.config.storage_size.to_string(),
}, },
@@ -387,6 +393,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
let spec = ClusterSpec { let spec = ClusterSpec {
instances: self.config.instances, instances: self.config.instances,
image_name: cnpg_image_name(self.config.version.as_deref()),
storage: Storage { storage: Storage {
size: self.config.storage_size.to_string(), size: self.config.storage_size.to_string(),
}, },
@@ -453,6 +460,22 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
} }
} }
/// Map a declared PG version to CNPG `spec.imageName`.
/// - `None` → operator default image
/// - `"16"` / `"16.4"` → `ghcr.io/cloudnative-pg/postgresql:<tag>`
/// - value containing `/` → used as a full image reference
fn cnpg_image_name(version: Option<&str>) -> Option<String> {
let version = version?.trim();
if version.is_empty() {
return None;
}
if version.contains('/') {
Some(version.to_string())
} else {
Some(format!("ghcr.io/cloudnative-pg/postgresql:{version}"))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -474,4 +497,17 @@ mod tests {
} }
); );
} }
#[test]
fn cnpg_image_name_maps_tags_and_full_refs() {
assert_eq!(cnpg_image_name(None), None);
assert_eq!(
cnpg_image_name(Some("16")).as_deref(),
Some("ghcr.io/cloudnative-pg/postgresql:16")
);
assert_eq!(
cnpg_image_name(Some("ghcr.io/example/pg:18")).as_deref(),
Some("ghcr.io/example/pg:18")
);
}
} }

View File

@@ -20,31 +20,46 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use async_trait::async_trait;
use base64::Engine; use base64::Engine;
use k8s_openapi::ByteString; use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::Secret as K8sSecret; use k8s_openapi::api::core::v1::Secret as K8sSecret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use serde::Serialize; use serde::Serialize;
use crate::interpret::Interpret; use crate::data::Version;
use crate::modules::k8s::resource::K8sResourceScore; use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::score::Score; use crate::score::Score;
use crate::topology::{K8sclient, Topology}; use crate::topology::{K8sclient, Topology};
use harmony_types::id::Id;
/// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from /// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from
/// pull-only registry credentials. Idempotent — a namespaced Secret apply. /// pull-only registry credentials. Idempotent — a namespaced Secret apply.
#[derive(Debug, Clone, Serialize)] #[derive(Clone, Serialize)]
pub struct RegistryPullSecretScore { pub struct RegistryPullSecretScore {
pub namespace: String, pub namespace: String,
/// Secret name — the same string goes in each pod's `imagePullSecrets`. /// Secret name — the same string goes in each pod's `imagePullSecrets`.
pub name: String, pub name: String,
/// Registry host the creds authenticate to, e.g. `hub.nationtech.io`. /// Registry host the creds authenticate to, e.g. `hub.nationtech.io`.
pub registry: String, pub registry: String,
#[serde(skip_serializing)]
pub username: String, pub username: String,
/// Pull-only robot token. Serialized into the Secret, never logged. /// Pull-only robot token. Serialized into the Secret, never logged.
#[serde(skip_serializing)]
pub token: String, pub token: String,
} }
impl std::fmt::Debug for RegistryPullSecretScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryPullSecretScore")
.field("namespace", &self.namespace)
.field("name", &self.name)
.field("registry", &self.registry)
.finish_non_exhaustive()
}
}
impl RegistryPullSecretScore { impl RegistryPullSecretScore {
/// Build the `.dockerconfigjson` Secret. The inner `auth` field is /// Build the `.dockerconfigjson` Secret. The inner `auth` field is
/// `base64(user:token)` per Docker's config format; the kubelet reads it to /// `base64(user:token)` per Docker's config format; the kubelet reads it to
@@ -85,7 +100,51 @@ impl<T: Topology + K8sclient + 'static> Score<T> for RegistryPullSecretScore {
} }
fn create_interpret(&self) -> Box<dyn Interpret<T>> { fn create_interpret(&self) -> Box<dyn Interpret<T>> {
K8sResourceScore::single(self.secret(), Some(self.namespace.clone())).create_interpret() Box::new(RegistryPullSecretInterpret {
score: self.clone(),
})
}
}
#[derive(Debug, Clone)]
struct RegistryPullSecretInterpret {
score: RegistryPullSecretScore,
}
#[async_trait]
impl<T: Topology + K8sclient> Interpret<T> for RegistryPullSecretInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
topology
.k8s_client()
.await
.map_err(|error| InterpretError::new(format!("get Kubernetes client: {error}")))?
.apply_redacted(&self.score.secret(), Some(&self.score.namespace))
.await
.map_err(|error| InterpretError::new(format!("apply registry pull Secret: {error}")))?;
Ok(Outcome::success(format!(
"applied registry pull Secret {}/{}",
self.score.namespace, self.score.name
)))
}
fn get_name(&self) -> InterpretName {
InterpretName::K8sResource
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
vec![]
} }
} }
@@ -122,4 +181,12 @@ mod tests {
let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t"); let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t");
assert_eq!(entry["auth"], expected_auth); assert_eq!(entry["auth"], expected_auth);
} }
#[test]
fn debug_and_score_serialization_omit_credentials() {
let score = sample();
assert!(!format!("{score:?}").contains("s3cr3t"));
assert!(!serde_json::to_string(&score).unwrap().contains("s3cr3t"));
}
} }

View File

@@ -1 +1,4 @@
pub mod ceph; pub mod ceph;
pub mod object_bucket;
pub use object_bucket::ObjectBucketScore;

View File

@@ -0,0 +1,643 @@
use std::collections::BTreeMap;
use std::time::Duration;
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::CustomResource;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::data::Version;
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::modules::k8s::resource::K8sResourceScore;
use crate::score::Score;
use crate::topology::{K8sclient, Topology};
use harmony_types::id::Id;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, Default)]
#[kube(
group = "objectbucket.io",
version = "v1alpha1",
kind = "ObjectBucketClaim",
plural = "objectbucketclaims",
namespaced = true,
schema = "disabled"
)]
#[serde(rename_all = "camelCase")]
pub struct ObjectBucketClaimSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub bucket_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_bucket_name: Option<String>,
pub storage_class_name: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub additional_config: BTreeMap<String, String>,
}
/// Provisions a Rook/lib-bucket-provisioner ObjectBucketClaim and synthesizes an
/// app-facing credentials Secret (`{name}-app`) with endpoint/bucket/keys.
#[derive(Debug, Clone, Serialize)]
pub struct ObjectBucketScore {
pub name: String,
pub namespace: String,
pub storage_class: String,
pub max_size: String,
/// When set, written into the app Secret instead of the cluster-internal RGW URL.
pub endpoint_override: Option<String>,
/// Full origins (e.g. `https://app.example.com`) applied via S3 PutBucketCors.
pub cors_origins: Vec<String>,
}
impl ObjectBucketScore {
pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
Self {
name: name.into(),
namespace: namespace.into(),
storage_class: "ceph-bucket".into(),
max_size: "10G".into(),
endpoint_override: None,
cors_origins: Vec::new(),
}
}
pub fn storage_class(mut self, storage_class: impl Into<String>) -> Self {
self.storage_class = storage_class.into();
self
}
pub fn max_size(mut self, max_size: impl Into<String>) -> Self {
self.max_size = max_size.into();
self
}
pub fn endpoint_override(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint_override = Some(endpoint.into());
self
}
pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.cors_origins = origins.into_iter().map(Into::into).collect();
self
}
pub fn app_secret_name(&self) -> String {
format!("{}-app", self.name)
}
fn claim(&self) -> ObjectBucketClaim {
let mut additional_config = BTreeMap::new();
additional_config.insert("maxSize".into(), self.max_size.clone());
ObjectBucketClaim {
metadata: ObjectMeta {
name: Some(self.name.clone()),
namespace: Some(self.namespace.clone()),
..ObjectMeta::default()
},
spec: ObjectBucketClaimSpec {
bucket_name: Some(self.name.clone()),
generate_bucket_name: None,
storage_class_name: self.storage_class.clone(),
additional_config,
},
}
}
}
impl Default for ObjectBucketClaim {
fn default() -> Self {
Self {
metadata: ObjectMeta::default(),
spec: ObjectBucketClaimSpec::default(),
}
}
}
impl<T: Topology + K8sclient + 'static> Score<T> for ObjectBucketScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(ObjectBucketInterpret {
score: self.clone(),
})
}
fn name(&self) -> String {
format!("ObjectBucketScore({}/{})", self.namespace, self.name)
}
}
#[derive(Debug, Clone)]
struct ObjectBucketInterpret {
score: ObjectBucketScore,
}
#[async_trait]
impl<T: Topology + K8sclient> Interpret<T> for ObjectBucketInterpret {
async fn execute(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
let client = topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?;
client
.ensure_namespace(&self.score.namespace)
.await
.map_err(|e| InterpretError::new(format!("ensure namespace: {e}")))?;
K8sResourceScore::single(self.score.claim(), Some(self.score.namespace.clone()))
.create_interpret()
.execute(inventory, topology)
.await?;
let (config, provisioner_secret) = wait_for_claim_outputs(
client.as_ref(),
&self.score.namespace,
&self.score.name,
Duration::from_secs(180),
)
.await?;
let credentials = bucket_credentials(
&config,
&provisioner_secret,
self.score.endpoint_override.as_deref(),
)?;
if !self.score.cors_origins.is_empty() {
apply_bucket_cors(&credentials, &self.score.cors_origins).await?;
}
let app_secret = app_secret(
&self.score.namespace,
&self.score.app_secret_name(),
&credentials,
);
client
.apply_redacted(&app_secret, Some(&self.score.namespace))
.await
.map_err(|e| InterpretError::new(format!("apply bucket credentials Secret: {e}")))?;
Ok(Outcome::success(format!(
"object bucket '{}/{}' ready",
self.score.namespace, self.score.name
)))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("ObjectBucketInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
}
struct BucketCredentials {
endpoint: String,
bucket: String,
region: String,
access_key: String,
secret_key: String,
}
async fn wait_for_claim_outputs(
client: &harmony_k8s::K8sClient,
namespace: &str,
name: &str,
timeout: Duration,
) -> Result<(ConfigMap, Secret), InterpretError> {
let start = std::time::Instant::now();
info!(
"Waiting for ObjectBucketClaim '{namespace}/{name}' credentials (up to {}s)...",
timeout.as_secs()
);
loop {
let cm = client
.get_resource::<ConfigMap>(name, Some(namespace))
.await
.map_err(|e| InterpretError::new(format!("get OBC ConfigMap: {e}")))?;
let secret = client
.get_resource::<Secret>(name, Some(namespace))
.await
.map_err(|e| InterpretError::new(format!("get OBC Secret: {e}")))?;
if let (Some(cm), Some(secret)) = (cm, secret)
&& config_has_bucket(&cm)
&& secret_has_keys(&secret)
{
info!("ObjectBucketClaim '{namespace}/{name}' credentials ready");
return Ok((cm, secret));
}
debug!("ObjectBucketClaim '{namespace}/{name}' credentials not ready yet");
if start.elapsed() > timeout {
return Err(InterpretError::new(format!(
"timed out waiting for ObjectBucketClaim '{namespace}/{name}' credentials after {}s",
timeout.as_secs()
)));
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
fn config_has_bucket(cm: &ConfigMap) -> bool {
cm.data
.as_ref()
.is_some_and(|data| data.contains_key("BUCKET_NAME") && data.contains_key("BUCKET_HOST"))
}
fn secret_has_keys(secret: &Secret) -> bool {
secret.data.as_ref().is_some_and(|data| {
data.contains_key("AWS_ACCESS_KEY_ID") && data.contains_key("AWS_SECRET_ACCESS_KEY")
})
}
fn bucket_credentials(
config: &ConfigMap,
provisioner: &Secret,
endpoint_override: Option<&str>,
) -> Result<BucketCredentials, InterpretError> {
let data = config
.data
.as_ref()
.ok_or_else(|| InterpretError::new("OBC ConfigMap has no data".to_string()))?;
let host = data
.get("BUCKET_HOST")
.ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_HOST".to_string()))?;
let port = data.get("BUCKET_PORT").map(String::as_str).unwrap_or("80");
let bucket = data
.get("BUCKET_NAME")
.ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?
.clone();
// Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty
// value. Prefer a place-neutral token over a fake AWS region name.
let region = data
.get("BUCKET_REGION")
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("default")
.to_string();
let scheme = if port == "443" { "https" } else { "http" };
let internal = if port == "80" || port == "443" {
format!("{scheme}://{host}")
} else {
format!("{scheme}://{host}:{port}")
};
let endpoint = endpoint_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or(internal);
let keys = provisioner
.data
.as_ref()
.ok_or_else(|| InterpretError::new("OBC Secret has no data".to_string()))?;
let access_key = decode_secret_key(keys, "AWS_ACCESS_KEY_ID")?;
let secret_key = decode_secret_key(keys, "AWS_SECRET_ACCESS_KEY")?;
Ok(BucketCredentials {
endpoint,
bucket,
region,
access_key,
secret_key,
})
}
fn decode_secret_key(
keys: &BTreeMap<String, ByteString>,
name: &str,
) -> Result<String, InterpretError> {
let raw = keys
.get(name)
.ok_or_else(|| InterpretError::new(format!("OBC Secret missing {name}")))?;
String::from_utf8(raw.0.clone())
.map_err(|e| InterpretError::new(format!("OBC Secret {name} is not utf8: {e}")))
}
fn app_secret(namespace: &str, name: &str, credentials: &BucketCredentials) -> Secret {
Secret {
metadata: ObjectMeta {
name: Some(name.to_string()),
namespace: Some(namespace.to_string()),
..ObjectMeta::default()
},
type_: Some("Opaque".into()),
data: Some(BTreeMap::from([
(
"endpoint".into(),
ByteString(credentials.endpoint.as_bytes().to_vec()),
),
(
"bucket".into(),
ByteString(credentials.bucket.as_bytes().to_vec()),
),
(
"region".into(),
ByteString(credentials.region.as_bytes().to_vec()),
),
("path-style".into(), ByteString(b"true".to_vec())),
(
"access-key".into(),
ByteString(credentials.access_key.as_bytes().to_vec()),
),
(
"secret-key".into(),
ByteString(credentials.secret_key.as_bytes().to_vec()),
),
])),
..Default::default()
}
}
async fn apply_bucket_cors(
credentials: &BucketCredentials,
origins: &[String],
) -> Result<(), InterpretError> {
let origins: Vec<&str> = origins
.iter()
.map(|o| o.trim())
.filter(|o| !o.is_empty())
.collect();
if origins.is_empty() {
return Ok(());
}
info!(
"Applying CORS on bucket '{}' at {} for origins {:?}",
credentials.bucket, credentials.endpoint, origins
);
let body = cors_configuration_xml(&origins);
let signed = sign_s3_put_cors(credentials, body.as_bytes())?;
let response = reqwest::Client::new()
.put(&signed.url)
.header("content-type", "application/xml")
.header("host", &signed.host)
.header("x-amz-content-sha256", &signed.payload_hash)
.header("x-amz-date", &signed.amz_date)
.header("authorization", &signed.authorization)
.body(body)
.send()
.await
.map_err(|e| {
InterpretError::new(format!(
"put bucket CORS on '{}' via {}: {e}",
credentials.bucket, credentials.endpoint
))
})?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(InterpretError::new(format!(
"put bucket CORS on '{}' via {} failed: {status} {text}",
credentials.bucket, credentials.endpoint
)));
}
Ok(())
}
struct SignedS3Request {
url: String,
host: String,
amz_date: String,
payload_hash: String,
authorization: String,
}
/// Path-style `PUT /{bucket}?cors` with AWS SigV4 (Ceph RGW compatible).
fn sign_s3_put_cors(
credentials: &BucketCredentials,
body: &[u8],
) -> Result<SignedS3Request, InterpretError> {
let endpoint = credentials
.endpoint
.trim_end_matches('/')
.parse::<url::Url>()
.map_err(|e| InterpretError::new(format!("invalid bucket endpoint: {e}")))?;
let host = endpoint
.host_str()
.ok_or_else(|| InterpretError::new("bucket endpoint missing host".to_string()))?;
let host_header = match endpoint.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let bucket = credentials.bucket.trim_matches('/');
// URI-encode path segments per AWS (unreserved stay literal).
let canonical_uri = format!("/{}", aws_uri_encode(bucket, false));
let url = format!(
"{}{}?cors",
credentials.endpoint.trim_end_matches('/'),
canonical_uri
);
let now = chrono::Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
let payload_hash = hex::encode(Sha256::digest(body));
// App secret may store place-neutral "default"; RGW on cb1 is configured as us-east-1.
let region = match credentials.region.as_str() {
"" | "default" => "us-east-1",
other => other,
};
let service = "s3";
let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request");
// Query params: name=value, sorted; empty value still needs '='.
let canonical_query = "cors=";
let canonical_headers = format!(
"content-type:application/xml\nhost:{host_header}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"
);
let signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"PUT\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
let signing_key = aws4_signing_key(&credentials.secret_key, &date_stamp, region, service);
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
credentials.access_key
);
Ok(SignedS3Request {
url,
host: host_header,
amz_date,
payload_hash,
authorization,
})
}
/// AWS SigV4 URI encode. `encode_slash` is false for path segments that must keep `/`.
fn aws_uri_encode(input: &str, encode_slash: bool) -> String {
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b'/' if !encode_slash => out.push('/'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn cors_configuration_xml(origins: &[&str]) -> String {
let mut rules = String::new();
for origin in origins {
let origin = xml_escape(origin);
rules.push_str(&format!(
r#"
<CORSRule>
<AllowedOrigin>{origin}</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<ExposeHeader>x-amz-request-id</ExposeHeader>
<ExposeHeader>x-amz-id-2</ExposeHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>"#
));
}
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><CORSConfiguration>{rules}\n</CORSConfiguration>"
)
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn aws4_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, region.as_bytes());
let k_service = hmac_sha256(&k_region, service.as_bytes());
hmac_sha256(&k_service, b"aws4_request")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claim_pins_bucket_name_and_max_size() {
let score = ObjectBucketScore::new("ns", "recipe1-files").max_size("10G");
let claim = score.claim();
assert_eq!(claim.spec.bucket_name.as_deref(), Some("recipe1-files"));
assert_eq!(claim.spec.storage_class_name, "ceph-bucket");
assert_eq!(
claim
.spec
.additional_config
.get("maxSize")
.map(String::as_str),
Some("10G")
);
assert_eq!(score.app_secret_name(), "recipe1-files-app");
}
#[test]
fn empty_bucket_region_defaults_to_default() {
let cm = ConfigMap {
data: Some(BTreeMap::from([
("BUCKET_HOST".into(), "rgw.svc".into()),
("BUCKET_PORT".into(), "80".into()),
("BUCKET_NAME".into(), "files".into()),
("BUCKET_REGION".into(), "".into()),
])),
..Default::default()
};
let provisioner = Secret {
data: Some(BTreeMap::from([
("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())),
("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())),
])),
..Default::default()
};
let credentials = bucket_credentials(&cm, &provisioner, None).unwrap();
assert_eq!(credentials.region, "default");
assert_eq!(credentials.endpoint, "http://rgw.svc");
}
#[test]
fn cors_xml_lists_origins_and_methods() {
let xml = cors_configuration_xml(&["https://app.example.com"]);
assert!(xml.contains("<AllowedOrigin>https://app.example.com</AllowedOrigin>"));
assert!(xml.contains("<AllowedMethod>PUT</AllowedMethod>"));
assert!(xml.contains("<AllowedHeader>*</AllowedHeader>"));
}
#[test]
fn sign_s3_put_cors_uses_cors_eq_query_and_us_east_1_for_default_region() {
let credentials = BucketCredentials {
endpoint: "https://s3.example.com".into(),
bucket: "my-bucket".into(),
region: "default".into(),
access_key: "AKIA".into(),
secret_key: "secret".into(),
};
let signed = sign_s3_put_cors(&credentials, b"<CORSConfiguration/>").unwrap();
assert_eq!(signed.url, "https://s3.example.com/my-bucket?cors");
assert!(signed.authorization.contains("/us-east-1/s3/aws4_request"));
// Signature is deterministic for fixed clock only; at least shape is present.
assert!(signed.authorization.contains("Signature="));
assert_eq!(signed.host, "s3.example.com");
}
#[test]
fn endpoint_override_replaces_internal_rgw_url() {
let cm = ConfigMap {
data: Some(BTreeMap::from([
("BUCKET_HOST".into(), "rgw.svc".into()),
("BUCKET_PORT".into(), "25080".into()),
("BUCKET_NAME".into(), "files".into()),
])),
..Default::default()
};
let provisioner = Secret {
data: Some(BTreeMap::from([
("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())),
("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())),
])),
..Default::default()
};
let credentials =
bucket_credentials(&cm, &provisioner, Some("https://s3.cb1.nationtech.io")).unwrap();
assert_eq!(credentials.endpoint, "https://s3.cb1.nationtech.io");
assert_eq!(credentials.bucket, "files");
}
}

View File

@@ -4,14 +4,15 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use harmony_config::{Config, ConfigClient}; use harmony_config::{Config, ConfigClient};
use harmony_k8s::ClusterConnection; use harmony_k8s::{ClusterConnection, K8sClient};
use harmony_types::id::Id; use harmony_types::id::Id;
use harmony_types::k8s_name::K8sName; use harmony_types::k8s_name::K8sName;
use k8s_openapi::api::core::v1::{Secret, ServiceAccount}; use k8s_openapi::api::core::v1::{Namespace, Secret, ServiceAccount};
use k8s_openapi::api::rbac::v1::{ use k8s_openapi::api::rbac::v1::{
ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject, ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
}; };
use kube::api::ObjectMeta; use kube::api::ObjectMeta;
use kube::config::{KubeConfigOptions, Kubeconfig};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -32,6 +33,7 @@ pub struct TenantCredentialScore {
namespace: K8sName, namespace: K8sName,
name: K8sName, name: K8sName,
rules: Vec<PolicyRule>, rules: Vec<PolicyRule>,
role_subjects: Vec<Subject>,
#[serde(skip)] #[serde(skip)]
store: Arc<ConfigClient>, store: Arc<ConfigClient>,
allow_insecure_source: bool, allow_insecure_source: bool,
@@ -59,11 +61,17 @@ impl TenantCredentialScore {
namespace, namespace,
name, name,
rules, rules,
role_subjects: Vec::new(),
store, store,
allow_insecure_source, allow_insecure_source,
} }
} }
pub fn with_role_subjects(mut self, subjects: Vec<Subject>) -> Self {
self.role_subjects = subjects;
self
}
fn service_account(&self) -> ServiceAccount { fn service_account(&self) -> ServiceAccount {
ServiceAccount { ServiceAccount {
metadata: ObjectMeta { metadata: ObjectMeta {
@@ -88,6 +96,8 @@ impl TenantCredentialScore {
} }
fn role_binding(&self) -> RoleBinding { fn role_binding(&self) -> RoleBinding {
let mut subjects = vec![self.subject()];
subjects.extend(self.role_subjects.clone());
RoleBinding { RoleBinding {
metadata: ObjectMeta { metadata: ObjectMeta {
name: Some(self.name.to_string()), name: Some(self.name.to_string()),
@@ -99,7 +109,7 @@ impl TenantCredentialScore {
kind: "Role".to_string(), kind: "Role".to_string(),
name: self.name.to_string(), name: self.name.to_string(),
}, },
subjects: Some(vec![self.subject()]), subjects: Some(subjects),
} }
} }
@@ -280,15 +290,50 @@ impl<T: Topology + K8sclient> Interpret<T> for TenantCredentialInterpret {
&token, &token,
&certificate_authority_data, &certificate_authority_data,
)?; )?;
let generated_config = kube::Config::from_custom_kubeconfig(
serde_yaml::from_str::<Kubeconfig>(&kubeconfig).map_err(|error| {
InterpretError::new(format!("parse generated tenant kubeconfig: {error}"))
})?,
&KubeConfigOptions::default(),
)
.await
.map_err(|error| {
InterpretError::new(format!("load generated tenant kubeconfig: {error}"))
})?;
let generated_client =
K8sClient::new(kube::Client::try_from(generated_config).map_err(|error| {
InterpretError::new(format!("create generated tenant client: {error}"))
})?);
if generated_client
.get_resource::<Namespace>(namespace, None)
.await
.map_err(|error| {
InterpretError::new(format!("verify generated tenant credentials: {error}"))
})?
.is_none()
{
return Err(InterpretError::new(format!(
"generated tenant credentials cannot read namespace '{namespace}'"
)));
}
let access = ClusterAccess { kubeconfig };
self.score self.score
.store .store
.set(&ClusterAccess { kubeconfig }) .set(&access)
.await .await
.map_err(|error| InterpretError::new(format!("store tenant ClusterAccess: {error}")))?; .map_err(|error| InterpretError::new(format!("store tenant ClusterAccess: {error}")))?;
let stored: ClusterAccess = self.score.store.get().await.map_err(|error| {
InterpretError::new(format!("verify stored ClusterAccess: {error}"))
})?;
if stored.kubeconfig != access.kubeconfig {
return Err(InterpretError::new(
"stored ClusterAccess does not match the verified tenant credentials".to_string(),
));
}
Ok(Outcome::success(format!( Ok(Outcome::success(format!(
"tenant deployer access stored for namespace '{}'", "tenant deployer access stored for namespace '{}' on cluster '{}' at '{}'",
self.score.namespace self.score.namespace, connection.name, connection.server
))) )))
} }
@@ -317,13 +362,16 @@ fn tenant_kubeconfig(
certificate_authority_data: &str, certificate_authority_data: &str,
) -> Result<String, InterpretError> { ) -> Result<String, InterpretError> {
let context = format!("{user}@{}", connection.name); let context = format!("{user}@{}", connection.name);
let mut cluster = serde_json::Map::from_iter([ let mut cluster =
("server".to_string(), connection.server.clone().into()), serde_json::Map::from_iter([("server".to_string(), connection.server.clone().into())]);
( if connection.tls_verified {
cluster.insert(
"certificate-authority-data".to_string(), "certificate-authority-data".to_string(),
certificate_authority_data.into(), certificate_authority_data.into(),
), );
]); } else {
cluster.insert("insecure-skip-tls-verify".to_string(), true.into());
}
if let Some(name) = &connection.tls_server_name { if let Some(name) = &connection.tls_server_name {
cluster.insert("tls-server-name".to_string(), name.clone().into()); cluster.insert("tls-server-name".to_string(), name.clone().into());
} }
@@ -374,6 +422,27 @@ mod tests {
assert!(!kubeconfig.contains("cluster-admin")); assert!(!kubeconfig.contains("cluster-admin"));
} }
#[test]
fn kubeconfig_preserves_insecure_source_tls_mode() {
let kubeconfig = tenant_kubeconfig(
&ClusterConnection {
name: "lab".to_string(),
server: "https://192.0.2.10:6443".to_string(),
tls_server_name: None,
proxy_url: None,
tls_verified: false,
},
"customer-fleet",
"fleet-deployer",
"tenant-token",
"unused-ca",
)
.unwrap();
assert!(kubeconfig.contains("insecure-skip-tls-verify: true"));
assert!(!kubeconfig.contains("certificate-authority-data"));
}
#[test] #[test]
fn score_serialization_excludes_config_destination() { fn score_serialization_excludes_config_destination() {
let score = TenantCredentialScore::new( let score = TenantCredentialScore::new(
@@ -387,4 +456,25 @@ mod tests {
let serialized = serde_json::to_string(&score).unwrap(); let serialized = serde_json::to_string(&score).unwrap();
assert!(!serialized.contains("store")); assert!(!serialized.contains("store"));
} }
#[test]
fn additional_role_subjects_share_the_deployer_role() {
let score = TenantCredentialScore::new(
"customer-fleet".parse().unwrap(),
"fleet-deployer".parse().unwrap(),
Vec::new(),
Arc::new(ConfigClient::new(Vec::new())),
false,
)
.with_role_subjects(vec![Subject {
api_group: Some("rbac.authorization.k8s.io".into()),
kind: "User".into(),
name: "alice@example.com".into(),
namespace: None,
}]);
let subjects = score.role_binding().subjects.unwrap();
assert_eq!(subjects.len(), 2);
assert_eq!(subjects[1].name, "alice@example.com");
}
} }

View File

@@ -0,0 +1,932 @@
//! Typed, additive Zitadel provisioning for [`super::ZitadelSetupScore`].
//!
//! Declared resources are created and selected mutable settings are updated.
//! Resources removed from a contract are not deleted, and role metadata drift
//! is not yet converged.
use std::{
collections::{HashMap, HashSet},
fmt,
};
use harmony_config::Config;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
macro_rules! named_ref {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
}
impl From<&str> for $name {
fn from(name: &str) -> Self {
Self::new(name)
}
}
impl From<String> for $name {
fn from(name: String) -> Self {
Self::new(name)
}
}
};
}
named_ref!(ZitadelProjectRef);
named_ref!(ZitadelHumanRef);
named_ref!(ZitadelMachineRef);
named_ref!(ZitadelBootstrapSecretRef);
/// An application identity is project-scoped because Zitadel permits the same
/// application name in different projects.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ZitadelApplicationRef {
project: ZitadelProjectRef,
name: String,
}
impl ZitadelApplicationRef {
pub fn new(project: ZitadelProjectRef, name: impl Into<String>) -> Self {
Self {
project,
name: name.into(),
}
}
pub fn project(&self) -> &ZitadelProjectRef {
&self.project
}
pub fn name(&self) -> &str {
&self.name
}
pub(crate) fn cache_key(&self) -> String {
format!("{}::{}", self.project.name(), self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ZitadelRoleRef {
pub project: ZitadelProjectRef,
pub key: String,
}
impl ZitadelRoleRef {
pub fn new(project: ZitadelProjectRef, key: impl Into<String>) -> Self {
Self {
project,
key: key.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "subject")]
pub enum ZitadelPrincipalRef {
Human(ZitadelHumanRef),
Machine(ZitadelMachineRef),
}
impl From<ZitadelHumanRef> for ZitadelPrincipalRef {
fn from(value: ZitadelHumanRef) -> Self {
Self::Human(value)
}
}
impl From<ZitadelMachineRef> for ZitadelPrincipalRef {
fn from(value: ZitadelMachineRef) -> Self {
Self::Machine(value)
}
}
impl ZitadelPrincipalRef {
pub fn username(&self) -> &str {
match self {
Self::Human(value) => value.name(),
Self::Machine(value) => value.name(),
}
}
}
/// Named bootstrap values stored under deployer-writable `harmony-state/`.
/// Missing keys are generated on first human create. Contract declarations
/// serialize only a [`ZitadelBootstrapSecretRef`], never the password itself.
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Config)]
#[config(secret)]
pub struct ZitadelBootstrapSecrets {
#[config(secret)]
values: HashMap<String, String>,
}
impl ZitadelBootstrapSecrets {
pub fn new() -> Self {
Self::default()
}
pub fn insert(
mut self,
reference: ZitadelBootstrapSecretRef,
value: impl Into<String>,
) -> Self {
self.values
.insert(reference.name().to_string(), value.into());
self
}
pub(crate) fn resolve(&self, reference: &ZitadelBootstrapSecretRef) -> Option<&str> {
self.values.get(reference.name()).map(String::as_str)
}
}
impl fmt::Debug for ZitadelBootstrapSecrets {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ZitadelBootstrapSecrets")
.field("values", &format_args!("[REDACTED; {}]", self.values.len()))
.finish()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelAccessTokenType {
#[default]
Bearer,
Jwt,
}
impl ZitadelAccessTokenType {
pub(crate) fn api_value(self) -> &'static str {
match self {
Self::Bearer => "OIDC_TOKEN_TYPE_BEARER",
Self::Jwt => "OIDC_TOKEN_TYPE_JWT",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelOidcResponseType {
Code,
IdToken,
IdTokenToken,
}
impl ZitadelOidcResponseType {
pub(crate) fn api_value(self) -> &'static str {
match self {
Self::Code => "OIDC_RESPONSE_TYPE_CODE",
Self::IdToken => "OIDC_RESPONSE_TYPE_ID_TOKEN",
Self::IdTokenToken => "OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelOidcGrantType {
AuthorizationCode,
Implicit,
RefreshToken,
DeviceCode,
TokenExchange,
}
impl ZitadelOidcGrantType {
pub(crate) fn api_value(self) -> &'static str {
match self {
Self::AuthorizationCode => "OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
Self::Implicit => "OIDC_GRANT_TYPE_IMPLICIT",
Self::RefreshToken => "OIDC_GRANT_TYPE_REFRESH_TOKEN",
Self::DeviceCode => "OIDC_GRANT_TYPE_DEVICE_CODE",
Self::TokenExchange => "OIDC_GRANT_TYPE_TOKEN_EXCHANGE",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelOidcAppType {
Web,
UserAgent,
Native,
}
impl ZitadelOidcAppType {
pub(crate) fn api_value(self) -> &'static str {
match self {
Self::Web => "OIDC_APP_TYPE_WEB",
Self::UserAgent => "OIDC_APP_TYPE_USER_AGENT",
Self::Native => "OIDC_APP_TYPE_NATIVE",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelOidcAuthMethod {
None,
Basic,
Post,
PrivateKeyJwt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelLoginVersion {
V1,
V2 {
#[serde(default)]
base_uri: Option<String>,
},
}
impl ZitadelLoginVersion {
pub fn v2() -> Self {
Self::V2 { base_uri: None }
}
pub fn v2_at(base_uri: impl Into<String>) -> Self {
Self::V2 {
base_uri: Some(base_uri.into()),
}
}
pub(crate) fn api_value(&self) -> serde_json::Value {
match self {
Self::V1 => serde_json::json!({ "loginV1": {} }),
Self::V2 { base_uri: None } => serde_json::json!({ "loginV2": {} }),
Self::V2 {
base_uri: Some(base_uri),
} => serde_json::json!({ "loginV2": { "baseUri": base_uri } }),
}
}
}
impl ZitadelOidcAuthMethod {
pub(crate) fn api_value(self) -> &'static str {
match self {
Self::None => "OIDC_AUTH_METHOD_TYPE_NONE",
Self::Basic => "OIDC_AUTH_METHOD_TYPE_BASIC",
Self::Post => "OIDC_AUTH_METHOD_TYPE_POST",
Self::PrivateKeyJwt => "OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelOidcTokenSettings {
#[serde(default)]
pub access_token_type: ZitadelAccessTokenType,
#[serde(default)]
pub id_token_role_assertion: bool,
#[serde(default)]
pub id_token_userinfo_assertion: bool,
#[serde(default)]
pub access_token_role_assertion: bool,
#[serde(default)]
pub clock_skew: Option<String>,
#[serde(default)]
pub dev_mode: bool,
}
impl Default for ZitadelOidcTokenSettings {
fn default() -> Self {
Self {
access_token_type: ZitadelAccessTokenType::Bearer,
id_token_role_assertion: false,
id_token_userinfo_assertion: true,
access_token_role_assertion: false,
clock_skew: None,
dev_mode: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelProjectDeclaration {
pub project: ZitadelProjectRef,
#[serde(default = "default_true")]
pub project_role_assertion: bool,
#[serde(default)]
pub project_role_check: bool,
#[serde(default)]
pub has_project_check: bool,
}
fn default_true() -> bool {
true
}
impl ZitadelProjectDeclaration {
pub fn new(project: ZitadelProjectRef) -> Self {
Self {
project,
project_role_assertion: true,
project_role_check: false,
has_project_check: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelRoleDeclaration {
pub role: ZitadelRoleRef,
pub display_name: String,
#[serde(default)]
pub group: Option<String>,
}
impl ZitadelRoleDeclaration {
pub fn new(role: ZitadelRoleRef, display_name: impl Into<String>) -> Self {
Self {
role,
display_name: display_name.into(),
group: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelOidcApplicationDeclaration {
pub application: ZitadelApplicationRef,
#[serde(default)]
pub redirect_uris: Vec<String>,
#[serde(default)]
pub post_logout_redirect_uris: Vec<String>,
pub response_types: Vec<ZitadelOidcResponseType>,
pub grant_types: Vec<ZitadelOidcGrantType>,
pub app_type: ZitadelOidcAppType,
pub auth_method: ZitadelOidcAuthMethod,
#[serde(default)]
pub login_version: Option<ZitadelLoginVersion>,
#[serde(default)]
pub token_settings: ZitadelOidcTokenSettings,
}
impl ZitadelOidcApplicationDeclaration {
pub fn web_pkce(application: ZitadelApplicationRef, redirect_uris: Vec<String>) -> Self {
Self {
application,
redirect_uris,
post_logout_redirect_uris: Vec::new(),
response_types: vec![ZitadelOidcResponseType::Code],
grant_types: vec![
ZitadelOidcGrantType::AuthorizationCode,
ZitadelOidcGrantType::RefreshToken,
],
app_type: ZitadelOidcAppType::UserAgent,
auth_method: ZitadelOidcAuthMethod::None,
login_version: None,
token_settings: ZitadelOidcTokenSettings::default(),
}
}
}
/// A Zitadel API application (resource server), scoped to its owning project
/// through [`ZitadelApplicationRef`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelApiApplicationDeclaration {
pub application: ZitadelApplicationRef,
}
impl ZitadelApiApplicationDeclaration {
pub fn new(application: ZitadelApplicationRef) -> Self {
Self { application }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelHumanDeclaration {
pub human: ZitadelHumanRef,
pub first_name: String,
pub last_name: String,
pub bootstrap_password: ZitadelBootstrapSecretRef,
#[serde(default)]
pub password_change_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ZitadelMachineKeyDeclaration {
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelMachineDeclaration {
pub machine: ZitadelMachineRef,
pub name: String,
#[serde(default)]
pub key: Option<ZitadelMachineKeyDeclaration>,
#[serde(default)]
pub client_secret: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelProjectRoleAssignment {
pub principal: ZitadelPrincipalRef,
pub project: ZitadelProjectRef,
pub roles: Vec<ZitadelRoleRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelOrgRoleAssignment {
pub principal: ZitadelPrincipalRef,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelInstanceRoleAssignment {
pub principal: ZitadelPrincipalRef,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZitadelContract {
#[serde(default)]
pub projects: Vec<ZitadelProjectDeclaration>,
#[serde(default)]
pub roles: Vec<ZitadelRoleDeclaration>,
#[serde(default)]
pub applications: Vec<ZitadelOidcApplicationDeclaration>,
#[serde(default)]
pub api_applications: Vec<ZitadelApiApplicationDeclaration>,
#[serde(default)]
pub humans: Vec<ZitadelHumanDeclaration>,
#[serde(default)]
pub machines: Vec<ZitadelMachineDeclaration>,
#[serde(default)]
pub project_role_assignments: Vec<ZitadelProjectRoleAssignment>,
#[serde(default)]
pub org_role_assignments: Vec<ZitadelOrgRoleAssignment>,
#[serde(default)]
pub instance_role_assignments: Vec<ZitadelInstanceRoleAssignment>,
}
impl ZitadelContract {
pub fn project(mut self, project: ZitadelProjectDeclaration) -> Self {
self.projects.push(project);
self
}
pub fn role(mut self, role: ZitadelRoleDeclaration) -> Self {
self.roles.push(role);
self
}
pub fn application(mut self, application: ZitadelOidcApplicationDeclaration) -> Self {
self.applications.push(application);
self
}
pub fn api_application(mut self, application: ZitadelApiApplicationDeclaration) -> Self {
self.api_applications.push(application);
self
}
pub fn human(mut self, human: ZitadelHumanDeclaration) -> Self {
self.humans.push(human);
self
}
pub fn machine(mut self, machine: ZitadelMachineDeclaration) -> Self {
self.machines.push(machine);
self
}
pub fn project_roles(mut self, assignment: ZitadelProjectRoleAssignment) -> Self {
self.project_role_assignments.push(assignment);
self
}
pub fn org_roles(mut self, assignment: ZitadelOrgRoleAssignment) -> Self {
self.org_role_assignments.push(assignment);
self
}
pub fn instance_roles(mut self, assignment: ZitadelInstanceRoleAssignment) -> Self {
self.instance_role_assignments.push(assignment);
self
}
pub fn validate(&self) -> Result<(), String> {
fn reject_duplicates<'a>(
kind: &str,
values: impl IntoIterator<Item = &'a str>,
) -> Result<(), String> {
let mut seen = HashSet::new();
for value in values {
if !seen.insert(value) {
return Err(format!("duplicate {kind} declaration '{value}'"));
}
}
Ok(())
}
reject_duplicates(
"project",
self.projects.iter().map(|item| item.project.name()),
)?;
let mut roles = HashSet::new();
for role in &self.roles {
if !roles.insert(role.role.clone()) {
return Err(format!(
"duplicate role declaration '{}::{}'",
role.role.project.name(),
role.role.key
));
}
}
let mut applications = HashSet::new();
for application in self
.applications
.iter()
.map(|item| &item.application)
.chain(self.api_applications.iter().map(|item| &item.application))
{
if !applications.insert(application.clone()) {
return Err(format!(
"duplicate application declaration '{}::{}'",
application.project().name(),
application.name()
));
}
}
reject_duplicates("human", self.humans.iter().map(|item| item.human.name()))?;
reject_duplicates(
"machine",
self.machines.iter().map(|item| item.machine.name()),
)?;
let humans: HashSet<&str> = self.humans.iter().map(|item| item.human.name()).collect();
if let Some(machine) = self
.machines
.iter()
.find(|item| humans.contains(item.machine.name()))
{
return Err(format!(
"principal '{}' is declared as both human and machine",
machine.machine.name()
));
}
let project_exists =
|project: &ZitadelProjectRef| self.projects.iter().any(|item| &item.project == project);
let principal_exists = |principal: &ZitadelPrincipalRef| match principal {
ZitadelPrincipalRef::Human(human) => {
self.humans.iter().any(|item| &item.human == human)
}
ZitadelPrincipalRef::Machine(machine) => {
self.machines.iter().any(|item| &item.machine == machine)
}
};
for role in &self.roles {
if !project_exists(&role.role.project) {
return Err(format!(
"role '{}' references undeclared project '{}'",
role.role.key,
role.role.project.name()
));
}
}
for app in &self.applications {
if !project_exists(app.application.project()) {
return Err(format!(
"application '{}' references undeclared project '{}'",
app.application.name(),
app.application.project().name()
));
}
if app.response_types.is_empty() || app.grant_types.is_empty() {
return Err(format!(
"application '{}' requires response_types and grant_types",
app.application.name()
));
}
}
for app in &self.api_applications {
if !project_exists(app.application.project()) {
return Err(format!(
"API application '{}' references undeclared project '{}'",
app.application.name(),
app.application.project().name()
));
}
}
for assignment in &self.project_role_assignments {
if !principal_exists(&assignment.principal) {
return Err(format!(
"project assignment references undeclared principal '{}'",
assignment.principal.username()
));
}
if !project_exists(&assignment.project) {
return Err(format!(
"project assignment references undeclared project '{}'",
assignment.project.name()
));
}
for role in &assignment.roles {
if role.project != assignment.project {
return Err(format!(
"role '{}' belongs to project '{}', not assignment project '{}'",
role.key,
role.project.name(),
assignment.project.name()
));
}
if !self.roles.iter().any(|item| &item.role == role) {
return Err(format!(
"assignment references undeclared role '{}'",
role.key
));
}
}
}
for assignment in self
.org_role_assignments
.iter()
.map(|item| &item.principal)
.chain(
self.instance_role_assignments
.iter()
.map(|item| &item.principal),
)
{
if !principal_exists(assignment) {
return Err(format!(
"membership references undeclared principal '{}'",
assignment.username()
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ZitadelProjectOutputRef {
project: ZitadelProjectRef,
}
impl ZitadelProjectOutputRef {
pub(crate) fn new(project: &ZitadelProjectRef) -> Self {
Self {
project: project.clone(),
}
}
pub fn project(&self) -> &ZitadelProjectRef {
&self.project
}
#[doc(hidden)]
pub fn config_map_name(&self) -> String {
format!("zitadel-{}-project", self.project.name())
}
#[doc(hidden)]
pub fn project_id_key(&self) -> &'static str {
"project_id"
}
#[doc(hidden)]
pub fn roles_claim_key(&self) -> &'static str {
"roles_claim"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ZitadelApplicationOutputRef {
application: ZitadelApplicationRef,
}
impl ZitadelApplicationOutputRef {
pub(crate) fn new(application: &ZitadelApplicationRef) -> Self {
Self {
application: application.clone(),
}
}
pub fn application(&self) -> &ZitadelApplicationRef {
&self.application
}
#[doc(hidden)]
pub fn config_map_name(&self) -> String {
format!(
"zitadel-{}-{}-oidc",
self.application.project().name(),
self.application.name()
)
}
#[doc(hidden)]
pub fn client_id_key(&self) -> &'static str {
"client_id"
}
#[doc(hidden)]
pub fn project_id_key(&self) -> &'static str {
"project_id"
}
#[doc(hidden)]
pub fn roles_claim_key(&self) -> &'static str {
"roles_claim"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ZitadelMachineOutputRef {
machine: ZitadelMachineRef,
}
impl ZitadelMachineOutputRef {
pub(crate) fn new(machine: &ZitadelMachineRef) -> Self {
Self {
machine: machine.clone(),
}
}
pub fn machine(&self) -> &ZitadelMachineRef {
&self.machine
}
#[doc(hidden)]
pub fn secret_name(&self) -> String {
format!("zitadel-{}-machine", self.machine.name())
}
#[doc(hidden)]
pub fn user_id_key(&self) -> &'static str {
"user_id"
}
#[doc(hidden)]
pub fn key_json_key(&self) -> &'static str {
"key.json"
}
#[doc(hidden)]
pub fn client_id_key(&self) -> &'static str {
"client_id"
}
#[doc(hidden)]
pub fn client_secret_key(&self) -> &'static str {
"client_secret"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declaration_serializes_only_bootstrap_secret_reference() {
let human = ZitadelHumanDeclaration {
human: "admin@example.test".into(),
first_name: "Admin".into(),
last_name: "User".into(),
bootstrap_password: ZitadelBootstrapSecretRef::new("initial-admin"),
password_change_required: true,
};
let json = serde_json::to_string(&human).unwrap();
assert!(json.contains("initial-admin"));
assert!(!json.contains("super-secret"));
let secrets = ZitadelBootstrapSecrets::new().insert(
ZitadelBootstrapSecretRef::new("initial-admin"),
"super-secret",
);
assert_eq!(
<ZitadelBootstrapSecrets as harmony_config::Config>::CLASS,
harmony_config::ConfigClass::Secret
);
let debug = format!("{secrets:?}");
assert!(debug.contains("[REDACTED; 1]"));
assert!(!debug.contains("super-secret"));
}
#[test]
fn same_application_name_is_scoped_by_project() {
let first = ZitadelApplicationRef::new("first".into(), "console");
let second = ZitadelApplicationRef::new("second".into(), "console");
assert_ne!(first, second);
assert_ne!(first.cache_key(), second.cache_key());
let first_output = ZitadelApplicationOutputRef::new(&first);
let second_output = ZitadelApplicationOutputRef::new(&second);
assert_ne!(
first_output.config_map_name(),
second_output.config_map_name()
);
}
#[test]
fn duplicate_scoped_application_is_rejected() {
let project = ZitadelProjectRef::new("app");
let application = ZitadelApplicationRef::new(project.clone(), "console");
let declaration = ZitadelOidcApplicationDeclaration::web_pkce(
application,
vec!["https://app.example.test/callback".into()],
);
let contract = ZitadelContract::default()
.project(ZitadelProjectDeclaration::new(project))
.application(declaration.clone())
.application(declaration);
assert!(
contract
.validate()
.unwrap_err()
.contains("duplicate application declaration 'app::console'")
);
}
#[test]
fn oidc_and_api_applications_share_scoped_uniqueness() {
let project = ZitadelProjectRef::new("app");
let application = ZitadelApplicationRef::new(project.clone(), "backend");
let contract = ZitadelContract::default()
.project(ZitadelProjectDeclaration::new(project))
.application(ZitadelOidcApplicationDeclaration::web_pkce(
application.clone(),
vec!["https://app.example.test/callback".into()],
))
.api_application(ZitadelApiApplicationDeclaration::new(application));
assert!(
contract
.validate()
.unwrap_err()
.contains("duplicate application declaration 'app::backend'")
);
}
#[test]
fn same_api_application_name_in_different_projects_is_valid() {
let first = ZitadelProjectRef::new("first");
let second = ZitadelProjectRef::new("second");
let contract = ZitadelContract::default()
.project(ZitadelProjectDeclaration::new(first.clone()))
.project(ZitadelProjectDeclaration::new(second.clone()))
.api_application(ZitadelApiApplicationDeclaration::new(
ZitadelApplicationRef::new(first, "backend"),
))
.api_application(ZitadelApiApplicationDeclaration::new(
ZitadelApplicationRef::new(second, "backend"),
));
contract.validate().unwrap();
}
#[test]
fn assignment_rejects_a_role_from_another_project() {
let project = ZitadelProjectRef::new("app");
let other = ZitadelProjectRef::new("other");
let human = ZitadelHumanRef::new("admin@example.test");
let role = ZitadelRoleRef::new(other.clone(), "admin");
let contract = ZitadelContract::default()
.project(ZitadelProjectDeclaration::new(project.clone()))
.project(ZitadelProjectDeclaration::new(other))
.role(ZitadelRoleDeclaration::new(role.clone(), "Admin"))
.human(ZitadelHumanDeclaration {
human: human.clone(),
first_name: "Admin".into(),
last_name: "User".into(),
bootstrap_password: ZitadelBootstrapSecretRef::new("admin"),
password_change_required: false,
})
.project_roles(ZitadelProjectRoleAssignment {
principal: human.into(),
project,
roles: vec![role],
});
assert!(
contract
.validate()
.unwrap_err()
.contains("not assignment project")
);
}
#[test]
fn semantic_outputs_serialize_without_kubernetes_coordinates() {
let project_ref = ZitadelProjectRef::new("fleet");
let app_ref = ZitadelApplicationRef::new(project_ref.clone(), "console");
let machine_ref = ZitadelMachineRef::new("callout");
let project = ZitadelProjectOutputRef::new(&project_ref);
let app = ZitadelApplicationOutputRef::new(&app_ref);
let machine = ZitadelMachineOutputRef::new(&machine_ref);
assert_eq!(project.project(), &project_ref);
assert_eq!(app.application(), &app_ref);
assert_eq!(machine.machine(), &machine_ref);
assert!(!serde_json::to_string(&app).unwrap().contains("config_map"));
assert_eq!(app.config_map_name(), "zitadel-fleet-console-oidc");
assert_eq!(machine.secret_name(), "zitadel-callout-machine");
assert_eq!(machine.client_secret_key(), "client_secret");
}
}

View File

@@ -1,12 +1,24 @@
pub mod admin_auth; pub mod admin_auth;
pub mod contract;
pub mod setup; pub mod setup;
pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, device_code_login}; pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, device_code_login};
pub use contract::{
ZitadelAccessTokenType, ZitadelApiApplicationDeclaration, ZitadelApplicationOutputRef,
ZitadelApplicationRef, ZitadelBootstrapSecretRef, ZitadelBootstrapSecrets, ZitadelContract,
ZitadelHumanDeclaration, ZitadelHumanRef, ZitadelInstanceRoleAssignment, ZitadelLoginVersion,
ZitadelMachineDeclaration, ZitadelMachineKeyDeclaration, ZitadelMachineOutputRef,
ZitadelMachineRef, ZitadelOidcAppType, ZitadelOidcApplicationDeclaration,
ZitadelOidcAuthMethod, ZitadelOidcGrantType, ZitadelOidcResponseType, ZitadelOidcTokenSettings,
ZitadelOrgRoleAssignment, ZitadelPrincipalRef, ZitadelProjectDeclaration,
ZitadelProjectOutputRef, ZitadelProjectRef, ZitadelProjectRoleAssignment,
ZitadelRoleDeclaration, ZitadelRoleRef,
};
pub use setup::{ pub use setup::{
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication, MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelCredentialsExportScore, ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelContractSetupScore,
ZitadelHumanUser, ZitadelMachineUser, ZitadelMember, ZitadelRole, ZitadelScheme, ZitadelCredentialsExportScore, ZitadelHumanUser, ZitadelMachineUser, ZitadelMember,
ZitadelSetupScore, mint_device_credentials, ZitadelRole, ZitadelScheme, ZitadelSetupScore, mint_device_credentials,
}; };
use harmony_k8s::KubernetesDistribution; use harmony_k8s::KubernetesDistribution;
@@ -20,8 +32,7 @@ use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
use async_trait::async_trait; use async_trait::async_trait;
use harmony_config::Config; use harmony_config::{Config, ConfigError, StateClient};
use harmony_macros::hurl;
use harmony_types::id::Id; use harmony_types::id::Id;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use non_blank_string_rs::NonBlankString; use non_blank_string_rs::NonBlankString;
@@ -32,7 +43,7 @@ use crate::{
data::Version, data::Version,
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory, inventory::Inventory,
modules::helm::chart::{HelmChartScore, HelmRepository}, modules::helm::chart::HelmChartScore,
modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef}, modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef},
score::Score, score::Score,
topology::{HelmCommand, K8sclient, Topology}, topology::{HelmCommand, K8sclient, Topology},
@@ -251,6 +262,35 @@ async fn read_namespace_scc_uid_start(
.map_err(|e| format!("parsing uid-range start '{start}': {e}")) .map_err(|e| format!("parsing uid-range start '{start}': {e}"))
} }
async fn read_masterkey(
k8s: &harmony_k8s::K8sClient,
namespace: &str,
) -> Result<Option<String>, InterpretError> {
let secret = k8s
.get_resource::<K8sSecret>(MASTERKEY_SECRET_NAME, Some(namespace))
.await
.map_err(|error| {
InterpretError::new(format!("Failed to read Zitadel masterkey Secret: {error}"))
})?;
match secret {
Some(secret) => {
let bytes = secret
.data
.as_ref()
.and_then(|data| data.get("masterkey"))
.ok_or_else(|| {
InterpretError::new(
"Existing Zitadel masterkey Secret has no masterkey".to_string(),
)
})?;
Ok(Some(String::from_utf8(bytes.0.clone()).map_err(
|error| InterpretError::new(format!("Zitadel masterkey is not UTF-8: {error}")),
)?))
}
None => Ok(None),
}
}
impl Default for ZitadelScore { impl Default for ZitadelScore {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -308,7 +348,13 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for ZitadelScore {
#[doc(hidden)] #[doc(hidden)]
fn create_interpret(&self) -> Box<dyn Interpret<T>> { fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(ZitadelInterpret { Box::new(self.interpret(None))
}
}
impl ZitadelScore {
fn interpret(&self, state_client: Option<StateClient>) -> ZitadelInterpret {
ZitadelInterpret {
host: self.host.clone(), host: self.host.clone(),
zitadel_version: self.zitadel_version.clone(), zitadel_version: self.zitadel_version.clone(),
external_secure: self.external_secure, external_secure: self.external_secure,
@@ -318,7 +364,34 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for ZitadelScore {
password_change_required: self.password_change_required, password_change_required: self.password_change_required,
database: self.database.clone(), database: self.database.clone(),
node_port: self.node_port, node_port: self.node_port,
}) state_client,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfiguredZitadelScore {
score: ZitadelScore,
#[serde(skip)]
state_client: StateClient,
}
impl ZitadelScore {
pub fn with_state_client(self, state_client: StateClient) -> ConfiguredZitadelScore {
ConfiguredZitadelScore {
score: self,
state_client,
}
}
}
impl<T: Topology + K8sclient + HelmCommand> Score<T> for ConfiguredZitadelScore {
fn name(&self) -> String {
"ZitadelScore".to_string()
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(self.score.interpret(Some(self.state_client.clone())))
} }
} }
@@ -335,6 +408,23 @@ struct ZitadelInterpret {
password_change_required: bool, password_change_required: bool,
database: Option<PostgreSQLRootAccountRef>, database: Option<PostgreSQLRootAccountRef>,
node_port: Option<u16>, node_port: Option<u16>,
state_client: Option<StateClient>,
}
impl ZitadelInterpret {
async fn get_state<T: Config>(&self) -> Result<T, ConfigError> {
match &self.state_client {
Some(client) => client.get().await,
None => harmony_config::get().await,
}
}
async fn set_state<T: Config>(&self, value: &T) -> Result<(), ConfigError> {
match &self.state_client {
Some(client) => client.set(value).await,
None => harmony_config::set(value).await,
}
}
} }
#[async_trait] #[async_trait]
@@ -437,19 +527,24 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
// emit a fresh random — misleading the operator). harmony_config // emit a fresh random — misleading the operator). harmony_config
// namespaces by install context (config-resolved), so two // namespaces by install context (config-resolved), so two
// installs in the same context share credentials. // installs in the same context share credentials.
let admin = match harmony_config::get::<ZitadelAdmin>().await { let admin = match self.get_state::<ZitadelAdmin>().await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(ConfigError::NotFound { .. }) => {
debug!("[Zitadel] No persisted admin credentials yet ({e}); generating"); debug!("[Zitadel] No persisted admin credentials yet; generating");
let a = ZitadelAdmin { let a = ZitadelAdmin {
username: "admin".to_string(), username: "admin".to_string(),
password: generate_secure_password(16), password: generate_secure_password(16),
}; };
harmony_config::set(&a).await.map_err(|err| { self.set_state(&a).await.map_err(|err| {
InterpretError::new(format!("Failed to persist Zitadel admin password: {err}")) InterpretError::new(format!("Failed to persist Zitadel admin password: {err}"))
})?; })?;
a a
} }
Err(error) => {
return Err(InterpretError::new(format!(
"Failed to load Zitadel admin password: {error}"
)));
}
}; };
let admin_username = admin.username.clone(); let admin_username = admin.username.clone();
let admin_password = admin.password.clone(); let admin_password = admin.password.clone();
@@ -489,38 +584,27 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
// 3. a freshly generated one. // 3. a freshly generated one.
// Then mirror the resolved value into harmony_config so a deleted/ // Then mirror the resolved value into harmony_config so a deleted/
// recreated namespace reuses it rather than minting a new (broken) key. // recreated namespace reuses it rather than minting a new (broken) key.
let existing_masterkey = k8s_client let existing_masterkey = read_masterkey(&k8s_client, &self.namespace).await?;
.get_resource::<K8sSecret>(MASTERKEY_SECRET_NAME, Some(&self.namespace))
.await
.ok()
.flatten()
.and_then(|s| s.data?.get("masterkey").cloned())
.and_then(|bs| String::from_utf8(bs.0).ok());
let masterkey = match existing_masterkey { let persisted_masterkey = match self.get_state::<ZitadelMasterkey>().await {
Some(k) => k, Ok(value) => Some(value.masterkey),
None => match harmony_config::get::<ZitadelMasterkey>().await { Err(ConfigError::NotFound { .. }) => None,
Ok(m) => m.masterkey, Err(error) => {
Err(e) => { return Err(InterpretError::new(format!(
debug!("[Zitadel] No persisted masterkey yet ({e}); generating"); "Failed to load Zitadel masterkey: {error}"
rng() )));
.sample_iter(&rand::distr::Alphanumeric) }
.take(32)
.map(char::from)
.collect::<String>()
}
},
}; };
let masterkey = existing_masterkey
if harmony_config::get::<ZitadelMasterkey>().await.is_err() { .or_else(|| persisted_masterkey.clone())
harmony_config::set(&ZitadelMasterkey { .unwrap_or_else(|| {
masterkey: masterkey.clone(), debug!("[Zitadel] No persisted masterkey yet; generating");
}) rng()
.await .sample_iter(&rand::distr::Alphanumeric)
.map_err(|e| { .take(32)
InterpretError::new(format!("Failed to persist Zitadel masterkey: {e}")) .map(char::from)
})?; .collect::<String>()
} });
debug!( debug!(
"[Zitadel] Created masterkey secret '{}' in namespace '{}'", "[Zitadel] Created masterkey secret '{}' in namespace '{}'",
@@ -528,7 +612,10 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
); );
let mut masterkey_data: BTreeMap<String, ByteString> = BTreeMap::new(); let mut masterkey_data: BTreeMap<String, ByteString> = BTreeMap::new();
masterkey_data.insert("masterkey".to_string(), ByteString(masterkey.into())); masterkey_data.insert(
"masterkey".to_string(),
ByteString(masterkey.clone().into()),
);
let masterkey_secret = K8sSecret { let masterkey_secret = K8sSecret {
metadata: ObjectMeta { metadata: ObjectMeta {
@@ -540,7 +627,7 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
..K8sSecret::default() ..K8sSecret::default()
}; };
match k8s_client let authoritative_masterkey = match k8s_client
.create(&masterkey_secret, Some(&self.namespace)) .create(&masterkey_secret, Some(&self.namespace))
.await .await
{ {
@@ -549,12 +636,21 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
"[Zitadel] Masterkey secret '{}' created", "[Zitadel] Masterkey secret '{}' created",
MASTERKEY_SECRET_NAME MASTERKEY_SECRET_NAME
); );
masterkey
} }
Err(KubeError::Api(ErrorResponse { code: 409, .. })) => { Err(KubeError::Api(ErrorResponse { code: 409, .. })) => {
info!( info!(
"[Zitadel] Masterkey secret '{}' already exists, leaving it untouched", "[Zitadel] Masterkey secret '{}' already exists, leaving it untouched",
MASTERKEY_SECRET_NAME MASTERKEY_SECRET_NAME
); );
read_masterkey(&k8s_client, &self.namespace)
.await?
.ok_or_else(|| {
InterpretError::new(
"Zitadel masterkey Secret disappeared after create conflict"
.to_string(),
)
})?
} }
Err(other) => { Err(other) => {
let msg = format!( let msg = format!(
@@ -566,6 +662,16 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
} }
}; };
if persisted_masterkey.as_deref() != Some(authoritative_masterkey.as_str()) {
self.set_state(&ZitadelMasterkey {
masterkey: authoritative_masterkey,
})
.await
.map_err(|error| {
InterpretError::new(format!("Failed to persist Zitadel masterkey: {error}"))
})?;
}
debug!( debug!(
"[Zitadel] Masterkey secret '{}' created successfully", "[Zitadel] Masterkey secret '{}' created successfully",
MASTERKEY_SECRET_NAME MASTERKEY_SECRET_NAME
@@ -976,15 +1082,17 @@ login:
// --- Step 6: Deploy Helm chart ------------------------------------ // --- Step 6: Deploy Helm chart ------------------------------------
let chart_name =
NonBlankString::from_str("oci://hub.nationtech.io/harmony/zitadel").unwrap();
info!( info!(
"[Zitadel] Deploying Helm chart 'zitadel/zitadel' as release 'zitadel' in namespace '{}'", "[Zitadel] Deploying Helm chart '{chart_name}' as release 'zitadel' in namespace '{}'",
self.namespace self.namespace
); );
let result = HelmChartScore { let result = HelmChartScore {
namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()), namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()),
release_name: NonBlankString::from_str("zitadel").unwrap(), release_name: NonBlankString::from_str("zitadel").unwrap(),
chart_name: NonBlankString::from_str("zitadel/zitadel").unwrap(), chart_name,
// Pinned: newer charts ship a login UI that expects a newer // Pinned: newer charts ship a login UI that expects a newer
// server; with v4.12.x the login pod crashloops on // server; with v4.12.x the login pod crashloops on
// Token.Invalid. 9.27.1 is the last chart matching this // Token.Invalid. 9.27.1 is the last chart matching this
@@ -1004,11 +1112,7 @@ login:
// explicit action.) // explicit action.)
install_only: true, install_only: true,
force_conflicts: false, force_conflicts: false,
repository: Some(HelmRepository::new( repository: None,
"zitadel".to_string(),
hurl!("https://charts.zitadel.com"),
true,
)),
} }
.interpret(inventory, topology) .interpret(inventory, topology)
.await; .await;

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ readme.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true
harmony = { path = "../harmony" } harmony = { path = "../harmony" }
harmony-k8s = { path = "../harmony-k8s" } harmony-k8s = { path = "../harmony-k8s" }
harmony_config = { path = "../harmony_config" } harmony_config = { path = "../harmony_config" }

View File

@@ -137,7 +137,31 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
app.validate_deploy_images(&options.images)?; app.validate_deploy_images(&options.images)?;
let images = options.images.clone(); let images = options.images.clone();
let scores = app.scores_with_options(ctx, options).await?; let scores = app.scores_with_options(ctx, options).await?;
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|s| s.clone_box()).collect(); let steps = interpret_scores(topology, scores).await?;
Ok(DeployReport {
context: ctx.name().to_string(),
namespace: ctx.namespace().to_string(),
cluster: ctx.cluster_target().map(str::to_string),
tag: ctx.version().to_string(),
images,
steps,
})
}
pub async fn interpret_scores<T: Topology + Send + Sync + 'static>(
topology: T,
scores: Vec<Box<dyn Score<T>>>,
) -> Result<Vec<StepOutcome>, AppError> {
interpret_scores_with_progress(topology, scores, |_, _| Ok(())).await
}
pub async fn interpret_scores_with_progress<T: Topology + Send + Sync + 'static>(
topology: T,
scores: Vec<Box<dyn Score<T>>>,
mut completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>,
) -> Result<Vec<StepOutcome>, AppError> {
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|score| score.clone_box()).collect();
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
maestro.register_all(scores); maestro.register_all(scores);
@@ -147,7 +171,8 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
.map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?; .map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?;
let mut steps = Vec::new(); let mut steps = Vec::new();
for score in to_run { let score_count = to_run.len();
for (index, score) in to_run.into_iter().enumerate() {
let name = score.name(); let name = score.name();
let outcome = maestro let outcome = maestro
.interpret(score) .interpret(score)
@@ -162,19 +187,14 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
outcome.status, outcome.message outcome.status, outcome.message
))); )));
} }
steps.push(StepOutcome { let step = StepOutcome {
name, name,
message: outcome.message, message: outcome.message,
}); };
completed(&step, index + 1 < score_count)?;
steps.push(step);
} }
Ok(DeployReport { Ok(steps)
context: ctx.name().to_string(),
namespace: ctx.namespace().to_string(),
cluster: ctx.cluster_target().map(str::to_string),
tag: ctx.version().to_string(),
images,
steps,
})
} }
/// Build + publish, then deploy (ADR-026 §4). /// Build + publish, then deploy (ADR-026 §4).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
//! Topology-neutral application declarations. K8sAnywhere is the first adapter;
//! declarations are not deployable until an adapter exists for the target topology.
mod k8s_anywhere;
mod model;
mod resources;
mod validation;
pub use harmony::modules::zitadel::contract as zitadel;
pub use model::{
Application, Command, Cpu, FileRef, HealthCheck, Image, ImageBuild, ImageRef, ImageSource,
LogicalEndpoint, ManagedTls, Memory, Port, PortRef, Protocol, PublicEndpointRef,
ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef,
ValueRef,
};
pub use resources::{
BucketRef, DatabaseRef, ManagedBucket, ManagedPostgres, ManagedResource, ManagedZitadel,
OidcRedirect, ZitadelRef,
};
pub use validation::ApplicationValidationError;

View File

@@ -0,0 +1,562 @@
use std::path::PathBuf;
use std::time::Duration;
use serde::Serialize;
use super::{ApplicationValidationError, BucketRef, DatabaseRef, ManagedResource, ZitadelRef};
use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef};
/// A topology-neutral application declaration. K8sAnywhere is currently its first adapter.
#[derive(Debug, Clone, Serialize)]
pub struct Application {
pub name: String,
pub images: Vec<Image>,
pub endpoints: Vec<LogicalEndpoint>,
pub resources: Vec<ManagedResource>,
pub services: Vec<Service>,
/// Routes are evaluated in declaration order.
pub routes: Vec<Route>,
pub rollout: RolloutIntent,
}
impl Application {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
images: Vec::new(),
endpoints: Vec::new(),
resources: Vec::new(),
services: Vec::new(),
routes: Vec::new(),
rollout: RolloutIntent::default(),
}
}
pub fn image(mut self, image: Image) -> Self {
self.images.push(image);
self
}
pub fn service(mut self, service: Service) -> Self {
self.services.push(service);
self
}
pub fn endpoint(mut self, endpoint: LogicalEndpoint) -> Self {
self.endpoints.push(endpoint);
self
}
pub fn resource(mut self, resource: impl Into<ManagedResource>) -> Self {
self.resources.push(resource.into());
self
}
pub fn route(mut self, route: Route) -> Self {
self.routes.push(route);
self
}
pub fn rollout(mut self, rollout: RolloutIntent) -> Self {
self.rollout = rollout;
self
}
pub fn validate(&self) -> Result<(), ApplicationValidationError> {
super::validation::validate(self)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Image {
pub name: String,
pub source: ImageSource,
}
impl Image {
pub fn new(name: impl Into<String>, reference: impl Into<String>) -> Self {
Self {
name: name.into(),
source: ImageSource::Reference(reference.into()),
}
}
pub fn reference(&self) -> ImageRef {
ImageRef(self.name.clone())
}
pub fn build(name: impl Into<String>, context: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
source: ImageSource::Build(ImageBuild {
context: context.into(),
dockerfile: PathBuf::from("Dockerfile"),
platform: None,
build_args: Vec::new(),
}),
}
}
pub fn dockerfile(mut self, dockerfile: impl Into<PathBuf>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.dockerfile = dockerfile.into();
}
self
}
pub fn platform(mut self, platform: impl Into<String>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.platform = Some(platform.into());
}
self
}
pub fn build_arg(mut self, name: impl Into<String>, value: Option<impl Into<String>>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.build_args.push((name.into(), value.map(Into::into)));
}
self
}
}
#[derive(Debug, Clone, Serialize)]
pub enum ImageSource {
Reference(String),
Build(ImageBuild),
}
#[derive(Debug, Clone, Serialize)]
pub struct ImageBuild {
pub context: PathBuf,
pub dockerfile: PathBuf,
pub platform: Option<String>,
pub build_args: Vec<(String, Option<String>)>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ImageRef(pub(crate) String);
impl ImageRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ServiceRef(pub(crate) String);
impl ServiceRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn port(&self, name: impl Into<String>) -> PortRef {
PortRef {
service: self.clone(),
port: name.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct PortRef {
pub(crate) service: ServiceRef,
pub(crate) port: String,
}
impl PortRef {
pub fn new(service: ServiceRef, port: impl Into<String>) -> Self {
Self {
service,
port: port.into(),
}
}
pub fn service(&self) -> &ServiceRef {
&self.service
}
pub fn name(&self) -> &str {
&self.port
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Service {
pub name: String,
pub image: ImageRef,
pub command: Option<Command>,
pub ports: Vec<Port>,
pub values: Vec<(String, ValueRef)>,
pub health: Option<HealthCheck>,
pub resources: ResourceIntent,
}
impl Service {
pub fn new(name: impl Into<String>, image: ImageRef) -> Self {
Self {
name: name.into(),
image,
command: None,
ports: Vec::new(),
values: Vec::new(),
health: None,
resources: ResourceIntent::default(),
}
}
pub fn reference(&self) -> ServiceRef {
ServiceRef(self.name.clone())
}
pub fn command(mut self, command: Command) -> Self {
self.command = Some(command);
self
}
pub fn port(mut self, port: Port) -> Self {
self.ports.push(port);
self
}
pub fn value(mut self, name: impl Into<String>, value: ValueRef) -> Self {
self.values.push((name.into(), value));
self
}
pub fn health(mut self, health: HealthCheck) -> Self {
self.health = Some(health);
self
}
pub fn resources(mut self, resources: ResourceIntent) -> Self {
self.resources = resources;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Command {
pub program: String,
pub args: Vec<String>,
}
impl Command {
pub fn new(
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Port {
pub name: String,
pub number: u16,
pub protocol: Protocol,
}
impl Port {
pub fn tcp(name: impl Into<String>, number: u16) -> Self {
Self {
name: name.into(),
number,
protocol: Protocol::Tcp,
}
}
pub fn udp(name: impl Into<String>, number: u16) -> Self {
Self {
name: name.into(),
number,
protocol: Protocol::Udp,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Protocol {
Tcp,
Udp,
}
/// A value is either literal or resolved from desired-state semantics.
#[derive(Debug, Clone, Serialize)]
pub enum ValueRef {
Literal(String),
ServiceHost(ServiceRef),
ServicePort(PortRef),
ServiceUrl {
scheme: String,
port: PortRef,
},
PublicEndpointOrigin(PublicEndpointRef),
PublicEndpointUrl {
endpoint: PublicEndpointRef,
path: String,
},
DatabaseJdbcUrl(DatabaseRef),
DatabaseUsername(DatabaseRef),
DatabasePassword(DatabaseRef),
BucketEndpoint(BucketRef),
BucketName(BucketRef),
BucketAccessKey(BucketRef),
BucketSecretKey(BucketRef),
BucketRegion(BucketRef),
BucketPathStyle(BucketRef),
ZitadelIssuer(ZitadelRef),
ZitadelManagementUrl(ZitadelRef),
OidcProjectId {
zitadel: ZitadelRef,
project: ZitadelProjectRef,
},
OidcClientId {
zitadel: ZitadelRef,
application: ZitadelApplicationRef,
},
MachineClientId {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
},
MachineClientSecret {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
},
/// Mount the secret key at `path` and set the value to that path.
File(FileRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum FileRef {
MachineJsonKey {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
path: String,
},
}
impl FileRef {
pub fn path(&self) -> &str {
match self {
Self::MachineJsonKey { path, .. } => path,
}
}
}
impl ValueRef {
pub fn literal(value: impl Into<String>) -> Self {
Self::Literal(value.into())
}
pub fn service_url(scheme: impl Into<String>, port: PortRef) -> Self {
Self::ServiceUrl {
scheme: scheme.into(),
port,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum HealthCheck {
Http {
port: PortRef,
path: String,
interval: Duration,
timeout: Duration,
initial_delay: Duration,
},
Tcp {
port: PortRef,
interval: Duration,
timeout: Duration,
initial_delay: Duration,
},
}
impl HealthCheck {
pub fn http(port: PortRef, path: impl Into<String>) -> Self {
Self::Http {
port,
path: path.into(),
interval: Duration::from_secs(10),
timeout: Duration::from_secs(2),
initial_delay: Duration::from_secs(0),
}
}
pub fn tcp(port: PortRef) -> Self {
Self::Tcp {
port,
interval: Duration::from_secs(10),
timeout: Duration::from_secs(2),
initial_delay: Duration::from_secs(0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Cpu {
Millicores(u32),
Cores(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Memory {
Mebibytes(u32),
Gibibytes(u32),
}
/// Portable scheduler intent with typed CPU and memory units.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ResourceIntent {
pub cpu_request: Option<Cpu>,
pub cpu_limit: Option<Cpu>,
pub memory_request: Option<Memory>,
pub memory_limit: Option<Memory>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Route {
pub endpoint: PublicEndpointRef,
pub path: String,
pub target: PortRef,
pub smoke_check: bool,
}
impl Route {
pub fn new(endpoint: PublicEndpointRef, path: impl Into<String>, target: PortRef) -> Self {
Self {
endpoint,
path: path.into(),
target,
smoke_check: true,
}
}
pub fn smoke_check(mut self, enabled: bool) -> Self {
self.smoke_check = enabled;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LogicalEndpoint {
pub name: String,
pub tls: ManagedTls,
}
impl LogicalEndpoint {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
tls: ManagedTls::Disabled,
}
}
pub fn managed_tls(mut self) -> Self {
self.tls = ManagedTls::Managed;
self
}
pub fn reference(&self) -> PublicEndpointRef {
PublicEndpointRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct PublicEndpointRef(pub(crate) String);
impl PublicEndpointRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn origin(&self) -> ValueRef {
ValueRef::PublicEndpointOrigin(self.clone())
}
pub fn url(&self, path: impl Into<String>) -> ValueRef {
ValueRef::PublicEndpointUrl {
endpoint: self.clone(),
path: path.into(),
}
}
}
impl From<super::ManagedPostgres> for ManagedResource {
fn from(value: super::ManagedPostgres) -> Self {
Self::Postgres(value)
}
}
impl From<super::ManagedBucket> for ManagedResource {
fn from(value: super::ManagedBucket) -> Self {
Self::Bucket(value)
}
}
impl From<super::ManagedZitadel> for ManagedResource {
fn from(value: super::ManagedZitadel) -> Self {
Self::Zitadel(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ManagedTls {
Disabled,
Managed,
}
#[derive(Debug, Clone, Serialize)]
pub struct RolloutIntent {
pub replicas: u32,
pub strategy: RolloutStrategy,
pub readiness: ReadinessIntent,
}
impl Default for RolloutIntent {
fn default() -> Self {
Self {
replicas: 1,
strategy: RolloutStrategy::Rolling,
readiness: ReadinessIntent::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum RolloutStrategy {
Rolling,
Replace,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReadinessIntent {
pub wait: bool,
pub timeout: Duration,
}
impl Default for ReadinessIntent {
fn default() -> Self {
Self {
wait: true,
timeout: Duration::from_secs(180),
}
}
}

View File

@@ -0,0 +1,266 @@
use serde::Serialize;
use harmony::modules::zitadel::{
ZitadelApplicationRef, ZitadelContract, ZitadelMachineRef, ZitadelProjectRef,
};
use super::{FileRef, PublicEndpointRef, ValueRef};
#[derive(Debug, Clone, Serialize)]
pub enum ManagedResource {
Postgres(ManagedPostgres),
Bucket(ManagedBucket),
Zitadel(ManagedZitadel),
}
/// S3-compatible object bucket (Rook ObjectBucketClaim / ceph-bucket by default).
#[derive(Debug, Clone, Serialize)]
pub struct ManagedBucket {
pub name: String,
pub storage_class: String,
/// Rook `additionalConfig.maxSize` (e.g. `"10G"`).
pub max_size: String,
/// Public/browser endpoint override (e.g. context `object_storage_endpoint`).
/// When set, app credentials use this URL instead of cluster-internal RGW DNS.
pub endpoint: Option<String>,
/// Public app endpoints whose origins are allowed by bucket CORS.
pub cors: Vec<PublicEndpointRef>,
}
impl ManagedBucket {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
storage_class: "ceph-bucket".into(),
max_size: "10G".into(),
endpoint: None,
cors: Vec::new(),
}
}
pub fn storage_class(mut self, storage_class: impl Into<String>) -> Self {
self.storage_class = storage_class.into();
self
}
pub fn max_size(mut self, max_size: impl Into<String>) -> Self {
self.max_size = max_size.into();
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn cors(mut self, endpoint: PublicEndpointRef) -> Self {
self.cors.push(endpoint);
self
}
pub fn reference(&self) -> BucketRef {
BucketRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct BucketRef(pub(crate) String);
impl BucketRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn endpoint(&self) -> ValueRef {
ValueRef::BucketEndpoint(self.clone())
}
pub fn bucket(&self) -> ValueRef {
ValueRef::BucketName(self.clone())
}
pub fn access_key(&self) -> ValueRef {
ValueRef::BucketAccessKey(self.clone())
}
pub fn secret_key(&self) -> ValueRef {
ValueRef::BucketSecretKey(self.clone())
}
pub fn region(&self) -> ValueRef {
ValueRef::BucketRegion(self.clone())
}
pub fn path_style(&self) -> ValueRef {
ValueRef::BucketPathStyle(self.clone())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedPostgres {
pub name: String,
pub instances: u32,
pub version: Option<String>,
/// Companion Service `{name}-rw-debug` for VPN/debug (NodePort toggle).
/// Default **off** (`ClusterIP`). In the OKD console set `spec.type: NodePort` to enable.
/// Ships preserve live type/nodePort. (Not a TLS Route: PG16 lacks direct TLS/SNI for routers.)
pub debug_route: bool,
}
impl ManagedPostgres {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
instances: 1,
version: None,
debug_route: false,
}
}
pub fn instances(mut self, instances: u32) -> Self {
self.instances = instances;
self
}
/// PostgreSQL major/minor tag (e.g. `"16"` / `"16.4"`) or a full
/// container image. Mapped to CNPG `spec.imageName`.
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
/// Declare a debug Service (ClusterIP off / NodePort on in the console).
pub fn debug_route(mut self) -> Self {
self.debug_route = true;
self
}
pub fn reference(&self) -> DatabaseRef {
DatabaseRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct DatabaseRef(pub(crate) String);
impl DatabaseRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn jdbc_url(&self) -> ValueRef {
ValueRef::DatabaseJdbcUrl(self.clone())
}
pub fn username(&self) -> ValueRef {
ValueRef::DatabaseUsername(self.clone())
}
pub fn password(&self) -> ValueRef {
ValueRef::DatabasePassword(self.clone())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedZitadel {
pub name: String,
pub endpoint: PublicEndpointRef,
pub version: String,
pub contract: ZitadelContract,
pub redirects: Vec<OidcRedirect>,
}
impl ManagedZitadel {
pub fn new(name: impl Into<String>, endpoint: PublicEndpointRef) -> Self {
Self {
name: name.into(),
endpoint,
version: "v4.12.1".to_string(),
contract: ZitadelContract::default(),
redirects: Vec::new(),
}
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
pub fn contract(mut self, contract: ZitadelContract) -> Self {
self.contract = contract;
self
}
pub fn redirect(
mut self,
application: ZitadelApplicationRef,
endpoint: PublicEndpointRef,
path: impl Into<String>,
) -> Self {
self.redirects.push(OidcRedirect {
application,
endpoint,
path: path.into(),
post_logout: false,
});
self
}
pub fn post_logout(
mut self,
application: ZitadelApplicationRef,
endpoint: PublicEndpointRef,
path: impl Into<String>,
) -> Self {
self.redirects.push(OidcRedirect {
application,
endpoint,
path: path.into(),
post_logout: true,
});
self
}
pub fn reference(&self) -> ZitadelRef {
ZitadelRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ZitadelRef(pub(crate) String);
impl ZitadelRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn project_id(&self, project: ZitadelProjectRef) -> ValueRef {
ValueRef::OidcProjectId {
zitadel: self.clone(),
project,
}
}
pub fn oidc_client_id(&self, application: ZitadelApplicationRef) -> ValueRef {
ValueRef::OidcClientId {
zitadel: self.clone(),
application,
}
}
pub fn machine_client_id(&self, machine: ZitadelMachineRef) -> ValueRef {
ValueRef::MachineClientId {
zitadel: self.clone(),
machine,
}
}
pub fn machine_client_secret(&self, machine: ZitadelMachineRef) -> ValueRef {
ValueRef::MachineClientSecret {
zitadel: self.clone(),
machine,
}
}
pub fn machine_json_key(&self, machine: ZitadelMachineRef, path: impl Into<String>) -> FileRef {
FileRef::MachineJsonKey {
zitadel: self.clone(),
machine,
path: path.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OidcRedirect {
pub application: ZitadelApplicationRef,
pub endpoint: PublicEndpointRef,
pub path: String,
pub post_logout: bool,
}

View File

@@ -0,0 +1,597 @@
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;
use super::{
Application, FileRef, HealthCheck, ImageSource, ManagedResource, PortRef, Protocol, ValueRef,
};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ApplicationValidationError {
#[error("{field} must be non-empty")]
Empty { field: String },
#[error("duplicate {kind} '{name}'")]
Duplicate { kind: &'static str, name: String },
#[error("unknown image '{0}'")]
UnknownImage(String),
#[error("unknown {kind} '{name}'")]
UnknownResource { kind: &'static str, name: String },
#[error("unknown service '{0}'")]
UnknownService(String),
#[error("service '{0}' has no addressable ports")]
ServiceHasNoPorts(String),
#[error("unknown port '{service}.{port}'")]
UnknownPort { service: String, port: String },
#[error("route target '{service}.{port}' must use TCP")]
NonTcpRoute { service: String, port: String },
#[error("route '{host}{path}' must start with '/'")]
InvalidRoutePath { host: String, path: String },
#[error("rollout replicas must be greater than zero")]
ZeroReplicas,
#[error("file value path '{0}' must be absolute")]
RelativeFilePath(String),
#[error("health check for '{service}' references another service '{target}'")]
CrossServiceHealthCheck { service: String, target: String },
#[error("invalid Zitadel contract '{name}': {reason}")]
InvalidZitadelContract { name: String, reason: String },
#[error("machine identity '{machine}' does not produce {credential}")]
MissingMachineCredential {
machine: String,
credential: &'static str,
},
}
pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationError> {
non_empty(&app.name, "application name")?;
if app.rollout.replicas == 0 {
return Err(ApplicationValidationError::ZeroReplicas);
}
if app.services.is_empty() {
return Err(ApplicationValidationError::Empty {
field: "application services".to_string(),
});
}
let mut images = BTreeSet::new();
for image in &app.images {
non_empty(&image.name, "image name")?;
match &image.source {
ImageSource::Reference(reference) => {
non_empty(reference, &format!("image '{}' reference", image.name))?
}
ImageSource::Build(build) => {
if build.context.as_os_str().is_empty() {
return Err(ApplicationValidationError::Empty {
field: format!("image '{}' build context", image.name),
});
}
}
}
if !images.insert(image.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "image",
name: image.name.clone(),
});
}
}
let endpoints: BTreeSet<_> = app
.endpoints
.iter()
.map(|endpoint| endpoint.name.as_str())
.collect();
if endpoints.len() != app.endpoints.len() {
return Err(ApplicationValidationError::Duplicate {
kind: "public endpoint",
name: "declaration".to_string(),
});
}
let mut databases = BTreeSet::new();
let mut buckets = BTreeSet::new();
let mut zitadels = BTreeMap::new();
for resource in &app.resources {
match resource {
ManagedResource::Postgres(database) => {
non_empty(&database.name, "database name")?;
if !databases.insert(database.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "database",
name: database.name.clone(),
});
}
}
ManagedResource::Bucket(bucket) => {
non_empty(&bucket.name, "bucket name")?;
non_empty(&bucket.storage_class, "bucket storage class")?;
non_empty(&bucket.max_size, "bucket max size")?;
if let Some(endpoint) = &bucket.endpoint {
non_empty(endpoint, "bucket endpoint")?;
}
for endpoint in &bucket.cors {
if !endpoints.contains(endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: endpoint.name().to_string(),
});
}
}
if !buckets.insert(bucket.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "bucket",
name: bucket.name.clone(),
});
}
}
ManagedResource::Zitadel(zitadel) => {
non_empty(&zitadel.name, "Zitadel name")?;
if zitadels.contains_key(zitadel.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "Zitadel",
name: zitadel.name.clone(),
});
}
zitadel.contract.validate().map_err(|reason| {
ApplicationValidationError::InvalidZitadelContract {
name: zitadel.name.clone(),
reason,
}
})?;
if !endpoints.contains(zitadel.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: zitadel.endpoint.name().to_string(),
});
}
zitadels.insert(zitadel.name.as_str(), zitadel);
for redirect in &zitadel.redirects {
if !endpoints.contains(redirect.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: redirect.endpoint.name().to_string(),
});
}
if !redirect.path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: redirect.endpoint.name().to_string(),
path: redirect.path.clone(),
});
}
if !zitadel
.contract
.applications
.iter()
.any(|application| application.application == redirect.application)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC application",
name: format!(
"{}/{}",
redirect.application.project().name(),
redirect.application.name()
),
});
}
}
}
}
}
let mut services = BTreeMap::new();
for service in &app.services {
non_empty(&service.name, "service name")?;
if !images.contains(service.image.name()) {
return Err(ApplicationValidationError::UnknownImage(
service.image.name().to_string(),
));
}
if services.insert(service.name.as_str(), service).is_some() {
return Err(ApplicationValidationError::Duplicate {
kind: "service",
name: service.name.clone(),
});
}
let mut ports = BTreeSet::new();
for port in &service.ports {
non_empty(&port.name, &format!("service '{}' port name", service.name))?;
if port.number == 0 {
return Err(ApplicationValidationError::Empty {
field: format!("service '{}' port number", service.name),
});
}
if !ports.insert(port.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "port",
name: format!("{}.{}", service.name, port.name),
});
}
}
}
let resolve_port = |reference: &PortRef| {
let service = services.get(reference.service.name()).ok_or_else(|| {
ApplicationValidationError::UnknownService(reference.service.name().to_string())
})?;
service
.ports
.iter()
.find(|port| port.name == reference.name())
.ok_or_else(|| ApplicationValidationError::UnknownPort {
service: reference.service.name().to_string(),
port: reference.name().to_string(),
})
};
for service in &app.services {
let mut values = BTreeSet::new();
for (name, value) in &service.values {
non_empty(name, &format!("service '{}' value name", service.name))?;
if !values.insert(name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "value",
name: format!("{}.{}", service.name, name),
});
}
match value {
ValueRef::ServiceHost(reference) => match services.get(reference.name()) {
None => {
return Err(ApplicationValidationError::UnknownService(
reference.name().to_string(),
));
}
Some(service) if service.ports.is_empty() => {
return Err(ApplicationValidationError::ServiceHasNoPorts(
reference.name().to_string(),
));
}
Some(_) => {}
},
ValueRef::ServicePort(reference)
| ValueRef::ServiceUrl {
port: reference, ..
} => {
resolve_port(reference)?;
}
ValueRef::PublicEndpointOrigin(reference) => {
if !endpoints.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: reference.name().to_string(),
});
}
}
ValueRef::PublicEndpointUrl { endpoint, path } => {
if !endpoints.contains(endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: endpoint.name().to_string(),
});
}
if !path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: endpoint.name().to_string(),
path: path.clone(),
});
}
}
ValueRef::DatabaseJdbcUrl(reference)
| ValueRef::DatabaseUsername(reference)
| ValueRef::DatabasePassword(reference) => {
if !databases.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "database",
name: reference.name().to_string(),
});
}
}
ValueRef::BucketEndpoint(reference)
| ValueRef::BucketName(reference)
| ValueRef::BucketAccessKey(reference)
| ValueRef::BucketSecretKey(reference)
| ValueRef::BucketRegion(reference)
| ValueRef::BucketPathStyle(reference) => {
if !buckets.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "bucket",
name: reference.name().to_string(),
});
}
}
ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => {
validate_zitadel(&zitadels, reference.name())?;
}
ValueRef::OidcProjectId {
zitadel: producer,
project,
} => {
let zitadel = validate_zitadel(&zitadels, producer.name())?;
if !zitadel
.contract
.projects
.iter()
.any(|item| item.project == *project)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC project",
name: project.name().to_string(),
});
}
}
ValueRef::OidcClientId {
zitadel: producer,
application,
} => {
let zitadel = validate_zitadel(&zitadels, producer.name())?;
if !zitadel
.contract
.applications
.iter()
.any(|item| item.application == *application)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC application",
name: format!(
"{}/{}",
application.project().name(),
application.name()
),
});
}
}
ValueRef::MachineClientId { zitadel, machine } => {
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if !declaration.client_secret {
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a client ID",
});
}
}
ValueRef::MachineClientSecret { zitadel, machine } => {
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if !declaration.client_secret {
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a client secret",
});
}
}
ValueRef::File(reference) => {
let FileRef::MachineJsonKey {
zitadel, machine, ..
} = reference;
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if declaration.key
!= Some(harmony::modules::zitadel::ZitadelMachineKeyDeclaration::Json)
{
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a JSON key",
});
}
if !reference.path().starts_with('/') {
return Err(ApplicationValidationError::RelativeFilePath(
reference.path().to_string(),
));
}
}
ValueRef::Literal(_) => {}
}
}
if let Some(health) = &service.health {
let reference = match health {
HealthCheck::Http { port, path, .. } => {
if !path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: service.name.clone(),
path: path.clone(),
});
}
port
}
HealthCheck::Tcp { port, .. } => port,
};
resolve_port(reference)?;
if reference.service.name() != service.name {
return Err(ApplicationValidationError::CrossServiceHealthCheck {
service: service.name.clone(),
target: reference.service.name().to_string(),
});
}
}
}
for route in &app.routes {
if !endpoints.contains(route.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: route.endpoint.name().to_string(),
});
}
if !route.path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: route.endpoint.name().to_string(),
path: route.path.clone(),
});
}
let port = resolve_port(&route.target)?;
if port.protocol != Protocol::Tcp {
return Err(ApplicationValidationError::NonTcpRoute {
service: route.target.service.name().to_string(),
port: route.target.name().to_string(),
});
}
}
Ok(())
}
fn validate_zitadel<'a>(
zitadels: &'a BTreeMap<&str, &super::ManagedZitadel>,
name: &str,
) -> Result<&'a super::ManagedZitadel, ApplicationValidationError> {
zitadels
.get(name)
.copied()
.ok_or_else(|| ApplicationValidationError::UnknownResource {
kind: "Zitadel",
name: name.to_string(),
})
}
fn validate_machine<'a>(
zitadels: &'a BTreeMap<&str, &'a super::ManagedZitadel>,
producer: &super::ZitadelRef,
machine: &harmony::modules::zitadel::ZitadelMachineRef,
) -> Result<&'a harmony::modules::zitadel::ZitadelMachineDeclaration, ApplicationValidationError> {
let zitadel = validate_zitadel(zitadels, producer.name())?;
zitadel
.contract
.machines
.iter()
.find(|item| item.machine == *machine)
.ok_or_else(|| ApplicationValidationError::UnknownResource {
kind: "machine identity",
name: machine.name().to_string(),
})
}
fn non_empty(value: &str, field: &str) -> Result<(), ApplicationValidationError> {
if value.trim().is_empty() {
Err(ApplicationValidationError::Empty {
field: field.to_string(),
})
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::{Image, LogicalEndpoint, ManagedZitadel, Port, Route, Service};
use harmony::modules::zitadel::{
ZitadelContract, ZitadelMachineDeclaration, ZitadelMachineRef,
};
fn valid_app() -> Application {
let image = Image::new("web", "example/web:1");
let web = Service::new("web", image.reference()).port(Port::tcp("http", 8080));
let web_ref = web.reference();
let endpoint = LogicalEndpoint::new("web");
let endpoint_ref = endpoint.reference();
Application::new("example")
.image(image)
.endpoint(endpoint)
.service(web)
.route(Route::new(endpoint_ref, "/", web_ref.port("http")))
}
#[test]
fn accepts_typed_references() {
valid_app().validate().unwrap();
}
#[test]
fn rejects_unknown_route_port() {
let mut app = valid_app();
app.routes[0].target = app.services[0].reference().port("admin");
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::UnknownPort {
service: "web".to_string(),
port: "admin".to_string(),
}
);
}
#[test]
fn rejects_unknown_semantic_endpoint() {
let mut app = valid_app();
app.services[0].values.push((
"ORIGIN".to_string(),
super::super::PublicEndpointRef::new("missing").origin(),
));
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: "missing".to_string(),
}
);
}
#[test]
fn rejects_unproduced_machine_client_secret() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let machine = ZitadelMachineRef::new("backend");
let identity = ManagedZitadel::new("identity", endpoint.reference()).contract(
ZitadelContract::default().machine(ZitadelMachineDeclaration {
machine: machine.clone(),
name: "Backend".to_string(),
key: None,
client_secret: false,
}),
);
app.services[0].values.push((
"CLIENT_SECRET".to_string(),
identity.reference().machine_client_secret(machine),
));
app.endpoints.push(endpoint);
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::MissingMachineCredential {
machine: "backend".to_string(),
credential: "a client secret",
}
);
}
#[test]
fn rejects_unproduced_machine_client_id() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let machine = ZitadelMachineRef::new("backend");
let identity = ManagedZitadel::new("identity", endpoint.reference()).contract(
ZitadelContract::default().machine(ZitadelMachineDeclaration {
machine: machine.clone(),
name: "Backend".to_string(),
key: None,
client_secret: false,
}),
);
app.services[0].values.push((
"CLIENT_ID".to_string(),
identity.reference().machine_client_id(machine),
));
app.endpoints.push(endpoint);
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::MissingMachineCredential {
machine: "backend".to_string(),
credential: "a client ID",
}
);
}
#[test]
fn rejects_duplicate_managed_zitadel() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let identity = ManagedZitadel::new("identity", endpoint.reference());
app.endpoints.push(endpoint);
app.resources.push(identity.clone().into());
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::Duplicate {
kind: "Zitadel",
name: "identity".to_string(),
}
);
}
}

View File

@@ -8,7 +8,7 @@ use std::sync::Arc;
use harmony::modules::tenant::ClusterAccess; use harmony::modules::tenant::ClusterAccess;
use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology};
use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, PromptSource}; use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, StateClient};
use harmony_k8s::K8sClient; use harmony_k8s::K8sClient;
use harmony_types::context::{ use harmony_types::context::{
ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace, ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace,
@@ -47,6 +47,9 @@ pub struct RemoteContext {
pub repository: OciRepository, pub repository: OciRepository,
pub domain: DomainName, pub domain: DomainName,
pub image_pull_secret: Option<K8sName>, pub image_pull_secret: Option<K8sName>,
/// Public S3-compatible endpoint for app/browser clients (e.g. `https://s3.cb1.nationtech.io`).
/// When set, ManagedBucket credentials use this instead of the cluster-internal RGW DNS.
pub object_storage_endpoint: Option<HttpUrl>,
pub access: OpenBaoClusterAccess, pub access: OpenBaoClusterAccess,
} }
@@ -129,6 +132,7 @@ pub struct AppContext {
kubeconfig: Option<PathBuf>, kubeconfig: Option<PathBuf>,
_kubeconfig_guard: Option<NamedTempFile>, _kubeconfig_guard: Option<NamedTempFile>,
config_client: Arc<ConfigClient>, config_client: Arc<ConfigClient>,
state_client: StateClient,
cluster_target: Option<String>, cluster_target: Option<String>,
} }
@@ -182,13 +186,15 @@ impl AppContext {
Profile::from(&context.spec) Profile::from(&context.spec)
); );
debug!("Context '{name}' definition: {:?}", context.spec); debug!("Context '{name}' definition: {:?}", context.spec);
let config_sources = build_config_sources(&context.spec, local_config_dir.clone()) let (config_client, state_client) =
.await build_config_clients(&context.spec, local_config_dir.clone())
.map_err(|e| { .await
ContextError::Config(format!("building config sources for context '{name}': {e}")) .map_err(|e| {
})?; ContextError::Config(format!(
harmony_config::init(config_sources.clone()).await; "building config sources for context '{name}': {e}"
let config_client = Arc::new(ConfigClient::new(config_sources)); ))
})?;
harmony_config::init_client(config_client.clone()).await;
let (guard, cluster_target) = match &context.spec { let (guard, cluster_target) = match &context.spec {
ContextSpec::Local(LocalContext::ManagedK3d) => { ContextSpec::Local(LocalContext::ManagedK3d) => {
info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')");
@@ -226,17 +232,19 @@ impl AppContext {
context.namespace context.namespace
); );
Ok(Self::new( let mut context = Self::new(
context, context,
version.into(), version.into(),
local_config_dir, local_config_dir,
config_client, config_client,
guard, guard,
cluster_target, cluster_target,
)) );
context.state_client = state_client;
Ok(context)
} }
fn new( pub(crate) fn new(
context: &Context, context: &Context,
version: String, version: String,
local_config_dir: Option<PathBuf>, local_config_dir: Option<PathBuf>,
@@ -244,6 +252,7 @@ impl AppContext {
guard: Option<NamedTempFile>, guard: Option<NamedTempFile>,
cluster_target: Option<String>, cluster_target: Option<String>,
) -> Self { ) -> Self {
let state_client = StateClient::new(config_client.clone(), config_client.clone());
Self { Self {
context: context.clone(), context: context.clone(),
version, version,
@@ -251,6 +260,7 @@ impl AppContext {
kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()), kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()),
_kubeconfig_guard: guard, _kubeconfig_guard: guard,
config_client, config_client,
state_client,
cluster_target, cluster_target,
} }
} }
@@ -282,6 +292,14 @@ impl AppContext {
ContextSpec::Remote(remote) => Some(remote.domain.as_ref()), ContextSpec::Remote(remote) => Some(remote.domain.as_ref()),
} }
} }
pub fn object_storage_endpoint(&self) -> Option<&str> {
match &self.context.spec {
ContextSpec::Local(_) => None,
ContextSpec::Remote(remote) => {
remote.object_storage_endpoint.as_ref().map(|u| u.as_ref())
}
}
}
pub fn service_host(&self, service: &str) -> String { pub fn service_host(&self, service: &str) -> String {
match &self.context.spec { match &self.context.spec {
ContextSpec::Local(_) => { ContextSpec::Local(_) => {
@@ -311,6 +329,12 @@ impl AppContext {
pub fn config_client(&self) -> &ConfigClient { pub fn config_client(&self) -> &ConfigClient {
&self.config_client &self.config_client
} }
pub(crate) fn config_client_arc(&self) -> Arc<ConfigClient> {
self.config_client.clone()
}
pub(crate) fn state_client(&self, scope: &str, migrate_legacy: bool) -> StateClient {
self.state_client.scoped(scope, migrate_legacy)
}
pub fn k3d_cluster(&self) -> Option<&str> { pub fn k3d_cluster(&self) -> Option<&str> {
match &self.context.spec { match &self.context.spec {
ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER), ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER),
@@ -417,18 +441,18 @@ fn kubeconfig_target(contents: &str) -> Result<String, ContextError> {
Ok(format!("{cluster} via context {current} ({server})")) Ok(format!("{cluster} via context {current} ({server})"))
} }
async fn build_config_sources( async fn build_config_clients(
spec: &ContextSpec, spec: &ContextSpec,
local_config_dir: Option<PathBuf>, local_config_dir: Option<PathBuf>,
) -> Result<Vec<Arc<dyn ConfigSource>>, ContextError> { ) -> Result<(Arc<ConfigClient>, StateClient), ContextError> {
let mut sources: Vec<Arc<dyn ConfigSource>> = Vec::new(); let source: Arc<dyn ConfigSource> = match spec {
match spec {
ContextSpec::Remote(remote) => { ContextSpec::Remote(remote) => {
let access = &remote.access; let access = &remote.access;
let source = harmony_config::openbao_source( harmony_config::openbao_source(
access.namespace.as_ref(), access.namespace.as_ref(),
Some(access.url.to_string()), Some(access.url.to_string()),
None,
None,
Some(access.zitadel_url.to_string()), Some(access.zitadel_url.to_string()),
Some(access.zitadel_audience.to_string()), Some(access.zitadel_audience.to_string()),
Some(access.role.to_string()), Some(access.role.to_string()),
@@ -439,8 +463,7 @@ async fn build_config_sources(
"reaching OpenBao for namespace '{}'", "reaching OpenBao for namespace '{}'",
access.namespace access.namespace
)) ))
})?; })?
sources.push(source);
} }
ContextSpec::Local(_) => { ContextSpec::Local(_) => {
let dir = local_config_dir let dir = local_config_dir
@@ -448,12 +471,10 @@ async fn build_config_sources(
.ok_or_else(|| { .ok_or_else(|| {
ContextError::Missing("local contexts need a config directory".to_string()) ContextError::Missing("local contexts need a config directory".to_string())
})?; })?;
sources.push(Arc::new(LocalFileSource::new(dir))); Arc::new(LocalFileSource::new(dir))
} }
} };
Ok(harmony_config::clients_for_source(source))
sources.push(Arc::new(PromptSource::new()));
Ok(sources)
} }
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile, ContextError> { fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile, ContextError> {
@@ -506,6 +527,7 @@ mod tests {
repository: "team/apps".parse().unwrap(), repository: "team/apps".parse().unwrap(),
domain: "example.com".parse().unwrap(), domain: "example.com".parse().unwrap(),
image_pull_secret: Some("registry-auth".parse().unwrap()), image_pull_secret: Some("registry-auth".parse().unwrap()),
object_storage_endpoint: None,
access: OpenBaoClusterAccess { access: OpenBaoClusterAccess {
namespace: "team/prod".parse().unwrap(), namespace: "team/prod".parse().unwrap(),
url: "https://bao.example.com".parse().unwrap(), url: "https://bao.example.com".parse().unwrap(),

View File

@@ -13,8 +13,12 @@
//! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves //! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves
//! its credentials and runtime state. The verbs converge the same Scores for //! its credentials and runtime state. The verbs converge the same Scores for
//! local and production targets (ADR-026 §1/§10). //! local and production targets (ADR-026 §1/§10).
//!
//! [`Application`] is a topology-neutral declaration model. K8sAnywhere is its
//! first adapter; topology-neutral does not imply every runtime is supported.
pub mod app; pub mod app;
pub mod application;
pub mod capabilities; pub mod capabilities;
pub mod chart; pub mod chart;
pub mod compose; pub mod compose;
@@ -24,10 +28,19 @@ pub mod error;
pub mod profile; pub mod profile;
pub mod publish; pub mod publish;
pub mod score; pub mod score;
pub mod tenant;
pub use app::{ pub use app::{
AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome,
WorkloadStatus, deploy, deploy_with_options, logs, ship, ship_with_options, status, WorkloadStatus, deploy, deploy_with_options, interpret_scores, interpret_scores_with_progress,
logs, ship, ship_with_options, status,
};
pub use application::{
Application, ApplicationValidationError, BucketRef, Command, Cpu, DatabaseRef, FileRef,
HealthCheck, Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedBucket,
ManagedPostgres, ManagedResource, ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port,
PortRef, Protocol, PublicEndpointRef, ReadinessIntent, ResourceIntent, RolloutIntent,
RolloutStrategy, Route, Service, ServiceRef, ValueRef, ZitadelRef, zitadel,
}; };
pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth};
pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image}; pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image};
@@ -39,9 +52,14 @@ pub use context::{
pub use deploy::ComposeDeploy; pub use deploy::ComposeDeploy;
pub use error::{AppError, ContextError, ImageError}; pub use error::{AppError, ContextError, ImageError};
pub use harmony::modules::tenant::ClusterAccess; pub use harmony::modules::tenant::ClusterAccess;
pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy};
pub use profile::Profile; pub use profile::Profile;
pub use publish::{ pub use publish::{
ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials, ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials,
is_digest_pinned, RegistryPullCredentials, is_digest_pinned,
}; };
pub use score::{ComposeAppScore, PublicEndpoint}; pub use score::{ComposeAppScore, PublicEndpoint};
pub use tenant::{
provision_application_tenant_on_context_with_progress,
provision_application_tenant_with_kubeconfig,
};

View File

@@ -21,6 +21,13 @@ pub struct RegistryCredentials {
pub token: String, pub token: String,
} }
#[derive(Serialize, Deserialize, JsonSchema, Config)]
#[config(secret)]
pub struct RegistryPullCredentials {
pub username: String,
pub token: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ImageSpec { pub struct ImageSpec {
pub name: String, pub name: String,
@@ -618,4 +625,9 @@ mod tests {
assert_eq!(credentials.username, "publisher"); assert_eq!(credentials.username, "publisher");
assert_eq!(credentials.token, "secret"); assert_eq!(credentials.token, "secret");
} }
#[test]
fn registry_pull_credentials_are_secret_config() {
assert_eq!(RegistryPullCredentials::CLASS, ConfigClass::Secret);
}
} }

237
harmony_app/src/tenant.rs Normal file
View File

@@ -0,0 +1,237 @@
use std::{path::PathBuf, sync::Arc};
use async_trait::async_trait;
use harmony::{
modules::tenant::{TenantCredentialScore, TenantScore},
score::Score,
topology::{K8sAnywhereConfig, K8sAnywhereTopology, tenant::TenantConfig},
};
use harmony_config::ConfigClient;
use harmony_types::k8s_name::K8sName;
use k8s_openapi::api::rbac::v1::{PolicyRule, Subject};
use crate::{
AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs,
OpenBaoClusterAccess, StepOutcome, deploy, interpret_scores_with_progress,
};
struct ApplicationTenantProvisioner {
tenant: TenantConfig,
credential_store: OpenBaoClusterAccess,
allow_insecure_source: bool,
}
pub async fn provision_application_tenant_on_context_with_progress(
kubeconfig: PathBuf,
kube_context: String,
tenant: TenantConfig,
credential_store: OpenBaoClusterAccess,
openbao_token: String,
owner_usernames: Vec<String>,
completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>,
) -> Result<(), AppError> {
let user_subjects = owner_usernames
.into_iter()
.map(|name| Subject {
api_group: Some("rbac.authorization.k8s.io".to_string()),
kind: "User".to_string(),
name,
namespace: None,
})
.collect();
// This operator-driven path preserves the selected session's TLS mode; the CLI warns when verification is disabled.
let scores = application_tenant_scores(
tenant,
&credential_store,
Some(openbao_token),
true,
user_subjects,
)
.await?;
let topology = K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig(
kubeconfig.to_string_lossy(),
Some(kube_context),
));
interpret_scores_with_progress(topology, scores, completed).await?;
Ok(())
}
async fn application_tenant_scores(
tenant: TenantConfig,
credential_store: &OpenBaoClusterAccess,
openbao_token: Option<String>,
allow_insecure_source: bool,
role_subjects: Vec<Subject>,
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
let source = harmony_config::openbao_source(
credential_store.namespace.as_ref(),
Some(credential_store.url.to_string()),
openbao_token,
None,
Some(credential_store.zitadel_url.to_string()),
Some(credential_store.zitadel_audience.to_string()),
Some(credential_store.role.to_string()),
)
.await
.ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?;
let namespace = tenant
.name
.parse::<K8sName>()
.map_err(|error| AppError::InvalidComposition(error.to_string()))?;
Ok(vec![
Box::new(TenantScore { config: tenant }),
Box::new(
TenantCredentialScore::new(
namespace,
"harmony-deployer"
.parse()
.expect("static Kubernetes name is valid"),
application_deployer_rules(),
Arc::new(ConfigClient::new(vec![source])),
allow_insecure_source,
)
.with_role_subjects(role_subjects),
),
])
}
#[async_trait]
impl HarmonyApp<K8sAnywhereTopology> for ApplicationTenantProvisioner {
fn identity(&self, _ctx: &AppContext) -> AppIdentity {
AppIdentity {
name: "application-tenant".to_string(),
namespace: self.tenant.name.clone(),
}
}
async fn scores(
&self,
_ctx: &AppContext,
_images: &ImageRefs,
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
application_tenant_scores(
self.tenant.clone(),
&self.credential_store,
None,
self.allow_insecure_source,
Vec::new(),
)
.await
}
}
/// Provision an application tenant with an administrator kubeconfig, then
/// store its namespace-scoped kubeconfig as `ClusterAccess` in OpenBao.
pub async fn provision_application_tenant_with_kubeconfig(
context: Context,
kubeconfig: PathBuf,
tenant: TenantConfig,
credential_store: OpenBaoClusterAccess,
allow_insecure_source: bool,
) -> anyhow::Result<()> {
if !matches!(context.spec, ContextSpec::Remote(_)) {
anyhow::bail!("application tenant provisioning requires a remote context");
}
let app = ApplicationTenantProvisioner {
tenant,
credential_store,
allow_insecure_source,
};
let ctx = AppContext::from_kubeconfig(&context, "bootstrap", kubeconfig)?;
deploy(&app, ctx.topology(), &ctx).await?;
Ok(())
}
fn application_deployer_rules() -> Vec<PolicyRule> {
let verbs = || {
[
"get", "list", "watch", "create", "update", "patch", "delete",
]
.map(String::from)
.to_vec()
};
vec![
rule(
"",
&[
"configmaps",
"persistentvolumeclaims",
"pods",
"secrets",
"serviceaccounts",
"services",
],
verbs(),
),
rule("", &["pods/log"], vec!["get".to_string()]),
rule(
"",
&["pods/exec", "pods/portforward"],
vec!["create".to_string()],
),
rule(
"apps",
&["deployments", "replicasets", "statefulsets"],
verbs(),
),
rule("batch", &["jobs"], verbs()),
rule(
"networking.k8s.io",
&["ingresses", "networkpolicies"],
verbs(),
),
// TLS passthrough to CNPG (debug_route) needs Routes; plain Ingress is HTTP-only.
rule("route.openshift.io", &["routes"], verbs()),
rule("policy", &["poddisruptionbudgets"], verbs()),
rule(
"rbac.authorization.k8s.io",
&["roles", "rolebindings"],
verbs(),
),
rule("postgresql.cnpg.io", &["clusters"], verbs()),
rule(
"objectbucket.io",
&["objectbucketclaims", "objectbuckets"],
verbs(),
),
]
}
fn rule(api_group: &str, resources: &[&str], verbs: Vec<String>) -> PolicyRule {
PolicyRule {
api_groups: Some(vec![api_group.to_string()]),
resources: Some(resources.iter().map(|value| (*value).to_string()).collect()),
verbs,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn application_deployer_can_manage_cnpg_obc_and_routes_without_fleet_permissions() {
let rules = application_deployer_rules();
assert!(rules.iter().any(|rule| {
rule.api_groups.as_deref() == Some(&["postgresql.cnpg.io".to_string()])
&& rule.resources.as_deref() == Some(&["clusters".to_string()])
}));
assert!(rules.iter().any(|rule| {
rule.api_groups.as_deref() == Some(&["objectbucket.io".to_string()])
&& rule
.resources
.as_ref()
.is_some_and(|r| r.iter().any(|n| n == "objectbucketclaims"))
}));
assert!(rules.iter().any(|rule| {
rule.api_groups.as_deref() == Some(&["route.openshift.io".to_string()])
&& rule.resources.as_deref() == Some(&["routes".to_string()])
}));
assert!(!rules.iter().any(|rule| {
rule.api_groups
.as_ref()
.is_some_and(|groups| groups.iter().any(|group| group == "fleet.nationtech.io"))
}));
}
}

View File

@@ -8,6 +8,9 @@ license.workspace = true
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
harmony_secret = { path = "../harmony_secret" }
harmony_types = { path = "../harmony_types" }
harmony_zitadel_auth = { path = "../harmony_zitadel_auth" }
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View File

@@ -5,14 +5,19 @@ use std::{
use crate::{ use crate::{
Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection, Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection,
ConnectionStatus, Identity, IdentityAccess, IdentityFilter, IdentityKind, IdentityWithAccess, ConnectionStatus, DeployerCreateRequest, DeployerCreateResult, Identity, IdentityAccess,
ImportedAccess, JwtRole, OpenBaoPolicy, RemovalPlan, Scope, TenantSummary, IdentityFilter, IdentityKind, IdentityWithAccess, ImportedAccess, JwtRole, OpenBaoPolicy,
ProvisionStep, RemovalPlan, Scope, TenantAuthConfig, TenantCapability, TenantCreateRequest,
TenantCreateResult, TenantDefinition, TenantSummary, valid_slug,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::{Client, Method, StatusCode}; use reqwest::{Client, Method, StatusCode};
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use uuid::Uuid; use uuid::Uuid;
use harmony_secret::OpenBaoPolicyManager;
use harmony_zitadel_auth::management::ManagementClient;
pub struct BackendAuth { pub struct BackendAuth {
client: Client, client: Client,
zitadel_url: String, zitadel_url: String,
@@ -33,6 +38,12 @@ struct PolicyDetails {
body: String, body: String,
} }
struct ValidatedTenantAuth {
zitadel: ManagementClient,
project: harmony_zitadel_auth::management::Project,
openbao_jwt_role: String,
}
impl BackendAuth { impl BackendAuth {
pub fn new( pub fn new(
zitadel_url: String, zitadel_url: String,
@@ -57,6 +68,426 @@ impl BackendAuth {
&self.zitadel_url &self.zitadel_url
} }
pub async fn tenant_definition(
&self,
slug: &str,
) -> Result<Option<TenantDefinition>, AuthError> {
let slug = Scope::new(slug, None)?.tenant;
self.json(&format!("harmony_auth/data/tenants/{slug}"))
.await?
.map(|data| serde_json::from_value(data["data"].clone()).map_err(backend))
.transpose()
}
async fn validate_tenant_auth(
&self,
config: &TenantAuthConfig,
completed: &mut impl FnMut(ProvisionStep) -> Result<(), AuthError>,
) -> Result<ValidatedTenantAuth, AuthError> {
if config.project.trim().is_empty() || config.groups_action.trim().is_empty() {
return Err(AuthError::Invalid(
"cloud identity project and groups Action cannot be empty".into(),
));
}
if !valid_slug(&config.openbao_kv_mount) || !valid_slug(&config.openbao_jwt_mount) {
return Err(AuthError::Invalid(
"secret-store mount names may contain lowercase letters, numbers, and dashes"
.into(),
));
}
let zitadel = ManagementClient::new(
&self.zitadel_url,
&self.zitadel_pat,
config.zitadel_org_id.clone(),
false,
)
.map_err(backend)?;
let project = zitadel.project(&config.project).await.map_err(backend)?;
completed(ProvisionStep::detail(format!(
"Found Zitadel project '{}' with Resource ID '{}'",
project.name, project.id
)))?;
if !zitadel
.action_in_token_flow(&config.groups_action)
.await
.map_err(backend)?
{
return Err(AuthError::Invalid(format!(
"Zitadel Action '{}' must be attached to the Complement Token flow at both Pre Userinfo creation and Pre access token creation",
config.groups_action
)));
}
completed(ProvisionStep::detail(format!(
"Found Zitadel Action '{}' on Complement Token triggers Pre Userinfo creation and Pre access token creation",
config.groups_action
)))?;
let jwt_config = self
.json(&format!("auth/{}/config", config.openbao_jwt_mount))
.await?
.ok_or_else(|| AuthError::Invalid("cloud secret login is not configured".into()))?;
if jwt_config["bound_issuer"]
.as_str()
.map(|issuer| issuer.trim_end_matches('/'))
!= Some(self.zitadel_url.trim_end_matches('/'))
{
return Err(AuthError::Invalid(
"cloud identity issuer does not match the secret-store login configuration".into(),
));
}
completed(ProvisionStep::detail(format!(
"OpenBao JWT mount '{}' trusts Zitadel issuer '{}'",
config.openbao_jwt_mount, self.zitadel_url
)))?;
let role_names = match config.openbao_jwt_role.as_deref() {
Some(role) if role.trim().is_empty() => {
return Err(AuthError::Invalid(
"secret login role cannot be empty".into(),
));
}
Some(role) => vec![role.to_string()],
None => self.role_names(&config.openbao_jwt_mount).await?,
};
let mut compatible = Vec::new();
for name in role_names {
let Some(role) = self
.json(&format!("auth/{}/role/{name}", config.openbao_jwt_mount))
.await?
else {
continue;
};
let audiences = strings(&role["bound_audiences"]);
if role["groups_claim"].as_str() == Some("groups")
&& audiences.len() == 1
&& audiences.first() == Some(&project.id)
{
compatible.push(name);
}
}
if compatible.is_empty() {
return Err(AuthError::Invalid(format!(
"no compatible OpenBao role found under auth/{}/role: expected groups_claim 'groups' and bound_audiences ['{}'] for Zitadel project '{}'",
config.openbao_jwt_mount, project.id, project.name
)));
}
if compatible.len() > 1 {
return Err(AuthError::Invalid(format!(
"found {} compatible OpenBao roles under auth/{}/role; set OPENBAO_JWT_ROLE to select one",
compatible.len(),
config.openbao_jwt_mount
)));
}
let openbao_jwt_role = compatible.pop().unwrap();
completed(ProvisionStep::detail(format!(
"Found OpenBao JWT role '{}' with Zitadel project audience '{}', groups_claim 'groups', and auth mount '{}'",
openbao_jwt_role, project.id, config.openbao_jwt_mount
)))?;
Ok(ValidatedTenantAuth {
zitadel,
project,
openbao_jwt_role,
})
}
pub async fn create_tenant(
&self,
request: TenantCreateRequest,
) -> Result<TenantCreateResult, AuthError> {
self.create_tenant_with_progress(request, |_| Ok(())).await
}
pub async fn create_tenant_with_progress(
&self,
request: TenantCreateRequest,
mut completed: impl FnMut(ProvisionStep) -> Result<(), AuthError>,
) -> Result<TenantCreateResult, AuthError> {
request.tenant.validate()?;
let auth = self
.validate_tenant_auth(&request.auth, &mut completed)
.await?;
let zitadel = &auth.zitadel;
let project = &auth.project;
completed(ProvisionStep::checkpoint(
"Tenant identity and secret-login baseline validated",
format!(
"Ensure Zitadel roles '{}:owner', '{}:deployer', and '{}:viewer' in project '{}' ({})",
request.tenant.slug,
request.tenant.slug,
request.tenant.slug,
project.name,
project.id
),
))?;
for (index, capability) in TenantCapability::ALL.into_iter().enumerate() {
let key = capability.role(&request.tenant.slug);
zitadel
.ensure_project_role(
&project.id,
&key,
&format!("{} {}", request.tenant.slug, capability.name()),
None,
)
.await
.map_err(backend)?;
let message = format!(
"Zitadel role '{}' is ready in project '{}' ({})",
key, project.name, project.id
);
completed(if index + 1 == TenantCapability::ALL.len() {
let next = if request.tenant.owner_usernames.is_empty() {
format!(
"Create OpenBao owner and deployer policies for tenant '{}'",
request.tenant.slug
)
} else {
format!(
"Grant Zitadel role '{}:owner' to {}",
request.tenant.slug,
request.tenant.owner_usernames.join(", ")
)
};
ProvisionStep::checkpoint(message, next)
} else {
ProvisionStep::detail(message)
})?;
}
for (index, username) in request.tenant.owner_usernames.iter().enumerate() {
let user = zitadel
.find_human(username)
.await
.map_err(backend)?
.ok_or_else(|| {
AuthError::Invalid(format!("human identity '{username}' not found"))
})?;
zitadel
.ensure_project_role_grant(
&user.id,
&project.id,
&[TenantCapability::Owner.role(&request.tenant.slug)],
)
.await
.map_err(backend)?;
let message = format!(
"Granted Zitadel role '{}' to owner '{}' (user ID '{}')",
TenantCapability::Owner.role(&request.tenant.slug),
username,
user.id
);
completed(if index + 1 == request.tenant.owner_usernames.len() {
ProvisionStep::checkpoint(
message,
format!(
"Create OpenBao owner and deployer policies for tenant '{}'",
request.tenant.slug
),
)
} else {
ProvisionStep::detail(message)
})?;
}
let policies = OpenBaoPolicyManager::new(
self.openbao_url.clone(),
self.openbao_token.clone(),
request.auth.openbao_jwt_mount.clone(),
);
for (index, capability) in [TenantCapability::Owner, TenantCapability::Deployer]
.into_iter()
.enumerate()
{
let policy = capability.policy_name(&request.tenant.slug);
let group = capability.role(&request.tenant.slug);
policies
.reconcile_acl_policy(
&policy,
Some(
&capability
.openbao_policy(&request.tenant.slug, &request.auth.openbao_kv_mount),
),
std::slice::from_ref(&group),
)
.await
.map_err(backend)?;
let message = format!(
"OpenBao policy '{}' grants group '{}' access to '{}/data/{}/*' and '{}/metadata/{}/*'",
policy,
group,
request.auth.openbao_kv_mount,
request.tenant.slug,
request.auth.openbao_kv_mount,
request.tenant.slug
);
completed(if index == 1 {
ProvisionStep::checkpoint(
message,
format!(
"Store the tenant definition at OpenBao path 'harmony_auth/data/tenants/{}'",
request.tenant.slug
),
)
} else {
ProvisionStep::detail(message)
})?;
}
self.ensure_intent_mount().await?;
completed(ProvisionStep::detail(
"OpenBao tenant-state mount 'harmony_auth/' is ready",
))?;
let changed =
self.tenant_definition(&request.tenant.slug).await?.as_ref() != Some(&request.tenant);
if changed {
self.openbao(
Method::POST,
&format!("harmony_auth/data/tenants/{}", request.tenant.slug),
Some(json!({ "data": request.tenant })),
)
.await?
.error_for_status()
.map_err(backend)?;
}
completed(ProvisionStep::detail(format!(
"Tenant definition {} at OpenBao path 'harmony_auth/data/tenants/{}'",
if changed { "stored" } else { "already matches" },
request.tenant.slug
)))?;
Ok(TenantCreateResult {
tenant: request.tenant,
project_id: project.id.clone(),
openbao_jwt_role: auth.openbao_jwt_role,
})
}
async fn prepare_deployer(
&self,
request: &DeployerCreateRequest,
completed: &mut impl FnMut(ProvisionStep),
) -> Result<(TenantDefinition, ValidatedTenantAuth), AuthError> {
let tenant = self
.tenant_definition(&request.tenant)
.await?
.ok_or_else(|| {
AuthError::Invalid(format!("tenant '{}' does not exist", request.tenant))
})?;
completed(ProvisionStep::detail(format!(
"Found tenant '{}' with namespace '{}'",
tenant.slug, tenant.namespace
)));
if request.username.trim().is_empty() || request.display_name.trim().is_empty() {
return Err(AuthError::Invalid(
"deployer account and display name cannot be empty".into(),
));
}
let auth = {
let mut report = |step| {
completed(step);
Ok(())
};
self.validate_tenant_auth(&request.auth, &mut report)
.await?
};
if auth
.zitadel
.find_machine(&request.username)
.await
.map_err(backend)?
.is_some()
{
return Err(AuthError::Invalid(format!(
"deployer account '{}' already exists; no access was changed",
request.username
)));
}
completed(ProvisionStep::checkpoint(
format!(
"Zitadel deployer account name '{}' is available",
request.username
),
format!(
"Create Zitadel machine account '{}' and grant role '{}:deployer'",
request.username, tenant.slug
),
));
Ok((tenant, auth))
}
pub async fn plan_deployer(&self, request: &DeployerCreateRequest) -> Result<(), AuthError> {
self.prepare_deployer(request, &mut |_| {})
.await
.map(|_| ())
}
pub async fn create_deployer_with_progress(
&self,
request: DeployerCreateRequest,
mut completed: impl FnMut(ProvisionStep),
) -> Result<DeployerCreateResult, AuthError> {
let (tenant, auth) = self.prepare_deployer(&request, &mut completed).await?;
let user = auth
.zitadel
.create_machine(&request.username, &request.display_name)
.await
.map_err(backend)?;
completed(ProvisionStep::detail(format!(
"Created Zitadel machine account '{}' with user ID '{}'",
request.username, user.id
)));
let deployer_role = TenantCapability::Deployer.role(&tenant.slug);
if let Err(error) = auth
.zitadel
.ensure_project_role_grant(
&user.id,
&auth.project.id,
std::slice::from_ref(&deployer_role),
)
.await
{
if let Err(rollback) = auth.zitadel.delete_user(&user.id).await {
return Err(AuthError::Backend(format!(
"{error}; deleting the partially created deployer also failed: {rollback}"
)));
}
return Err(backend(error));
}
completed(ProvisionStep::detail(format!(
"Granted Zitadel role '{}' to machine account '{}' in project '{}' ({})",
deployer_role, request.username, auth.project.name, auth.project.id
)));
let key = match auth.zitadel.create_json_machine_key(&user.id).await {
Ok(key) => key,
Err(error) => {
if let Err(rollback) = auth.zitadel.delete_user(&user.id).await {
return Err(AuthError::Backend(format!(
"{error}; deleting the partially created deployer also failed: {rollback}"
)));
}
return Err(backend(error));
}
};
completed(ProvisionStep::detail(format!(
"Generated one-time Zitadel credentials for machine account '{}'",
request.username
)));
Ok(DeployerCreateResult {
tenant: tenant.slug,
username: request.username,
user_id: user.id,
key_json: key.json,
})
}
pub async fn delete_machine_identity(
&self,
org_id: Option<String>,
user_id: &str,
) -> Result<(), AuthError> {
ManagementClient::new(&self.zitadel_url, &self.zitadel_pat, org_id, false)
.map_err(backend)?
.delete_user(user_id)
.await
.map_err(backend)
}
pub async fn validate(&self) -> Result<(), String> { pub async fn validate(&self) -> Result<(), String> {
let status = self.connection_status().await; let status = self.connection_status().await;
match (status.zitadel.connected, status.openbao.connected) { match (status.zitadel.connected, status.openbao.connected) {
@@ -151,16 +582,16 @@ impl BackendAuth {
Ok(Some(body["data"].clone())) Ok(Some(body["data"].clone()))
} }
async fn role_names(&self) -> Result<Vec<String>, AuthError> { async fn role_names(&self, mount: &str) -> Result<Vec<String>, AuthError> {
Ok(self Ok(self
.json("auth/jwt/role?list=true") .json(&format!("auth/{mount}/role?list=true"))
.await? .await?
.map_or_else(Vec::new, |data| strings(&data["keys"]))) .map_or_else(Vec::new, |data| strings(&data["keys"])))
} }
async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> { async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> {
let mut roles = Vec::new(); let mut roles = Vec::new();
for name in self.role_names().await? { for name in self.role_names("jwt").await? {
if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? { if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? {
let subject_id = role_subject(&raw); let subject_id = role_subject(&raw);
if !subject_id.is_empty() { if !subject_id.is_empty() {
@@ -245,6 +676,15 @@ impl BackendAuth {
.await? .await?
.error_for_status() .error_for_status()
.map_err(backend)?; .map_err(backend)?;
} else {
let mount = &mounts["harmony_auth/"];
if mount["type"].as_str() != Some("kv")
|| mount["options"]["version"].as_str() != Some("2")
{
return Err(AuthError::Invalid(
"OpenBao mount 'harmony_auth/' must be KV v2".into(),
));
}
} }
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use harmony_secret::render_tenant_policy;
use harmony_types::k8s_name::K8sName;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use thiserror::Error; use thiserror::Error;
@@ -153,6 +155,222 @@ pub struct TenantSummary {
pub services: usize, pub services: usize,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TenantCapability {
Owner,
Deployer,
Viewer,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProvisionStep {
message: String,
next_operation: Option<String>,
}
impl ProvisionStep {
pub(crate) fn detail(message: impl Into<String>) -> Self {
Self {
message: message.into(),
next_operation: None,
}
}
pub(crate) fn checkpoint(
message: impl Into<String>,
next_operation: impl Into<String>,
) -> Self {
Self {
message: message.into(),
next_operation: Some(next_operation.into()),
}
}
pub fn message(&self) -> &str {
&self.message
}
pub fn next_operation(&self) -> Option<&str> {
self.next_operation.as_deref()
}
}
impl TenantCapability {
pub const ALL: [Self; 3] = [Self::Owner, Self::Deployer, Self::Viewer];
pub fn name(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Deployer => "deployer",
Self::Viewer => "viewer",
}
}
pub fn role(self, tenant: &str) -> String {
format!("{tenant}:{}", self.name())
}
pub fn policy_name(self, tenant: &str) -> String {
format!("harmony-{tenant}-{}", self.name())
}
pub fn openbao_policy(self, tenant: &str, mount: &str) -> String {
let capabilities = match self {
Self::Owner => &["create", "delete", "patch", "read", "update"][..],
Self::Deployer | Self::Viewer => &["read"][..],
};
render_tenant_policy(mount, tenant, capabilities, self == Self::Deployer)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TenantResources {
pub cpu_request_cores: f32,
pub cpu_limit_cores: f32,
pub memory_request_gb: f32,
pub memory_limit_gb: f32,
pub storage_total_gb: f32,
pub service_limit: u32,
}
impl Default for TenantResources {
fn default() -> Self {
Self {
cpu_request_cores: 4.0,
cpu_limit_cores: 4.0,
memory_request_gb: 4.0,
memory_limit_gb: 4.0,
storage_total_gb: 20.0,
service_limit: 10,
}
}
}
impl TenantResources {
pub fn validate(&self) -> Result<(), AuthError> {
let finite_positive = |value: f32| value.is_finite() && value > 0.0;
if !finite_positive(self.cpu_request_cores)
|| !finite_positive(self.cpu_limit_cores)
|| self.cpu_request_cores > self.cpu_limit_cores
{
return Err(AuthError::Invalid(
"CPU values must be finite and positive, with request <= limit".into(),
));
}
if !finite_positive(self.memory_request_gb)
|| !finite_positive(self.memory_limit_gb)
|| self.memory_request_gb > self.memory_limit_gb
{
return Err(AuthError::Invalid(
"memory values must be finite and positive, with request <= limit".into(),
));
}
if !finite_positive(self.storage_total_gb) || self.service_limit == 0 {
return Err(AuthError::Invalid(
"storage and service limits must be positive".into(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TenantDefinition {
pub id: String,
pub slug: String,
pub namespace: String,
pub resources: TenantResources,
#[serde(default)]
pub owner_usernames: Vec<String>,
}
impl TenantDefinition {
pub fn new(
id: impl Into<String>,
slug: &str,
namespace: impl Into<String>,
resources: TenantResources,
) -> Result<Self, AuthError> {
let slug = Scope::new(slug, None)?.tenant;
let id = id.into();
let namespace = namespace.into();
if id.trim().is_empty() || namespace.trim().is_empty() {
return Err(AuthError::Invalid(
"tenant ID and namespace cannot be empty".into(),
));
}
namespace
.parse::<K8sName>()
.map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?;
resources.validate()?;
Ok(Self {
id,
slug,
namespace,
resources,
owner_usernames: Vec::new(),
})
}
pub fn with_owner_usernames(mut self, usernames: impl IntoIterator<Item = String>) -> Self {
self.owner_usernames.extend(usernames);
self.owner_usernames.sort();
self.owner_usernames.dedup();
self
}
pub fn validate(&self) -> Result<(), AuthError> {
if Scope::new(&self.slug, None)?.tenant != self.slug
|| self.id.trim().is_empty()
|| self.namespace.trim().is_empty()
{
return Err(AuthError::Invalid("invalid tenant definition".into()));
}
self.namespace
.parse::<K8sName>()
.map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?;
self.resources.validate()
}
}
#[derive(Clone, Debug)]
pub struct TenantCreateRequest {
pub tenant: TenantDefinition,
pub auth: TenantAuthConfig,
}
#[derive(Clone, Debug)]
pub struct TenantAuthConfig {
pub project: String,
pub zitadel_org_id: Option<String>,
pub groups_action: String,
pub openbao_kv_mount: String,
pub openbao_jwt_mount: String,
pub openbao_jwt_role: Option<String>,
}
pub struct TenantCreateResult {
pub tenant: TenantDefinition,
pub project_id: String,
pub openbao_jwt_role: String,
}
#[derive(Clone, Debug)]
pub struct DeployerCreateRequest {
pub tenant: String,
pub username: String,
pub display_name: String,
pub auth: TenantAuthConfig,
}
pub struct DeployerCreateResult {
pub tenant: String,
pub username: String,
pub user_id: String,
pub key_json: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct IdentityWithAccess { pub struct IdentityWithAccess {
pub identity: Identity, pub identity: Identity,
@@ -383,4 +601,51 @@ mod tests {
Err(AuthError::Invalid(_)) Err(AuthError::Invalid(_))
)); ));
} }
#[test]
fn tenant_capabilities_follow_adr_027() {
assert_eq!(TenantCapability::Owner.role("acme"), "acme:owner");
assert_eq!(
TenantCapability::Deployer.policy_name("acme"),
"harmony-acme-deployer"
);
assert!(
TenantCapability::Owner
.openbao_policy("acme", "secret")
.contains("\"update\"")
);
let deployer = TenantCapability::Deployer.openbao_policy("acme", "secret");
assert!(deployer.contains("path \"secret/data/acme/*\" { capabilities = [\"read\"] }"));
assert!(deployer.contains(
"path \"secret/data/acme/harmony-state/*\" { capabilities = [\"create\", \"read\", \"update\"] }"
));
}
#[test]
fn provision_steps_distinguish_details_from_checkpoints() {
let detail = ProvisionStep::detail("found project");
let checkpoint = ProvisionStep::checkpoint("baseline ready", "create tenant roles");
assert_eq!(detail.message(), "found project");
assert_eq!(detail.next_operation(), None);
assert_eq!(checkpoint.next_operation(), Some("create tenant roles"));
}
#[test]
fn tenant_definition_normalizes_slug_and_rejects_invalid_limits() {
let tenant = TenantDefinition::new(
"tenant-1",
" Acme ",
"acme-apps",
TenantResources::default(),
)
.unwrap();
assert_eq!(tenant.slug, "acme");
let invalid = TenantResources {
cpu_request_cores: f32::INFINITY,
..TenantResources::default()
};
assert!(TenantDefinition::new("tenant-1", "acme", "acme", invalid).is_err());
}
} }

View File

@@ -11,7 +11,18 @@ path = "src/main.rs"
[dependencies] [dependencies]
harmony_auth = { path = "../harmony_auth" } harmony_auth = { path = "../harmony_auth" }
harmony_app = { path = "../harmony_app" }
harmony-k8s = { path = "../harmony-k8s" }
harmony_config = { path = "../harmony_config" }
harmony_types = { path = "../harmony_types" }
clap.workspace = true clap.workspace = true
inquire.workspace = true
schemars = "0.8"
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-subscriber = { workspace = true, features = ["env-filter"] }
[dev-dependencies]
tempfile.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,12 @@ pub struct ConfigClient {
sources: Vec<Arc<dyn ConfigSource>>, sources: Vec<Arc<dyn ConfigSource>>,
} }
impl std::fmt::Debug for ConfigClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigClient").finish_non_exhaustive()
}
}
impl ConfigClient { impl ConfigClient {
pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self { pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self {
Self { sources } Self { sources }
@@ -151,6 +157,15 @@ impl ConfigClient {
} }
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> { pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
self.get_inner(false).await
}
async fn get_strict<T: Config>(&self) -> Result<T, ConfigError> {
self.get_inner(true).await
}
async fn get_inner<T: Config>(&self, preserve_invalid: bool) -> Result<T, ConfigError> {
let mut invalid = None;
for source in &self.sources { for source in &self.sources {
if let Some(value) = source.get(T::CLASS, T::KEY).await? { if let Some(value) = source.get(T::CLASS, T::KEY).await? {
// A deser failure means the stored value is shaped for a // A deser failure means the stored value is shaped for a
@@ -159,16 +174,25 @@ impl ConfigClient {
// later source — or a re-prompt — overwrites the stale entry. // later source — or a re-prompt — overwrites the stale entry.
match serde_json::from_value::<T>(value) { match serde_json::from_value::<T>(value) {
Ok(config) => return Ok(config), Ok(config) => return Ok(config),
Err(e) => warn!( Err(source) => {
"Stale value for key {} in source; falling through ({e})", warn!(
T::KEY "Stale value for key {} in source; falling through ({source})",
), T::KEY
);
invalid = Some(source);
}
} }
} }
} }
Err(ConfigError::NotFound { match (invalid, preserve_invalid) {
key: T::KEY.to_string(), (Some(source), true) => Err(ConfigError::Deserialization {
}) key: T::KEY.to_string(),
source,
}),
_ => Err(ConfigError::NotFound {
key: T::KEY.to_string(),
}),
}
} }
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> { pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
@@ -230,9 +254,134 @@ impl ConfigClient {
} }
} }
struct ScopedSource {
prefix: String,
source: Arc<dyn ConfigSource>,
}
impl ScopedSource {
fn new(prefix: impl Into<String>, source: Arc<dyn ConfigSource>) -> Self {
Self {
prefix: prefix.into(),
source,
}
}
fn key(&self, key: &str) -> String {
format!("{}/{key}", self.prefix)
}
}
#[async_trait]
impl ConfigSource for ScopedSource {
async fn get(
&self,
class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
self.source.get(class, &self.key(key)).await
}
async fn set(
&self,
class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
self.source.set(class, &self.key(key), value).await
}
}
#[derive(Clone)]
pub struct StateClient {
state: Arc<ConfigClient>,
legacy: Option<Arc<ConfigClient>>,
}
impl std::fmt::Debug for StateClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StateClient").finish_non_exhaustive()
}
}
impl StateClient {
pub fn new(state: Arc<ConfigClient>, legacy: Arc<ConfigClient>) -> Self {
Self {
state,
legacy: Some(legacy),
}
}
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
match self.state.get_strict::<T>().await {
Err(ConfigError::NotFound { .. }) => {
let Some(legacy) = &self.legacy else {
return Err(ConfigError::NotFound {
key: T::KEY.to_string(),
});
};
let value = legacy.get::<T>().await?;
self.state.set(&value).await?;
Ok(value)
}
result => result,
}
}
pub async fn set<T: Config>(&self, value: &T) -> Result<(), ConfigError> {
self.state.set(value).await
}
pub fn scoped(&self, scope: &str, migrate_legacy: bool) -> Self {
let scope = match scope {
"" => "%00".to_string(),
"." => "%2E".to_string(),
".." => "%2E%2E".to_string(),
_ => scope
.bytes()
.map(|byte| match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
char::from(byte).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect(),
};
Self {
state: Arc::new(ConfigClient::new(
self.state
.sources
.iter()
.map(|source| {
Arc::new(ScopedSource::new(&scope, source.clone())) as Arc<dyn ConfigSource>
})
.collect(),
)),
legacy: if migrate_legacy {
self.legacy.clone()
} else {
None
},
}
}
}
pub fn clients_for_source(source: Arc<dyn ConfigSource>) -> (Arc<ConfigClient>, StateClient) {
let config = Arc::new(ConfigClient::new(vec![
Arc::new(EnvSource),
source.clone(),
Arc::new(PromptSource::new()),
]));
let state = Arc::new(ConfigClient::new(vec![Arc::new(ScopedSource::new(
harmony_secret::HARMONY_STATE_SUBPATH,
source,
))]));
(config.clone(), StateClient::new(state, config))
}
/// Build an OpenBao-backed `StoreSource` purely from env — the default chain. /// Build an OpenBao-backed `StoreSource` purely from env — the default chain.
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> { async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
openbao_source(namespace, None, None, None, None).await openbao_source(namespace, None, None, None, None, None, None).await
} }
/// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env /// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env
@@ -243,6 +392,8 @@ async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
pub async fn openbao_source( pub async fn openbao_source(
namespace: &str, namespace: &str,
openbao_url: Option<String>, openbao_url: Option<String>,
openbao_token: Option<String>,
openbao_kv_mount: Option<String>,
zitadel_sso_url: Option<String>, zitadel_sso_url: Option<String>,
zitadel_audience: Option<String>, zitadel_audience: Option<String>,
openbao_jwt_role: Option<String>, openbao_jwt_role: Option<String>,
@@ -260,6 +411,7 @@ pub async fn openbao_source(
let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL")); let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL"));
let jwt_role = openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE")); let jwt_role = openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE"));
let jwt_auth_mount = env("OPENBAO_JWT_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()); let jwt_auth_mount = env("OPENBAO_JWT_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string());
let token = openbao_token.or_else(|| env("OPENBAO_TOKEN"));
// Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine // Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine
// keyfile (path or inline JSON) plus the project-ID audience. Absent any // keyfile (path or inline JSON) plus the project-ID audience. Absent any
@@ -284,14 +436,18 @@ pub async fn openbao_source(
} }
}; };
let kv_mount = env("OPENBAO_KV_MOUNT").unwrap_or_else(|| "secret".to_string()); let kv_mount = openbao_kv_mount
.or_else(|| env("OPENBAO_KV_MOUNT"))
.unwrap_or_else(|| "secret".to_string());
let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true"); let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true");
let inline_machine_identity = zitadel_jwt_bearer.as_ref().is_some_and(|config| { let inline_machine_identity = token.is_none()
config && zitadel_jwt_bearer.as_ref().is_some_and(|config| {
.key_json config
.as_deref() .key_json
.is_some_and(|key| !key.trim().is_empty()) .as_deref()
}) && jwt_role.is_some(); .is_some_and(|key| !key.trim().is_empty())
})
&& jwt_role.is_some();
let store = if inline_machine_identity { let store = if inline_machine_identity {
match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) { match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) {
(Some(config), Some(role)) => { (Some(config), Some(role)) => {
@@ -313,7 +469,7 @@ pub async fn openbao_source(
kv_mount, kv_mount,
auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()), auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()),
skip_tls, skip_tls,
token: env("OPENBAO_TOKEN"), token,
username: env("OPENBAO_USERNAME"), username: env("OPENBAO_USERNAME"),
password: env("OPENBAO_PASSWORD"), password: env("OPENBAO_PASSWORD"),
zitadel_sso_url: sso_url, zitadel_sso_url: sso_url,
@@ -328,7 +484,7 @@ pub async fn openbao_source(
match store { match store {
Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))), Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))),
Err(e) => { Err(e) => {
warn!("OpenBao unreachable ({e}); source omitted from chain"); warn!("OpenBao source unavailable ({e}); source omitted from chain");
None None
} }
} }
@@ -337,8 +493,12 @@ pub async fn openbao_source(
static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None); static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None);
pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) { pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) {
init_client(Arc::new(ConfigClient::new(sources))).await;
}
pub async fn init_client(client: Arc<ConfigClient>) {
let mut manager = CONFIG_CLIENT.lock().await; let mut manager = CONFIG_CLIENT.lock().await;
*manager = Some(Arc::new(ConfigClient::new(sources))); *manager = Some(client);
} }
pub async fn get<T: Config>() -> Result<T, ConfigError> { pub async fn get<T: Config>() -> Result<T, ConfigError> {
@@ -494,6 +654,99 @@ mod tests {
} }
} }
#[tokio::test]
async fn state_client_migrates_legacy_values_without_writing_legacy_source() {
let mut legacy_data = std::collections::HashMap::new();
legacy_data.insert(
TestConfig::KEY.to_string(),
serde_json::json!({"name": "legacy", "count": 1}),
);
let state = Arc::new(MockSource::new());
let legacy = Arc::new(MockSource::with_data(legacy_data));
let client = StateClient::new(
Arc::new(ConfigClient::new(vec![state.clone()])),
Arc::new(ConfigClient::new(vec![legacy.clone()])),
);
assert_eq!(client.get::<TestConfig>().await.unwrap().name, "legacy");
assert_eq!(state.set_call_count(), 1);
assert_eq!(legacy.set_call_count(), 0);
}
#[tokio::test]
async fn context_state_is_namespaced_and_component_scoped() {
let source = Arc::new(MockSource::new());
let (_, state) = clients_for_source(source.clone());
state
.scoped("identity/api", false)
.set(&TestConfig {
name: "scoped".into(),
count: 1,
})
.await
.unwrap();
assert_eq!(
source.observed(),
vec![(
ConfigClass::Standard,
"harmony-state/identity%2Fapi/TestConfig".into(),
"set"
)]
);
}
#[tokio::test]
async fn scoped_state_migrates_legacy_only_when_enabled() {
let mut data = std::collections::HashMap::new();
data.insert(
TestConfig::KEY.to_string(),
serde_json::json!({"name": "legacy", "count": 1}),
);
let state = Arc::new(MockSource::new());
let legacy = Arc::new(MockSource::with_data(data));
let client = StateClient::new(
Arc::new(ConfigClient::new(vec![state])),
Arc::new(ConfigClient::new(vec![legacy])),
);
let first = client.scoped("first", true);
let second = client.scoped("second", false);
assert_eq!(first.get::<TestConfig>().await.unwrap().name, "legacy");
assert!(matches!(
second.get::<TestConfig>().await,
Err(ConfigError::NotFound { .. })
));
}
#[tokio::test]
async fn malformed_state_does_not_fall_back_to_legacy() {
let mut state_data = std::collections::HashMap::new();
state_data.insert(
TestConfig::KEY.to_string(),
serde_json::json!({"name": "invalid", "count": "not-a-number"}),
);
let mut legacy_data = std::collections::HashMap::new();
legacy_data.insert(
TestConfig::KEY.to_string(),
serde_json::json!({"name": "legacy", "count": 1}),
);
let legacy = Arc::new(MockSource::with_data(legacy_data));
let client = StateClient::new(
Arc::new(ConfigClient::new(vec![Arc::new(MockSource::with_data(
state_data,
))])),
Arc::new(ConfigClient::new(vec![legacy.clone()])),
);
assert!(matches!(
client.get::<TestConfig>().await,
Err(ConfigError::Deserialization { .. })
));
assert_eq!(legacy.get_call_count(), 0);
}
/// A `FieldPrompter` double: returns canned answers and records which /// A `FieldPrompter` double: returns canned answers and records which
/// fields it was asked for, so a test can assert only the missing fields /// fields it was asked for, so a test can assert only the missing fields
/// were prompted. /// were prompted.
@@ -1010,19 +1263,44 @@ mod tests {
source source
.set( .set(
ConfigClass::Standard, ConfigClass::Standard,
"TestConfig", "state/TestConfig",
&serde_json::to_value(&config).unwrap(), &serde_json::to_value(&config).unwrap(),
) )
.await .await
.unwrap(); .unwrap();
let file_path = dir.path().join("TestConfig.json"); let file_path = dir.path().join("state/TestConfig.json");
let contents = std::fs::read_to_string(&file_path).unwrap(); let contents = std::fs::read_to_string(&file_path).unwrap();
let parsed: TestConfig = serde_json::from_str(&contents).unwrap(); let parsed: TestConfig = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed, config); assert_eq!(parsed, config);
} }
#[cfg(unix)]
#[tokio::test]
async fn local_file_source_protects_secret_config() {
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let source = LocalFileSource::new(dir.path().to_path_buf());
source
.set(
ConfigClass::Secret,
"Credentials",
&serde_json::json!({"token": "secret"}),
)
.await
.unwrap();
let mode = std::fs::metadata(dir.path().join("Credentials.json"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[tokio::test] #[tokio::test]
async fn test_sqlite_set_and_get() { async fn test_sqlite_set_and_get() {
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -1389,6 +1667,22 @@ mod tests {
assert!(result.is_none()); assert!(result.is_none());
} }
#[tokio::test]
async fn explicit_openbao_token_builds_source_without_environment_auth() {
let source = openbao_source(
"tenant",
Some("https://explicit-token.invalid".into()),
Some("context-token".into()),
None,
None,
None,
None,
)
.await;
assert!(source.is_some());
}
#[tokio::test] #[tokio::test]
async fn test_full_chain_with_prompt_source_falls_through_to_prompt() { async fn test_full_chain_with_prompt_source_falls_through_to_prompt() {
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -1423,7 +1717,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_store_source_error_falls_through_to_sqlite() { async fn test_store_source_error_stops_the_chain() {
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
let temp_file = NamedTempFile::new().unwrap(); let temp_file = NamedTempFile::new().unwrap();
@@ -1449,9 +1743,8 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result: TestConfig = manager.get().await.unwrap(); let result: Result<TestConfig, ConfigError> = manager.get().await;
assert_eq!(result.name, "from_sqlite"); assert!(matches!(result, Err(ConfigError::StoreError(_))));
assert_eq!(result.count, 42);
} }
#[derive(Debug)] #[derive(Debug)]

View File

@@ -1,6 +1,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
#[cfg(unix)]
use tokio::io::AsyncWriteExt;
use crate::{ConfigClass, ConfigError, ConfigSource}; use crate::{ConfigClass, ConfigError, ConfigSource};
@@ -52,19 +54,45 @@ impl ConfigSource for LocalFileSource {
async fn set( async fn set(
&self, &self,
_class: ConfigClass, class: ConfigClass,
key: &str, key: &str,
value: &serde_json::Value, value: &serde_json::Value,
) -> Result<(), ConfigError> { ) -> Result<(), ConfigError> {
fs::create_dir_all(&self.base_path).await?;
let path = self.file_path_for(key); let path = self.file_path_for(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let contents = let contents =
serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization { serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization {
key: key.to_string(), key: key.to_string(),
source: e, source: e,
})?; })?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if class == ConfigClass::Secret {
0o600
} else {
0o666
};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(mode)
.open(&path)
.await?;
if class == ConfigClass::Secret {
file.set_permissions(std::fs::Permissions::from_mode(mode))
.await?;
}
file.write_all(contents.as_bytes()).await?;
file.flush().await?;
}
#[cfg(not(unix))]
fs::write(&path, contents).await?; fs::write(&path, contents).await?;
Ok(()) Ok(())

View File

@@ -1,8 +1,6 @@
use crate::{ConfigClass, ConfigError, ConfigSource};
use async_trait::async_trait; use async_trait::async_trait;
use harmony_secret::SecretStore; use harmony_secret::SecretStore;
use log::warn;
use crate::{ConfigClass, ConfigError, ConfigSource};
pub struct StoreSource<S> { pub struct StoreSource<S> {
namespace: String, namespace: String,
@@ -34,14 +32,7 @@ impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> {
Ok(Some(value)) Ok(Some(value))
} }
Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None), Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None),
// Log before swallowing: a down/misconfigured OpenBao must not look identical to "key absent". Err(e) => Err(ConfigError::StoreError(e)),
Err(e) => {
warn!(
"StoreSource: get for key '{key}' failed ({e}); treating as \
absent and falling through to the next source"
);
Ok(None)
}
} }
} }

View File

@@ -6,37 +6,23 @@
//! the role's `groups_claim`, so attaching/detaching the policy binds //! the role's `groups_claim`, so attaching/detaching the policy binds
//! for every member's existing tokens at request time — O(groups) //! for every member's existing tokens at request time — O(groups)
//! writes per deployment change, regardless of fleet size (ADR-025). //! writes per deployment change, regardless of fleet size (ADR-025).
//!
//! External groups are matched to the token claim through a group alias
//! on the JWT mount; we create group + alias on first grant so the grant
//! can precede any device's first login.
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::StatusCode;
use serde_json::json;
use tokio::sync::OnceCell;
use harmony_reconciler_contracts::{ use harmony_reconciler_contracts::{
DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError, DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError,
validate_image_pull_secret_reference, validate_image_pull_secret_reference,
}; };
use crate::OpenBaoPolicyManager;
const JWT_AUTH_MOUNT: &str = "jwt"; const JWT_AUTH_MOUNT: &str = "jwt";
pub struct OpenBaoDeploymentSecretGrants { pub struct OpenBaoDeploymentSecretGrants {
client: reqwest::Client, policies: OpenBaoPolicyManager,
base_url: String,
token: String,
kv_mount: String, kv_mount: String,
/// Path prefix under the KV mount where the fleet's secrets live /// Path prefix under the KV mount where the fleet's secrets live
/// (`<kv_mount>/data/<prefix>/<deployment>/…`). /// (`<kv_mount>/data/<prefix>/<deployment>/…`).
secret_prefix: String, secret_prefix: String,
jwt_mount: String,
/// JWT auth mount accessor, resolved once and reused — needed to bind
/// a group alias to the login claim value.
jwt_accessor: OnceCell<String>,
} }
impl OpenBaoDeploymentSecretGrants { impl OpenBaoDeploymentSecretGrants {
@@ -58,20 +44,12 @@ impl OpenBaoDeploymentSecretGrants {
jwt_mount: String, jwt_mount: String,
) -> Self { ) -> Self {
Self { Self {
client: reqwest::Client::new(), policies: OpenBaoPolicyManager::new(base_url, token, jwt_mount),
base_url: base_url.trim_end_matches('/').to_string(),
token,
kv_mount, kv_mount,
secret_prefix, secret_prefix,
jwt_mount,
jwt_accessor: OnceCell::new(),
} }
} }
fn err(context: impl std::fmt::Display, e: impl std::fmt::Display) -> SecretAccessError {
SecretAccessError::Backend(format!("{context}: {e}"))
}
fn policy_name(deployment: &DeploymentName) -> String { fn policy_name(deployment: &DeploymentName) -> String {
format!("deployment-{}", deployment.as_str()) format!("deployment-{}", deployment.as_str())
} }
@@ -103,243 +81,6 @@ path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#,
} }
Ok(hcl) Ok(hcl)
} }
async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<reqwest::Response, SecretAccessError> {
let mut req = self
.client
.request(method.clone(), format!("{}/v1/{path}", self.base_url))
.header("X-Vault-Token", &self.token);
if let Some(body) = body {
req = req.json(&body);
}
req.send()
.await
.map_err(|e| Self::err(format!("{method} {path}"), e))
}
/// JWT mount accessor from `sys/auth`, cached for the client's life.
async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> {
self.jwt_accessor
.get_or_try_init(|| async {
let body: serde_json::Value = self
.request(reqwest::Method::GET, "sys/auth", None)
.await?
.error_for_status()
.map_err(|e| Self::err("GET sys/auth", e))?
.json()
.await
.map_err(|e| Self::err("parse sys/auth", e))?;
// sys/auth nests mounts under `data` over the HTTP API but
// emits them at the document root via the CLI; tolerate both.
let mount_key = format!("{}/", self.jwt_mount);
body.get("data")
.and_then(|d| d.get(&mount_key))
.or_else(|| body.get(&mount_key))
.and_then(|m| m.get("accessor"))
.and_then(|a| a.as_str())
.map(str::to_string)
.ok_or_else(|| {
Self::err(
"resolve jwt accessor",
format!("mount '{mount_key}' not found in sys/auth"),
)
})
})
.await
.map(String::as_str)
}
async fn upsert_policy(
&self,
deployment: &DeploymentName,
image_pull_secrets: &[String],
) -> Result<(), SecretAccessError> {
let name = Self::policy_name(deployment);
let hcl = self.policy_hcl(deployment, image_pull_secrets)?;
self.request(
reqwest::Method::PUT,
&format!("sys/policies/acl/{name}"),
Some(json!({ "policy": hcl })),
)
.await?
.error_for_status()
.map_err(|e| Self::err(format!("write policy {name}"), e))?;
Ok(())
}
async fn delete_policy(&self, deployment: &DeploymentName) -> Result<(), SecretAccessError> {
let name = Self::policy_name(deployment);
let resp = self
.request(
reqwest::Method::DELETE,
&format!("sys/policies/acl/{name}"),
None,
)
.await?;
if !resp.status().is_success() && resp.status() != StatusCode::NOT_FOUND {
return Err(Self::err(format!("delete policy {name}"), resp.status()));
}
Ok(())
}
/// Group's current policy list, or `None` when the group doesn't
/// exist.
async fn read_group_policies(
&self,
group: &str,
) -> Result<Option<Vec<String>>, SecretAccessError> {
let resp = self
.request(
reqwest::Method::GET,
&format!("identity/group/name/{group}"),
None,
)
.await?;
if resp.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let body: serde_json::Value = resp
.error_for_status()
.map_err(|e| Self::err(format!("read group {group}"), e))?
.json()
.await
.map_err(|e| Self::err(format!("parse group {group}"), e))?;
Ok(Some(
body["data"]["policies"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
))
}
async fn write_group_policies(
&self,
group: &str,
policies: &[String],
) -> Result<(), SecretAccessError> {
self.request(
reqwest::Method::POST,
&format!("identity/group/name/{group}"),
Some(json!({ "type": "external", "policies": policies })),
)
.await?
.error_for_status()
.map_err(|e| Self::err(format!("write group {group}"), e))?;
Ok(())
}
/// Ensure the external group exists with its alias bound to the JWT
/// mount, and that it carries `policy`.
async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
match self.read_group_policies(group).await? {
Some(policies) if policies.iter().any(|p| p == policy) => {}
Some(mut policies) => {
policies.push(policy.to_string());
self.write_group_policies(group, &policies).await?;
}
None => {
self.write_group_policies(group, &[policy.to_string()])
.await?;
}
}
self.ensure_group_alias(group).await?;
Ok(())
}
async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
if let Some(policies) = self.read_group_policies(group).await?
&& policies.iter().any(|p| p == policy)
{
let remaining: Vec<String> = policies.into_iter().filter(|p| p != policy).collect();
self.write_group_policies(group, &remaining).await?;
}
Ok(())
}
async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> {
let accessor = self.jwt_accessor().await?.to_string();
let body: serde_json::Value = self
.request(
reqwest::Method::GET,
&format!("identity/group/name/{group}"),
None,
)
.await?
.error_for_status()
.map_err(|e| Self::err(format!("read group {group}"), e))?
.json()
.await
.map_err(|e| Self::err(format!("parse group {group}"), e))?;
if !body["data"]["alias"]["id"]
.as_str()
.unwrap_or("")
.is_empty()
{
return Ok(());
}
let canonical_id = body["data"]["id"]
.as_str()
.ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?;
let resp = self
.request(
reqwest::Method::POST,
"identity/group-alias",
Some(json!({
"name": group,
"mount_accessor": accessor,
"canonical_id": canonical_id,
})),
)
.await?;
if resp.status().is_success() {
return Ok(());
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let alias_exists = status == StatusCode::BAD_REQUEST
&& body.to_ascii_lowercase().contains("alias")
&& body.to_ascii_lowercase().contains("already");
if !alias_exists {
return Err(Self::err(
format!("create group alias {group}"),
format!("{status}: {body}"),
));
}
Ok(())
}
/// Names of all identity groups, empty when none exist.
async fn list_groups(&self) -> Result<Vec<String>, SecretAccessError> {
let resp = self
.request(reqwest::Method::GET, "identity/group/name?list=true", None)
.await?;
if resp.status() == StatusCode::NOT_FOUND {
return Ok(vec![]);
}
let body: serde_json::Value = resp
.error_for_status()
.map_err(|e| Self::err("list groups", e))?
.json()
.await
.map_err(|e| Self::err("parse group list", e))?;
Ok(body["data"]["keys"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
} }
#[async_trait] #[async_trait]
@@ -350,20 +91,12 @@ impl DeploymentSecretGrants for OpenBaoDeploymentSecretGrants {
) -> Result<(), SecretAccessError> { ) -> Result<(), SecretAccessError> {
for (deployment, groups, image_pull_secrets) in grants { for (deployment, groups, image_pull_secrets) in grants {
let policy = Self::policy_name(deployment); let policy = Self::policy_name(deployment);
let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect(); let policy_hcl = (!groups.is_empty())
for group in self.list_groups().await? { .then(|| self.policy_hcl(deployment, image_pull_secrets))
if !allowed.contains(group.as_str()) { .transpose()?;
self.detach(&group, &policy).await?; self.policies
} .reconcile_acl_policy(&policy, policy_hcl.as_deref(), groups)
} .await?;
if groups.is_empty() {
self.delete_policy(deployment).await?;
continue;
}
self.upsert_policy(deployment, image_pull_secrets).await?;
for group in groups {
self.attach(group, &policy).await?;
}
} }
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,6 @@
pub mod config; pub mod config;
mod deployment_grants; mod deployment_grants;
mod openbao_policy;
pub mod store; pub mod store;
use crate::config::SECRET_NAMESPACE; use crate::config::SECRET_NAMESPACE;
@@ -33,6 +34,7 @@ use tokio::sync::OnceCell;
pub use deployment_grants::OpenBaoDeploymentSecretGrants; pub use deployment_grants::OpenBaoDeploymentSecretGrants;
pub use harmony_secret_derive::Secret; pub use harmony_secret_derive::Secret;
pub use openbao_policy::{HARMONY_STATE_SUBPATH, OpenBaoPolicyManager, render_tenant_policy};
// The Secret trait remains the same. // The Secret trait remains the same.
// pub trait Secret: Serialize + DeserializeOwned + Sized { // pub trait Secret: Serialize + DeserializeOwned + Sized {

View File

@@ -0,0 +1,343 @@
use std::{collections::HashSet, fmt};
use harmony_reconciler_contracts::SecretAccessError;
use reqwest::StatusCode;
use serde_json::json;
use tokio::sync::OnceCell;
pub const HARMONY_STATE_SUBPATH: &str = "harmony-state";
pub fn render_tenant_policy(
mount: &str,
tenant: &str,
capabilities: &[&str],
writable_state: bool,
) -> String {
let capabilities = capabilities
.iter()
.map(|capability| format!("\"{capability}\""))
.collect::<Vec<_>>()
.join(", ");
let mut policy = format!(
"path \"{mount}/data/{tenant}/*\" {{ capabilities = [{capabilities}] }}\n\
path \"{mount}/metadata/{tenant}/*\" {{ capabilities = [\"list\", \"read\"] }}"
);
if writable_state {
policy.push_str(&format!(
"\npath \"{mount}/data/{tenant}/{HARMONY_STATE_SUBPATH}/*\" {{ capabilities = [\"create\", \"read\", \"update\"] }}"
));
}
policy
}
/// Reconciles an OpenBao ACL policy with external identity groups and JWT aliases.
pub struct OpenBaoPolicyManager {
client: reqwest::Client,
base_url: String,
token: String,
jwt_mount: String,
jwt_accessor: OnceCell<String>,
}
impl fmt::Debug for OpenBaoPolicyManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenBaoPolicyManager")
.field("base_url", &self.base_url)
.field("jwt_mount", &self.jwt_mount)
.finish_non_exhaustive()
}
}
impl OpenBaoPolicyManager {
pub fn new(base_url: String, token: String, jwt_mount: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
token,
jwt_mount,
jwt_accessor: OnceCell::new(),
}
}
/// Upserts `policy_hcl` and grants it only to `groups`.
///
/// Passing `None` removes the policy. Other policies attached to each
/// identity group are preserved.
pub async fn reconcile_acl_policy(
&self,
name: &str,
policy_hcl: Option<&str>,
groups: &[String],
) -> Result<(), SecretAccessError> {
let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect();
for group in self.list_groups().await? {
if !allowed.contains(group.as_str()) {
self.detach(&group, name).await?;
}
}
let Some(policy_hcl) = policy_hcl else {
self.delete_policy(name).await?;
return Ok(());
};
self.upsert_policy(name, policy_hcl).await?;
for group in groups {
self.attach(group, name).await?;
}
Ok(())
}
fn err(context: impl fmt::Display, error: impl fmt::Display) -> SecretAccessError {
SecretAccessError::Backend(format!("{context}: {error}"))
}
async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<reqwest::Response, SecretAccessError> {
let mut request = self
.client
.request(method.clone(), format!("{}/v1/{path}", self.base_url))
.header("X-Vault-Token", &self.token);
if let Some(body) = body {
request = request.json(&body);
}
request
.send()
.await
.map_err(|error| Self::err(format!("{method} {path}"), error))
}
async fn upsert_policy(&self, name: &str, policy_hcl: &str) -> Result<(), SecretAccessError> {
self.request(
reqwest::Method::PUT,
&format!("sys/policies/acl/{name}"),
Some(json!({ "policy": policy_hcl })),
)
.await?
.error_for_status()
.map_err(|error| Self::err(format!("write policy {name}"), error))?;
Ok(())
}
async fn delete_policy(&self, name: &str) -> Result<(), SecretAccessError> {
let response = self
.request(
reqwest::Method::DELETE,
&format!("sys/policies/acl/{name}"),
None,
)
.await?;
if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND {
return Err(Self::err(
format!("delete policy {name}"),
response.status(),
));
}
Ok(())
}
async fn read_group_policies(
&self,
group: &str,
) -> Result<Option<Vec<String>>, SecretAccessError> {
let response = self
.request(
reqwest::Method::GET,
&format!("identity/group/name/{group}"),
None,
)
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let body: serde_json::Value = response
.error_for_status()
.map_err(|error| Self::err(format!("read group {group}"), error))?
.json()
.await
.map_err(|error| Self::err(format!("parse group {group}"), error))?;
Ok(Some(
body["data"]["policies"]
.as_array()
.map(|policies| {
policies
.iter()
.filter_map(|policy| policy.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
))
}
async fn write_group_policies(
&self,
group: &str,
policies: &[String],
) -> Result<(), SecretAccessError> {
self.request(
reqwest::Method::POST,
&format!("identity/group/name/{group}"),
Some(json!({ "type": "external", "policies": policies })),
)
.await?
.error_for_status()
.map_err(|error| Self::err(format!("write group {group}"), error))?;
Ok(())
}
async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
match self.read_group_policies(group).await? {
Some(policies) if policies.iter().any(|existing| existing == policy) => {}
Some(mut policies) => {
policies.push(policy.to_string());
self.write_group_policies(group, &policies).await?;
}
None => {
self.write_group_policies(group, &[policy.to_string()])
.await?;
}
}
self.ensure_group_alias(group).await
}
async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
if let Some(policies) = self.read_group_policies(group).await?
&& policies.iter().any(|existing| existing == policy)
{
let remaining: Vec<String> = policies
.into_iter()
.filter(|existing| existing != policy)
.collect();
self.write_group_policies(group, &remaining).await?;
}
Ok(())
}
async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> {
let accessor = self.jwt_accessor().await?.to_string();
let body: serde_json::Value = self
.request(
reqwest::Method::GET,
&format!("identity/group/name/{group}"),
None,
)
.await?
.error_for_status()
.map_err(|error| Self::err(format!("read group {group}"), error))?
.json()
.await
.map_err(|error| Self::err(format!("parse group {group}"), error))?;
if !body["data"]["alias"]["id"]
.as_str()
.unwrap_or("")
.is_empty()
{
return Ok(());
}
let canonical_id = body["data"]["id"]
.as_str()
.ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?;
let response = self
.request(
reqwest::Method::POST,
"identity/group-alias",
Some(json!({
"name": group,
"mount_accessor": accessor,
"canonical_id": canonical_id,
})),
)
.await?;
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_lower = body.to_ascii_lowercase();
if status != StatusCode::BAD_REQUEST
|| !body_lower.contains("alias")
|| !body_lower.contains("already")
{
return Err(Self::err(
format!("create group alias {group}"),
format!("{status}: {body}"),
));
}
Ok(())
}
async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> {
self.jwt_accessor
.get_or_try_init(|| async {
let body: serde_json::Value = self
.request(reqwest::Method::GET, "sys/auth", None)
.await?
.error_for_status()
.map_err(|error| Self::err("GET sys/auth", error))?
.json()
.await
.map_err(|error| Self::err("parse sys/auth", error))?;
// The HTTP API usually nests mounts under `data`, while some
// compatible responses expose them at the document root.
let mount_key = format!("{}/", self.jwt_mount);
body.get("data")
.and_then(|data| data.get(&mount_key))
.or_else(|| body.get(&mount_key))
.and_then(|mount| mount.get("accessor"))
.and_then(|accessor| accessor.as_str())
.map(str::to_string)
.ok_or_else(|| {
Self::err(
"resolve jwt accessor",
format!("mount '{mount_key}' not found in sys/auth"),
)
})
})
.await
.map(String::as_str)
}
async fn list_groups(&self) -> Result<Vec<String>, SecretAccessError> {
let response = self
.request(reqwest::Method::GET, "identity/group/name?list=true", None)
.await?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(vec![]);
}
let body: serde_json::Value = response
.error_for_status()
.map_err(|error| Self::err("list groups", error))?
.json()
.await
.map_err(|error| Self::err("parse group list", error))?;
Ok(body["data"]["keys"]
.as_array()
.map(|groups| {
groups
.iter()
.filter_map(|group| group.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_omits_token() {
let manager = OpenBaoPolicyManager::new(
"https://openbao.example".into(),
"super-secret-token".into(),
"jwt".into(),
);
assert!(!format!("{manager:?}").contains("super-secret-token"));
}
}

View File

@@ -87,9 +87,9 @@ impl OpenbaoSecretStore {
options.base_url options.base_url
); );
// 1. If token is provided via env var, use it directly // 1. If a token is provided, use it directly.
if let Some(t) = &options.token { if let Some(t) = &options.token {
debug!("OPENBAO_STORE: Using token from environment variable"); debug!("OPENBAO_STORE: Using supplied token");
return Self::with_token( return Self::with_token(
&options.base_url, &options.base_url,
options.skip_tls, options.skip_tls,

View File

@@ -28,8 +28,12 @@ tokio = { workspace = true, features = ["time"] }
arc-swap = "1" arc-swap = "1"
time = "0.3" time = "0.3"
tracing = { workspace = true } tracing = { workspace = true }
thiserror.workspace = true
jsonwebtoken = "9" jsonwebtoken = "9"
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
axum = { version = "0.8", optional = true } axum = { version = "0.8", optional = true }
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
[dev-dependencies]
httptest = "0.16"

View File

@@ -4,6 +4,7 @@ pub mod config;
mod device_groups; mod device_groups;
pub mod jwks; pub mod jwks;
pub mod login; pub mod login;
pub mod management;
pub mod session; pub mod session;
pub use config::{OperatorCookieKey, ZitadelAuthConfig}; pub use config::{OperatorCookieKey, ZitadelAuthConfig};

View File

@@ -0,0 +1,790 @@
use std::fmt;
use base64::Engine;
use reqwest::{Method, StatusCode};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum ManagementError {
#[error("failed to build Zitadel HTTP client: {0}")]
BuildClient(reqwest::Error),
#[error("Zitadel Management API request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("invalid Zitadel Management API response: {0}")]
Json(#[from] serde_json::Error),
#[error("Zitadel {operation} returned {status}: {body}")]
Api {
operation: &'static str,
status: StatusCode,
body: String,
},
#[error("Zitadel project '{0}' does not exist")]
ProjectNotFound(String),
#[error("Zitadel user '{username}' is {actual}, not {expected}")]
WrongUserKind {
username: String,
expected: UserKind,
actual: UserKind,
},
#[error("Zitadel user '{0}' has no human or machine type")]
UnknownUserKind(String),
#[error("invalid base64 machine key: {0}")]
MachineKeyBase64(#[from] base64::DecodeError),
#[error("machine key is not UTF-8: {0}")]
MachineKeyUtf8(#[from] std::string::FromUtf8Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserKind {
Human,
Machine,
}
impl fmt::Display for UserKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Human => "human",
Self::Machine => "machine",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: String,
pub username: String,
}
pub struct CreatedMachineKey {
pub json: String,
}
#[derive(Clone)]
pub struct ManagementClient {
http: reqwest::Client,
base_url: String,
pat: String,
org_id: Option<String>,
host_header: Option<String>,
}
impl fmt::Debug for ManagementClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ManagementClient")
.field("base_url", &self.base_url)
.field("org_id", &self.org_id)
.field("host_header", &self.host_header)
.finish_non_exhaustive()
}
}
impl ManagementClient {
pub fn new(
base_url: impl Into<String>,
pat: impl Into<String>,
org_id: Option<String>,
accept_invalid_certs: bool,
) -> Result<Self, ManagementError> {
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(accept_invalid_certs)
.build()
.map_err(ManagementError::BuildClient)?;
Ok(Self {
http,
base_url: base_url.into().trim_end_matches('/').to_string(),
pat: pat.into(),
org_id,
host_header: None,
})
}
pub fn with_host_header(mut self, host: impl Into<String>) -> Self {
self.host_header = Some(host.into());
self
}
fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let mut request = self
.http
.request(method, format!("{}{}", self.base_url, path))
.bearer_auth(&self.pat);
if let Some(org_id) = &self.org_id {
request = request.header("x-zitadel-orgid", org_id);
}
if let Some(host) = &self.host_header {
request = request.header("host", host);
}
request
}
async fn response(
&self,
operation: &'static str,
request: reqwest::RequestBuilder,
) -> Result<String, ManagementError> {
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return Err(ManagementError::Api {
operation,
status,
body,
});
}
Ok(body)
}
pub async fn project(&self, name: &str) -> Result<Project, ManagementError> {
let body = self
.response(
"project search",
self.request(Method::POST, "/management/v1/projects/_search")
.json(&json!({
"queries": [{ "nameQuery": {
"name": name,
"method": "TEXT_QUERY_METHOD_EQUALS"
}}]
})),
)
.await?;
serde_json::from_str::<ProjectSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|project| project.name == name)
.map(Into::into)
.ok_or_else(|| ManagementError::ProjectNotFound(name.to_string()))
}
pub async fn ensure_project_role(
&self,
project_id: &str,
key: &str,
display_name: &str,
group: Option<&str>,
) -> Result<(), ManagementError> {
let path = format!("/management/v1/projects/{project_id}/roles/_search");
let body = self
.response(
"role search",
self.request(Method::POST, &path).json(&json!({})),
)
.await?;
if serde_json::from_str::<RoleSearchResult>(&body)?
.result
.unwrap_or_default()
.iter()
.any(|role| role.key == key)
{
return Ok(());
}
let mut payload = json!({ "roleKey": key, "displayName": display_name });
if let Some(group) = group {
payload["group"] = group.into();
}
let path = format!("/management/v1/projects/{project_id}/roles");
self.response(
"role creation",
self.request(Method::POST, &path).json(&payload),
)
.await?;
Ok(())
}
pub async fn find_human(&self, username: &str) -> Result<Option<User>, ManagementError> {
self.find_user(username, UserKind::Human).await
}
pub async fn find_machine(&self, username: &str) -> Result<Option<User>, ManagementError> {
self.find_user(username, UserKind::Machine).await
}
async fn find_user(
&self,
username: &str,
expected: UserKind,
) -> Result<Option<User>, ManagementError> {
let body = self
.response(
"user search",
self.request(Method::POST, "/management/v1/users/_search")
.json(&json!({ "queries": [{ "userNameQuery": {
"userName": username,
"method": "TEXT_QUERY_METHOD_EQUALS"
}}] })),
)
.await?;
let mut found = serde_json::from_str::<UserSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|user| {
user.user_name.as_deref() == Some(username)
|| user.preferred_login_name.as_deref() == Some(username)
});
if found.is_none() {
let body = self
.response(
"user login search",
self.request(Method::POST, "/management/v1/users/_search")
.json(&json!({ "queries": [{ "loginNameQuery": {
"loginName": username,
"method": "TEXT_QUERY_METHOD_EQUALS"
}}] })),
)
.await?;
found = serde_json::from_str::<UserSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|user| {
user.user_name.as_deref() == Some(username)
|| user.preferred_login_name.as_deref() == Some(username)
});
}
let Some(found) = found else {
return Ok(None);
};
let actual = match (found.human.is_some(), found.machine.is_some()) {
(true, false) => UserKind::Human,
(false, true) => UserKind::Machine,
_ => return Err(ManagementError::UnknownUserKind(username.to_string())),
};
if actual != expected {
return Err(ManagementError::WrongUserKind {
username: username.to_string(),
expected,
actual,
});
}
Ok(Some(User {
id: found.id,
username: username.to_string(),
}))
}
pub async fn ensure_machine(
&self,
username: &str,
name: &str,
) -> Result<User, ManagementError> {
if let Some(user) = self.find_machine(username).await? {
return Ok(user);
}
self.create_machine(username, name).await
}
pub async fn create_machine(
&self,
username: &str,
name: &str,
) -> Result<User, ManagementError> {
let body = self
.response(
"machine user creation",
self.request(Method::POST, "/management/v1/users/machine")
.json(&json!({
"userName": username,
"name": name,
"description": "Provisioned by Harmony",
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
})),
)
.await?;
let created: UserCreateResponse = serde_json::from_str(&body)?;
Ok(User {
id: created.user_id,
username: username.to_string(),
})
}
pub async fn create_json_machine_key(
&self,
user_id: &str,
) -> Result<CreatedMachineKey, ManagementError> {
let path = format!("/management/v1/users/{user_id}/keys");
let body = self
.response(
"machine key creation",
self.request(Method::POST, &path)
.json(&json!({ "type": "KEY_TYPE_JSON" })),
)
.await?;
let key: MachineKeyResponse = serde_json::from_str(&body)?;
Ok(CreatedMachineKey {
json: String::from_utf8(
base64::engine::general_purpose::STANDARD.decode(key.key_details)?,
)?,
})
}
pub async fn delete_user(&self, user_id: &str) -> Result<(), ManagementError> {
let path = format!("/management/v1/users/{user_id}");
self.response("user deletion", self.request(Method::DELETE, &path))
.await?;
Ok(())
}
pub async fn ensure_project_role_grant(
&self,
user_id: &str,
project_id: &str,
role_keys: &[String],
) -> Result<String, ManagementError> {
let body = self
.response(
"user grant search",
self.request(Method::POST, "/management/v1/users/grants/_search")
.json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })),
)
.await?;
let grant = serde_json::from_str::<UserGrantSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|grant| grant.project_id == project_id);
if let Some(grant) = grant {
let mut roles = grant.role_keys;
let original_role_count = roles.len();
for role in role_keys {
if !roles.contains(role) {
roles.push(role.clone());
}
}
if roles.len() != original_role_count {
let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id);
let response = self
.request(Method::PUT, &path)
.json(&json!({ "roleKeys": roles }))
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() && !is_no_changes(&body) {
return Err(ManagementError::Api {
operation: "user grant update",
status,
body,
});
}
}
return Ok(grant.id);
}
let path = format!("/management/v1/users/{user_id}/grants");
let body = self
.response(
"user grant creation",
self.request(Method::POST, &path)
.json(&json!({ "projectId": project_id, "roleKeys": role_keys })),
)
.await?;
Ok(serde_json::from_str::<UserGrantCreateResponse>(&body)?.user_grant_id)
}
pub async fn set_project_role_grant(
&self,
user_id: &str,
project_id: &str,
role_keys: &[String],
) -> Result<String, ManagementError> {
let body = self
.response(
"user grant search",
self.request(Method::POST, "/management/v1/users/grants/_search")
.json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })),
)
.await?;
if let Some(grant) = serde_json::from_str::<UserGrantSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|grant| grant.project_id == project_id)
{
let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id);
let response = self
.request(Method::PUT, &path)
.json(&json!({ "roleKeys": role_keys }))
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() && !is_no_changes(&body) {
return Err(ManagementError::Api {
operation: "user grant update",
status,
body,
});
}
return Ok(grant.id);
}
let path = format!("/management/v1/users/{user_id}/grants");
let body = self
.response(
"user grant creation",
self.request(Method::POST, &path)
.json(&json!({ "projectId": project_id, "roleKeys": role_keys })),
)
.await?;
Ok(serde_json::from_str::<UserGrantCreateResponse>(&body)?.user_grant_id)
}
pub async fn action_in_token_flow(&self, name: &str) -> Result<bool, ManagementError> {
let body = self
.response(
"action search",
self.request(Method::POST, "/management/v1/actions/_search")
.json(&json!({ "queries": [{ "actionNameQuery": { "name": name }}] })),
)
.await?;
let Some(action) = serde_json::from_str::<ActionSearchResult>(&body)?
.result
.unwrap_or_default()
.into_iter()
.find(|action| action.name == name)
else {
return Ok(false);
};
let flow: serde_json::Value = serde_json::from_str(
&self
.response(
"token flow read",
self.request(Method::GET, "/management/v1/flows/2"),
)
.await?,
)?;
Ok(["4", "5"].into_iter().all(|trigger| {
flow["flow"]["triggerActions"]
.as_array()
.into_iter()
.flatten()
.filter(|entry| {
entry["triggerType"]["id"].as_str() == Some(trigger)
|| entry["triggerType"].as_str() == Some(trigger)
})
.flat_map(|entry| entry["actions"].as_array().into_iter().flatten())
.any(|entry| entry["id"].as_str() == Some(action.id.as_str()))
}))
}
}
#[derive(Deserialize)]
struct ProjectSearchResult {
result: Option<Vec<ProjectEntry>>,
}
#[derive(Deserialize)]
struct ProjectEntry {
id: String,
name: String,
}
impl From<ProjectEntry> for Project {
fn from(value: ProjectEntry) -> Self {
Self {
id: value.id,
name: value.name,
}
}
}
#[derive(Deserialize)]
struct RoleSearchResult {
result: Option<Vec<RoleEntry>>,
}
#[derive(Deserialize)]
struct RoleEntry {
key: String,
}
#[derive(Deserialize)]
struct UserSearchResult {
result: Option<Vec<UserEntry>>,
}
#[derive(Deserialize)]
struct UserEntry {
id: String,
#[serde(rename = "userName")]
user_name: Option<String>,
#[serde(rename = "preferredLoginName")]
preferred_login_name: Option<String>,
human: Option<serde_json::Value>,
machine: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct UserCreateResponse {
#[serde(rename = "userId")]
user_id: String,
}
#[derive(Deserialize)]
struct MachineKeyResponse {
#[serde(rename = "keyDetails")]
key_details: String,
}
fn is_no_changes(body: &str) -> bool {
// Zitadel returns gRPC 9 FAILED_PRECONDITION with several command
// ids when a PUT matches stored state (OIDC config: COMMAND-1m88i;
// user grants: COMMAND-Rs8fy). Treat all as idempotent success.
body.contains("\"code\":9")
&& (body.contains("COMMAND-1m88i")
|| body.contains("COMMAND-Rs8fy")
|| body.contains("No changes")
|| body.contains("has not been changed"))
}
#[derive(Deserialize)]
struct UserGrantSearchResult {
result: Option<Vec<UserGrantEntry>>,
}
#[derive(Deserialize)]
struct UserGrantEntry {
id: String,
#[serde(rename = "projectId")]
project_id: String,
#[serde(rename = "roleKeys", default)]
role_keys: Vec<String>,
}
#[derive(Deserialize)]
struct UserGrantCreateResponse {
#[serde(rename = "userGrantId")]
user_grant_id: String,
}
#[derive(Deserialize)]
struct ActionSearchResult {
result: Option<Vec<ActionEntry>>,
}
#[derive(Deserialize)]
struct ActionEntry {
id: String,
name: String,
}
#[cfg(test)]
mod tests {
use httptest::{Expectation, Server, matchers::*, responders::*};
use serde_json::json;
use super::*;
fn client(server: &Server) -> ManagementClient {
ManagementClient::new(
server.url_str(""),
"super-secret-pat",
Some("org-1".into()),
false,
)
.unwrap()
}
#[tokio::test]
async fn project_lookup_is_exact_and_sends_org_context() {
let server = Server::run();
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/management/v1/projects/_search"),
request::headers(contains(("x-zitadel-orgid", "org-1"))),
request::body(json_decoded(eq(json!({
"queries": [{"nameQuery": {
"name": "fleet",
"method": "TEXT_QUERY_METHOD_EQUALS"
}}]
}))))
])
.respond_with(json_encoded(json!({
"result": [
{"id": "near", "name": "fleet-dev"},
{"id": "exact", "name": "fleet"}
]
}))),
);
assert_eq!(client(&server).project("fleet").await.unwrap().id, "exact");
}
#[tokio::test]
async fn machine_lookup_rejects_a_human_with_the_same_username() {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/management/v1/users/_search"))
.respond_with(json_encoded(json!({
"result": [{"id": "human-1", "userName": "robot", "human": {}}]
}))),
);
assert!(matches!(
client(&server).find_machine("robot").await,
Err(ManagementError::WrongUserKind {
expected: UserKind::Machine,
actual: UserKind::Human,
..
})
));
}
#[tokio::test]
async fn create_machine_propagates_an_account_name_conflict() {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/management/v1/users/machine"))
.respond_with(status_code(409)),
);
assert!(matches!(
client(&server).create_machine("robot", "Robot").await,
Err(ManagementError::Api {
status: StatusCode::CONFLICT,
..
})
));
}
#[tokio::test]
async fn machine_key_decodes_the_one_time_json_payload() {
let server = Server::run();
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/management/v1/users/user-1/keys"),
request::body(json_decoded(eq(json!({"type": "KEY_TYPE_JSON"}))))
])
.respond_with(json_encoded(json!({
"keyId": "key-1",
"keyDetails": "eyJrZXkiOiJwcml2YXRlIn0="
}))),
);
assert_eq!(
client(&server)
.create_json_machine_key("user-1")
.await
.unwrap()
.json,
r#"{"key":"private"}"#
);
}
#[tokio::test]
async fn grant_update_adds_roles_without_removing_existing_ones() {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/management/v1/users/grants/_search",
))
.respond_with(json_encoded(json!({
"result": [{
"id": "grant-1",
"projectId": "project-1",
"roleKeys": ["existing"]
}]
}))),
);
server.expect(
Expectation::matching(all_of![
request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"),
request::body(json_decoded(eq(json!({
"roleKeys": ["existing", "requested"]
}))))
])
.respond_with(status_code(200)),
);
let roles = vec!["requested".to_string()];
assert_eq!(
client(&server)
.ensure_project_role_grant("user-1", "project-1", &roles)
.await
.unwrap(),
"grant-1"
);
}
#[tokio::test]
async fn set_grant_replaces_roles_for_declarative_contracts() {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/management/v1/users/grants/_search",
))
.respond_with(json_encoded(json!({
"result": [{
"id": "grant-1",
"projectId": "project-1",
"roleKeys": ["stale"]
}]
}))),
);
server.expect(
Expectation::matching(all_of![
request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"),
request::body(json_decoded(eq(json!({ "roleKeys": ["declared"] }))))
])
.respond_with(status_code(200)),
);
assert_eq!(
client(&server)
.set_project_role_grant("user-1", "project-1", &["declared".to_string()])
.await
.unwrap(),
"grant-1"
);
}
#[tokio::test]
async fn action_requires_both_token_flow_triggers() {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/management/v1/actions/_search",
))
.respond_with(json_encoded(json!({
"result": [{"id": "action-1", "name": "harmonyGroupsClaim"}]
}))),
);
server.expect(
Expectation::matching(request::method_path("GET", "/management/v1/flows/2"))
.respond_with(json_encoded(json!({
"flow": {"triggerActions": [
{"triggerType": {"id": "4"}, "actions": [{"id": "action-1"}]},
{"triggerType": {"id": "5"}, "actions": [{"id": "action-1"}]}
]}
}))),
);
assert!(
client(&server)
.action_in_token_flow("harmonyGroupsClaim")
.await
.unwrap()
);
}
#[test]
fn debug_does_not_expose_the_pat() {
let server = Server::run();
let debug = format!("{:?}", client(&server));
assert!(!debug.contains("super-secret-pat"));
}
}