1use 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
18pub type InUse = Arc<dyn Fn(&OrgId, &str) -> Vec<String> + Send + Sync>;
20
21pub type Changed = Arc<dyn Fn(&OrgId, &str) -> Vec<String> + Send + Sync>;
24
25pub type Refresh =
28 Arc<dyn Fn(&OrgId, &str) -> Result<crate::stack::secrets::Refreshed> + Send + Sync>;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Binding {
33 pub name: String,
35 pub driver: String,
36 pub version: u64,
37 pub stack: String,
38}
39
40pub type Bindings = Arc<dyn Fn(&OrgId) -> Vec<Binding> + Send + Sync>;
42
43#[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 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
68fn 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 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
117fn 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#[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 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 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 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 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 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 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 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 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 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 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}