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