Skip to main content

greentic_deployer/cli/
secrets.rs

1//! `gtc op secrets {list,put,get,rotate}` (`A3`).
2//!
3//! Operates on the env's bound `Secrets` env-pack. The actual backend
4//! dispatch (AWS Secrets Manager, Azure Key Vault, dev-store, Vault, etc.)
5//! lives in `greentic-secrets-lib`; the env-pack registry (A9) is what binds
6//! a `PackDescriptor` to a concrete backend at runtime. A3 ships the
7//! command surface, enforces the env-must-have-secrets-pack precondition,
8//! and reports the resolved kind in every envelope.
9//!
10//! `put` is live for the `greentic.secrets.dev-store` kind (the default
11//! binding `op env init` creates): it writes the value into the env's local
12//! dev store at the same path the runtime reader (greentic-start
13//! `SecretsClient::open(<env_dir>)`) resolves, so a put is immediately
14//! visible to served revisions. All other kinds — and get/rotate against any
15//! live backend — return `NotYetImplemented` and point at the gating PR
16//! (A9 — env-pack registry + handler dispatch).
17//! `list` returns the *namespace* keys the env owns (always `secret://<env>/...`)
18//! — no actual material is fetched.
19
20use std::path::{Path, PathBuf};
21
22use chrono::Utc;
23use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, Environment, SecretRef};
24use greentic_secrets_lib::{DevStore, SecretFormat, SecretsStore, canonical_secret_store_key};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use crate::environment::{EnvFlock, EnvironmentStore, LocalFsStore};
29
30use super::{
31    AuditCtx, AuditGens, OpError, OpFlags, OpOutcome, audit_and_record, resolve_idempotency_key,
32};
33
34const NOUN: &str = "secrets";
35
36/// `PackDescriptor::path()` of the local dev-store secrets backend — the
37/// default binding `op env init` creates and the only kind `put` dispatches
38/// to in Phase A. Shared with `env apply` (PR-2), which pre-checks the bound
39/// backend at validation time so a non-dev-store env fails before any
40/// mutation instead of mid-run.
41pub(super) const DEV_STORE_KIND_PATH: &str = "greentic.secrets.dev-store";
42
43/// Same override the runtime reader honors (`greentic-start
44/// `dev_store_path::override_path`): when set, both writer and reader use
45/// this path instead of the env-dir defaults below. `pub(crate)` so the P0b
46/// snapshot can note when the dev-store is redirected off the env tree.
47pub(crate) const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
48
49/// Dev-store candidates relative to the env dir. MUST mirror greentic-start's
50/// `dev_store_path.rs` (`STORE_RELATIVE` / `STORE_STATE_RELATIVE`) — the
51/// runtime's `SecretsClient::open(<env_dir>)` resolves the same chain, so a
52/// put here is what a served revision reads back. `pub(crate)` so the P0b
53/// snapshot (`environment::snapshot`) captures the same paths this writes.
54pub(crate) const DEV_STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
55pub(crate) const DEV_STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SecretsListPayload {
59    pub environment_id: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SecretsPutPayload {
64    pub environment_id: String,
65    /// Path relative to the env's secret namespace. The full SecretRef is
66    /// rendered as `secret://<env>/<path>`.
67    pub path: String,
68    /// The value is intentionally typed as a plain JSON string so payload
69    /// transport stays uniform; the live backend handler (A9) is what reads
70    /// this and converts to the backend-native shape.
71    pub value: String,
72    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
73    /// surface; when absent, the verb mints one per invocation.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub idempotency_key: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SecretsGetPayload {
80    pub environment_id: String,
81    pub path: String,
82    /// When true, the decrypted value is included in the outcome envelope.
83    /// Default false — only presence + metadata is returned, so a `get` does
84    /// not leak the value into CI logs / audit trails.
85    #[serde(default)]
86    pub reveal: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SecretsRotatePayload {
91    pub environment_id: String,
92    pub path: String,
93}
94
95/// `op secrets list`. Returns the env's secret-ref namespace plus the kind
96/// of the bound secrets env-pack. Phase A does not yet enumerate live
97/// backend-side keys (no handler dispatch); the operator gets the namespace
98/// plus backend identity, which is what wizards need to know to write into
99/// the right place.
100pub fn list(
101    store: &LocalFsStore,
102    flags: &OpFlags,
103    payload: Option<SecretsListPayload>,
104) -> Result<OpOutcome, OpError> {
105    if flags.schema_only {
106        return Ok(OpOutcome::new(NOUN, "list", list_schema()));
107    }
108    let payload = resolve_payload::<SecretsListPayload>(flags, payload)?;
109    let env_id = parse_env_id(&payload.environment_id)?;
110    let env = store.load(&env_id)?;
111    let secrets = require_secrets_pack(&env, &env_id)?;
112    // Walk every SecretRef known in the env so the operator can audit what
113    // the env *expects* to be present. This is purely structural — the
114    // backend itself may have more or fewer keys.
115    let mut known_refs: Vec<String> = env
116        .credentials_ref
117        .as_ref()
118        .map(|c| c.as_str().to_string())
119        .into_iter()
120        .collect();
121    if let Some(bs) = env
122        .bundles
123        .iter()
124        .map(|b| b.authorization_ref.to_string_lossy().into_owned())
125        .next()
126    {
127        // authorization_ref is a path, not a secret://, but include it for
128        // visibility into where bundle auth resolves.
129        known_refs.push(format!("auth://{bs}"));
130    }
131    Ok(OpOutcome::new(
132        NOUN,
133        "list",
134        json!({
135            "environment_id": env_id.as_str(),
136            "secrets_kind": secrets.kind.to_string(),
137            "namespace": format!("secret://{}/", env_id.as_str()),
138            "known_refs": known_refs,
139            "snapshot_at": Utc::now(),
140            "note": "Phase A: namespace + known-refs only; live backend enumeration lands in A9.",
141        }),
142    ))
143}
144
145pub fn put(
146    store: &LocalFsStore,
147    flags: &OpFlags,
148    payload: Option<SecretsPutPayload>,
149) -> Result<OpOutcome, OpError> {
150    if flags.schema_only {
151        return Ok(OpOutcome::new(NOUN, "put", put_schema()));
152    }
153    let payload = resolve_payload::<SecretsPutPayload>(flags, payload)?;
154    let env_id = parse_env_id(&payload.environment_id)?;
155    let idempotency_key = resolve_idempotency_key(payload.idempotency_key.clone())?;
156    let ctx = AuditCtx {
157        env_id: env_id.clone(),
158        noun: NOUN,
159        verb: "put",
160        target: json!({"path": payload.path}),
161        idempotency_key: Some(idempotency_key.as_str().to_string()),
162    };
163    audit_and_record(store, ctx, |_committed| {
164        let env = store.load(&env_id)?;
165        let secrets = require_secrets_pack(&env, &env_id)?;
166        let rel_path = payload.path.trim_start_matches('/');
167        // Build the resolved SecretRef so we can validate the env-scoping.
168        let secret_uri = format!("secret://{}/{rel_path}", env_id.as_str());
169        SecretRef::try_new(secret_uri.clone())
170            .map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
171        // Make sure the value is non-empty — writing empty strings to a real
172        // backend is almost always a bug.
173        if payload.value.is_empty() {
174            return Err(OpError::InvalidArgument(
175                "value must not be empty".to_string(),
176            ));
177        }
178        let kind_path = secrets.kind.path();
179        let (store_uri, extra) =
180            put_env_secret(store, &env, &env_id, kind_path, rel_path, &payload.value)?;
181        // Preserve the pre-extraction field order (backend-specific field before
182        // `written`): base identity fields, then the backend `extra`, then the
183        // `written` flag.
184        let mut result = json!({
185            "environment_id": env_id.as_str(),
186            "secret_ref": secret_uri,
187            "store_uri": store_uri,
188            "secrets_kind": secrets.kind.to_string(),
189        });
190        if let (Value::Object(result_map), Value::Object(extra_map)) = (&mut result, extra) {
191            result_map.extend(extra_map);
192        }
193        result["written"] = Value::Bool(true);
194        Ok((OpOutcome::new(NOUN, "put", result), AuditGens::NONE))
195    })
196}
197
198/// `op secrets get`. Reads a secret back for the dev-store and Vault backends
199/// (symmetric to [`put`]); other kinds return `NotYetImplemented` (A9). Reads
200/// are not audited (matching [`list`]). By default only presence + metadata is
201/// returned; `reveal: true` includes the decrypted value.
202pub fn get(
203    store: &LocalFsStore,
204    flags: &OpFlags,
205    payload: Option<SecretsGetPayload>,
206) -> Result<OpOutcome, OpError> {
207    if flags.schema_only {
208        return Ok(OpOutcome::new(NOUN, "get", get_schema()));
209    }
210    let payload = resolve_payload::<SecretsGetPayload>(flags, payload)?;
211    let env_id = parse_env_id(&payload.environment_id)?;
212    let env = store.load(&env_id)?;
213    let secrets = require_secrets_pack(&env, &env_id)?;
214    let rel_path = payload.path.trim_start_matches('/');
215    let secret_uri = format!("secret://{}/{rel_path}", env_id.as_str());
216    SecretRef::try_new(secret_uri.clone())
217        .map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
218
219    let kind = secrets.kind.to_string();
220    let kind_path = secrets.kind.path();
221    let (value, store_uri, extra) = get_env_secret(store, &env, &env_id, kind_path, rel_path)?;
222    Ok(OpOutcome::new(
223        NOUN,
224        "get",
225        get_result_json(
226            env_id.as_str(),
227            &secret_uri,
228            &store_uri,
229            &kind,
230            extra,
231            value,
232            payload.reveal,
233        ),
234    ))
235}
236
237pub fn rotate(
238    store: &LocalFsStore,
239    flags: &OpFlags,
240    payload: Option<SecretsRotatePayload>,
241) -> Result<OpOutcome, OpError> {
242    if flags.schema_only {
243        return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
244    }
245    let payload = resolve_payload::<SecretsRotatePayload>(flags, payload)?;
246    let env_id = parse_env_id(&payload.environment_id)?;
247    let ctx = AuditCtx {
248        env_id: env_id.clone(),
249        noun: NOUN,
250        verb: "rotate",
251        target: json!({"path": payload.path}),
252        idempotency_key: None,
253    };
254    audit_and_record(store, ctx, |_committed| {
255        let env = store.load(&env_id)?;
256        let _secrets = require_secrets_pack(&env, &env_id)?;
257        SecretRef::try_new(format!(
258            "secret://{}/{}",
259            env_id.as_str(),
260            payload.path.trim_start_matches('/')
261        ))
262        .map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
263        Err(OpError::NotYetImplemented(
264            "secret rotation depends on backend-specific rotate hooks; lands in A9".to_string(),
265        ))
266    })
267}
268
269// --- internals -----------------------------------------------------------
270
271/// Persist `value` at `rel_path` (`<tenant>/<team>/<pack>/<name>`) into the
272/// env's configured secrets backend, dispatching on `kind_path` (dev-store or
273/// Vault). Returns `(store_uri, backend_extra)` where `backend_extra` is the
274/// backend-identifying JSON fragment for the op outcome (`store_path` for the
275/// dev store, `vault_addr` for Vault). Shared by `op secrets put` and
276/// `op updates enroll` so the two write surfaces cannot drift.
277pub(super) fn put_env_secret(
278    store: &LocalFsStore,
279    env: &Environment,
280    env_id: &EnvId,
281    kind_path: &str,
282    rel_path: &str,
283    value: &str,
284) -> Result<(String, Value), OpError> {
285    if kind_path == DEV_STORE_KIND_PATH {
286        validate_dev_store_secret_path(rel_path)?;
287        let store_uri = format!("secrets://{}/{rel_path}", env_id.as_str());
288        let dev_path = resolve_dev_store_path(
289            &store.env_dir(env_id)?,
290            std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
291        );
292        dev_store_put(&dev_path, &store_uri, value)?;
293        Ok((
294            store_uri,
295            json!({"store_path": dev_path.display().to_string()}),
296        ))
297    } else if kind_path == crate::defaults::VAULT_SECRETS_PATH {
298        // Same ref shape as the dev store; the difference is the backend.
299        validate_dev_store_secret_path(rel_path)?;
300        let store_uri = format!("secrets://{}/{rel_path}", env_id.as_str());
301        let vault_addr = vault_seed_put(store, env, &store_uri, value)?;
302        Ok((store_uri, json!({"vault_addr": vault_addr})))
303    } else {
304        Err(OpError::NotYetImplemented(
305            "secrets backend dispatch beyond the dev-store and Vault lands in A9 \
306             (env-pack registry)"
307                .to_string(),
308        ))
309    }
310}
311
312/// Read the value at `rel_path` back from the env's configured secrets backend,
313/// dispatching on `kind_path`. Returns `(value, store_uri, backend_extra)`;
314/// `value` is `None` when the key is absent. Counterpart to [`put_env_secret`];
315/// shared by `op secrets get` and `op updates status`.
316pub(super) fn get_env_secret(
317    store: &LocalFsStore,
318    env: &Environment,
319    env_id: &EnvId,
320    kind_path: &str,
321    rel_path: &str,
322) -> Result<(Option<String>, String, Value), OpError> {
323    if kind_path == DEV_STORE_KIND_PATH {
324        validate_dev_store_secret_path(rel_path)?;
325        let store_uri = format!("secrets://{}/{rel_path}", env_id.as_str());
326        let dev_path = resolve_dev_store_path(
327            &store.env_dir(env_id)?,
328            std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
329        );
330        // A missing store file means nothing was ever written for this env —
331        // absence, not an error (mirrors `dev_store_has`'s existence guard).
332        let value = if dev_path.exists() {
333            dev_store_get_value(&dev_path, &store_uri)?
334        } else {
335            None
336        };
337        Ok((
338            value,
339            store_uri,
340            json!({"store_path": dev_path.display().to_string()}),
341        ))
342    } else if kind_path == crate::defaults::VAULT_SECRETS_PATH {
343        validate_dev_store_secret_path(rel_path)?;
344        let store_uri = format!("secrets://{}/{rel_path}", env_id.as_str());
345        let (value, vault_addr) = vault_seed_get(store, env, &store_uri)?;
346        Ok((value, store_uri, json!({"vault_addr": vault_addr})))
347    } else {
348        Err(OpError::NotYetImplemented(
349            "secrets backend dispatch beyond the dev-store and Vault lands in A9 \
350             (env-pack registry)"
351                .to_string(),
352        ))
353    }
354}
355
356/// Build the `get` outcome body: identity fields + a `present` flag, plus the
357/// decrypted value only when `reveal` is set (so a non-revealing `get` never
358/// puts material into logs/audit). `extra` carries the backend-specific field
359/// (`store_path` for dev-store, `vault_addr` for Vault).
360fn get_result_json(
361    env_id: &str,
362    secret_ref: &str,
363    store_uri: &str,
364    secrets_kind: &str,
365    extra: Value,
366    value: Option<String>,
367    reveal: bool,
368) -> Value {
369    let mut body = json!({
370        "environment_id": env_id,
371        "secret_ref": secret_ref,
372        "store_uri": store_uri,
373        "secrets_kind": secrets_kind,
374        "present": value.is_some(),
375    });
376    if let Value::Object(extra_map) = extra
377        && let Value::Object(map) = &mut body
378    {
379        map.extend(extra_map);
380    }
381    if reveal && let Some(v) = value {
382        body["value"] = Value::String(v);
383    }
384    body
385}
386
387/// Seed a Vault-backed secret through the embedded [`SecretsCore`]: the value is
388/// envelope-encrypted via `transit/encrypt` and written to the KV record the
389/// worker reads back (a raw `vault kv put` would not produce that envelope, so
390/// the runtime could not decrypt it).
391///
392/// The Vault *connection* is assembled from two sources. The env's Vault binding
393/// supplies the non-secret mounts/prefix/transit, so the seeded path matches
394/// exactly what the worker reads. The operator's ambient environment supplies the
395/// admin credential (`VAULT_TOKEN`, which must hold `transit/encrypt` + KV write)
396/// and a reachable `VAULT_ADDR` — seeding runs from the operator host, not the
397/// pod, so it authenticates with a token rather than the pod's Kubernetes-role
398/// identity. The provider exposes only an env-driven `build_backend()` and this
399/// crate is `#![forbid(unsafe_code)]`, so the deployer cannot inject the binding's
400/// mounts into the process env; it instead fails closed when the ambient env would
401/// not resolve to the binding's values. Returns the Vault address used.
402fn vault_seed_put(
403    store: &LocalFsStore,
404    env: &Environment,
405    store_uri: &str,
406    value: &str,
407) -> Result<String, OpError> {
408    use crate::env_packs::k8s::manifests::SecretsBackend;
409    use greentic_secrets_lib::core::{CoreBuilder, rt};
410
411    // A Vault-backed env is single-tenant at the runtime (greentic-start scopes
412    // one SecretsCore to the env owner and fails closed otherwise), so seeding
413    // requires an owner and writes under it.
414    let tenant = env
415        .host_config
416        .tenant_org_id
417        .clone()
418        .filter(|t| !t.trim().is_empty())
419        .ok_or_else(|| {
420            OpError::InvalidArgument(
421                "a Vault-backed env must be tenant-owned before seeding; set the owner with \
422                 `op env update <env> --tenant-org <tenant>`"
423                    .to_string(),
424            )
425        })?;
426
427    // Non-secret connection config (mounts/prefix/transit) from the env binding.
428    let SecretsBackend::Vault(vault) = super::env::resolve_secrets_backend(store, env)? else {
429        return Err(OpError::Conflict(
430            "env secrets binding is not Vault-backed".to_string(),
431        ));
432    };
433
434    // Admin credential + reachable address come from the operator's environment.
435    // The address is intentionally NOT matched against the binding's `addr`: the
436    // binding holds the in-cluster service DNS the worker pod dials, which the
437    // operator host generally cannot reach — it seeds via a port-forward or
438    // ingress. The seeded address is returned in the outcome for visibility, and
439    // a wrong target surfaces loudly as a missing-secret read at runtime.
440    if std::env::var("VAULT_TOKEN")
441        .map(|t| t.trim().is_empty())
442        .unwrap_or(true)
443    {
444        return Err(OpError::InvalidArgument(
445            "seeding a Vault-backed secret needs an admin `VAULT_TOKEN` (with `transit/encrypt` \
446             and KV write) exported in the environment"
447                .to_string(),
448        ));
449    }
450    let addr = match std::env::var("VAULT_ADDR") {
451        Ok(a) if !a.trim().is_empty() => a,
452        _ => {
453            return Err(OpError::InvalidArgument(
454                "seeding a Vault-backed secret needs `VAULT_ADDR` exported (a Vault address \
455                 reachable from here, e.g. a port-forward to the in-cluster Vault)"
456                    .to_string(),
457            ));
458        }
459    };
460
461    // The seed must land where the worker reads: `build_backend()` takes the
462    // mounts/prefix/transit/namespace from ambient env (or provider defaults), and
463    // this crate cannot set them, so fail closed when the operator's ambient env
464    // would not resolve to the binding's path-determining values.
465    vault_seed_path_consistency(&vault, |var| {
466        std::env::var(var).ok().and_then(|v| {
467            let trimmed = v.trim();
468            (!trimmed.is_empty()).then(|| trimmed.to_string())
469        })
470    })?;
471
472    // Construct the embedded core over the env-driven Vault backend and write the
473    // value verbatim (the broker envelope-encrypts). Driven through the secrets
474    // runtime so the async backend runs from this synchronous verb.
475    rt::sync_await(async {
476        let components = greentic_secrets_lib::vault::build_backend()
477            .await
478            .map_err(|e| OpError::Conflict(format!("vault backend init failed: {e}")))?;
479        let core = CoreBuilder::default()
480            .tenant(tenant.as_str())
481            .backend(components.backend, components.key_provider)
482            .build()
483            .await
484            .map_err(|e| OpError::Conflict(format!("vault secrets core build failed: {e}")))?;
485        core.put_text(store_uri, value)
486            .await
487            .map_err(|e| OpError::Conflict(format!("vault put failed: {e}")))?;
488        Ok::<(), OpError>(())
489    })?;
490
491    Ok(addr)
492}
493
494/// Read a Vault-backed secret back through the embedded [`SecretsCore`] — the
495/// counterpart to [`vault_seed_put`]. Assembles the same connection (binding
496/// mounts + ambient `VAULT_TOKEN`/`VAULT_ADDR`, with the same path-consistency
497/// guard) and `get_text`s the store URI; the broker `transit/decrypt`s the
498/// envelope. Returns `(Some(plaintext), addr)` when present, `(None, addr)`
499/// when the key is absent. The admin `VAULT_TOKEN` must hold `transit/decrypt`
500/// + KV read.
501fn vault_seed_get(
502    store: &LocalFsStore,
503    env: &Environment,
504    store_uri: &str,
505) -> Result<(Option<String>, String), OpError> {
506    use crate::env_packs::k8s::manifests::SecretsBackend;
507    use greentic_secrets_lib::core::{CoreBuilder, Error as CoreError, SecretsError, rt};
508
509    let tenant = env
510        .host_config
511        .tenant_org_id
512        .clone()
513        .filter(|t| !t.trim().is_empty())
514        .ok_or_else(|| {
515            OpError::InvalidArgument(
516                "a Vault-backed env must be tenant-owned before reading; set the owner with \
517                 `op env update <env> --tenant-org <tenant>`"
518                    .to_string(),
519            )
520        })?;
521
522    let SecretsBackend::Vault(vault) = super::env::resolve_secrets_backend(store, env)? else {
523        return Err(OpError::Conflict(
524            "env secrets binding is not Vault-backed".to_string(),
525        ));
526    };
527
528    if std::env::var("VAULT_TOKEN")
529        .map(|t| t.trim().is_empty())
530        .unwrap_or(true)
531    {
532        return Err(OpError::InvalidArgument(
533            "reading a Vault-backed secret needs an admin `VAULT_TOKEN` (with `transit/decrypt` \
534             and KV read) exported in the environment"
535                .to_string(),
536        ));
537    }
538    let addr = match std::env::var("VAULT_ADDR") {
539        Ok(a) if !a.trim().is_empty() => a,
540        _ => {
541            return Err(OpError::InvalidArgument(
542                "reading a Vault-backed secret needs `VAULT_ADDR` exported (a Vault address \
543                 reachable from here, e.g. a port-forward to the in-cluster Vault)"
544                    .to_string(),
545            ));
546        }
547    };
548
549    vault_seed_path_consistency(&vault, |var| {
550        std::env::var(var).ok().and_then(|v| {
551            let trimmed = v.trim();
552            (!trimmed.is_empty()).then(|| trimmed.to_string())
553        })
554    })?;
555
556    let value: Option<String> = rt::sync_await(async {
557        let components = greentic_secrets_lib::vault::build_backend()
558            .await
559            .map_err(|e| OpError::Conflict(format!("vault backend init failed: {e}")))?;
560        let core = CoreBuilder::default()
561            .tenant(tenant.as_str())
562            .backend(components.backend, components.key_provider)
563            .build()
564            .await
565            .map_err(|e| OpError::Conflict(format!("vault secrets core build failed: {e}")))?;
566        match core.get_text(store_uri).await {
567            Ok(text) => Ok(Some(text)),
568            Err(SecretsError::Core(CoreError::NotFound { .. })) => Ok(None),
569            Err(e) => Err(OpError::Conflict(format!("vault get failed: {e}"))),
570        }
571    })?;
572
573    Ok((value, addr))
574}
575
576/// Fail closed when the operator's ambient Vault environment would not resolve
577/// to the binding's path-determining values, so a seed cannot silently land
578/// somewhere the worker will never read. `ambient(var)` returns the trimmed,
579/// non-empty value of a `VAULT_*` variable, else `None`.
580///
581/// Each tuple is `(env var, the binding's value, the provider default applied
582/// when the var is unset)`. The KV mount/prefix and transit mount/key choose the
583/// record location and envelope; the Enterprise **namespace** prefixes *every*
584/// path, so an absent binding namespace (default `""`) requires the ambient var
585/// to be absent too — a stray `VAULT_NAMESPACE` would otherwise seed a different
586/// namespace than the (namespace-less) worker reads. The k8s auth mount is
587/// deliberately excluded: it governs login, not where the record lands, and is
588/// unused here because seeding authenticates with a static `VAULT_TOKEN`.
589fn vault_seed_path_consistency(
590    vault: &crate::env_packs::k8s::manifests::VaultBackend,
591    ambient: impl Fn(&str) -> Option<String>,
592) -> Result<(), OpError> {
593    use crate::env_packs::k8s::manifests::{
594        VAULT_DEFAULT_KV_MOUNT, VAULT_DEFAULT_KV_PREFIX, VAULT_DEFAULT_TRANSIT_KEY,
595        VAULT_DEFAULT_TRANSIT_MOUNT,
596    };
597    let checks = [
598        (
599            "VAULT_KV_MOUNT",
600            vault.kv_mount.as_str(),
601            VAULT_DEFAULT_KV_MOUNT,
602        ),
603        (
604            "VAULT_KV_PREFIX",
605            vault.kv_prefix.as_str(),
606            VAULT_DEFAULT_KV_PREFIX,
607        ),
608        (
609            "VAULT_TRANSIT_MOUNT",
610            vault.transit_mount.as_str(),
611            VAULT_DEFAULT_TRANSIT_MOUNT,
612        ),
613        (
614            "VAULT_TRANSIT_KEY",
615            vault.transit_key.as_str(),
616            VAULT_DEFAULT_TRANSIT_KEY,
617        ),
618        (
619            "VAULT_NAMESPACE",
620            vault.namespace.as_deref().unwrap_or(""),
621            "",
622        ),
623    ];
624    for (var, binding_value, default) in checks {
625        let ambient_value = ambient(var);
626        let effective = ambient_value.as_deref().unwrap_or(default);
627        if effective != binding_value {
628            return Err(OpError::InvalidArgument(format!(
629                "the env's Vault binding requires {var}=`{binding_value}` but the seed would use \
630                 `{effective}`; export {var}=`{binding_value}` so the seeded record matches what \
631                 the worker reads"
632            )));
633        }
634    }
635    Ok(())
636}
637
638/// Where the env's dev store lives, mirroring the runtime reader's chain
639/// (greentic-start `dev_store_path`): explicit override env var, else the
640/// first *existing* default candidate under the env dir, else the primary
641/// default (created on first write).
642pub(super) fn resolve_dev_store_path(env_dir: &Path, override_path: Option<PathBuf>) -> PathBuf {
643    if let Some(path) = override_path {
644        return path;
645    }
646    let primary = env_dir.join(DEV_STORE_RELATIVE);
647    if primary.exists() {
648        return primary;
649    }
650    let fallback = env_dir.join(DEV_STORE_STATE_RELATIVE);
651    if fallback.exists() {
652        return fallback;
653    }
654    primary
655}
656
657/// Validate that `rel_path` (leading `/` already trimmed) is a writable
658/// dev-store secret path: exactly `<tenant>/<team>/<pack>/<name>` with
659/// store-canonical team and name segments.
660///
661/// The dev store's native key shape is the runtime's `secrets://` (plural)
662/// URI: `secrets://<env>/<tenant>/<team>/<pack>/<name>`; the backend handler
663/// converts the logical `secret://` ref 1:1. `DevStore::put` itself rejects
664/// any other depth, so enforce the shape upfront with a teachable error
665/// instead of surfacing the backend's "uri is missing category" — exactly
666/// four non-empty segments.
667///
668/// Shared between `put` (pre-write) and `env apply`'s pre-mutation manifest
669/// validation (PR-2) so the two surfaces cannot drift.
670pub(super) fn validate_dev_store_secret_path(rel_path: &str) -> Result<(), OpError> {
671    let shape_err = || {
672        OpError::InvalidArgument(format!(
673            "dev-store secret path must be `<tenant>/<team>/<pack>/<name>` \
674             (e.g. `default/_/messaging-telegram/telegram_bot_token`); \
675             got `{rel_path}`"
676        ))
677    };
678    let segs: Vec<&str> = rel_path.split('/').collect();
679    let [_tenant, team, _pack, name] = segs[..] else {
680        return Err(shape_err());
681    };
682    if segs.iter().any(|s| s.is_empty()) {
683        return Err(shape_err());
684    }
685    // The runtime reader canonicalizes the team segment before lookup
686    // (greentic-start `secrets_manager::canonical_team` maps `default`/
687    // empty — trimmed, case-insensitive — to `_`), so a literal
688    // `default` team would be written under a key no lookup ever uses.
689    // Same policy as the name segment: reject instead of silently
690    // transforming.
691    if !is_canonical_team(team) {
692        return Err(OpError::InvalidArgument(format!(
693            "team segment `{team}` is not store-canonical: the runtime \
694             reads the default team as `_` — pass `_` (or a real team \
695             name without surrounding whitespace)"
696        )));
697    }
698    // The runtime reader canonicalizes the name segment before lookup
699    // (greentic-start `secret_name::canonical_secret_name`), so a
700    // non-canonical name would be written but never found. Reject
701    // instead of silently transforming — producer and consumer must
702    // share one derivation, and we share it by only accepting
703    // already-canonical input.
704    if !is_canonical_secret_name(name) {
705        return Err(OpError::InvalidArgument(format!(
706            "secret name `{name}` is not store-canonical: use lowercase \
707             a-z, 0-9 and single `_` separators (no leading/trailing `_`)"
708        )));
709    }
710    Ok(())
711}
712
713/// A segment is writable iff the runtime reader's canonicalization maps it to
714/// itself — anything else is written under a key no lookup will ever use. Both
715/// checks call the shared `greentic-secrets` definitions (`normalize_team` /
716/// `canonical_secret_name`) — the same functions the runtime reader and the
717/// deployer's resolver use — so the predicate can't drift from the
718/// transformation it guards.
719fn is_canonical_team(team: &str) -> bool {
720    // `normalize_team` returns `None` for the team-less cases (`default`,
721    // empty, whitespace, AND the `_` placeholder itself). The canonical
722    // string form of a team-less segment is `TEAM_PLACEHOLDER` (`_`), so a
723    // segment is store-canonical iff it equals its normalization rendered
724    // back through that placeholder — this accepts `_` (and real team names)
725    // while still rejecting `default`/empty.
726    greentic_secrets_lib::normalize_team(Some(team))
727        .as_deref()
728        .unwrap_or(greentic_secrets_lib::TEAM_PLACEHOLDER)
729        == team
730}
731
732fn is_canonical_secret_name(name: &str) -> bool {
733    greentic_secrets_lib::canonical_secret_name(name) == name
734}
735
736/// Write one value into the dev store from this sync context.
737///
738/// `DevStore::put` is async; same constraint as
739/// `runtime_secrets::block_on_async_resolution` — the caller may sit on a
740/// current-thread runtime (where `block_in_place` panics) or no runtime at
741/// all, so hop to a dedicated OS thread that owns its own current-thread
742/// runtime.
743///
744/// The backend is load-snapshot-at-open / persist-full-snapshot-on-write
745/// (its internal flock covers each step, NOT the open→put window), so two
746/// concurrent writers silently lose the slower one's update. Serialize the
747/// whole cycle with a blocking sidecar flock (`<store>.lock`) held from
748/// before `DevStore::with_path` (the snapshot load) until after `put` (the
749/// persist). The sidecar — not the store file itself — because the
750/// backend's own flock on the store file would deadlock against ours.
751/// This serializes `op secrets put` writers; other tools writing the same
752/// store (`greentic-secrets apply`, the runtime's QA persist) don't take
753/// this lock — closing that belongs in the backend (A9 follow-up).
754///
755/// Failures map to `OpError::Io` keyed on the store path — the dev store is
756/// a local file, and adding a dedicated `OpError` variant would break
757/// Map a deploy-spec [`SecretRef`] (`secret://`) to its runtime dev-store URI
758/// (`secrets://`), delegating to the one authoritative converter in
759/// `greentic-secrets` ([`SecretRef::to_store_uri`]) instead of a local
760/// `replacen`. It additionally re-canonicalizes the team segment (`default` →
761/// `_`), and errors when the ref is not a store-aligned 5-segment URI (a scheme
762/// flip alone has no canonical store location for other shapes).
763pub(super) fn secret_ref_to_store_uri(secret_ref: &SecretRef) -> Result<String, OpError> {
764    secret_ref
765        .to_store_uri()
766        .map(|uri| uri.to_string())
767        .map_err(|e| {
768            OpError::InvalidArgument(format!(
769                "secret ref `{}` is not a store-aligned URI: {e}",
770                secret_ref.as_str()
771            ))
772        })
773}
774
775/// downstream exhaustive matches (greentic-operator's HTTP status mapping).
776/// Error messages carry the backend's text only — never secret material.
777pub(super) fn dev_store_put(path: &Path, uri: &str, value: &str) -> Result<(), OpError> {
778    let io_err = |message: String| OpError::Io {
779        path: path.to_path_buf(),
780        source: std::io::Error::other(message),
781    };
782    if let Some(parent) = path.parent() {
783        std::fs::create_dir_all(parent).map_err(|source| OpError::Io {
784            path: parent.to_path_buf(),
785            source,
786        })?;
787    }
788    let _write_lock = EnvFlock::acquire(&dev_store_lock_path(path))
789        .map_err(|source| OpError::Store(source.into()))?;
790    let store = DevStore::with_path(path.to_path_buf())
791        .map_err(|e| io_err(format!("open dev store: {e}")))?;
792    std::thread::scope(|scope| {
793        scope
794            .spawn(|| {
795                tokio::runtime::Builder::new_current_thread()
796                    .enable_all()
797                    .build()
798                    .map_err(|e| io_err(format!("build runtime: {e}")))?
799                    .block_on(store.put(uri, SecretFormat::Text, value.as_bytes()))
800                    .map_err(|e| io_err(format!("dev store write: {e}")))
801            })
802            .join()
803            .expect("dev-store write thread panicked")
804    })
805}
806
807/// Persist a bound credential's material into the env dev store at the
808/// location [`resolve_credentials_token`] reads it back from — the
809/// secret-backend write the credentials-bootstrap runner drives through its
810/// secret sink. Mirrors `op secrets put`'s dev-store write exactly so a
811/// bound token resolves identically on later live verbs (reconcile /
812/// apply-revision / requirements).
813pub(super) fn put_credential_material(
814    env_dir: &Path,
815    secret_ref: &SecretRef,
816    value: &str,
817) -> Result<(), OpError> {
818    let store_uri = secret_ref_to_store_uri(secret_ref)?;
819    let dev_path = resolve_dev_store_path(
820        env_dir,
821        std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
822    );
823    dev_store_put(&dev_path, &store_uri, value)
824}
825
826/// Whether the env's dev store already holds a non-empty value at `rel_path`
827/// (`<tenant>/<team>/<pack>/<name>`). `env apply` uses this so a paste-sourced
828/// secret (`from_env` absent) that is already stored is treated as satisfied —
829/// no re-prompt, no missing input — making the store the source of truth for
830/// pasted values across re-applies. A missing store file (fresh env) reads as
831/// `false`.
832pub(super) fn dev_store_has(
833    env_dir: &Path,
834    env_id: &EnvId,
835    rel_path: &str,
836) -> Result<bool, OpError> {
837    let dev_path = resolve_dev_store_path(
838        env_dir,
839        std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
840    );
841    if !dev_path.exists() {
842        return Ok(false);
843    }
844    let uri = format!(
845        "secrets://{}/{}",
846        env_id.as_str(),
847        rel_path.trim_start_matches('/')
848    );
849    dev_store_contains(&dev_path, &uri)
850}
851
852/// Read one key from a dev store, reporting only presence. Delegates to
853/// [`dev_store_get_value`] — a `get` error (missing key / unreadable) maps to
854/// `false` (absence), so apply re-collects the value rather than aborting.
855fn dev_store_contains(path: &Path, uri: &str) -> Result<bool, OpError> {
856    Ok(dev_store_get_value(path, uri)?.is_some())
857}
858
859/// Read one key's value from a dev store, returning `None` when the key is
860/// absent / empty / not valid UTF-8 (a missing secret is absence, not a hard
861/// error — the only hard failure is being unable to open the store file). Same
862/// dedicated-thread runtime hop as [`dev_store_put`] (the caller may sit on a
863/// current-thread runtime where `block_in_place` panics).
864fn dev_store_get_value(path: &Path, uri: &str) -> Result<Option<String>, OpError> {
865    let io_err = |message: String| OpError::Io {
866        path: path.to_path_buf(),
867        source: std::io::Error::other(message),
868    };
869    let store = DevStore::with_path(path.to_path_buf())
870        .map_err(|e| io_err(format!("open dev store: {e}")))?;
871    std::thread::scope(|scope| {
872        scope
873            .spawn(|| {
874                let rt = tokio::runtime::Builder::new_current_thread()
875                    .enable_all()
876                    .build()
877                    .map_err(|e| io_err(format!("build runtime: {e}")))?;
878                Ok(rt.block_on(async {
879                    match store.get(uri).await {
880                        Ok(bytes) if !bytes.is_empty() => String::from_utf8(bytes).ok(),
881                        _ => None,
882                    }
883                }))
884            })
885            .join()
886            .expect("dev-store read thread panicked")
887    })
888}
889
890/// Resolve an environment's bound `credentials_ref` to the deployer's bearer
891/// token for live cluster verbs (`op env reconcile` / `apply-revision` /
892/// `credentials requirements`).
893///
894/// Mirrors `runtime_secrets::resolve_runtime_secrets` precedence so an operator
895/// supplies the deployer's ServiceAccount token exactly the way every other
896/// secret is supplied — environment variable first (keyed by the canonical
897/// store key), then the env's dev store (the same file [`put`] writes):
898///
899/// - `Ok(None)` — no `credentials_ref` is bound. The caller connects with the
900///   ambient kubeconfig / in-cluster identity (the pre-closure behaviour).
901/// - `Ok(Some(token))` — the ref resolves to a non-empty value; the caller
902///   binds it onto the kube config (overriding the ambient identity).
903/// - `Err(Conflict)` — a ref IS bound but no material is found. Fail closed:
904///   silently falling back to the ambient (often broader-privileged) identity
905///   when an env explicitly declares a bound credential would be a
906///   privilege-escalation surprise.
907pub(crate) fn resolve_credentials_token(
908    store: &LocalFsStore,
909    env: &Environment,
910    env_id: &EnvId,
911) -> Result<Option<String>, OpError> {
912    let Some(secret_ref) = env.credentials_ref.as_ref() else {
913        return Ok(None);
914    };
915    let store_uri = secret_ref_to_store_uri(secret_ref)?;
916    let mut checked: Vec<String> = Vec::new();
917
918    if let Some(env_key) = canonical_secret_store_key(&store_uri) {
919        checked.push(format!("env {env_key}"));
920        if let Ok(value) = std::env::var(&env_key)
921            && !value.is_empty()
922        {
923            return Ok(Some(value));
924        }
925    }
926
927    let dev_path = resolve_dev_store_path(
928        &store.env_dir(env_id)?,
929        std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
930    );
931    checked.push(dev_path.display().to_string());
932    if dev_path.exists()
933        && let Some(value) = dev_store_get_value(&dev_path, &store_uri)?
934    {
935        return Ok(Some(value));
936    }
937
938    Err(OpError::Conflict(format!(
939        "environment `{}` declares credentials_ref `{}` but no secret material was \
940         found (looked in: {}); supply it via `op secrets put` or the corresponding \
941         environment variable before running live cluster verbs",
942        env_id.as_str(),
943        secret_ref.as_str(),
944        checked.join(", "),
945    )))
946}
947
948/// Sidecar lock path for a dev store file: the full path with `.lock`
949/// appended (`.dev.secrets.env` → `.dev.secrets.env.lock`). Appending to the
950/// whole path (not just the file name) keeps the directory component intact
951/// without the extract-fallback-reassemble dance.
952fn dev_store_lock_path(store_path: &Path) -> PathBuf {
953    let mut lock = store_path.as_os_str().to_os_string();
954    lock.push(".lock");
955    PathBuf::from(lock)
956}
957
958fn resolve_payload<T: serde::de::DeserializeOwned>(
959    flags: &OpFlags,
960    payload: Option<T>,
961) -> Result<T, OpError> {
962    if let Some(p) = payload {
963        return Ok(p);
964    }
965    if let Some(path) = &flags.answers {
966        return super::load_answers::<T>(path);
967    }
968    Err(OpError::InvalidArgument(
969        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
970    ))
971}
972
973fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
974    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
975}
976
977/// The env-must-have-secrets-pack precondition every secrets verb enforces.
978/// Shared with `env apply`'s validation (PR-2).
979pub(super) fn require_secrets_pack<'a>(
980    env: &'a greentic_deploy_spec::Environment,
981    env_id: &EnvId,
982) -> Result<&'a EnvPackBinding, OpError> {
983    env.pack_for_slot(CapabilitySlot::Secrets).ok_or_else(|| {
984        OpError::Conflict(format!(
985            "env `{env_id}` has no secrets env-pack bound; bind one with `op env-packs add` first"
986        ))
987    })
988}
989
990fn list_schema() -> Value {
991    json!({
992        "$schema": "https://json-schema.org/draft/2020-12/schema",
993        "title": "SecretsListPayload",
994        "type": "object",
995        "required": ["environment_id"],
996        "additionalProperties": false,
997        "properties": {"environment_id": {"type": "string"}}
998    })
999}
1000
1001fn put_schema() -> Value {
1002    json!({
1003        "$schema": "https://json-schema.org/draft/2020-12/schema",
1004        "title": "SecretsPutPayload",
1005        "type": "object",
1006        "required": ["environment_id", "path", "value"],
1007        "additionalProperties": false,
1008        "properties": {
1009            "environment_id": {"type": "string"},
1010            "path": {"type": "string", "description": "Relative path under secret://<env>/. For the dev-store backend: <tenant>/<team>/<pack>/<name> (e.g. default/_/messaging-telegram/telegram_bot_token). Use `_` for the default team — a literal `default` team is rejected (the runtime reads the default team as `_`)."},
1011            "value": {"type": "string"},
1012            "idempotency_key": {"type": ["string", "null"], "description": "Caller-supplied idempotency key; minted per invocation when absent."}
1013        }
1014    })
1015}
1016
1017fn get_schema() -> Value {
1018    json!({
1019        "$schema": "https://json-schema.org/draft/2020-12/schema",
1020        "title": "SecretsGetPayload",
1021        "type": "object",
1022        "required": ["environment_id", "path"],
1023        "additionalProperties": false,
1024        "properties": {
1025            "environment_id": {"type": "string"},
1026            "path": {"type": "string"},
1027            "reveal": {"type": "boolean", "default": false, "description": "Include the decrypted value in the outcome. Default false — presence + metadata only."}
1028        }
1029    })
1030}
1031
1032fn rotate_schema() -> Value {
1033    json!({
1034        "$schema": "https://json-schema.org/draft/2020-12/schema",
1035        "title": "SecretsRotatePayload",
1036        "type": "object",
1037        "required": ["environment_id", "path"],
1038        "additionalProperties": false,
1039        "properties": {
1040            "environment_id": {"type": "string"},
1041            "path": {"type": "string"}
1042        }
1043    })
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use crate::cli::tests_common::{make_binding, make_env};
1050    use tempfile::tempdir;
1051
1052    fn env_with_secrets() -> greentic_deploy_spec::Environment {
1053        env_with_secrets_kind("greentic.secrets.dev-store@1.0.0")
1054    }
1055
1056    /// A store-aligned credentials ref (`secret://<env>/<tenant>/<team>/<pack>/<name>`)
1057    /// and its `secrets://` store URI — the deployer's bound ServiceAccount token.
1058    const CREDS_REF: &str = "secret://local/default/_/k8s-deployer/sa_token";
1059    const CREDS_STORE_URI: &str = "secrets://local/default/_/k8s-deployer/sa_token";
1060
1061    fn env_with_credentials_ref(ref_str: &str) -> greentic_deploy_spec::Environment {
1062        let mut env = make_env("local");
1063        env.credentials_ref = Some(SecretRef::try_new(ref_str).expect("well-formed ref"));
1064        env
1065    }
1066
1067    #[test]
1068    fn resolve_credentials_token_none_when_no_ref() {
1069        let dir = tempdir().unwrap();
1070        let store = LocalFsStore::new(dir.path());
1071        let env = make_env("local");
1072        store.save(&env).unwrap();
1073        let env_id = EnvId::try_from("local").unwrap();
1074        assert_eq!(
1075            resolve_credentials_token(&store, &env, &env_id).unwrap(),
1076            None
1077        );
1078    }
1079
1080    #[test]
1081    fn resolve_credentials_token_reads_from_env_dev_store() {
1082        let dir = tempdir().unwrap();
1083        let store = LocalFsStore::new(dir.path());
1084        let env = env_with_credentials_ref(CREDS_REF);
1085        store.save(&env).unwrap();
1086        let env_id = EnvId::try_from("local").unwrap();
1087        // Seed the token where `op secrets put` would write it, then resolve it.
1088        let dev_path = resolve_dev_store_path(&store.env_dir(&env_id).unwrap(), None);
1089        dev_store_put(&dev_path, CREDS_STORE_URI, "sa-bearer-xyz").unwrap();
1090        assert_eq!(
1091            resolve_credentials_token(&store, &env, &env_id).unwrap(),
1092            Some("sa-bearer-xyz".to_string())
1093        );
1094    }
1095
1096    #[test]
1097    fn resolve_credentials_token_fails_closed_when_ref_present_but_unresolved() {
1098        let dir = tempdir().unwrap();
1099        let store = LocalFsStore::new(dir.path());
1100        let env = env_with_credentials_ref(CREDS_REF);
1101        store.save(&env).unwrap();
1102        let env_id = EnvId::try_from("local").unwrap();
1103        // No material seeded anywhere → fail closed rather than silently
1104        // falling back to ambient identity.
1105        let err = resolve_credentials_token(&store, &env, &env_id).unwrap_err();
1106        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1107    }
1108
1109    #[test]
1110    fn resolve_credentials_token_accepts_the_bootstrap_advertised_ref_shape() {
1111        // The K8s bootstrap README tells operators to bind
1112        // `secret://<env>/<DEPLOYER_TOKEN_STORE_PATH>`. That exact shape must be
1113        // store-aligned so the resolver can read it — regression for a ref that
1114        // `SecretRef::to_store_uri` would reject (e.g. the old `…/k8s/deployer-token`).
1115        use crate::env_packs::k8s::bootstrap::DEPLOYER_TOKEN_STORE_PATH;
1116        let dir = tempdir().unwrap();
1117        let store = LocalFsStore::new(dir.path());
1118        let ref_str = format!("secret://local/{DEPLOYER_TOKEN_STORE_PATH}");
1119        let secret_ref = SecretRef::try_new(&ref_str).expect("documented ref must be well-formed");
1120        let env = env_with_credentials_ref(&ref_str);
1121        store.save(&env).unwrap();
1122        let env_id = EnvId::try_from("local").unwrap();
1123        // Seed at the store URI the documented ref maps to (this conversion is
1124        // exactly what the resolver does — and what the old shape failed).
1125        let store_uri =
1126            secret_ref_to_store_uri(&secret_ref).expect("documented ref is store-aligned");
1127        let dev_path = resolve_dev_store_path(&store.env_dir(&env_id).unwrap(), None);
1128        dev_store_put(&dev_path, &store_uri, "sa-bearer-doc").unwrap();
1129        assert_eq!(
1130            resolve_credentials_token(&store, &env, &env_id).unwrap(),
1131            Some("sa-bearer-doc".to_string())
1132        );
1133    }
1134
1135    #[test]
1136    fn list_reports_namespace_and_kind() {
1137        let dir = tempdir().unwrap();
1138        let store = LocalFsStore::new(dir.path());
1139        store.save(&env_with_secrets()).unwrap();
1140        let outcome = list(
1141            &store,
1142            &OpFlags::default(),
1143            Some(SecretsListPayload {
1144                environment_id: "local".to_string(),
1145            }),
1146        )
1147        .unwrap();
1148        assert_eq!(
1149            outcome.result.get("secrets_kind").and_then(|v| v.as_str()),
1150            Some("greentic.secrets.dev-store@1.0.0")
1151        );
1152        assert_eq!(
1153            outcome.result.get("namespace").and_then(|v| v.as_str()),
1154            Some("secret://local/")
1155        );
1156    }
1157
1158    #[test]
1159    fn list_rejects_env_without_secrets_pack() {
1160        let dir = tempdir().unwrap();
1161        let store = LocalFsStore::new(dir.path());
1162        store.save(&make_env("local")).unwrap();
1163        let err = list(
1164            &store,
1165            &OpFlags::default(),
1166            Some(SecretsListPayload {
1167                environment_id: "local".to_string(),
1168            }),
1169        )
1170        .unwrap_err();
1171        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1172    }
1173
1174    fn env_with_secrets_kind(kind: &str) -> greentic_deploy_spec::Environment {
1175        let mut env = make_env("local");
1176        env.packs.push(make_binding(CapabilitySlot::Secrets, kind));
1177        env
1178    }
1179
1180    fn read_back(store_path: &str, uri: &str) -> Vec<u8> {
1181        crate::cli::tests_common::dev_store_read(Path::new(store_path), uri)
1182    }
1183
1184    #[test]
1185    fn put_vault_requires_tenant_owned_env() {
1186        let dir = tempdir().unwrap();
1187        let store = LocalFsStore::new(dir.path());
1188        // A Vault-bound env with no tenant owner: seeding must fail closed
1189        // before any Vault I/O, because the runtime scopes a Vault SecretsCore
1190        // to the env owner (greentic-start #305).
1191        store
1192            .save(&env_with_secrets_kind("greentic.secrets.vault@0.1.0"))
1193            .unwrap();
1194        let err = put(
1195            &store,
1196            &OpFlags::default(),
1197            Some(SecretsPutPayload {
1198                environment_id: "local".to_string(),
1199                path: "tenant-default/_/messaging-telegram/telegram_bot_token".to_string(),
1200                value: "tok-dummy-123".to_string(),
1201                idempotency_key: None,
1202            }),
1203        )
1204        .unwrap_err();
1205        match err {
1206            OpError::InvalidArgument(m) => assert!(m.contains("tenant-owned"), "msg: {m}"),
1207            other => panic!("expected InvalidArgument, got {other:?}"),
1208        }
1209    }
1210
1211    fn vault_backend_fixture(
1212        namespace: Option<&str>,
1213    ) -> crate::env_packs::k8s::manifests::VaultBackend {
1214        use crate::env_packs::k8s::manifests::{
1215            VAULT_DEFAULT_AUTH_MOUNT, VAULT_DEFAULT_KV_MOUNT, VAULT_DEFAULT_KV_PREFIX,
1216            VAULT_DEFAULT_TRANSIT_KEY, VAULT_DEFAULT_TRANSIT_MOUNT, VaultBackend,
1217        };
1218        VaultBackend {
1219            addr: "http://vault.example:8200".to_string(),
1220            k8s_role: "gtc-worker".to_string(),
1221            kv_mount: VAULT_DEFAULT_KV_MOUNT.to_string(),
1222            kv_prefix: VAULT_DEFAULT_KV_PREFIX.to_string(),
1223            auth_mount: VAULT_DEFAULT_AUTH_MOUNT.to_string(),
1224            transit_mount: VAULT_DEFAULT_TRANSIT_MOUNT.to_string(),
1225            transit_key: VAULT_DEFAULT_TRANSIT_KEY.to_string(),
1226            namespace: namespace.map(str::to_string),
1227        }
1228    }
1229
1230    #[test]
1231    fn vault_seed_path_consistency_accepts_defaults_with_no_ambient() {
1232        // All-default binding + nothing exported ⇒ effective values == defaults.
1233        let vault = vault_backend_fixture(None);
1234        assert!(vault_seed_path_consistency(&vault, |_| None).is_ok());
1235    }
1236
1237    #[test]
1238    fn vault_seed_path_consistency_rejects_kv_prefix_mismatch() {
1239        let mut vault = vault_backend_fixture(None);
1240        vault.kv_prefix = "tenant-a".to_string();
1241        // Ambient unset ⇒ effective prefix = default `greentic` != `tenant-a`.
1242        let err = vault_seed_path_consistency(&vault, |_| None).unwrap_err();
1243        match err {
1244            OpError::InvalidArgument(m) => assert!(m.contains("VAULT_KV_PREFIX"), "msg: {m}"),
1245            other => panic!("expected InvalidArgument, got {other:?}"),
1246        }
1247    }
1248
1249    #[test]
1250    fn vault_seed_path_consistency_requires_ambient_namespace_when_binding_sets_one() {
1251        let vault = vault_backend_fixture(Some("team-a"));
1252        // Binding namespace `team-a`, ambient unset ⇒ effective `` != `team-a`.
1253        let err = vault_seed_path_consistency(&vault, |_| None).unwrap_err();
1254        match err {
1255            OpError::InvalidArgument(m) => assert!(m.contains("VAULT_NAMESPACE"), "msg: {m}"),
1256            other => panic!("expected InvalidArgument, got {other:?}"),
1257        }
1258    }
1259
1260    #[test]
1261    fn vault_seed_path_consistency_rejects_stray_namespace_when_binding_has_none() {
1262        let vault = vault_backend_fixture(None);
1263        // Binding has no namespace, but the operator's env sets one ⇒ the seed
1264        // would land in `team-b` while the (namespace-less) worker reads root.
1265        let err = vault_seed_path_consistency(&vault, |var| {
1266            (var == "VAULT_NAMESPACE").then(|| "team-b".to_string())
1267        })
1268        .unwrap_err();
1269        match err {
1270            OpError::InvalidArgument(m) => assert!(m.contains("VAULT_NAMESPACE"), "msg: {m}"),
1271            other => panic!("expected InvalidArgument, got {other:?}"),
1272        }
1273    }
1274
1275    #[test]
1276    fn vault_seed_path_consistency_accepts_matching_namespace() {
1277        let vault = vault_backend_fixture(Some("team-a"));
1278        let result = vault_seed_path_consistency(&vault, |var| {
1279            (var == "VAULT_NAMESPACE").then(|| "team-a".to_string())
1280        });
1281        assert!(result.is_ok());
1282    }
1283
1284    #[test]
1285    fn put_non_dev_store_backend_returns_not_yet_implemented() {
1286        let dir = tempdir().unwrap();
1287        let store = LocalFsStore::new(dir.path());
1288        store
1289            .save(&env_with_secrets_kind("greentic.secrets.aws-sm@1.0.0"))
1290            .unwrap();
1291        let err = put(
1292            &store,
1293            &OpFlags::default(),
1294            Some(SecretsPutPayload {
1295                environment_id: "local".to_string(),
1296                path: "credentials/aws".to_string(),
1297                value: "secret-material".to_string(),
1298                idempotency_key: None,
1299            }),
1300        )
1301        .unwrap_err();
1302        assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
1303    }
1304
1305    #[test]
1306    fn put_writes_through_to_env_dev_store() {
1307        let dir = tempdir().unwrap();
1308        let store = LocalFsStore::new(dir.path());
1309        store.save(&env_with_secrets()).unwrap();
1310        let outcome = put(
1311            &store,
1312            &OpFlags::default(),
1313            Some(SecretsPutPayload {
1314                environment_id: "local".to_string(),
1315                path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
1316                value: "tok-dummy-123".to_string(),
1317                idempotency_key: None,
1318            }),
1319        )
1320        .unwrap();
1321        let result = &outcome.result;
1322        assert_eq!(
1323            result.get("store_uri").and_then(|v| v.as_str()),
1324            Some("secrets://local/default/_/messaging-telegram/telegram_bot_token")
1325        );
1326        assert_eq!(result.get("written").and_then(|v| v.as_bool()), Some(true));
1327        // The outcome must never echo the value.
1328        let envelope = serde_json::to_string(&outcome).unwrap();
1329        assert!(!envelope.contains("tok-dummy-123"));
1330        let store_path = result
1331            .get("store_path")
1332            .and_then(|v| v.as_str())
1333            .expect("store_path in outcome");
1334        let bytes = read_back(
1335            store_path,
1336            "secrets://local/default/_/messaging-telegram/telegram_bot_token",
1337        );
1338        assert_eq!(bytes, b"tok-dummy-123".to_vec());
1339    }
1340
1341    #[test]
1342    fn put_rejects_default_team_segment() {
1343        // The runtime reads the default team as `_`; a literal `default`
1344        // segment would be written but never looked up.
1345        let dir = tempdir().unwrap();
1346        let store = LocalFsStore::new(dir.path());
1347        store.save(&env_with_secrets()).unwrap();
1348        for team in ["default", "Default", "DEFAULT"] {
1349            let err = put(
1350                &store,
1351                &OpFlags::default(),
1352                Some(SecretsPutPayload {
1353                    environment_id: "local".to_string(),
1354                    path: format!("acme/{team}/messaging-telegram/telegram_bot_token"),
1355                    value: "tok-dummy".to_string(),
1356                    idempotency_key: None,
1357                }),
1358            )
1359            .unwrap_err();
1360            assert!(
1361                matches!(&err, OpError::InvalidArgument(msg) if msg.contains('_')),
1362                "team `{team}` got {err:?}"
1363            );
1364        }
1365    }
1366
1367    #[test]
1368    fn canonical_team_accepts_placeholder_and_real_teams() {
1369        // The `_` placeholder IS the canonical team-less segment. Routing the
1370        // validator through the lib's `normalize_team` (which returns `None`
1371        // for `_`) must not make the documented `default/_/...` path
1372        // unwritable — regression for the secrets-lib consolidation.
1373        assert!(
1374            is_canonical_team("_"),
1375            "`_` is the canonical team-less segment"
1376        );
1377        assert!(is_canonical_team("legal"), "a real team name is canonical");
1378        assert!(!is_canonical_team("default"));
1379        assert!(!is_canonical_team("Default"));
1380        assert!(!is_canonical_team(""));
1381        assert!(!is_canonical_team(" _ "));
1382    }
1383
1384    #[test]
1385    fn concurrent_puts_do_not_lose_writes() {
1386        // The dev backend is load-snapshot / persist-full-snapshot; without
1387        // the sidecar flock spanning open→put, concurrent writers lose
1388        // updates silently (each persists a snapshot missing the other's
1389        // key). With the lock, every key must survive.
1390        let dir = tempdir().unwrap();
1391        let store = LocalFsStore::new(dir.path());
1392        store.save(&env_with_secrets()).unwrap();
1393        let names: Vec<String> = (0..8).map(|i| format!("concurrent_key_{i}")).collect();
1394        let store = &store;
1395        std::thread::scope(|scope| {
1396            for name in &names {
1397                scope.spawn(move || {
1398                    let outcome = put(
1399                        store,
1400                        &OpFlags::default(),
1401                        Some(SecretsPutPayload {
1402                            environment_id: "local".to_string(),
1403                            path: format!("default/_/demo-pack/{name}"),
1404                            value: format!("value-{name}"),
1405                            idempotency_key: None,
1406                        }),
1407                    )
1408                    .unwrap();
1409                    assert_eq!(
1410                        outcome.result.get("written").and_then(|v| v.as_bool()),
1411                        Some(true)
1412                    );
1413                });
1414            }
1415        });
1416        let store_path = dir
1417            .path()
1418            .join("local")
1419            .join(DEV_STORE_RELATIVE)
1420            .display()
1421            .to_string();
1422        for name in &names {
1423            let bytes = read_back(
1424                &store_path,
1425                &format!("secrets://local/default/_/demo-pack/{name}"),
1426            );
1427            assert_eq!(bytes, format!("value-{name}").into_bytes());
1428        }
1429    }
1430
1431    #[test]
1432    fn dev_store_lock_path_is_sidecar() {
1433        assert_eq!(
1434            dev_store_lock_path(Path::new("/x/.greentic/dev/.dev.secrets.env")),
1435            Path::new("/x/.greentic/dev/.dev.secrets.env.lock")
1436        );
1437        assert_eq!(
1438            dev_store_lock_path(Path::new("state/dev-store.dat")),
1439            Path::new("state/dev-store.dat.lock")
1440        );
1441    }
1442
1443    #[test]
1444    fn put_rejects_non_canonical_name_segment() {
1445        let dir = tempdir().unwrap();
1446        let store = LocalFsStore::new(dir.path());
1447        store.save(&env_with_secrets()).unwrap();
1448        let err = put(
1449            &store,
1450            &OpFlags::default(),
1451            Some(SecretsPutPayload {
1452                environment_id: "local".to_string(),
1453                path: "default/_/messaging-telegram/TELEGRAM-BOT-TOKEN".to_string(),
1454                value: "tok-dummy".to_string(),
1455                idempotency_key: None,
1456            }),
1457        )
1458        .unwrap_err();
1459        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1460    }
1461
1462    #[test]
1463    fn put_rejects_wrong_depth_path() {
1464        // `DevStore::put` only accepts the 5-segment `secrets://` shape; the
1465        // verb rejects other depths upfront with a teachable message.
1466        let dir = tempdir().unwrap();
1467        let store = LocalFsStore::new(dir.path());
1468        store.save(&env_with_secrets()).unwrap();
1469        for path in ["credentials/aws", "default/_/pack/extra/name", "a//b/c"] {
1470            let err = put(
1471                &store,
1472                &OpFlags::default(),
1473                Some(SecretsPutPayload {
1474                    environment_id: "local".to_string(),
1475                    path: path.to_string(),
1476                    value: "v".to_string(),
1477                    idempotency_key: None,
1478                }),
1479            )
1480            .unwrap_err();
1481            assert!(
1482                matches!(&err, OpError::InvalidArgument(msg) if msg.contains("<tenant>/<team>/<pack>/<name>")),
1483                "path `{path}` got {err:?}"
1484            );
1485        }
1486    }
1487
1488    #[test]
1489    fn resolve_dev_store_path_override_wins() {
1490        let dir = tempdir().unwrap();
1491        let override_path = dir.path().join("custom.dat");
1492        assert_eq!(
1493            resolve_dev_store_path(dir.path(), Some(override_path.clone())),
1494            override_path
1495        );
1496    }
1497
1498    #[test]
1499    fn resolve_dev_store_path_prefers_existing_candidate() {
1500        let dir = tempdir().unwrap();
1501        let fallback = dir.path().join(DEV_STORE_STATE_RELATIVE);
1502        std::fs::create_dir_all(fallback.parent().unwrap()).unwrap();
1503        std::fs::write(&fallback, b"").unwrap();
1504        assert_eq!(resolve_dev_store_path(dir.path(), None), fallback);
1505        // Once the primary exists it wins over the state fallback.
1506        let primary = dir.path().join(DEV_STORE_RELATIVE);
1507        std::fs::create_dir_all(primary.parent().unwrap()).unwrap();
1508        std::fs::write(&primary, b"").unwrap();
1509        assert_eq!(resolve_dev_store_path(dir.path(), None), primary);
1510    }
1511
1512    #[test]
1513    fn resolve_dev_store_path_defaults_to_primary() {
1514        let dir = tempdir().unwrap();
1515        assert_eq!(
1516            resolve_dev_store_path(dir.path(), None),
1517            dir.path().join(DEV_STORE_RELATIVE)
1518        );
1519    }
1520
1521    #[test]
1522    fn canonical_name_fixed_points() {
1523        assert!(is_canonical_secret_name("telegram_bot_token"));
1524        assert!(is_canonical_secret_name("a1"));
1525        assert!(!is_canonical_secret_name(""));
1526        assert!(!is_canonical_secret_name("TELEGRAM_BOT_TOKEN"));
1527        assert!(!is_canonical_secret_name("bot-token"));
1528        assert!(!is_canonical_secret_name("_leading"));
1529        assert!(!is_canonical_secret_name("trailing_"));
1530        assert!(!is_canonical_secret_name("double__underscore"));
1531    }
1532
1533    #[test]
1534    fn put_rejects_empty_value() {
1535        let dir = tempdir().unwrap();
1536        let store = LocalFsStore::new(dir.path());
1537        store.save(&env_with_secrets()).unwrap();
1538        let err = put(
1539            &store,
1540            &OpFlags::default(),
1541            Some(SecretsPutPayload {
1542                environment_id: "local".to_string(),
1543                path: "x".to_string(),
1544                value: "".to_string(),
1545                idempotency_key: None,
1546            }),
1547        )
1548        .unwrap_err();
1549        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1550    }
1551
1552    #[test]
1553    fn get_reads_back_put_value_from_dev_store() {
1554        let dir = tempdir().unwrap();
1555        let store = LocalFsStore::new(dir.path());
1556        store.save(&env_with_secrets()).unwrap();
1557        let path = "default/_/messaging-telegram/telegram_bot_token";
1558        put(
1559            &store,
1560            &OpFlags::default(),
1561            Some(SecretsPutPayload {
1562                environment_id: "local".to_string(),
1563                path: path.to_string(),
1564                value: "tok-roundtrip-456".to_string(),
1565                idempotency_key: None,
1566            }),
1567        )
1568        .unwrap();
1569
1570        // reveal=false → present, but the value never appears in the envelope.
1571        let outcome = get(
1572            &store,
1573            &OpFlags::default(),
1574            Some(SecretsGetPayload {
1575                environment_id: "local".to_string(),
1576                path: path.to_string(),
1577                reveal: false,
1578            }),
1579        )
1580        .unwrap();
1581        assert_eq!(
1582            outcome.result.get("present").and_then(|v| v.as_bool()),
1583            Some(true)
1584        );
1585        assert!(outcome.result.get("value").is_none());
1586        let envelope = serde_json::to_string(&outcome).unwrap();
1587        assert!(!envelope.contains("tok-roundtrip-456"));
1588
1589        // reveal=true → the decrypted value is included.
1590        let outcome = get(
1591            &store,
1592            &OpFlags::default(),
1593            Some(SecretsGetPayload {
1594                environment_id: "local".to_string(),
1595                path: path.to_string(),
1596                reveal: true,
1597            }),
1598        )
1599        .unwrap();
1600        assert_eq!(
1601            outcome.result.get("value").and_then(|v| v.as_str()),
1602            Some("tok-roundtrip-456")
1603        );
1604    }
1605
1606    #[test]
1607    fn get_absent_key_returns_present_false() {
1608        let dir = tempdir().unwrap();
1609        let store = LocalFsStore::new(dir.path());
1610        store.save(&env_with_secrets()).unwrap();
1611        let outcome = get(
1612            &store,
1613            &OpFlags::default(),
1614            Some(SecretsGetPayload {
1615                environment_id: "local".to_string(),
1616                path: "default/_/messaging-telegram/never_written".to_string(),
1617                reveal: true,
1618            }),
1619        )
1620        .unwrap();
1621        assert_eq!(
1622            outcome.result.get("present").and_then(|v| v.as_bool()),
1623            Some(false)
1624        );
1625        assert!(outcome.result.get("value").is_none());
1626    }
1627
1628    #[test]
1629    fn get_vault_requires_tenant_owned_env() {
1630        let dir = tempdir().unwrap();
1631        let store = LocalFsStore::new(dir.path());
1632        // Mirror put: a Vault env with no tenant owner must fail closed before
1633        // any Vault I/O (the runtime scopes a Vault SecretsCore to the owner).
1634        store
1635            .save(&env_with_secrets_kind("greentic.secrets.vault@0.1.0"))
1636            .unwrap();
1637        let err = get(
1638            &store,
1639            &OpFlags::default(),
1640            Some(SecretsGetPayload {
1641                environment_id: "local".to_string(),
1642                path: "tenant-default/_/messaging-telegram/telegram_bot_token".to_string(),
1643                reveal: false,
1644            }),
1645        )
1646        .unwrap_err();
1647        match err {
1648            OpError::InvalidArgument(m) => assert!(m.contains("tenant-owned"), "msg: {m}"),
1649            other => panic!("expected InvalidArgument, got {other:?}"),
1650        }
1651    }
1652
1653    #[test]
1654    fn get_non_dev_store_backend_returns_not_yet_implemented() {
1655        let dir = tempdir().unwrap();
1656        let store = LocalFsStore::new(dir.path());
1657        store
1658            .save(&env_with_secrets_kind("greentic.secrets.aws-sm@1.0.0"))
1659            .unwrap();
1660        let err = get(
1661            &store,
1662            &OpFlags::default(),
1663            Some(SecretsGetPayload {
1664                environment_id: "local".to_string(),
1665                path: "default/_/pack/key_name".to_string(),
1666                reveal: false,
1667            }),
1668        )
1669        .unwrap_err();
1670        assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
1671    }
1672}