feat/declarative-application-deployment #345
@@ -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,
|
||||
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
|
||||
|
||||
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?
|
||||
6. Can control flow become linear and local?
|
||||
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.
|
||||
|
||||
|
||||
1930
Cargo.lock
generated
1930
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
# Harmony Auth CLI
|
||||
|
||||
> **Status: read-only commands implemented.** Mutation commands follow the
|
||||
> ADR-027 group migration.
|
||||
> **Status:** read-only inspection and ADR-027 tenant identity provisioning are
|
||||
> implemented. Identity grant and revoke commands remain pending.
|
||||
|
||||
`harmony-auth` inspects and manages the relationship between Zitadel
|
||||
identities and OpenBao access. It presents tenants, identities, and Harmony
|
||||
permissions first. JWT roles, policy names, subject claims, and HCL remain
|
||||
available through advanced output.
|
||||
`harmony-auth` inspects access and provisions tenants across Zitadel, OpenBao,
|
||||
and Kubernetes. It presents tenants, identities, and Harmony permissions first.
|
||||
JWT roles, policy names, subject claims, and HCL remain available through
|
||||
advanced output.
|
||||
|
||||
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.
|
||||
@@ -33,43 +33,61 @@ shown as imported access. The CLI does not rename, rewrite, or hide them.
|
||||
|
||||
```text
|
||||
harmony-auth
|
||||
├── context configure
|
||||
├── connection check
|
||||
├── identity list
|
||||
├── identity show <subject-id>
|
||||
├── tenant list
|
||||
├── tenant create <tenant>
|
||||
├── tenant deployer create <tenant> <account>
|
||||
└── tenant show <tenant>
|
||||
```
|
||||
|
||||
There are no flat aliases. `harmony-auth list` is not valid.
|
||||
|
||||
## Connection and credentials
|
||||
## Context and credentials
|
||||
|
||||
Every command except `--help` and `--version` requires:
|
||||
|
||||
| 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:
|
||||
Every command selects one named environment through `--context` or
|
||||
`HARMONY_CONTEXT`:
|
||||
|
||||
```sh
|
||||
export ZITADEL_URL=https://sso.example.com
|
||||
export ZITADEL_PAT=...
|
||||
export OPENBAO_URL=https://secrets.example.com
|
||||
export OPENBAO_TOKEN=...
|
||||
harmony-auth connection check --context prod
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
The CLI does not persist profiles or credentials. Browser profile storage and
|
||||
session credential refresh remain web UI concerns.
|
||||
Secret values never appear in help output, normal output, JSON, or logs.
|
||||
|
||||
`connection check` attempts both backends even when one fails. It reports each
|
||||
status without printing provider response bodies:
|
||||
@@ -222,6 +240,126 @@ manual migration.
|
||||
|
||||
## 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
|
||||
|
||||
```sh
|
||||
@@ -275,10 +413,13 @@ The first release uses these result shapes:
|
||||
|
||||
| Command | `result` fields |
|
||||
|---|---|
|
||||
| `context configure` | `context`, `path` |
|
||||
| `connection check` | `zitadel: { connected }`, `openbao: { connected }` |
|
||||
| `identity list` | `identities: [{ identity, access }]` |
|
||||
| `identity show` | `identity`, `access` |
|
||||
| `tenant list` | `tenants: [{ scope, humans, services }]` |
|
||||
| `tenant create` | `tenant`, `applied` |
|
||||
| `tenant deployer create` | `tenant`, `account`, `credentials`, `applied` |
|
||||
| `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` |
|
||||
|
||||
`identity` contains `subject_id`, `kind`, `display_name`, `login_name`,
|
||||
@@ -358,6 +499,7 @@ harmony_auth_cli ─┘
|
||||
`harmony_auth_cli` owns:
|
||||
|
||||
- Clap arguments and environment mapping
|
||||
- named Harmony context profile loading
|
||||
- terminal and JSON rendering
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
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.
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn fleet_context() -> anyhow::Result<Context> {
|
||||
repository: oci_repository!("customer/fleet"),
|
||||
domain: domain!("fleet.example.com"),
|
||||
image_pull_secret: None,
|
||||
object_storage_endpoint: None,
|
||||
access: tenant_access("fleet-deployer")?,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
repository: "apps".parse()?,
|
||||
domain: "example.com".parse()?,
|
||||
image_pull_secret: None,
|
||||
object_storage_endpoint: None,
|
||||
access: OpenBaoClusterAccess {
|
||||
namespace: "platform/example-app".parse()?,
|
||||
url: "https://secrets.example.com".parse()?,
|
||||
|
||||
@@ -278,6 +278,8 @@ impl HarmonyApp<K8sAnywhereTopology> for FleetTenantProvisionApp {
|
||||
let source = harmony_config::openbao_source(
|
||||
store.namespace.as_ref(),
|
||||
Some(store.url.to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(store.zitadel_url.to_string()),
|
||||
Some(store.zitadel_audience.to_string()),
|
||||
Some(store.role.to_string()),
|
||||
@@ -450,6 +452,7 @@ mod tests {
|
||||
repository: "harmony".parse().unwrap(),
|
||||
domain: "fleet.example.com".parse().unwrap(),
|
||||
image_pull_secret: None,
|
||||
object_storage_endpoint: None,
|
||||
access: OpenBaoClusterAccess {
|
||||
namespace: "customer/fleet".parse().unwrap(),
|
||||
url: "https://secrets.example.com".parse().unwrap(),
|
||||
|
||||
@@ -152,6 +152,18 @@ impl K8sClient {
|
||||
.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.
|
||||
pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
|
||||
where
|
||||
@@ -178,6 +190,21 @@ impl K8sClient {
|
||||
namespace: Option<&str>,
|
||||
write_mode: WriteMode,
|
||||
) -> 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
|
||||
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
|
||||
<K as Resource>::DynamicType: Default,
|
||||
@@ -187,7 +214,9 @@ impl K8sClient {
|
||||
resource.meta().name,
|
||||
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 gvk = GroupVersionKind {
|
||||
@@ -219,6 +248,10 @@ impl K8sClient {
|
||||
.expect("Kubernetes resource must have a name");
|
||||
|
||||
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?;
|
||||
return Ok(resource.clone());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use kube::config::{KubeConfigOptions, Kubeconfig};
|
||||
use kube::{Client, Config, Discovery, Error};
|
||||
use log::error;
|
||||
use log::{error, info};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
|
||||
@@ -61,6 +61,22 @@ impl ClusterConnection {
|
||||
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> {
|
||||
@@ -121,6 +137,21 @@ impl std::fmt::Debug for 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.
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self {
|
||||
@@ -174,7 +205,13 @@ impl K8sClient {
|
||||
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}"))
|
||||
.ok();
|
||||
let config = match Config::from_custom_kubeconfig(k, opts).await {
|
||||
@@ -184,6 +221,17 @@ impl K8sClient {
|
||||
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) {
|
||||
Ok(client) => client,
|
||||
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]
|
||||
fn connection_records_insecure_tls() {
|
||||
let kubeconfig: Kubeconfig = serde_yaml::from_str(
|
||||
|
||||
@@ -10,8 +10,6 @@ use kube::{
|
||||
Error, Resource,
|
||||
api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList},
|
||||
core::ErrorResponse,
|
||||
runtime::conditions,
|
||||
runtime::wait::await_condition,
|
||||
};
|
||||
use log::{debug, info};
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -276,15 +274,25 @@ impl K8sClient {
|
||||
namespace: Option<&str>,
|
||||
timeout: Option<Duration>,
|
||||
) -> 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 establish = await_condition(api, name, conditions::is_deployment_completed());
|
||||
match tokio::time::timeout(timeout, establish).await {
|
||||
Ok(Ok(_)) => Ok(()),
|
||||
Ok(Err(error)) => Err(format!("Failed waiting for deployment {name}: {error}")),
|
||||
match tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
match self.get_resource::<Deployment>(name, namespace).await {
|
||||
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!(
|
||||
"Timed out after {}s waiting for deployment {name}",
|
||||
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
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ walkdir = "2.5.0"
|
||||
harmony_inventory_agent = { path = "../harmony_inventory_agent" }
|
||||
harmony_secret_derive = { path = "../harmony_secret_derive" }
|
||||
harmony_secret = { path = "../harmony_secret" }
|
||||
harmony_zitadel_auth = { path = "../harmony_zitadel_auth" }
|
||||
hmac = "0.12"
|
||||
askama.workspace = true
|
||||
sha2 = "0.10"
|
||||
sqlx.workspace = true
|
||||
|
||||
@@ -114,9 +114,9 @@ impl K8sTenantManager {
|
||||
},
|
||||
"spec": {
|
||||
"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),
|
||||
"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.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb),
|
||||
"pods": "20",
|
||||
|
||||
@@ -261,6 +261,10 @@ impl<T: Topology + HelmCommand> Interpret<T> for HelmChartInterpret {
|
||||
&self.score.chart_name,
|
||||
"--namespace",
|
||||
&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 {
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct PostgreSQLConfig {
|
||||
pub cluster_name: String,
|
||||
pub instances: u32,
|
||||
pub storage_size: StorageSize,
|
||||
pub version: Option<String>,
|
||||
pub role: PostgreSQLClusterRole,
|
||||
/// **Note :** on OpenShfit based clusters, the namespace `default` has security
|
||||
/// settings incompatible with the default CNPG behavior.
|
||||
@@ -90,6 +91,7 @@ impl Default for PostgreSQLConfig {
|
||||
role: PostgreSQLClusterRole::Primary,
|
||||
namespace: "harmony".to_string(),
|
||||
wait_for_ready: true,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
|
||||
role: PostgreSQLClusterRole::Primary,
|
||||
namespace: config.namespace.clone(),
|
||||
wait_for_ready: config.wait_for_ready,
|
||||
version: config.version.clone(),
|
||||
};
|
||||
|
||||
info!(
|
||||
@@ -146,6 +147,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
|
||||
role: PostgreSQLClusterRole::Replica(replica_cluster_config),
|
||||
namespace: config.namespace.clone(),
|
||||
wait_for_ready: config.wait_for_ready,
|
||||
version: config.version.clone(),
|
||||
};
|
||||
|
||||
info!(
|
||||
|
||||
@@ -6,6 +6,8 @@ pub use score_connect::*;
|
||||
pub use score_k8s::*;
|
||||
mod score_public;
|
||||
pub use score_public::*;
|
||||
mod score_debug_route;
|
||||
pub use score_debug_route::*;
|
||||
|
||||
pub mod failover;
|
||||
mod operator;
|
||||
|
||||
194
harmony/src/modules/postgresql/score_debug_route.rs
Normal file
194
harmony/src/modules/postgresql/score_debug_route.rs
Normal 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!()
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ impl K8sPostgreSQLScore {
|
||||
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 {
|
||||
PostgreSQLRootAccountRef {
|
||||
host: format!(
|
||||
@@ -298,6 +303,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
|
||||
|
||||
let spec = ClusterSpec {
|
||||
instances: self.config.instances,
|
||||
image_name: cnpg_image_name(self.config.version.as_deref()),
|
||||
storage: Storage {
|
||||
size: self.config.storage_size.to_string(),
|
||||
},
|
||||
@@ -387,6 +393,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
|
||||
|
||||
let spec = ClusterSpec {
|
||||
instances: self.config.instances,
|
||||
image_name: cnpg_image_name(self.config.version.as_deref()),
|
||||
storage: Storage {
|
||||
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)]
|
||||
mod tests {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,31 +20,46 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use k8s_openapi::ByteString;
|
||||
use k8s_openapi::api::core::v1::Secret as K8sSecret;
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::interpret::Interpret;
|
||||
use crate::modules::k8s::resource::K8sResourceScore;
|
||||
use crate::data::Version;
|
||||
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||
use crate::inventory::Inventory;
|
||||
use crate::score::Score;
|
||||
use crate::topology::{K8sclient, Topology};
|
||||
use harmony_types::id::Id;
|
||||
|
||||
/// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from
|
||||
/// pull-only registry credentials. Idempotent — a namespaced Secret apply.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct RegistryPullSecretScore {
|
||||
pub namespace: String,
|
||||
/// Secret name — the same string goes in each pod's `imagePullSecrets`.
|
||||
pub name: String,
|
||||
/// Registry host the creds authenticate to, e.g. `hub.nationtech.io`.
|
||||
pub registry: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub username: String,
|
||||
/// Pull-only robot token. Serialized into the Secret, never logged.
|
||||
#[serde(skip_serializing)]
|
||||
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 {
|
||||
/// Build the `.dockerconfigjson` Secret. The inner `auth` field is
|
||||
/// `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>> {
|
||||
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");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub mod ceph;
|
||||
pub mod object_bucket;
|
||||
|
||||
pub use object_bucket::ObjectBucketScore;
|
||||
|
||||
643
harmony/src/modules/storage/object_bucket.rs
Normal file
643
harmony/src/modules/storage/object_bucket.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harmony_config::{Config, ConfigClient};
|
||||
use harmony_k8s::ClusterConnection;
|
||||
use harmony_k8s::{ClusterConnection, K8sClient};
|
||||
use harmony_types::id::Id;
|
||||
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::{
|
||||
ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
|
||||
};
|
||||
use kube::api::ObjectMeta;
|
||||
use kube::config::{KubeConfigOptions, Kubeconfig};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -32,6 +33,7 @@ pub struct TenantCredentialScore {
|
||||
namespace: K8sName,
|
||||
name: K8sName,
|
||||
rules: Vec<PolicyRule>,
|
||||
role_subjects: Vec<Subject>,
|
||||
#[serde(skip)]
|
||||
store: Arc<ConfigClient>,
|
||||
allow_insecure_source: bool,
|
||||
@@ -59,11 +61,17 @@ impl TenantCredentialScore {
|
||||
namespace,
|
||||
name,
|
||||
rules,
|
||||
role_subjects: Vec::new(),
|
||||
store,
|
||||
allow_insecure_source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_role_subjects(mut self, subjects: Vec<Subject>) -> Self {
|
||||
self.role_subjects = subjects;
|
||||
self
|
||||
}
|
||||
|
||||
fn service_account(&self) -> ServiceAccount {
|
||||
ServiceAccount {
|
||||
metadata: ObjectMeta {
|
||||
@@ -88,6 +96,8 @@ impl TenantCredentialScore {
|
||||
}
|
||||
|
||||
fn role_binding(&self) -> RoleBinding {
|
||||
let mut subjects = vec![self.subject()];
|
||||
subjects.extend(self.role_subjects.clone());
|
||||
RoleBinding {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(self.name.to_string()),
|
||||
@@ -99,7 +109,7 @@ impl TenantCredentialScore {
|
||||
kind: "Role".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,
|
||||
&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
|
||||
.store
|
||||
.set(&ClusterAccess { kubeconfig })
|
||||
.set(&access)
|
||||
.await
|
||||
.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!(
|
||||
"tenant deployer access stored for namespace '{}'",
|
||||
self.score.namespace
|
||||
"tenant deployer access stored for namespace '{}' on cluster '{}' at '{}'",
|
||||
self.score.namespace, connection.name, connection.server
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -317,13 +362,16 @@ fn tenant_kubeconfig(
|
||||
certificate_authority_data: &str,
|
||||
) -> Result<String, InterpretError> {
|
||||
let context = format!("{user}@{}", connection.name);
|
||||
let mut cluster = serde_json::Map::from_iter([
|
||||
("server".to_string(), connection.server.clone().into()),
|
||||
(
|
||||
let mut cluster =
|
||||
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.into(),
|
||||
),
|
||||
]);
|
||||
);
|
||||
} else {
|
||||
cluster.insert("insecure-skip-tls-verify".to_string(), true.into());
|
||||
}
|
||||
if let Some(name) = &connection.tls_server_name {
|
||||
cluster.insert("tls-server-name".to_string(), name.clone().into());
|
||||
}
|
||||
@@ -374,6 +422,27 @@ mod tests {
|
||||
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]
|
||||
fn score_serialization_excludes_config_destination() {
|
||||
let score = TenantCredentialScore::new(
|
||||
@@ -387,4 +456,25 @@ mod tests {
|
||||
let serialized = serde_json::to_string(&score).unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
932
harmony/src/modules/zitadel/contract.rs
Normal file
932
harmony/src/modules/zitadel/contract.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
pub mod admin_auth;
|
||||
pub mod contract;
|
||||
pub mod setup;
|
||||
|
||||
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::{
|
||||
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
|
||||
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelCredentialsExportScore,
|
||||
ZitadelHumanUser, ZitadelMachineUser, ZitadelMember, ZitadelRole, ZitadelScheme,
|
||||
ZitadelSetupScore, mint_device_credentials,
|
||||
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelContractSetupScore,
|
||||
ZitadelCredentialsExportScore, ZitadelHumanUser, ZitadelMachineUser, ZitadelMember,
|
||||
ZitadelRole, ZitadelScheme, ZitadelSetupScore, mint_device_credentials,
|
||||
};
|
||||
|
||||
use harmony_k8s::KubernetesDistribution;
|
||||
@@ -20,8 +32,7 @@ use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harmony_config::Config;
|
||||
use harmony_macros::hurl;
|
||||
use harmony_config::{Config, ConfigError, StateClient};
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use non_blank_string_rs::NonBlankString;
|
||||
@@ -32,7 +43,7 @@ use crate::{
|
||||
data::Version,
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
modules::helm::chart::{HelmChartScore, HelmRepository},
|
||||
modules::helm::chart::HelmChartScore,
|
||||
modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef},
|
||||
score::Score,
|
||||
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}"))
|
||||
}
|
||||
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -308,7 +348,13 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for ZitadelScore {
|
||||
|
||||
#[doc(hidden)]
|
||||
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(),
|
||||
zitadel_version: self.zitadel_version.clone(),
|
||||
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,
|
||||
database: self.database.clone(),
|
||||
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,
|
||||
database: Option<PostgreSQLRootAccountRef>,
|
||||
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]
|
||||
@@ -437,19 +527,24 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
||||
// emit a fresh random — misleading the operator). harmony_config
|
||||
// namespaces by install context (config-resolved), so two
|
||||
// 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,
|
||||
Err(e) => {
|
||||
debug!("[Zitadel] No persisted admin credentials yet ({e}); generating");
|
||||
Err(ConfigError::NotFound { .. }) => {
|
||||
debug!("[Zitadel] No persisted admin credentials yet; generating");
|
||||
let a = ZitadelAdmin {
|
||||
username: "admin".to_string(),
|
||||
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}"))
|
||||
})?;
|
||||
a
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to load Zitadel admin password: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let admin_username = admin.username.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.
|
||||
// Then mirror the resolved value into harmony_config so a deleted/
|
||||
// recreated namespace reuses it rather than minting a new (broken) key.
|
||||
let existing_masterkey = k8s_client
|
||||
.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 existing_masterkey = read_masterkey(&k8s_client, &self.namespace).await?;
|
||||
|
||||
let masterkey = match existing_masterkey {
|
||||
Some(k) => k,
|
||||
None => match harmony_config::get::<ZitadelMasterkey>().await {
|
||||
Ok(m) => m.masterkey,
|
||||
Err(e) => {
|
||||
debug!("[Zitadel] No persisted masterkey yet ({e}); generating");
|
||||
rng()
|
||||
.sample_iter(&rand::distr::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
}
|
||||
},
|
||||
let persisted_masterkey = match self.get_state::<ZitadelMasterkey>().await {
|
||||
Ok(value) => Some(value.masterkey),
|
||||
Err(ConfigError::NotFound { .. }) => None,
|
||||
Err(error) => {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to load Zitadel masterkey: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if harmony_config::get::<ZitadelMasterkey>().await.is_err() {
|
||||
harmony_config::set(&ZitadelMasterkey {
|
||||
masterkey: masterkey.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
InterpretError::new(format!("Failed to persist Zitadel masterkey: {e}"))
|
||||
})?;
|
||||
}
|
||||
let masterkey = existing_masterkey
|
||||
.or_else(|| persisted_masterkey.clone())
|
||||
.unwrap_or_else(|| {
|
||||
debug!("[Zitadel] No persisted masterkey yet; generating");
|
||||
rng()
|
||||
.sample_iter(&rand::distr::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"[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();
|
||||
masterkey_data.insert("masterkey".to_string(), ByteString(masterkey.into()));
|
||||
masterkey_data.insert(
|
||||
"masterkey".to_string(),
|
||||
ByteString(masterkey.clone().into()),
|
||||
);
|
||||
|
||||
let masterkey_secret = K8sSecret {
|
||||
metadata: ObjectMeta {
|
||||
@@ -540,7 +627,7 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
||||
..K8sSecret::default()
|
||||
};
|
||||
|
||||
match k8s_client
|
||||
let authoritative_masterkey = match k8s_client
|
||||
.create(&masterkey_secret, Some(&self.namespace))
|
||||
.await
|
||||
{
|
||||
@@ -549,12 +636,21 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
||||
"[Zitadel] Masterkey secret '{}' created",
|
||||
MASTERKEY_SECRET_NAME
|
||||
);
|
||||
masterkey
|
||||
}
|
||||
Err(KubeError::Api(ErrorResponse { code: 409, .. })) => {
|
||||
info!(
|
||||
"[Zitadel] Masterkey secret '{}' already exists, leaving it untouched",
|
||||
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) => {
|
||||
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!(
|
||||
"[Zitadel] Masterkey secret '{}' created successfully",
|
||||
MASTERKEY_SECRET_NAME
|
||||
@@ -976,15 +1082,17 @@ login:
|
||||
|
||||
// --- Step 6: Deploy Helm chart ------------------------------------
|
||||
|
||||
let chart_name =
|
||||
NonBlankString::from_str("oci://hub.nationtech.io/harmony/zitadel").unwrap();
|
||||
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
|
||||
);
|
||||
|
||||
let result = HelmChartScore {
|
||||
namespace: Some(NonBlankString::from_str(&self.namespace).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
|
||||
// server; with v4.12.x the login pod crashloops on
|
||||
// Token.Invalid. 9.27.1 is the last chart matching this
|
||||
@@ -1004,11 +1112,7 @@ login:
|
||||
// explicit action.)
|
||||
install_only: true,
|
||||
force_conflicts: false,
|
||||
repository: Some(HelmRepository::new(
|
||||
"zitadel".to_string(),
|
||||
hurl!("https://charts.zitadel.com"),
|
||||
true,
|
||||
)),
|
||||
repository: None,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
harmony = { path = "../harmony" }
|
||||
harmony-k8s = { path = "../harmony-k8s" }
|
||||
harmony_config = { path = "../harmony_config" }
|
||||
|
||||
@@ -137,7 +137,31 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
|
||||
app.validate_deploy_images(&options.images)?;
|
||||
let images = options.images.clone();
|
||||
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);
|
||||
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}")))?;
|
||||
|
||||
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 outcome = maestro
|
||||
.interpret(score)
|
||||
@@ -162,19 +187,14 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
|
||||
outcome.status, outcome.message
|
||||
)));
|
||||
}
|
||||
steps.push(StepOutcome {
|
||||
let step = StepOutcome {
|
||||
name,
|
||||
message: outcome.message,
|
||||
});
|
||||
};
|
||||
completed(&step, index + 1 < score_count)?;
|
||||
steps.push(step);
|
||||
}
|
||||
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,
|
||||
})
|
||||
Ok(steps)
|
||||
}
|
||||
|
||||
/// Build + publish, then deploy (ADR-026 §4).
|
||||
|
||||
1742
harmony_app/src/application/k8s_anywhere.rs
Normal file
1742
harmony_app/src/application/k8s_anywhere.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
harmony_app/src/application/mod.rs
Normal file
20
harmony_app/src/application/mod.rs
Normal 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;
|
||||
562
harmony_app/src/application/model.rs
Normal file
562
harmony_app/src/application/model.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
266
harmony_app/src/application/resources.rs
Normal file
266
harmony_app/src/application/resources.rs
Normal 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,
|
||||
}
|
||||
597
harmony_app/src/application/validation.rs
Normal file
597
harmony_app/src/application/validation.rs
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
use harmony::modules::tenant::ClusterAccess;
|
||||
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_types::context::{
|
||||
ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace,
|
||||
@@ -47,6 +47,9 @@ pub struct RemoteContext {
|
||||
pub repository: OciRepository,
|
||||
pub domain: DomainName,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ pub struct AppContext {
|
||||
kubeconfig: Option<PathBuf>,
|
||||
_kubeconfig_guard: Option<NamedTempFile>,
|
||||
config_client: Arc<ConfigClient>,
|
||||
state_client: StateClient,
|
||||
cluster_target: Option<String>,
|
||||
}
|
||||
|
||||
@@ -182,13 +186,15 @@ impl AppContext {
|
||||
Profile::from(&context.spec)
|
||||
);
|
||||
debug!("Context '{name}' definition: {:?}", context.spec);
|
||||
let config_sources = build_config_sources(&context.spec, local_config_dir.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ContextError::Config(format!("building config sources for context '{name}': {e}"))
|
||||
})?;
|
||||
harmony_config::init(config_sources.clone()).await;
|
||||
let config_client = Arc::new(ConfigClient::new(config_sources));
|
||||
let (config_client, state_client) =
|
||||
build_config_clients(&context.spec, local_config_dir.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ContextError::Config(format!(
|
||||
"building config sources for context '{name}': {e}"
|
||||
))
|
||||
})?;
|
||||
harmony_config::init_client(config_client.clone()).await;
|
||||
let (guard, cluster_target) = match &context.spec {
|
||||
ContextSpec::Local(LocalContext::ManagedK3d) => {
|
||||
info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')");
|
||||
@@ -226,17 +232,19 @@ impl AppContext {
|
||||
context.namespace
|
||||
);
|
||||
|
||||
Ok(Self::new(
|
||||
let mut context = Self::new(
|
||||
context,
|
||||
version.into(),
|
||||
local_config_dir,
|
||||
config_client,
|
||||
guard,
|
||||
cluster_target,
|
||||
))
|
||||
);
|
||||
context.state_client = state_client;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
fn new(
|
||||
pub(crate) fn new(
|
||||
context: &Context,
|
||||
version: String,
|
||||
local_config_dir: Option<PathBuf>,
|
||||
@@ -244,6 +252,7 @@ impl AppContext {
|
||||
guard: Option<NamedTempFile>,
|
||||
cluster_target: Option<String>,
|
||||
) -> Self {
|
||||
let state_client = StateClient::new(config_client.clone(), config_client.clone());
|
||||
Self {
|
||||
context: context.clone(),
|
||||
version,
|
||||
@@ -251,6 +260,7 @@ impl AppContext {
|
||||
kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()),
|
||||
_kubeconfig_guard: guard,
|
||||
config_client,
|
||||
state_client,
|
||||
cluster_target,
|
||||
}
|
||||
}
|
||||
@@ -282,6 +292,14 @@ impl AppContext {
|
||||
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 {
|
||||
match &self.context.spec {
|
||||
ContextSpec::Local(_) => {
|
||||
@@ -311,6 +329,12 @@ impl AppContext {
|
||||
pub fn config_client(&self) -> &ConfigClient {
|
||||
&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> {
|
||||
match &self.context.spec {
|
||||
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})"))
|
||||
}
|
||||
|
||||
async fn build_config_sources(
|
||||
async fn build_config_clients(
|
||||
spec: &ContextSpec,
|
||||
local_config_dir: Option<PathBuf>,
|
||||
) -> Result<Vec<Arc<dyn ConfigSource>>, ContextError> {
|
||||
let mut sources: Vec<Arc<dyn ConfigSource>> = Vec::new();
|
||||
|
||||
match spec {
|
||||
) -> Result<(Arc<ConfigClient>, StateClient), ContextError> {
|
||||
let source: Arc<dyn ConfigSource> = match spec {
|
||||
ContextSpec::Remote(remote) => {
|
||||
let access = &remote.access;
|
||||
let source = harmony_config::openbao_source(
|
||||
harmony_config::openbao_source(
|
||||
access.namespace.as_ref(),
|
||||
Some(access.url.to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(access.zitadel_url.to_string()),
|
||||
Some(access.zitadel_audience.to_string()),
|
||||
Some(access.role.to_string()),
|
||||
@@ -439,8 +463,7 @@ async fn build_config_sources(
|
||||
"reaching OpenBao for namespace '{}'",
|
||||
access.namespace
|
||||
))
|
||||
})?;
|
||||
sources.push(source);
|
||||
})?
|
||||
}
|
||||
ContextSpec::Local(_) => {
|
||||
let dir = local_config_dir
|
||||
@@ -448,12 +471,10 @@ async fn build_config_sources(
|
||||
.ok_or_else(|| {
|
||||
ContextError::Missing("local contexts need a config directory".to_string())
|
||||
})?;
|
||||
sources.push(Arc::new(LocalFileSource::new(dir)));
|
||||
Arc::new(LocalFileSource::new(dir))
|
||||
}
|
||||
}
|
||||
|
||||
sources.push(Arc::new(PromptSource::new()));
|
||||
Ok(sources)
|
||||
};
|
||||
Ok(harmony_config::clients_for_source(source))
|
||||
}
|
||||
|
||||
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile, ContextError> {
|
||||
@@ -506,6 +527,7 @@ mod tests {
|
||||
repository: "team/apps".parse().unwrap(),
|
||||
domain: "example.com".parse().unwrap(),
|
||||
image_pull_secret: Some("registry-auth".parse().unwrap()),
|
||||
object_storage_endpoint: None,
|
||||
access: OpenBaoClusterAccess {
|
||||
namespace: "team/prod".parse().unwrap(),
|
||||
url: "https://bao.example.com".parse().unwrap(),
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
//! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves
|
||||
//! its credentials and runtime state. The verbs converge the same Scores for
|
||||
//! 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 application;
|
||||
pub mod capabilities;
|
||||
pub mod chart;
|
||||
pub mod compose;
|
||||
@@ -24,10 +28,19 @@ pub mod error;
|
||||
pub mod profile;
|
||||
pub mod publish;
|
||||
pub mod score;
|
||||
pub mod tenant;
|
||||
|
||||
pub use app::{
|
||||
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 chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image};
|
||||
@@ -39,9 +52,14 @@ pub use context::{
|
||||
pub use deploy::ComposeDeploy;
|
||||
pub use error::{AppError, ContextError, ImageError};
|
||||
pub use harmony::modules::tenant::ClusterAccess;
|
||||
pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy};
|
||||
pub use profile::Profile;
|
||||
pub use publish::{
|
||||
ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials,
|
||||
is_digest_pinned,
|
||||
RegistryPullCredentials, is_digest_pinned,
|
||||
};
|
||||
pub use score::{ComposeAppScore, PublicEndpoint};
|
||||
pub use tenant::{
|
||||
provision_application_tenant_on_context_with_progress,
|
||||
provision_application_tenant_with_kubeconfig,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,13 @@ pub struct RegistryCredentials {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema, Config)]
|
||||
#[config(secret)]
|
||||
pub struct RegistryPullCredentials {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageSpec {
|
||||
pub name: String,
|
||||
@@ -618,4 +625,9 @@ mod tests {
|
||||
assert_eq!(credentials.username, "publisher");
|
||||
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
237
harmony_app/src/tenant.rs
Normal 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"))
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ license.workspace = true
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
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
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -5,14 +5,19 @@ use std::{
|
||||
|
||||
use crate::{
|
||||
Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection,
|
||||
ConnectionStatus, Identity, IdentityAccess, IdentityFilter, IdentityKind, IdentityWithAccess,
|
||||
ImportedAccess, JwtRole, OpenBaoPolicy, RemovalPlan, Scope, TenantSummary,
|
||||
ConnectionStatus, DeployerCreateRequest, DeployerCreateResult, Identity, IdentityAccess,
|
||||
IdentityFilter, IdentityKind, IdentityWithAccess, ImportedAccess, JwtRole, OpenBaoPolicy,
|
||||
ProvisionStep, RemovalPlan, Scope, TenantAuthConfig, TenantCapability, TenantCreateRequest,
|
||||
TenantCreateResult, TenantDefinition, TenantSummary, valid_slug,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde_json::{Map, Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use harmony_secret::OpenBaoPolicyManager;
|
||||
use harmony_zitadel_auth::management::ManagementClient;
|
||||
|
||||
pub struct BackendAuth {
|
||||
client: Client,
|
||||
zitadel_url: String,
|
||||
@@ -33,6 +38,12 @@ struct PolicyDetails {
|
||||
body: String,
|
||||
}
|
||||
|
||||
struct ValidatedTenantAuth {
|
||||
zitadel: ManagementClient,
|
||||
project: harmony_zitadel_auth::management::Project,
|
||||
openbao_jwt_role: String,
|
||||
}
|
||||
|
||||
impl BackendAuth {
|
||||
pub fn new(
|
||||
zitadel_url: String,
|
||||
@@ -57,6 +68,426 @@ impl BackendAuth {
|
||||
&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> {
|
||||
let status = self.connection_status().await;
|
||||
match (status.zitadel.connected, status.openbao.connected) {
|
||||
@@ -151,16 +582,16 @@ impl BackendAuth {
|
||||
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
|
||||
.json("auth/jwt/role?list=true")
|
||||
.json(&format!("auth/{mount}/role?list=true"))
|
||||
.await?
|
||||
.map_or_else(Vec::new, |data| strings(&data["keys"])))
|
||||
}
|
||||
|
||||
async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> {
|
||||
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? {
|
||||
let subject_id = role_subject(&raw);
|
||||
if !subject_id.is_empty() {
|
||||
@@ -245,6 +676,15 @@ impl BackendAuth {
|
||||
.await?
|
||||
.error_for_status()
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use harmony_secret::render_tenant_policy;
|
||||
use harmony_types::k8s_name::K8sName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
@@ -153,6 +155,222 @@ pub struct TenantSummary {
|
||||
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)]
|
||||
pub struct IdentityWithAccess {
|
||||
pub identity: Identity,
|
||||
@@ -383,4 +601,51 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,18 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
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
|
||||
inquire.workspace = true
|
||||
schemars = "0.8"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,12 @@ pub struct ConfigClient {
|
||||
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 {
|
||||
pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self {
|
||||
Self { sources }
|
||||
@@ -151,6 +157,15 @@ impl ConfigClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
if let Some(value) = source.get(T::CLASS, T::KEY).await? {
|
||||
// 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.
|
||||
match serde_json::from_value::<T>(value) {
|
||||
Ok(config) => return Ok(config),
|
||||
Err(e) => warn!(
|
||||
"Stale value for key {} in source; falling through ({e})",
|
||||
T::KEY
|
||||
),
|
||||
Err(source) => {
|
||||
warn!(
|
||||
"Stale value for key {} in source; falling through ({source})",
|
||||
T::KEY
|
||||
);
|
||||
invalid = Some(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ConfigError::NotFound {
|
||||
key: T::KEY.to_string(),
|
||||
})
|
||||
match (invalid, preserve_invalid) {
|
||||
(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> {
|
||||
@@ -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.
|
||||
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
|
||||
@@ -243,6 +392,8 @@ async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
|
||||
pub async fn openbao_source(
|
||||
namespace: &str,
|
||||
openbao_url: Option<String>,
|
||||
openbao_token: Option<String>,
|
||||
openbao_kv_mount: Option<String>,
|
||||
zitadel_sso_url: Option<String>,
|
||||
zitadel_audience: 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 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 token = openbao_token.or_else(|| env("OPENBAO_TOKEN"));
|
||||
|
||||
// Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine
|
||||
// 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 inline_machine_identity = zitadel_jwt_bearer.as_ref().is_some_and(|config| {
|
||||
config
|
||||
.key_json
|
||||
.as_deref()
|
||||
.is_some_and(|key| !key.trim().is_empty())
|
||||
}) && jwt_role.is_some();
|
||||
let inline_machine_identity = token.is_none()
|
||||
&& zitadel_jwt_bearer.as_ref().is_some_and(|config| {
|
||||
config
|
||||
.key_json
|
||||
.as_deref()
|
||||
.is_some_and(|key| !key.trim().is_empty())
|
||||
})
|
||||
&& jwt_role.is_some();
|
||||
let store = if inline_machine_identity {
|
||||
match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) {
|
||||
(Some(config), Some(role)) => {
|
||||
@@ -313,7 +469,7 @@ pub async fn openbao_source(
|
||||
kv_mount,
|
||||
auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()),
|
||||
skip_tls,
|
||||
token: env("OPENBAO_TOKEN"),
|
||||
token,
|
||||
username: env("OPENBAO_USERNAME"),
|
||||
password: env("OPENBAO_PASSWORD"),
|
||||
zitadel_sso_url: sso_url,
|
||||
@@ -328,7 +484,7 @@ pub async fn openbao_source(
|
||||
match store {
|
||||
Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))),
|
||||
Err(e) => {
|
||||
warn!("OpenBao unreachable ({e}); source omitted from chain");
|
||||
warn!("OpenBao source unavailable ({e}); source omitted from chain");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -337,8 +493,12 @@ pub async fn openbao_source(
|
||||
static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None);
|
||||
|
||||
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;
|
||||
*manager = Some(Arc::new(ConfigClient::new(sources)));
|
||||
*manager = Some(client);
|
||||
}
|
||||
|
||||
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
|
||||
/// fields it was asked for, so a test can assert only the missing fields
|
||||
/// were prompted.
|
||||
@@ -1010,19 +1263,44 @@ mod tests {
|
||||
source
|
||||
.set(
|
||||
ConfigClass::Standard,
|
||||
"TestConfig",
|
||||
"state/TestConfig",
|
||||
&serde_json::to_value(&config).unwrap(),
|
||||
)
|
||||
.await
|
||||
.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 parsed: TestConfig = serde_json::from_str(&contents).unwrap();
|
||||
|
||||
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]
|
||||
async fn test_sqlite_set_and_get() {
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -1389,6 +1667,22 @@ mod tests {
|
||||
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]
|
||||
async fn test_full_chain_with_prompt_source_falls_through_to_prompt() {
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -1423,7 +1717,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_source_error_falls_through_to_sqlite() {
|
||||
async fn test_store_source_error_stops_the_chain() {
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
@@ -1449,9 +1743,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result: TestConfig = manager.get().await.unwrap();
|
||||
assert_eq!(result.name, "from_sqlite");
|
||||
assert_eq!(result.count, 42);
|
||||
let result: Result<TestConfig, ConfigError> = manager.get().await;
|
||||
assert!(matches!(result, Err(ConfigError::StoreError(_))));
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{ConfigClass, ConfigError, ConfigSource};
|
||||
|
||||
@@ -52,19 +54,45 @@ impl ConfigSource for LocalFileSource {
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
_class: ConfigClass,
|
||||
class: ConfigClass,
|
||||
key: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<(), ConfigError> {
|
||||
fs::create_dir_all(&self.base_path).await?;
|
||||
|
||||
let path = self.file_path_for(key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let contents =
|
||||
serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization {
|
||||
key: key.to_string(),
|
||||
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?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::{ConfigClass, ConfigError, ConfigSource};
|
||||
use async_trait::async_trait;
|
||||
use harmony_secret::SecretStore;
|
||||
use log::warn;
|
||||
|
||||
use crate::{ConfigClass, ConfigError, ConfigSource};
|
||||
|
||||
pub struct StoreSource<S> {
|
||||
namespace: String,
|
||||
@@ -34,14 +32,7 @@ impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> {
|
||||
Ok(Some(value))
|
||||
}
|
||||
Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None),
|
||||
// Log before swallowing: a down/misconfigured OpenBao must not look identical to "key absent".
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"StoreSource: get for key '{key}' failed ({e}); treating as \
|
||||
absent and falling through to the next source"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(ConfigError::StoreError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,37 +6,23 @@
|
||||
//! the role's `groups_claim`, so attaching/detaching the policy binds
|
||||
//! for every member's existing tokens at request time — O(groups)
|
||||
//! 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 reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use harmony_reconciler_contracts::{
|
||||
DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError,
|
||||
validate_image_pull_secret_reference,
|
||||
};
|
||||
|
||||
use crate::OpenBaoPolicyManager;
|
||||
|
||||
const JWT_AUTH_MOUNT: &str = "jwt";
|
||||
|
||||
pub struct OpenBaoDeploymentSecretGrants {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
policies: OpenBaoPolicyManager,
|
||||
kv_mount: String,
|
||||
/// Path prefix under the KV mount where the fleet's secrets live
|
||||
/// (`<kv_mount>/data/<prefix>/<deployment>/…`).
|
||||
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 {
|
||||
@@ -58,20 +44,12 @@ impl OpenBaoDeploymentSecretGrants {
|
||||
jwt_mount: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
policies: OpenBaoPolicyManager::new(base_url, token, jwt_mount),
|
||||
kv_mount,
|
||||
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 {
|
||||
format!("deployment-{}", deployment.as_str())
|
||||
}
|
||||
@@ -103,243 +81,6 @@ path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#,
|
||||
}
|
||||
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]
|
||||
@@ -350,20 +91,12 @@ impl DeploymentSecretGrants for OpenBaoDeploymentSecretGrants {
|
||||
) -> Result<(), SecretAccessError> {
|
||||
for (deployment, groups, image_pull_secrets) in grants {
|
||||
let policy = Self::policy_name(deployment);
|
||||
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, &policy).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?;
|
||||
}
|
||||
let policy_hcl = (!groups.is_empty())
|
||||
.then(|| self.policy_hcl(deployment, image_pull_secrets))
|
||||
.transpose()?;
|
||||
self.policies
|
||||
.reconcile_acl_policy(&policy, policy_hcl.as_deref(), groups)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod config;
|
||||
mod deployment_grants;
|
||||
mod openbao_policy;
|
||||
pub mod store;
|
||||
|
||||
use crate::config::SECRET_NAMESPACE;
|
||||
@@ -33,6 +34,7 @@ use tokio::sync::OnceCell;
|
||||
|
||||
pub use deployment_grants::OpenBaoDeploymentSecretGrants;
|
||||
pub use harmony_secret_derive::Secret;
|
||||
pub use openbao_policy::{HARMONY_STATE_SUBPATH, OpenBaoPolicyManager, render_tenant_policy};
|
||||
|
||||
// The Secret trait remains the same.
|
||||
// pub trait Secret: Serialize + DeserializeOwned + Sized {
|
||||
|
||||
343
harmony_secret/src/openbao_policy.rs
Normal file
343
harmony_secret/src/openbao_policy.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -87,9 +87,9 @@ impl OpenbaoSecretStore {
|
||||
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 {
|
||||
debug!("OPENBAO_STORE: Using token from environment variable");
|
||||
debug!("OPENBAO_STORE: Using supplied token");
|
||||
return Self::with_token(
|
||||
&options.base_url,
|
||||
options.skip_tls,
|
||||
|
||||
@@ -28,8 +28,12 @@ tokio = { workspace = true, features = ["time"] }
|
||||
arc-swap = "1"
|
||||
time = "0.3"
|
||||
tracing = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
|
||||
jsonwebtoken = "9"
|
||||
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
|
||||
axum = { version = "0.8", optional = true }
|
||||
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
httptest = "0.16"
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod config;
|
||||
mod device_groups;
|
||||
pub mod jwks;
|
||||
pub mod login;
|
||||
pub mod management;
|
||||
pub mod session;
|
||||
|
||||
pub use config::{OperatorCookieKey, ZitadelAuthConfig};
|
||||
|
||||
790
harmony_zitadel_auth/src/management.rs
Normal file
790
harmony_zitadel_auth/src/management.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user