1use std::sync::Arc;
10use std::time::Duration;
11
12use serde_json::{Map, Value, json};
13
14use crate::audit::{Actor, AuditLog, NewEntry, Origin, Query, Visibility};
15use crate::auth::{AuthStore, PrincipalKind};
16use crate::error::{Error, Result};
17use crate::server::http::{Request, Response};
18use crate::server::mcp::{Audited, glob_match};
19use crate::server::{Caller, Registry, Tool};
20
21pub const SECRET_READS: &[&str] = &["secret_get", "secret_resolve", "app_webhook"];
25
26pub const DEPLOY_TOOLS: &[&str] = &[
28 "stack_deploy",
29 "stack_redeploy",
30 "stack_rollback",
31 "stack_scale",
32 "app_deploy",
33 "app_rollback",
34 "preview_redeploy",
35 "build_run",
36];
37
38const SAFE_KEYS: &[&str] = &[
42 "org",
43 "name",
44 "app",
45 "stack",
46 "project",
47 "environment",
48 "service",
49 "slot",
50 "replica",
51 "replicas",
52 "version",
53 "driver",
54 "role",
55 "id",
56 "user_id",
57 "email",
58 "deployment",
59 "builder",
60 "tag",
61 "rotate",
62 "volumes",
63 "all",
64 "wait",
65 "dry_run",
66 "duration_s",
67 "kind",
68 "provider",
69 "event",
70 "status",
71 "scopes_count",
72 "ssh_user",
73 "fingerprint",
74 "volume",
75 "snapshot",
76 "backup",
77 "instance",
78 "stamp",
79 "port",
80 "allow_nesting",
81];
82
83const TARGET_KEYS: &[&str] = &[
85 "name",
86 "app",
87 "stack",
88 "project",
89 "service",
90 "environment",
91 "id",
92 "user_id",
93 "email",
94];
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub struct Class {
99 pub read_only: bool,
101 pub secret_read: bool,
103}
104
105pub fn class(tool: &Tool) -> Class {
106 let ann = |k: &str| {
107 tool.annotations
108 .as_ref()
109 .and_then(|a| a.get(k))
110 .and_then(Value::as_bool)
111 == Some(true)
112 };
113 let secret_read = SECRET_READS.contains(&tool.name.as_str()) || ann("isbSecretRead");
114 Class {
115 read_only: ann("readOnlyHint") && !secret_read,
116 secret_read,
117 }
118}
119
120pub fn class_for(tool: &Tool, args: &Value) -> Class {
125 let c = class(tool);
126 let arg = tool
127 .annotations
128 .as_ref()
129 .and_then(|a| a.get("isbSecretReadArg"))
130 .and_then(Value::as_str);
131 match arg {
132 Some(k) if args.get(k).and_then(Value::as_bool) == Some(true) => Class {
133 read_only: false,
134 secret_read: true,
135 },
136 _ => c,
137 }
138}
139
140pub fn scope_allows(scopes: &[String], tool: &str, c: Class) -> bool {
142 scopes.is_empty()
143 || scopes.iter().any(|s| match s.as_str() {
144 "admin" => true,
145 "read" => c.read_only,
146 "deploy" => c.read_only || DEPLOY_TOOLS.contains(&tool) || super::kube::deploys(tool),
147 s => s.strip_prefix("tool:").is_some_and(|g| glob_match(g, tool)),
148 })
149}
150
151pub fn actor(c: &Caller) -> Actor {
153 match c {
154 Caller::Local { uid } => Actor::local(*uid),
155 Caller::Access(id) => {
156 let mut a = Actor::anonymous(format!("access:{}", id.name()));
157 a.email = id.email.clone();
158 a
159 }
160 Caller::Unauthenticated { .. } => Actor::anonymous("anonymous"),
161 Caller::Superadmin(s) => Actor::from_principal(&s.principal),
162 Caller::User { principal } => {
163 let mut a = Actor::from_principal(principal);
164 if principal.kind == PrincipalKind::Access {
165 a.name = format!("access:{}", principal.user.email);
166 }
167 a
168 }
169 }
170}
171
172pub fn safe_details(args: &Value) -> Value {
174 let mut out = Map::new();
175 if let Some(o) = args.as_object() {
176 for k in SAFE_KEYS {
177 let v = match o.get(*k) {
178 Some(Value::String(s))
179 if !s.is_empty() && s.len() <= 128 && !s.chars().any(char::is_control) =>
180 {
181 json!(s)
182 }
183 Some(v @ (Value::Number(_) | Value::Bool(_))) => v.clone(),
184 _ => continue,
185 };
186 out.insert((*k).to_string(), v);
187 }
188 }
189 Value::Object(out)
190}
191
192fn target(details: &Value) -> Option<String> {
193 TARGET_KEYS.iter().find_map(|k| match details.get(*k)? {
194 Value::String(s) => Some(s.clone()),
195 v @ Value::Number(_) => Some(v.to_string()),
196 _ => None,
197 })
198}
199
200pub fn outcome(r: std::result::Result<(), &Error>) -> String {
202 match r {
203 Ok(()) => "ok".into(),
204 Err(e) => crate::rpc::error_json(e)["code"]
205 .as_str()
206 .unwrap_or("error")
207 .to_string(),
208 }
209}
210
211fn row_org(action: &str, args: &Value) -> Option<String> {
213 let named = args.get("org").and_then(Value::as_str);
214 if super::PLATFORM_TOOLS.contains(&action)
215 || super::CROSS_ORG_READS.contains(&action)
216 || super::superadmin::TOOLS.contains(&action)
217 || super::accounts::USER_TOOLS.contains(&action)
218 || action.starts_with("superadmin.")
219 {
220 return named.and_then(|o| crate::org::OrgId::new(o).ok().map(|o| o.to_string()));
221 }
222 super::arg_org(args).ok().map(|o| o.to_string())
223}
224
225pub fn entry(a: &Audited, record_all: bool) -> Option<NewEntry> {
228 let terminal = a.action.starts_with("terminal.") || a.action.starts_with("ssh.");
229 let cls = a.tool.map(|t| class_for(t, a.args)).unwrap_or_default();
230 let refused = matches!(a.outcome, Err(Error::Forbidden(_)));
231 let superadmin = matches!(a.caller, Caller::Superadmin(_)) || a.caller.downscope().is_some();
233 if !(terminal || !cls.read_only || record_all || refused || superadmin) {
234 return None;
235 }
236 let details = super::kube::details(a);
237 let target = if a.action.starts_with("org_") {
239 details.get("org").and_then(Value::as_str).map(String::from)
240 } else {
241 target(&details)
242 };
243 Some(NewEntry {
244 org: row_org(a.action, a.args),
245 actor: actor(a.caller),
246 origin: a.origin.clone(),
247 action: a.action.to_string(),
248 target,
249 details,
250 outcome: outcome(a.outcome),
251 })
252}
253
254pub fn hook(log: Arc<AuditLog>, record_all: bool) -> crate::server::mcp::Audit {
256 Arc::new(move |a: &Audited| {
257 if let Some(e) = entry(a, record_all) {
258 if let Err(err) = log.append(e) {
259 eprintln!("isb serve: {err}");
260 }
261 }
262 })
263}
264
265pub fn visibility(c: &Caller, org: Option<&str>) -> Result<Visibility> {
269 match c {
270 Caller::Local { .. } | Caller::Superadmin(_) => Ok(Visibility::All),
271 Caller::User { principal: p } if p.platform_admin => Ok(Visibility::All),
272 Caller::User { principal: p } => {
273 let Some(o) = org else {
274 let orgs: Vec<String> = p
276 .orgs
277 .iter()
278 .filter(|(o, _)| p.can_manage_members(o))
279 .map(|(o, _)| o.to_string())
280 .collect();
281 if orgs.is_empty() {
282 return Err(Error::Forbidden(
283 "the audit log is for org owners and admins".into(),
284 ));
285 }
286 return Ok(Visibility::Orgs(orgs));
287 };
288 let o = crate::org::OrgId::new(o)?;
289 if p.can_manage_members(&o) {
290 Ok(Visibility::Orgs(vec![o.to_string()]))
291 } else {
292 Err(Error::Forbidden(format!(
293 "org {o}'s audit log is for its owners and admins"
294 )))
295 }
296 }
297 _ => Err(Error::Forbidden("sign in to read the audit log".into())),
298 }
299}
300
301pub fn register(r: &mut Registry, log: Arc<AuditLog>) -> Result<()> {
303 let l = log.clone();
304 r.register(
305 Tool::new(
306 "audit_list",
307 "Who did what: audit log entries, newest first (or oldest first after `after`, for tailing). Org owners and admins see their org's entries; platform admins see every org and platform-level entries (sign-ins, users, org changes). Filters: actor (glob on name or email), action (glob: `secret_*`, `auth.*`), target (glob), outcome (`ok`, `error`, or a code), surface, since/until (unix ms). Page with `before` = the last id you got.",
308 json!({
309 "type": "object",
310 "properties": {
311 "org": {"type": "string", "description": "One org's entries (an org admin's own when omitted)."},
312 "platform": {"type": "boolean", "description": "Only platform-level entries (platform admins)."},
313 "actor": {"type": "string"},
314 "user_id": {"type": "integer"},
315 "token_id": {"type": "integer"},
316 "action": {"type": "string"},
317 "target": {"type": "string"},
318 "outcome": {"type": "string"},
319 "surface": {"type": "string", "enum": ["mcp", "rest", "cli", "web", "webhook"]},
320 "since": {"type": "integer", "description": "Unix milliseconds, inclusive."},
321 "until": {"type": "integer", "description": "Unix milliseconds, exclusive."},
322 "before": {"type": "integer", "description": "Entries older than this id."},
323 "after": {"type": "integer", "description": "Entries newer than this id, oldest first."},
324 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Default 100."}
325 },
326 "additionalProperties": false
327 }),
328 move |a: Value, c: &Caller| -> Result<Value> {
329 let q: Query = serde_json::from_value(a)
330 .map_err(|e| Error::invalid(format!("bad arguments: {e}")))?;
331 let vis = visibility(c, q.org.as_deref())?;
332 if q.platform && vis != Visibility::All {
333 return Err(Error::Forbidden(
334 "platform-level entries are for platform admins".into(),
335 ));
336 }
337 let entries = l.list(&q, &vis)?;
338 let limit = q.limit.unwrap_or(100).clamp(1, 1000);
339 let next = (entries.len() == limit && q.after.is_none())
340 .then(|| entries.last().map(|e| e.id))
341 .flatten();
342 Ok(json!({"entries": entries, "next_before": next, "head": l.head()?}))
343 },
344 )
345 .title("Audit log")
346 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
347 )?;
348 r.register(
349 Tool::new(
350 "audit_verify",
351 "Walk the audit log's hash chain: ok, how many rows, the head (id and hash: keep a copy elsewhere to pin the log), and the first row that does not check out. Platform admins.",
352 json!({"type": "object", "properties": {}, "additionalProperties": false}),
353 move |_a: Value, _c: &Caller| -> Result<Value> {
354 let a = log.verify()?;
355 let h = log.history_verify()?;
356 Ok(json!({"ok": a.ok && h.ok, "audit": a, "history": h}))
357 },
358 )
359 .title("Verify the audit log")
360 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
361 )?;
362 Ok(())
363}
364
365pub fn history_visibility(c: &Caller, org: Option<&str>) -> Result<Visibility> {
369 match c {
370 Caller::Local { .. } | Caller::Superadmin(_) => Ok(Visibility::All),
371 Caller::User { principal: p } if p.platform_admin => Ok(Visibility::All),
372 Caller::User { principal: p } => match org {
373 Some(o) => {
374 let o = crate::org::OrgId::new(o)?;
375 if p.role_in(&o).is_some() {
376 Ok(Visibility::Orgs(vec![o.to_string()]))
377 } else {
378 Err(Error::Forbidden(format!("no access to org {o}")))
379 }
380 }
381 None => Ok(Visibility::Orgs(
382 p.orgs.iter().map(|(o, _)| o.to_string()).collect(),
383 )),
384 },
385 _ => Err(Error::Forbidden("sign in to read the history".into())),
386 }
387}
388
389pub fn register_history(r: &mut Registry, log: Arc<AuditLog>) -> Result<()> {
391 r.register(
392 Tool::new(
393 "history_query",
394 "What happened, merged into one timeline: the stack controller's events (deploys, rollouts, health, restarts), incus lifecycle events in every project including changes made outside isb (instance-created/deleted, image-alias-deleted, ...) with who requested them, audit rows (tool calls, sign-ins), and markers for when isb was not watching (serve.started, serve.stopped, incus.gap). Newest first, or oldest first with ascending=true; page with `before` = the `next` you got. `object` finds everything about an instance, image, volume, stack, app or service (substring, or exact=true). correlate=true links incus instance events to the audit row that likely caused them (inferred, by time and name). Org members see their orgs; host-level rows (images, pools, other projects) are for platform admins; audit rows for org owners and admins.",
395 json!({
396 "type": "object",
397 "properties": {
398 "org": {"type": "string"},
399 "platform": {"type": "boolean", "description": "Only host-level rows (platform admins)."},
400 "object": {"type": "string"},
401 "exact": {"type": "boolean"},
402 "kind": {"type": "string", "description": "Glob on the kind or audit action: instance-*, deploy.*, secret_*."},
403 "source": {"type": "string", "description": "audit, controller, incus, marker; comma-separated. Default all."},
404 "actor": {"type": "string", "description": "Glob on who."},
405 "since": {"type": "integer", "description": "Unix milliseconds, inclusive."},
406 "until": {"type": "integer", "description": "Unix milliseconds, exclusive."},
407 "before": {"type": "string", "description": "The `next` of the previous page."},
408 "limit": {"type": "integer", "minimum": 1, "maximum": 1000},
409 "ascending": {"type": "boolean"},
410 "correlate": {"type": "boolean"}
411 },
412 "additionalProperties": false
413 }),
414 move |a: Value, c: &Caller| -> Result<Value> {
415 let q: crate::history::HistoryQuery = serde_json::from_value(a)
416 .map_err(|e| Error::invalid(format!("bad arguments: {e}")))?;
417 let hvis = history_visibility(c, q.org.as_deref())?;
418 if q.platform && hvis != Visibility::All {
419 return Err(Error::Forbidden(
420 "host-level history is for platform admins".into(),
421 ));
422 }
423 let avis = visibility(c, q.org.as_deref()).ok();
424 Ok(serde_json::to_value(log.timeline(&q, &hvis, avis.as_ref())?)?)
425 },
426 )
427 .title("History")
428 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
429 )
430}
431
432fn param(req: &Request, key: &str) -> Option<String> {
433 req.query.as_deref()?.split('&').find_map(|kv| {
434 let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
435 (k == key).then(|| v.to_string())
436 })
437}
438
439pub fn stream_route(log: Arc<AuditLog>, users: Arc<AuthStore>) -> crate::server::Routes {
442 Arc::new(move |req: &Request| {
443 if req.path == "/api/v1/history/stream" {
444 return Some(history_stream(req, &log, &users));
445 }
446 if req.path != "/api/v1/audit/stream" {
447 return None;
448 }
449 if req.method != "GET" {
450 return Some(Response::text(405, "method not allowed").header("Allow", "GET"));
451 }
452 let err = |status: u16, code: &str, m: &str| {
453 Response::json(status, &json!({"error": code, "message": m}))
454 };
455 let Some(p) = users.principal_from_request(req) else {
456 return Some(err(401, "unauthenticated", "sign in first"));
457 };
458 let caller = Caller::User {
459 principal: Arc::new(p),
460 };
461 let org = param(req, "org").filter(|o| !o.is_empty());
462 let vis = match visibility(&caller, org.as_deref()) {
463 Ok(v) => v,
464 Err(e) => return Some(err(403, "forbidden", &e.to_string())),
465 };
466 let mut q = Query {
467 org,
468 ..Default::default()
469 };
470 let start = req
471 .header("last-event-id")
472 .map(String::from)
473 .or_else(|| param(req, "after"))
474 .and_then(|s| s.parse::<i64>().ok());
475 let log = log.clone();
476 let mut after = match start {
477 Some(a) => a,
478 None => log.head().unwrap_or(0),
479 };
480 let mut seen = log.generation();
481 Some(Response::stream(
482 200,
483 "text/event-stream",
484 Box::new(move |w: &mut dyn std::io::Write| {
485 loop {
486 q.after = Some(after);
487 q.limit = Some(200);
488 let rows = log.list(&q, &vis).map_err(std::io::Error::other)?;
489 for e in &rows {
490 let data = serde_json::to_string(e).unwrap_or_default();
491 write!(w, "id: {}\nevent: audit\ndata: {data}\n\n", e.id)?;
492 after = e.id;
493 }
494 if rows.is_empty() {
495 w.write_all(b": keepalive\n\n")?;
496 }
497 w.flush()?;
498 if rows.len() < 200 {
499 seen = log.wait_change(seen, Duration::from_secs(15));
500 }
501 }
502 }),
503 ))
504 })
505}
506
507fn history_stream(req: &Request, log: &Arc<AuditLog>, users: &Arc<AuthStore>) -> Response {
511 use crate::history::Item;
512 if req.method != "GET" {
513 return Response::text(405, "method not allowed").header("Allow", "GET");
514 }
515 let err = |status: u16, code: &str, m: &str| {
516 Response::json(status, &json!({"error": code, "message": m}))
517 };
518 let Some(p) = users.principal_from_request(req) else {
519 return err(401, "unauthenticated", "sign in first");
520 };
521 let caller = Caller::User {
522 principal: Arc::new(p),
523 };
524 let org = param(req, "org").filter(|o| !o.is_empty());
525 let hvis = match history_visibility(&caller, org.as_deref()) {
526 Ok(v) => v,
527 Err(e) => return err(403, "forbidden", &e.to_string()),
528 };
529 let avis = visibility(&caller, org.as_deref()).ok();
530 let start = req
531 .header("last-event-id")
532 .map(String::from)
533 .or_else(|| param(req, "after"))
534 .and_then(|s| {
535 let (a, h) = s.split_once('.')?;
536 Some((a.parse::<i64>().ok()?, h.parse::<i64>().ok()?))
537 });
538 let (mut a_after, mut h_after) = match start {
539 Some(x) => x,
540 None => (log.head().unwrap_or(0), log.history_head().unwrap_or(0)),
541 };
542 let log = log.clone();
543 let mut seen = log.generation();
544 Response::stream(
545 200,
546 "text/event-stream",
547 Box::new(move |w: &mut dyn std::io::Write| {
548 loop {
549 let mut items: Vec<Item> = Vec::new();
550 let rows = log
551 .history_after(h_after, &hvis, 200)
552 .map_err(std::io::Error::other)?;
553 if let Some(l) = rows.last() {
554 h_after = l.id;
555 }
556 items.extend(
557 rows.into_iter()
558 .filter(|r| org.is_none() || r.org == org)
559 .map(Item::from_record),
560 );
561 if let Some(av) = &avis {
562 let q = Query {
563 org: org.clone(),
564 after: Some(a_after),
565 limit: Some(200),
566 ..Default::default()
567 };
568 let rows = log.list(&q, av).map_err(std::io::Error::other)?;
569 if let Some(l) = rows.last() {
570 a_after = l.id;
571 }
572 items.extend(rows.into_iter().map(Item::from_audit));
573 }
574 items.sort_by_key(|i| i.time);
575 for it in &items {
576 let data = serde_json::to_string(it).unwrap_or_default();
577 write!(
578 w,
579 "id: {a_after}.{h_after}\nevent: history\ndata: {data}\n\n"
580 )?;
581 }
582 if items.is_empty() {
583 w.write_all(b": keepalive\n\n")?;
584 }
585 w.flush()?;
586 seen = log.wait_change(seen, Duration::from_secs(15));
587 }
588 }),
589 )
590}
591
592pub fn audited_webhooks(inner: crate::server::Routes, log: Arc<AuditLog>) -> crate::server::Routes {
595 Arc::new(move |req: &Request| {
596 let r = inner(req)?;
597 let Some((org, app)) = req
598 .path
599 .strip_prefix("/api/v1/webhooks/")
600 .and_then(|p| p.split_once('/'))
601 else {
602 return Some(r);
603 };
604 if req.method != "POST" || r.status == 404 && crate::org::OrgId::new(org).is_err() {
605 return Some(r);
606 }
607 let h = |n: &str| req.header(n).is_some();
608 let provider = if h("x-hub-signature-256") {
609 "github"
610 } else if h("x-gitea-signature") || h("x-forgejo-signature") {
611 "gitea"
612 } else if h("x-gitlab-token") {
613 "gitlab"
614 } else {
615 "token"
616 };
617 let body: Value = serde_json::from_slice(&r.body).unwrap_or(Value::Null);
618 let outcome = match r.status {
619 202 => "ok",
620 200 if body.get("ignored").is_some() => "ignored",
621 200 => "ok",
622 401 => "unauthorized",
623 404 => "not_found",
624 400 => "invalid",
625 _ => "error",
626 };
627 let event = req
628 .header("x-github-event")
629 .or_else(|| req.header("x-gitea-event"))
630 .or_else(|| req.header("x-forgejo-event"))
631 .or_else(|| req.header("x-gitlab-event"));
632 let mut args = json!({"org": org, "app": app, "status": r.status});
633 if let Some(e) = event {
634 args["event"] = json!(e);
635 }
636 if let Some(d) = body.get("deployment") {
637 args["deployment"] = d.clone();
638 }
639 let details = safe_details(&args);
640 let e = NewEntry {
641 org: crate::org::OrgId::new(org).ok().map(|o| o.to_string()),
642 actor: Actor::webhook(provider),
643 origin: Origin {
644 surface: "webhook".into(),
645 ip: crate::auth::http::client_ip(req),
646 user_agent: req.header("user-agent").map(String::from),
647 request_id: req
648 .header("x-github-delivery")
649 .or_else(|| req.header("x-gitea-delivery"))
650 .or_else(|| req.header("x-gitlab-event-uuid"))
651 .map(String::from),
652 },
653 action: "webhook.deploy".into(),
654 target: Some(app.to_string()),
655 details,
656 outcome: outcome.into(),
657 };
658 if let Err(err) = log.append(e) {
659 eprintln!("isb serve: {err}");
660 }
661 Some(r)
662 })
663}
664
665#[cfg(test)]
666mod tests {
667
668 #[test]
669 fn a_reveal_argument_makes_a_secret_read() {
670 let t = Tool {
671 name: "database_get".into(),
672 title: None,
673 description: String::new(),
674 input_schema: serde_json::json!({"type": "object"}),
675 annotations: Some(
676 serde_json::json!({"readOnlyHint": true, "isbSecretReadArg": "reveal"}),
677 ),
678 handler: std::sync::Arc::new(|_, _| Ok(Value::Null)),
679 };
680 let plain = class_for(&t, &serde_json::json!({"name": "pg"}));
681 assert!(plain.read_only && !plain.secret_read);
682 let reveal = class_for(&t, &serde_json::json!({"name": "pg", "reveal": true}));
683 assert!(!reveal.read_only && reveal.secret_read);
684 assert!(scope_allows(&["read".to_string()], "database_get", plain));
685 assert!(!scope_allows(&["read".to_string()], "database_get", reveal));
686 }
687 use super::*;
688 use crate::auth::Role;
689 use crate::server::ToolPolicy;
690 use crate::server::http::Peer;
691 use crate::server::mcp::{Authenticated, Endpoint, Hooks};
692 use std::collections::HashMap;
693
694 const SECRET: &str = "hunter2-very-secret-value";
695
696 struct T {
697 ep: Endpoint,
698 log: Arc<AuditLog>,
699 _dir: tempfile::TempDir,
700 }
701
702 fn endpoint() -> T {
705 use super::super::tests::{token, user};
706 let dir = tempfile::tempdir().unwrap();
707 let log = Arc::new(
708 AuditLog::open(
709 &dir.path().join("audit.db"),
710 crate::audit::DEFAULT_RETENTION,
711 )
712 .unwrap(),
713 );
714 let mut r = Registry::new();
715 let ro = json!({"readOnlyHint": true});
716 let write = json!({"destructiveHint": false});
717 for (name, ann) in [
718 ("secret_set", &write),
719 ("stack_deploy", &write),
720 ("sandbox_exec", &write),
721 ("stack_status", &ro),
722 ("secret_get", &ro),
723 ] {
724 r.register(
725 Tool::new(name, "", json!({}), |a: Value, _c: &Caller| {
726 Ok(json!({"echo": a.get("name"), "value": "c2VjcmV0"}))
727 })
728 .annotations(ann.clone()),
729 )
730 .unwrap();
731 }
732 register(&mut r, log.clone()).unwrap();
733 let callers: HashMap<&str, Caller> = HashMap::from([
734 ("agent", token(&[("acme", Role::Member)], &[])),
735 ("ro", token(&[("acme", Role::Member)], &["read"])),
736 ("viewer", user(&[("acme", Role::Viewer)], false)),
737 ("admin", user(&[("acme", Role::Admin)], false)),
738 ("member", user(&[("acme", Role::Member)], false)),
739 ("beta", user(&[("beta", Role::Owner)], false)),
740 ("root", user(&[], true)),
741 ]);
742 let authn: crate::server::mcp::Authn = Arc::new(move |req, _| {
743 let Some(a) = req.header("authorization") else {
744 return Authenticated::None;
745 };
746 match callers.get(a.trim_start_matches("Bearer ")) {
747 Some(Caller::User { principal }) => Authenticated::User(principal.clone()),
748 _ => Authenticated::Refused,
749 }
750 });
751 let ep = Endpoint {
752 registry: Arc::new(r),
753 policy: ToolPolicy::default(),
754 access: None,
755 healthz: Arc::new(|| (true, json!({}))),
756 routes: None,
757 public_routes: None,
758 hooks: Hooks {
759 authn: Some(authn),
760 authorize: Some(Arc::new(|c, tool, args, scope| {
761 super::super::authorize_class(
762 c,
763 &tool.name,
764 class_for(tool, &args),
765 args,
766 scope,
767 false,
768 )
769 })),
770 events: None,
771 terminal: None,
772 ssh: None,
773 audit: Some(hook(log.clone(), false)),
774 ..Default::default()
775 },
776 };
777 T { ep, log, _dir: dir }
778 }
779
780 impl T {
781 fn rest(&self, who: Option<&str>, tool: &str, args: Value) -> u16 {
782 let mut headers = vec![("User-Agent".to_string(), "t/1".to_string())];
783 if let Some(w) = who {
784 headers.push(("Authorization".into(), format!("Bearer {w}")));
785 }
786 let peer = match who {
787 Some(_) => Peer::Tcp("127.0.0.1:5000".parse().unwrap()),
788 None => Peer::Unix { uid: Some(1000) },
789 };
790 let r = self.ep.handle(&crate::server::http::Request {
791 method: "POST".into(),
792 path: format!("/api/v1/tools/{tool}"),
793 query: None,
794 headers,
795 body: serde_json::to_vec(&args).unwrap(),
796 peer,
797 });
798 r.status
799 }
800
801 fn mcp(&self, who: &str, tool: &str, args: Value) -> Value {
802 let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
803 "params": {"name": tool, "arguments": args}});
804 let r = self.ep.handle(&crate::server::http::Request {
805 method: "POST".into(),
806 path: "/mcp".into(),
807 query: None,
808 headers: vec![("Authorization".into(), format!("Bearer {who}"))],
809 body: serde_json::to_vec(&body).unwrap(),
810 peer: Peer::Tcp("127.0.0.1:5000".parse().unwrap()),
811 });
812 serde_json::from_slice(&r.body).unwrap()
813 }
814
815 fn new_rows(&self, after: i64) -> Vec<crate::audit::Entry> {
816 self.log
817 .list(
818 &Query {
819 after: Some(after),
820 ..Default::default()
821 },
822 &Visibility::All,
823 )
824 .unwrap()
825 }
826
827 fn one(&self, f: impl FnOnce()) -> crate::audit::Entry {
828 let h = self.log.head().unwrap();
829 f();
830 let mut rows = self.new_rows(h);
831 assert_eq!(rows.len(), 1, "{rows:#?}");
832 rows.remove(0)
833 }
834 }
835
836 #[test]
837 #[expect(
838 clippy::too_many_lines,
839 reason = "predates the lint ratchet; split it when next changed"
840 )]
841 fn tool_calls_are_audited_without_values() {
842 let t = endpoint();
843 let e = t.one(|| {
845 let st = t.rest(
846 Some("agent"),
847 "secret_set",
848 json!({"org": "acme", "name": "DB_PASSWORD", "value": SECRET}),
849 );
850 assert_eq!(st, 200);
851 });
852 assert_eq!(
853 (e.action.as_str(), e.org.as_deref(), e.target.as_deref()),
854 ("secret_set", Some("acme"), Some("DB_PASSWORD"))
855 );
856 assert_eq!(
857 (e.actor.as_str(), e.actor_kind.as_str(), e.surface.as_str()),
858 ("u@x.io", "agent", "rest")
859 );
860 assert_eq!(
861 (e.user_id, e.token_id, e.token_name.as_deref()),
862 (Some(7), Some(3), Some("ci"))
863 );
864 assert_eq!(
865 (e.outcome.as_str(), e.user_agent.as_deref()),
866 ("ok", Some("t/1"))
867 );
868 assert!(e.request_id.is_some());
869 assert_eq!(e.details, json!({"org": "acme", "name": "DB_PASSWORD"}));
870 let e = t.one(|| {
872 let r = t.mcp(
873 "agent",
874 "stack_deploy",
875 json!({
876 "org": "acme", "name": "web",
877 "compose": format!("services: {{web: {{environment: {{P: {SECRET}}}}}}}"),
878 "secrets": {"db": SECRET}, "vars": {"X": SECRET},
879 "env": {"K": SECRET}, "argv": ["echo", SECRET], "stdin": SECRET,
880 "service": SECRET.repeat(10),
881 }),
882 );
883 assert_eq!(r["result"]["isError"], false);
884 });
885 assert_eq!(
886 (e.action.as_str(), e.surface.as_str()),
887 ("stack_deploy", "mcp")
888 );
889 assert_eq!(e.details, json!({"org": "acme", "name": "web"}));
890 let e = t.one(|| {
892 let st = t.rest(
893 Some("member"),
894 "secret_get",
895 json!({"org": "acme", "name": "DB_PASSWORD"}),
896 );
897 assert_eq!(st, 200);
898 });
899 assert_eq!(
900 (e.action.as_str(), e.actor_kind.as_str()),
901 ("secret_get", "person")
902 );
903 let h = t.log.head().unwrap();
904 let st = t.rest(
905 Some("member"),
906 "stack_status",
907 json!({"org": "acme", "name": "web"}),
908 );
909 assert_eq!(st, 200);
910 assert!(t.new_rows(h).is_empty());
911 for (who, tool) in [
914 ("viewer", "stack_deploy"),
915 ("viewer", "secret_get"),
916 ("ro", "sandbox_exec"),
917 ("beta", "secret_set"),
918 ] {
919 let e = t.one(|| {
920 let st = t.rest(
921 Some(who),
922 tool,
923 json!({"org": "acme", "name": "x", "value": SECRET}),
924 );
925 assert_eq!(st, 403);
926 });
927 assert_eq!(
928 (e.action.as_str(), e.outcome.as_str()),
929 (tool, "forbidden"),
930 "{who}"
931 );
932 }
933 let e = t.one(|| {
935 let r = t.ep.handle(&crate::server::http::Request {
936 method: "POST".into(),
937 path: "/orgs/acme/api/v1/tools/secret_get".into(),
938 query: None,
939 headers: vec![("Authorization".into(), "Bearer viewer".into())],
940 body: br#"{"name": "DB_PASSWORD"}"#.to_vec(),
941 peer: Peer::Tcp("127.0.0.1:5000".parse().unwrap()),
942 });
943 assert_eq!(r.status, 403);
944 });
945 assert_eq!(
946 (e.org.as_deref(), e.outcome.as_str()),
947 (Some("acme"), "forbidden")
948 );
949 let e = t.one(|| {
951 let st = t.rest(None, "stack_deploy", json!({"org": "acme", "name": "api"}));
952 assert_eq!(st, 200);
953 });
954 assert_eq!(
955 (e.actor.as_str(), e.actor_kind.as_str(), e.surface.as_str()),
956 ("local(uid 1000)", "local", "cli")
957 );
958 assert!(t.log.verify().unwrap().ok);
960 let p = t.log.path().unwrap().to_path_buf();
961 let mut bytes = std::fs::read(&p).unwrap();
962 if let Ok(w) = std::fs::read(p.with_extension("db-wal")) {
963 bytes.extend(w);
964 }
965 for needle in [SECRET, "c2VjcmV0", "hunter2"] {
966 assert!(
967 !bytes.windows(needle.len()).any(|w| w == needle.as_bytes()),
968 "{needle} leaked"
969 );
970 }
971 }
972
973 #[test]
974 fn audit_list_shows_each_reader_their_share() {
975 let t = endpoint();
976 t.rest(
977 Some("agent"),
978 "secret_set",
979 json!({"org": "acme", "name": "a"}),
980 );
981 t.rest(
982 Some("beta"),
983 "secret_set",
984 json!({"org": "beta", "name": "b"}),
985 );
986 t.log
987 .append(NewEntry {
988 actor: Actor::claimed("x@y.io"),
989 action: "auth.login".into(),
990 outcome: "invalid_credentials".into(),
991 ..Default::default()
992 })
993 .unwrap();
994 let list = |who: &str, args: Value| t.mcp(who, "audit_list", args)["result"].clone();
995 let orgs = |v: &Value| -> Vec<Option<String>> {
996 v["structuredContent"]["entries"]
997 .as_array()
998 .unwrap()
999 .iter()
1000 .map(|e| e["org"].as_str().map(String::from))
1001 .collect()
1002 };
1003 let acme = vec![Some("acme".to_string())];
1004 assert_eq!(orgs(&list("admin", json!({}))), acme);
1006 assert_eq!(orgs(&list("admin", json!({"org": "acme"}))), acme);
1007 assert_eq!(list("admin", json!({"org": "beta"}))["isError"], true);
1008 assert_eq!(list("admin", json!({"platform": true}))["isError"], true);
1009 for who in ["member", "viewer", "agent"] {
1011 let r = list(who, json!({"org": "acme"}));
1012 assert_eq!(r["isError"], true, "{who}");
1013 }
1014 let all = orgs(&list("root", json!({})));
1016 assert!(
1017 all.contains(&None) && all.contains(&Some("beta".into())),
1018 "{all:?}"
1019 );
1020 assert_eq!(orgs(&list("root", json!({"platform": true}))), [None]);
1021 let r = t.mcp("admin", "audit_verify", json!({}));
1023 assert_eq!(r["result"]["isError"], true);
1024 let v = t.mcp("root", "audit_verify", json!({}));
1025 assert_eq!(v["result"]["structuredContent"]["ok"], true);
1026 }
1027
1028 #[test]
1029 fn ssh_sessions_are_recorded_with_user_and_key() {
1030 use super::super::tests::token;
1031 let c = token(&[("acme", Role::Member)], &[]);
1032 let args = json!({
1033 "org": "acme", "name": "box", "ssh_user": "dev",
1034 "fingerprint": "SHA256:OGav3hSvMQSOfHiDB0OdyYFOPHDbUJTzSsNvCdLadvQ",
1035 "duration_s": 42,
1036 });
1037 let origin = Origin::default();
1038 for action in ["ssh.open", "ssh.close"] {
1039 let e = entry(
1040 &Audited {
1041 caller: &c,
1042 action,
1043 tool: None,
1044 args: &args,
1045 outcome: Ok(()),
1046 origin: &origin,
1047 },
1048 false,
1049 )
1050 .expect("SSH sessions are always recorded");
1051 assert_eq!(e.org.as_deref(), Some("acme"));
1052 assert_eq!(e.target.as_deref(), Some("box"));
1053 assert_eq!(e.details["ssh_user"], "dev");
1054 assert_eq!(e.details["duration_s"], 42);
1055 assert!(
1056 e.details["fingerprint"]
1057 .as_str()
1058 .unwrap()
1059 .starts_with("SHA256:")
1060 );
1061 }
1062 }
1063}