Skip to main content

isb_daemon/daemon/
secrets.rs

1//! The `secret_*` tools: the org's secret store over MCP and the unix
2//! socket. Values travel base64 in arguments and results.
3
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use serde::Deserialize;
8use serde::de::DeserializeOwned;
9use serde_json::{Value, json};
10
11use crate::error::{Error, Result};
12use crate::org::OrgId;
13use crate::secrets::Secrets;
14use crate::server::{Caller, Registry, Tool};
15use crate::stack::secrets::Cycle;
16
17type Handler = Box<dyn Fn(&Secrets, Value, &Caller) -> Result<Value> + Send + Sync>;
18
19/// The stacks in an org whose services use a stored secret.
20pub type InUse = Arc<dyn Fn(&OrgId, &str) -> Vec<String> + Send + Sync>;
21
22/// What a new secret version did: per stack service, its `on_change`
23/// action, and what was not cycled (workspaces) and why.
24#[derive(Debug, Clone, Default)]
25pub struct Outcome {
26    pub cycles: Vec<Cycle>,
27    pub skipped: Vec<Skipped>,
28}
29
30/// Something using a secret that a new version does not restart.
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32pub struct Skipped {
33    /// `workspace`.
34    pub kind: String,
35    pub name: String,
36    pub reason: String,
37}
38
39impl Outcome {
40    /// `rolled`, `services` and `skipped`, as the tools answer them.
41    fn fill(&self, v: &mut Value) {
42        v["rolled"] = json!(crate::stack::secrets::cycled_stacks(&self.cycles));
43        v["services"] = json!(self.cycles);
44        v["skipped"] = json!(self.skipped);
45    }
46}
47
48/// Before a new value is stored: make it take effect where the old one is
49/// kept (a stack secret's `rotate`, such as a database's password). An
50/// error stops the set.
51pub type Prepare =
52    Arc<dyn Fn(&OrgId, &str, &[u8]) -> Result<Vec<crate::stack::secrets::Applied>> + Send + Sync>;
53
54/// A stored secret got a new value: cycle what uses it, per its
55/// `on_change`.
56pub type Changed = Arc<dyn Fn(&OrgId, &str) -> Outcome + Send + Sync>;
57
58/// Re-read every stack reference to a name through its driver now; returns
59/// (driver, version) per driver and what the new version did.
60pub type Refresh = Arc<dyn Fn(&OrgId, &str) -> Result<(Vec<(String, u64)>, Outcome)> + Send + Sync>;
61
62/// One stack's use of a secret, as deployed.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Binding {
65    /// A store name, or a driver reference (`vault/item/field`).
66    pub name: String,
67    pub driver: String,
68    pub version: u64,
69    pub stack: String,
70}
71
72/// Every secret the org's deployed stacks use.
73pub type Bindings = Arc<dyn Fn(&OrgId) -> Vec<Binding> + Send + Sync>;
74
75/// How the secret tools reach the stacks.
76#[derive(Clone)]
77pub struct Hooks {
78    pub prepare: Prepare,
79    pub in_use: InUse,
80    pub changed: Changed,
81    pub refresh: Refresh,
82    pub bindings: Bindings,
83}
84
85impl Hooks {
86    /// No stacks at all.
87    pub fn none() -> Hooks {
88        Hooks {
89            prepare: Arc::new(|_, _, _| Ok(Vec::new())),
90            in_use: Arc::new(|_, _| Vec::new()),
91            changed: Arc::new(|_, _| Outcome::default()),
92            refresh: Arc::new(|_, _| Ok((Vec::new(), Outcome::default()))),
93            bindings: Arc::new(|_| Vec::new()),
94        }
95    }
96}
97
98fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
99    serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
100}
101
102/// The org a call acts on.
103fn org_for(c: &Caller, org: Option<&str>) -> Result<OrgId> {
104    let org = match org {
105        Some(o) => OrgId::new(o)?,
106        None => OrgId::default_org(),
107    };
108    // P1.7: bind a remote caller (token or identity) to its own org here and
109    // refuse any other. Until then every caller reaches every org.
110    let _ = c.is_trusted();
111    Ok(org)
112}
113
114#[derive(Deserialize)]
115#[serde(deny_unknown_fields)]
116struct Named {
117    #[serde(default)]
118    org: Option<String>,
119    name: String,
120}
121
122#[derive(Deserialize)]
123#[serde(deny_unknown_fields)]
124struct OrgOnly {
125    #[serde(default)]
126    org: Option<String>,
127}
128
129fn props(extra: Value) -> Value {
130    let mut p = json!({
131        "org": {"type": "string", "description": "The org (default: default)."},
132    });
133    if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
134        p.extend(e.clone());
135    }
136    p
137}
138
139fn obj(props: Value, required: &[&str]) -> Value {
140    json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
141}
142
143fn name_prop() -> Value {
144    json!({"name": {"type": "string", "description": "Secret name: 1-128 of [A-Za-z0-9_.-], not starting with '.'."}})
145}
146
147fn value_of(b64: &str) -> Result<Vec<u8>> {
148    crate::rpc::b64_decode(b64).map_err(|_| Error::invalid("value must be base64"))
149}
150
151/// `secret_list`'s answer: the stored secrets with the stacks using each,
152/// then the references stacks use that are not in the store.
153fn listing(stored: Vec<crate::secrets::SecretMeta>, used: Vec<Binding>) -> Result<Value> {
154    let stacks_of = |name: &str| -> Vec<String> {
155        let mut v: Vec<String> = used
156            .iter()
157            .filter(|b| b.name == name)
158            .map(|b| b.stack.clone())
159            .collect();
160        v.sort();
161        v.dedup();
162        v
163    };
164    let mut secrets = Vec::new();
165    for m in &stored {
166        let mut v = serde_json::to_value(m)?;
167        v["used_by"] = json!(stacks_of(&m.name));
168        secrets.push(v);
169    }
170    let mut refs: BTreeMap<&str, Value> = BTreeMap::new();
171    for b in used
172        .iter()
173        .filter(|b| !stored.iter().any(|m| m.name == b.name))
174    {
175        refs.entry(b.name.as_str()).or_insert_with(|| {
176            json!({"name": b.name, "driver": b.driver, "version": b.version, "used_by": stacks_of(&b.name)})
177        });
178    }
179    Ok(json!({"secrets": secrets, "references": refs.into_values().collect::<Vec<_>>()}))
180}
181
182/// Register the secret tools.
183#[expect(
184    clippy::too_many_lines,
185    reason = "predates the lint ratchet; split it when next changed"
186)]
187pub fn register(r: &mut Registry, secrets: Arc<Secrets>, hooks: Hooks) -> Result<()> {
188    let Hooks {
189        prepare,
190        in_use,
191        changed,
192        refresh,
193        bindings,
194    } = hooks;
195    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
196    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
197    let write = json!({"destructiveHint": false, "openWorldHint": false});
198    let value_prop = json!({"value": {"type": "string", "description": "The value, base64."}});
199
200    let mut add = |name: &str,
201                   title: &str,
202                   desc: &str,
203                   schema: Value,
204                   ann: &Value,
205                   f: Handler|
206     -> Result<()> {
207        let s = secrets.clone();
208        r.register(
209            Tool::new(name, desc, schema, move |a, c| f(&s, a, c))
210                .title(title)
211                .annotations(ann.clone()),
212        )
213    };
214
215    add(
216        "secret_create",
217        "Create a secret",
218        "Create a secret in an org's store (fails if one by that name exists). The value is base64; it is encrypted at rest and never listed.",
219        obj(
220            props(json!({
221                "name": name_prop()["name"],
222                "value": value_prop["value"],
223                "driver": {"type": "string", "description": "Where it is stored (default: local)."},
224                "labels": {"type": "object", "additionalProperties": {"type": "string"}}
225            })),
226            &["name", "value"],
227        ),
228        &write,
229        Box::new(|s, a, c| {
230            #[derive(Deserialize)]
231            #[serde(deny_unknown_fields)]
232            struct A {
233                #[serde(default)]
234                org: Option<String>,
235                name: String,
236                value: String,
237                #[serde(default)]
238                driver: Option<String>,
239                #[serde(default)]
240                labels: BTreeMap<String, String>,
241            }
242            let a: A = args(a)?;
243            let org = org_for(c, a.org.as_deref())?;
244            let m = s.create(
245                &org,
246                &a.name,
247                a.driver.as_deref(),
248                &value_of(&a.value)?,
249                &a.labels,
250            )?;
251            Ok(serde_json::to_value(m)?)
252        }),
253    )?;
254    let on_set = changed.clone();
255    add(
256        "secret_set",
257        "Set a secret",
258        "Give a secret a new value (base64), bumping its version; creates it in the local store if missing. Each stack service using it acts per its `on_change`: `roll` (default; a rolling update), `restart` (in place) or `none` (files updated, replicas stale until they next start). `services` lists what each service did, `rolled` the stacks that roll or restart, `skipped` what was not cycled and why (workspaces get the file, never a restart). A stack secret with a `rotate` command (a database app's password) is first changed where the old value is kept, in a running replica (`applied`); if that fails, nothing is stored. A database app's URL secret follows its password.",
259        obj(
260            props(json!({"name": name_prop()["name"], "value": value_prop["value"]})),
261            &["name", "value"],
262        ),
263        &write,
264        Box::new(move |s, a, c| {
265            #[derive(Deserialize)]
266            #[serde(deny_unknown_fields)]
267            struct A {
268                #[serde(default)]
269                org: Option<String>,
270                name: String,
271                value: String,
272            }
273            let a: A = args(a)?;
274            let org = org_for(c, a.org.as_deref())?;
275            let value = value_of(&a.value)?;
276            let applied = prepare(&org, &a.name, &value)?;
277            let m = s.set(&org, &a.name, &value)?;
278            let outcome = on_set(&org, &a.name);
279            let mut v = serde_json::to_value(m)?;
280            outcome.fill(&mut v);
281            v["applied"] = json!(applied);
282            Ok(v)
283        }),
284    )?;
285    add(
286        "secret_get",
287        "Read a secret",
288        "A secret's value (base64, in `value`) and metadata.",
289        obj(props(name_prop()), &["name"]),
290        &ro,
291        Box::new(|s, a, c| {
292            let a: Named = args(a)?;
293            let org = org_for(c, a.org.as_deref())?;
294            let (v, m) = s.get(&org, &a.name)?;
295            Ok(json!({"meta": m, "value": crate::rpc::b64_encode(&v)}))
296        }),
297    )?;
298    add(
299        "secret_list",
300        "List secrets",
301        "An org's secrets: name, driver, version, timestamps, labels, and the deployed stacks using each (`used_by`). Never values. `references` lists the driver references (such as 1Password's vault/item/field) stacks use, which live outside the store.",
302        obj(props(json!({})), &[]),
303        &ro,
304        Box::new(move |s, a, c| {
305            let a: OrgOnly = args(a)?;
306            let org = org_for(c, a.org.as_deref())?;
307            listing(s.list(&org)?, bindings(&org))
308        }),
309    )?;
310    add(
311        "secret_inspect",
312        "Inspect a secret",
313        "One secret's metadata (never its value).",
314        obj(props(name_prop()), &["name"]),
315        &ro,
316        Box::new(|s, a, c| {
317            let a: Named = args(a)?;
318            let org = org_for(c, a.org.as_deref())?;
319            Ok(serde_json::to_value(s.inspect(&org, &a.name)?)?)
320        }),
321    )?;
322    let used = in_use;
323    add(
324        "secret_delete",
325        "Delete a secret",
326        "Delete a secret. Refused while a deployed stack's services use it.",
327        obj(props(name_prop()), &["name"]),
328        &destructive,
329        Box::new(move |s, a, c| {
330            let a: Named = args(a)?;
331            let org = org_for(c, a.org.as_deref())?;
332            s.inspect(&org, &a.name)?;
333            let stacks = used(&org, &a.name);
334            if !stacks.is_empty() {
335                return Err(Error::invalid(format!(
336                    "secret {} is in use by stack {}; remove it from the stack first",
337                    a.name,
338                    stacks.join(", ")
339                )));
340            }
341            s.delete(&org, &a.name)?;
342            Ok(json!({"ok": true}))
343        }),
344    )?;
345    add(
346        "secret_refresh",
347        "Refresh a secret",
348        "Re-read an externally stored secret from its source now; if its version moved, each stack service using it acts per its `on_change` (`services`, `rolled`, `skipped` as for secret_set). `name` is a store name, or a stack's driver reference. A no-op for the local store.",
349        obj(
350            props(
351                json!({"name": {"type": "string", "description": "A store name, or a driver reference a stack uses."}}),
352            ),
353            &["name"],
354        ),
355        &write,
356        Box::new(move |s, a, c| {
357            let a: Named = args(a)?;
358            let org = org_for(c, a.org.as_deref())?;
359            // A driver reference (`op://...`) is no store name; only the
360            // stacks know it.
361            let meta = if crate::secrets::validate_name(&a.name).is_ok() {
362                match s.refresh(&org, &a.name) {
363                    Ok(m) => Some(m),
364                    Err(Error::NotFound(_)) => None,
365                    Err(e) => return Err(e),
366                }
367            } else {
368                None
369            };
370            let (refs, outcome) = refresh(&org, &a.name)?;
371            let mut v = match (meta, refs.first()) {
372                (Some(m), _) => serde_json::to_value(m)?,
373                (None, Some((driver, version))) => {
374                    json!({"org": org, "name": a.name, "driver": driver, "version": version})
375                }
376                (None, None) => return Err(crate::secrets::not_found(&org, &a.name)),
377            };
378            outcome.fill(&mut v);
379            Ok(v)
380        }),
381    )?;
382    add(
383        "secret_resolve",
384        "Resolve compose secrets",
385        "Local callers only (`isb up`): the values (base64) of a compose file's store-backed secrets (`external`, `age`, `driver`), read from the org's store and decrypted with the daemon's key.",
386        obj(
387            props(
388                json!({"secrets": {"type": "object", "description": "Top-level compose secrets, by key."}}),
389            ),
390            &["secrets"],
391        ),
392        &ro,
393        Box::new(|s, a, c| {
394            #[derive(Deserialize)]
395            #[serde(deny_unknown_fields)]
396            struct A {
397                #[serde(default)]
398                org: Option<String>,
399                secrets: BTreeMap<String, crate::spec::SecretDef>,
400            }
401            // A decryption oracle for the daemon's key: superadmins only
402            // (the socket's `isb up`, or an HTTP caller with its reach),
403            // whatever --deny-tools says.
404            if !c.is_trusted() {
405                return Err(Error::invalid(
406                    "secret_resolve is for superadmins (the local socket: isb up) only",
407                ));
408            }
409            let a: A = args(a)?;
410            let org = org_for(c, a.org.as_deref())?;
411            let values = crate::stack::secrets::resolve(s, &org, &a.secrets)?;
412            let out: BTreeMap<String, String> = values
413                .iter()
414                .map(|(k, v)| (k.clone(), crate::rpc::b64_encode(v)))
415                .collect();
416            Ok(json!({"values": out}))
417        }),
418    )?;
419    add(
420        "secret_reencrypt",
421        "Re-encrypt secrets",
422        "Re-encrypt every stored value in the org (or every org, with all=true) to the current recipients: the daemon's key and the break-glass recipients. Run after changing the recipients.",
423        obj(
424            props(json!({"all": {"type": "boolean", "description": "Every org."}})),
425            &[],
426        ),
427        &write,
428        Box::new(|s, a, c| {
429            #[derive(Deserialize)]
430            #[serde(deny_unknown_fields)]
431            struct A {
432                #[serde(default)]
433                org: Option<String>,
434                #[serde(default)]
435                all: bool,
436            }
437            let a: A = args(a)?;
438            if a.all && a.org.is_some() {
439                return Err(Error::invalid("pass org or all, not both"));
440            }
441            // P1.7: `all` spans orgs; only platform admins may use it.
442            let n = if a.all {
443                s.reencrypt(None)?
444            } else {
445                s.reencrypt(Some(&org_for(c, a.org.as_deref())?))?
446            };
447            Ok(json!({"reencrypted": n, "recipients": s.recipients()}))
448        }),
449    )?;
450    add(
451        "secret_recipients",
452        "Secret recipients",
453        "The age recipients values are encrypted to (public keys only): the daemon's own key first, then the break-glass recipients. `isb secret encrypt` encrypts compose `age:` values to these.",
454        obj(props(json!({})), &[]),
455        &ro,
456        Box::new(|s, a, c| {
457            let a: OrgOnly = args(a)?;
458            org_for(c, a.org.as_deref())?;
459            Ok(json!({
460                "public_key": s.keyring().public_key(),
461                "recipients": s.recipients(),
462            }))
463        }),
464    )?;
465    Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::secrets::{Keyring, LocalDriver};
472
473    fn registry(dir: &std::path::Path) -> Registry {
474        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
475        let s = Arc::new(Secrets::new(LocalDriver::new(dir, Arc::new(k))));
476        let in_use: InUse = Arc::new(|org: &OrgId, name: &str| {
477            if org.is_default() && name == "used" {
478                vec!["app".to_string()]
479            } else {
480                vec![]
481            }
482        });
483        let cycle = |stack: &str, service: &str, action| Cycle {
484            stack: stack.into(),
485            service: service.into(),
486            key: "k".into(),
487            secret: "used".into(),
488            from: 1,
489            to: 2,
490            action,
491            error: None,
492        };
493        let changed: Changed = Arc::new(move |org: &OrgId, name: &str| {
494            if org.is_default() && name == "used" {
495                Outcome {
496                    cycles: vec![
497                        cycle("app", "web", crate::spec::OnChange::Restart),
498                        cycle("app", "api", crate::spec::OnChange::Roll),
499                        cycle("other", "cron", crate::spec::OnChange::None),
500                    ],
501                    skipped: vec![Skipped {
502                        kind: "workspace".into(),
503                        name: "main".into(),
504                        reason: "delivered".into(),
505                    }],
506                }
507            } else {
508                Outcome::default()
509            }
510        });
511        let refresh: Refresh = Arc::new(move |_org: &OrgId, name: &str| {
512            if name == "op://v/item" {
513                Ok((
514                    vec![("vault".to_string(), 4)],
515                    Outcome {
516                        cycles: vec![cycle("app", "web", crate::spec::OnChange::Roll)],
517                        skipped: vec![],
518                    },
519                ))
520            } else {
521                Ok((vec![], Outcome::default()))
522            }
523        });
524        let bindings: Bindings = Arc::new(|org: &OrgId| {
525            let b = |name: &str, driver: &str, stack: &str| Binding {
526                name: name.into(),
527                driver: driver.into(),
528                version: 3,
529                stack: stack.into(),
530            };
531            if org.is_default() {
532                vec![
533                    b("used", "local", "web"),
534                    b("used", "local", "app"),
535                    b("used", "local", "app"),
536                    b("vault/item/field", "onepassword", "app"),
537                ]
538            } else {
539                vec![]
540            }
541        });
542        let mut r = Registry::new();
543        register(
544            &mut r,
545            s,
546            Hooks {
547                prepare: Arc::new(|_, name: &str, v: &[u8]| {
548                    if name == "locked" && v == b"bad" {
549                        return Err(Error::invalid("rotate failed"));
550                    }
551                    Ok(vec![])
552                }),
553                in_use,
554                changed,
555                refresh,
556                bindings,
557            },
558        )
559        .unwrap();
560        r
561    }
562
563    #[test]
564    fn set_and_refresh_roll_and_resolve_is_local_only() {
565        let dir = tempfile::tempdir().unwrap();
566        let r = registry(dir.path());
567        let v = crate::rpc::b64_encode(b"one");
568        call(&r, "secret_create", json!({"name": "used", "value": v})).unwrap();
569        let m = call(&r, "secret_set", json!({"name": "used", "value": v})).unwrap();
570        // Only stacks that roll or restart count as rolled; a `none`
571        // service is listed, with its action, but not cycled.
572        assert_eq!(m["rolled"], json!(["app"]));
573        assert_eq!(m["version"], 2);
574        let actions: Vec<(String, String)> = m["services"]
575            .as_array()
576            .unwrap()
577            .iter()
578            .map(|c| {
579                (
580                    format!(
581                        "{}/{}",
582                        c["stack"].as_str().unwrap(),
583                        c["service"].as_str().unwrap()
584                    ),
585                    c["action"].as_str().unwrap().to_string(),
586                )
587            })
588            .collect();
589        assert_eq!(
590            actions,
591            [
592                ("app/web".to_string(), "restart".to_string()),
593                ("app/api".to_string(), "roll".to_string()),
594                ("other/cron".to_string(), "none".to_string()),
595            ]
596        );
597        assert_eq!(m["services"][0]["from"], 1);
598        assert_eq!(m["services"][0]["to"], 2);
599        assert_eq!(m["skipped"][0]["kind"], "workspace");
600        // A driver reference is no store name; the stacks answer for it.
601        let m = call(&r, "secret_refresh", json!({"name": "op://v/item"})).unwrap();
602        assert_eq!(
603            (
604                m["driver"].as_str(),
605                m["version"].as_u64(),
606                m["rolled"].clone()
607            ),
608            (Some("vault"), Some(4), json!(["app"]))
609        );
610        assert!(matches!(
611            call(&r, "secret_refresh", json!({"name": "op://v/other"})),
612            Err(Error::NotFound(_))
613        ));
614        let m = call(&r, "secret_refresh", json!({"name": "used"})).unwrap();
615        assert_eq!(m["version"], 2);
616        // secret_resolve reads store-backed sources for isb up...
617        let res = call(
618            &r,
619            "secret_resolve",
620            json!({"secrets": {"a": {"external": true, "name": "used"}, "f": {"file": "./x"}}}),
621        )
622        .unwrap();
623        assert_eq!(res["values"]["a"], json!(v));
624        assert!(res["values"].get("f").is_none());
625        // ...and never for a remote caller.
626        let remote = Caller::Unauthenticated {
627            addr: "127.0.0.1:1".parse().unwrap(),
628        };
629        let e = (r.get("secret_resolve").unwrap().handler)(
630            json!({"secrets": {"a": {"external": true, "name": "used"}}}),
631            &remote,
632        )
633        .unwrap_err();
634        assert!(e.to_string().contains("superadmins"), "{e}");
635    }
636
637    #[test]
638    fn list_says_who_uses_what() {
639        let dir = tempfile::tempdir().unwrap();
640        let r = registry(dir.path());
641        let v = crate::rpc::b64_encode(b"x");
642        for n in ["used", "idle"] {
643            call(&r, "secret_create", json!({"name": n, "value": v})).unwrap();
644        }
645        let l = call(&r, "secret_list", json!({})).unwrap();
646        let by = |n: &str| {
647            l["secrets"]
648                .as_array()
649                .unwrap()
650                .iter()
651                .find(|s| s["name"] == n)
652                .unwrap()["used_by"]
653                .clone()
654        };
655        assert_eq!(by("used"), json!(["app", "web"]));
656        assert_eq!(by("idle"), json!([]));
657        assert_eq!(
658            l["references"],
659            json!([{"name": "vault/item/field", "driver": "onepassword", "version": 3, "used_by": ["app"]}])
660        );
661        let l = call(&r, "secret_list", json!({"org": "norm"})).unwrap();
662        assert_eq!(l["references"], json!([]));
663    }
664
665    fn call(r: &Registry, tool: &str, a: Value) -> Result<Value> {
666        let local = Caller::Local { uid: None };
667        (r.get(tool).unwrap().handler)(a, &local)
668    }
669
670    #[test]
671    fn tools_round_trip() {
672        let dir = tempfile::tempdir().unwrap();
673        let r = registry(dir.path());
674        let v = crate::rpc::b64_encode(b"pa\x00ss");
675        let m = call(
676            &r,
677            "secret_create",
678            json!({"name": "db", "value": v, "labels": {"a": "b"}}),
679        )
680        .unwrap();
681        assert_eq!(
682            (m["version"].as_u64(), m["org"].as_str()),
683            (Some(1), Some("default"))
684        );
685        assert!(call(&r, "secret_create", json!({"name": "db", "value": v})).is_err());
686        let g = call(&r, "secret_get", json!({"name": "db"})).unwrap();
687        assert_eq!(
688            crate::rpc::b64_decode(g["value"].as_str().unwrap()).unwrap(),
689            b"pa\x00ss"
690        );
691        let m = call(
692            &r,
693            "secret_set",
694            json!({"name": "db", "value": crate::rpc::b64_encode(b"new")}),
695        )
696        .unwrap();
697        assert_eq!(m["version"], 2);
698        assert_eq!(m["labels"]["a"], "b");
699        // Orgs are separate.
700        call(
701            &r,
702            "secret_create",
703            json!({"org": "norm", "name": "db", "value": v}),
704        )
705        .unwrap();
706        let l = call(&r, "secret_list", json!({"org": "norm"})).unwrap();
707        assert_eq!(l["secrets"].as_array().unwrap().len(), 1);
708        assert!(call(&r, "secret_list", json!({"org": "Bad Org"})).is_err());
709        // Listing and inspecting never carry the value.
710        let l = call(&r, "secret_list", json!({})).unwrap();
711        let i = call(&r, "secret_inspect", json!({"name": "db"})).unwrap();
712        for v in [&l, &i] {
713            let t = v.to_string();
714            assert!(!t.contains("value") && !t.contains("bmV3"), "{t}");
715        }
716        assert_eq!(i["version"], 2);
717        assert_eq!(
718            call(&r, "secret_refresh", json!({"name": "db"})).unwrap()["version"],
719            2
720        );
721        let re = call(&r, "secret_reencrypt", json!({"all": true})).unwrap();
722        assert_eq!(re["reencrypted"], 2);
723        let re = call(&r, "secret_reencrypt", json!({"org": "norm"})).unwrap();
724        assert_eq!(re["reencrypted"], 1);
725        assert!(call(&r, "secret_reencrypt", json!({"org": "norm", "all": true})).is_err());
726        let rc = call(&r, "secret_recipients", json!({})).unwrap();
727        assert_eq!(rc["recipients"][0], rc["public_key"]);
728        // Delete: refused while a stack uses it, NotFound when absent.
729        call(&r, "secret_create", json!({"name": "used", "value": v})).unwrap();
730        let e = call(&r, "secret_delete", json!({"name": "used"})).unwrap_err();
731        assert!(e.to_string().contains("in use by stack app"), "{e}");
732        call(&r, "secret_delete", json!({"name": "db"})).unwrap();
733        assert!(matches!(
734            call(&r, "secret_delete", json!({"name": "db"})),
735            Err(Error::NotFound(_))
736        ));
737        // A failed rotate stores nothing.
738        call(&r, "secret_create", json!({"name": "locked", "value": v})).unwrap();
739        let bad = crate::rpc::b64_encode(b"bad");
740        assert!(call(&r, "secret_set", json!({"name": "locked", "value": bad})).is_err());
741        assert_eq!(
742            call(&r, "secret_inspect", json!({"name": "locked"})).unwrap()["version"],
743            1
744        );
745        // Bad input.
746        assert!(call(&r, "secret_set", json!({"name": "x", "value": "!!"})).is_err());
747        assert!(call(&r, "secret_get", json!({"name": "x", "extra": 1})).is_err());
748        assert!(call(&r, "secret_get", json!({"name": "../etc"})).is_err());
749    }
750}