1use 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
36pub(super) const DEV_STORE_KIND_PATH: &str = "greentic.secrets.dev-store";
42
43pub(crate) const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
48
49pub(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 pub path: String,
68 pub value: String,
72 #[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 #[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
95pub 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 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 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 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 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 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
198pub 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
269pub(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 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
312pub(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 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
356fn 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
387fn 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 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 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 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 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 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
494fn 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
576fn 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
638pub(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
657pub(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 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 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
713fn is_canonical_team(team: &str) -> bool {
720 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
736pub(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
775pub(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
807pub(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
826pub(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
852fn dev_store_contains(path: &Path, uri: &str) -> Result<bool, OpError> {
856 Ok(dev_store_get_value(path, uri)?.is_some())
857}
858
859fn 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
890pub(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
948fn 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
977pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}