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 "sandbox_start",
37 "sandbox_stop",
38];
39
40const SAFE_KEYS: &[&str] = &[
44 "org",
45 "name",
46 "app",
47 "stack",
48 "project",
49 "environment",
50 "service",
51 "slot",
52 "replica",
53 "replicas",
54 "version",
55 "driver",
56 "role",
57 "id",
58 "user_id",
59 "email",
60 "user",
61 "platform_admin",
62 "all_orgs",
63 "deployment",
64 "builder",
65 "tag",
66 "rotate",
67 "volumes",
68 "all",
69 "wait",
70 "dry_run",
71 "duration_s",
72 "kind",
73 "provider",
74 "event",
75 "status",
76 "scopes_count",
77 "ssh_user",
78 "fingerprint",
79 "volume",
80 "snapshot",
81 "backup",
82 "instance",
83 "stamp",
84 "port",
85 "device",
86 "allow_nesting",
87];
88
89const TARGET_KEYS: &[&str] = &[
91 "name",
92 "app",
93 "stack",
94 "project",
95 "service",
96 "environment",
97 "id",
98 "user_id",
99 "email",
100];
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub struct Class {
105 pub read_only: bool,
107 pub secret_read: bool,
109}
110
111pub fn class(tool: &Tool) -> Class {
112 let ann = |k: &str| {
113 tool.annotations
114 .as_ref()
115 .and_then(|a| a.get(k))
116 .and_then(Value::as_bool)
117 == Some(true)
118 };
119 let secret_read = SECRET_READS.contains(&tool.name.as_str()) || ann("isbSecretRead");
120 Class {
121 read_only: ann("readOnlyHint") && !secret_read,
122 secret_read,
123 }
124}
125
126pub fn class_for(tool: &Tool, args: &Value) -> Class {
131 let c = class(tool);
132 let arg = tool
133 .annotations
134 .as_ref()
135 .and_then(|a| a.get("isbSecretReadArg"))
136 .and_then(Value::as_str);
137 match arg {
138 Some(k) if args.get(k).and_then(Value::as_bool) == Some(true) => Class {
139 read_only: false,
140 secret_read: true,
141 },
142 _ => c,
143 }
144}
145
146pub fn scope_allows(scopes: &[String], tool: &str, c: Class) -> bool {
148 scopes.is_empty()
149 || scopes.iter().any(|s| match s.as_str() {
150 "admin" => true,
151 "read" => c.read_only,
152 "deploy" => c.read_only || DEPLOY_TOOLS.contains(&tool) || super::kube::deploys(tool),
153 s => s.strip_prefix("tool:").is_some_and(|g| glob_match(g, tool)),
154 })
155}
156
157pub fn actor(c: &Caller) -> Actor {
159 match c {
160 Caller::Local { uid } => Actor::local(*uid),
161 Caller::Access(id) => {
162 let mut a = Actor::anonymous(format!("access:{}", id.name()));
163 a.email = id.email.clone();
164 a
165 }
166 Caller::Unauthenticated { .. } => Actor::anonymous("anonymous"),
167 Caller::Superadmin(s) => Actor::from_principal(&s.principal),
168 Caller::User { principal } => {
169 let mut a = Actor::from_principal(principal);
170 if principal.kind == PrincipalKind::Access {
171 a.name = format!("access:{}", principal.user.email);
172 }
173 a
174 }
175 }
176}
177
178pub fn safe_details(args: &Value) -> Value {
180 let mut out = Map::new();
181 if let Some(o) = args.as_object() {
182 for k in SAFE_KEYS {
183 let v = match o.get(*k) {
184 Some(Value::String(s))
185 if !s.is_empty() && s.len() <= 128 && !s.chars().any(char::is_control) =>
186 {
187 json!(s)
188 }
189 Some(v @ (Value::Number(_) | Value::Bool(_))) => v.clone(),
190 _ => continue,
191 };
192 out.insert((*k).to_string(), v);
193 }
194 }
195 Value::Object(out)
196}
197
198fn target(details: &Value) -> Option<String> {
199 TARGET_KEYS.iter().find_map(|k| match details.get(*k)? {
200 Value::String(s) => Some(s.clone()),
201 v @ Value::Number(_) => Some(v.to_string()),
202 _ => None,
203 })
204}
205
206pub fn outcome(r: std::result::Result<(), &Error>) -> String {
208 match r {
209 Ok(()) => "ok".into(),
210 Err(e) => crate::rpc::error_json(e)["code"]
211 .as_str()
212 .unwrap_or("error")
213 .to_string(),
214 }
215}
216
217fn row_org(action: &str, args: &Value) -> Option<String> {
219 let named = args.get("org").and_then(Value::as_str);
220 if super::PLATFORM_TOOLS.contains(&action)
221 || super::CROSS_ORG_READS.contains(&action)
222 || super::superadmin::TOOLS.contains(&action)
223 || super::accounts::USER_TOOLS.contains(&action)
224 || action.starts_with("superadmin.")
225 {
226 return named.and_then(|o| crate::org::OrgId::new(o).ok().map(|o| o.to_string()));
227 }
228 super::arg_org(args).ok().map(|o| o.to_string())
229}
230
231pub fn entry(a: &Audited, record_all: bool) -> Option<NewEntry> {
234 let terminal = a.action.starts_with("terminal.") || a.action.starts_with("ssh.");
235 let cls = a.tool.map(|t| class_for(t, a.args)).unwrap_or_default();
236 let refused = matches!(a.outcome, Err(Error::Forbidden(_)));
237 let superadmin = matches!(a.caller, Caller::Superadmin(_)) || a.caller.downscope().is_some();
239 if !(terminal || !cls.read_only || record_all || refused || superadmin) {
240 return None;
241 }
242 let details = super::kube::details(a);
243 let target = if a.action.starts_with("org_") {
245 details.get("org").and_then(Value::as_str).map(String::from)
246 } else {
247 target(&details)
248 };
249 Some(NewEntry {
250 org: row_org(a.action, a.args),
251 actor: actor(a.caller),
252 origin: a.origin.clone(),
253 action: a.action.to_string(),
254 target,
255 details,
256 outcome: outcome(a.outcome),
257 })
258}
259
260pub fn hook(log: Arc<AuditLog>, record_all: bool) -> crate::server::mcp::Audit {
262 Arc::new(move |a: &Audited| {
263 if let Some(e) = entry(a, record_all) {
264 if let Err(err) = log.append(e) {
265 eprintln!("isb serve: {err}");
266 }
267 }
268 })
269}
270
271pub fn visibility(c: &Caller, org: Option<&str>) -> Result<Visibility> {
275 match c {
276 Caller::Local { .. } | Caller::Superadmin(_) => Ok(Visibility::All),
277 Caller::User { principal: p } if p.platform_admin => Ok(Visibility::All),
278 Caller::User { principal: p } => {
279 let Some(o) = org else {
280 let orgs: Vec<String> = p
282 .orgs
283 .iter()
284 .filter(|(o, _)| p.can_manage_members(o))
285 .map(|(o, _)| o.to_string())
286 .collect();
287 if orgs.is_empty() {
288 return Err(Error::Forbidden(
289 "the audit log is for org owners and admins".into(),
290 ));
291 }
292 return Ok(Visibility::Orgs(orgs));
293 };
294 let o = crate::org::OrgId::new(o)?;
295 if p.can_manage_members(&o) {
296 Ok(Visibility::Orgs(vec![o.to_string()]))
297 } else {
298 Err(Error::Forbidden(format!(
299 "org {o}'s audit log is for its owners and admins"
300 )))
301 }
302 }
303 _ => Err(Error::Forbidden("sign in to read the audit log".into())),
304 }
305}
306
307pub fn register(r: &mut Registry, log: Arc<AuditLog>) -> Result<()> {
309 let l = log.clone();
310 r.register(
311 Tool::new(
312 "audit_list",
313 "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.",
314 json!({
315 "type": "object",
316 "properties": {
317 "org": {"type": "string", "description": "One org's entries (an org admin's own when omitted)."},
318 "platform": {"type": "boolean", "description": "Only platform-level entries (platform admins)."},
319 "actor": {"type": "string"},
320 "user_id": {"type": "integer"},
321 "token_id": {"type": "integer"},
322 "action": {"type": "string"},
323 "target": {"type": "string"},
324 "outcome": {"type": "string"},
325 "surface": {"type": "string", "enum": ["mcp", "rest", "cli", "web", "webhook"]},
326 "since": {"type": "integer", "description": "Unix milliseconds, inclusive."},
327 "until": {"type": "integer", "description": "Unix milliseconds, exclusive."},
328 "before": {"type": "integer", "description": "Entries older than this id."},
329 "after": {"type": "integer", "description": "Entries newer than this id, oldest first."},
330 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Default 100."}
331 },
332 "additionalProperties": false
333 }),
334 move |a: Value, c: &Caller| -> Result<Value> {
335 let q: Query = serde_json::from_value(a)
336 .map_err(|e| Error::invalid(format!("bad arguments: {e}")))?;
337 let vis = visibility(c, q.org.as_deref())?;
338 if q.platform && vis != Visibility::All {
339 return Err(Error::Forbidden(
340 "platform-level entries are for platform admins".into(),
341 ));
342 }
343 let entries = l.list(&q, &vis)?;
344 let limit = q.limit.unwrap_or(100).clamp(1, 1000);
345 let next = (entries.len() == limit && q.after.is_none())
346 .then(|| entries.last().map(|e| e.id))
347 .flatten();
348 Ok(json!({"entries": entries, "next_before": next, "head": l.head()?}))
349 },
350 )
351 .title("Audit log")
352 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
353 )?;
354 r.register(
355 Tool::new(
356 "audit_verify",
357 "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.",
358 json!({"type": "object", "properties": {}, "additionalProperties": false}),
359 move |_a: Value, _c: &Caller| -> Result<Value> {
360 let a = log.verify()?;
361 let h = log.history_verify()?;
362 Ok(json!({"ok": a.ok && h.ok, "audit": a, "history": h}))
363 },
364 )
365 .title("Verify the audit log")
366 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
367 )?;
368 Ok(())
369}
370
371pub fn history_visibility(c: &Caller, org: Option<&str>) -> Result<Visibility> {
375 match c {
376 Caller::Local { .. } | Caller::Superadmin(_) => Ok(Visibility::All),
377 Caller::User { principal: p } if p.platform_admin => Ok(Visibility::All),
378 Caller::User { principal: p } => match org {
379 Some(o) => {
380 let o = crate::org::OrgId::new(o)?;
381 if p.role_in(&o).is_some() {
382 Ok(Visibility::Orgs(vec![o.to_string()]))
383 } else {
384 Err(Error::Forbidden(format!("no access to org {o}")))
385 }
386 }
387 None => Ok(Visibility::Orgs(
388 p.orgs.iter().map(|(o, _)| o.to_string()).collect(),
389 )),
390 },
391 _ => Err(Error::Forbidden("sign in to read the history".into())),
392 }
393}
394
395pub fn register_history(r: &mut Registry, log: Arc<AuditLog>) -> Result<()> {
397 r.register(
398 Tool::new(
399 "history_query",
400 "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.",
401 json!({
402 "type": "object",
403 "properties": {
404 "org": {"type": "string"},
405 "platform": {"type": "boolean", "description": "Only host-level rows (platform admins)."},
406 "object": {"type": "string"},
407 "exact": {"type": "boolean"},
408 "kind": {"type": "string", "description": "Glob on the kind or audit action: instance-*, deploy.*, secret_*."},
409 "source": {"type": "string", "description": "audit, controller, incus, marker; comma-separated. Default all."},
410 "actor": {"type": "string", "description": "Glob on who."},
411 "since": {"type": "integer", "description": "Unix milliseconds, inclusive."},
412 "until": {"type": "integer", "description": "Unix milliseconds, exclusive."},
413 "before": {"type": "string", "description": "The `next` of the previous page."},
414 "limit": {"type": "integer", "minimum": 1, "maximum": 1000},
415 "ascending": {"type": "boolean"},
416 "correlate": {"type": "boolean"}
417 },
418 "additionalProperties": false
419 }),
420 move |a: Value, c: &Caller| -> Result<Value> {
421 let q: crate::history::HistoryQuery = serde_json::from_value(a)
422 .map_err(|e| Error::invalid(format!("bad arguments: {e}")))?;
423 let hvis = history_visibility(c, q.org.as_deref())?;
424 if q.platform && hvis != Visibility::All {
425 return Err(Error::Forbidden(
426 "host-level history is for platform admins".into(),
427 ));
428 }
429 let avis = visibility(c, q.org.as_deref()).ok();
430 Ok(serde_json::to_value(log.timeline(&q, &hvis, avis.as_ref())?)?)
431 },
432 )
433 .title("History")
434 .annotations(json!({"readOnlyHint": true, "openWorldHint": false})),
435 )
436}
437
438fn param(req: &Request, key: &str) -> Option<String> {
439 req.query.as_deref()?.split('&').find_map(|kv| {
440 let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
441 (k == key).then(|| v.to_string())
442 })
443}
444
445pub fn stream_route(log: Arc<AuditLog>, users: Arc<AuthStore>) -> crate::server::Routes {
448 Arc::new(move |req: &Request| {
449 if req.path == "/api/v1/history/stream" {
450 return Some(history_stream(req, &log, &users));
451 }
452 if req.path != "/api/v1/audit/stream" {
453 return None;
454 }
455 if req.method != "GET" {
456 return Some(Response::text(405, "method not allowed").header("Allow", "GET"));
457 }
458 let err = |status: u16, code: &str, m: &str| {
459 Response::json(status, &json!({"error": code, "message": m}))
460 };
461 let Some(p) = users.principal_from_request(req) else {
462 return Some(err(401, "unauthenticated", "sign in first"));
463 };
464 let caller = Caller::User {
465 principal: Arc::new(p),
466 };
467 let org = param(req, "org").filter(|o| !o.is_empty());
468 let vis = match visibility(&caller, org.as_deref()) {
469 Ok(v) => v,
470 Err(e) => return Some(err(403, "forbidden", &e.to_string())),
471 };
472 let mut q = Query {
473 org,
474 ..Default::default()
475 };
476 let start = req
477 .header("last-event-id")
478 .map(String::from)
479 .or_else(|| param(req, "after"))
480 .and_then(|s| s.parse::<i64>().ok());
481 let log = log.clone();
482 let mut after = match start {
483 Some(a) => a,
484 None => log.head().unwrap_or(0),
485 };
486 let mut seen = log.generation();
487 Some(Response::stream(
488 200,
489 "text/event-stream",
490 Box::new(move |w: &mut dyn std::io::Write| {
491 loop {
492 q.after = Some(after);
493 q.limit = Some(200);
494 let rows = log.list(&q, &vis).map_err(std::io::Error::other)?;
495 for e in &rows {
496 let data = serde_json::to_string(e).unwrap_or_default();
497 write!(w, "id: {}\nevent: audit\ndata: {data}\n\n", e.id)?;
498 after = e.id;
499 }
500 if rows.is_empty() {
501 w.write_all(b": keepalive\n\n")?;
502 }
503 w.flush()?;
504 if rows.len() < 200 {
505 seen = log.wait_change(seen, Duration::from_secs(15));
506 }
507 }
508 }),
509 ))
510 })
511}
512
513fn history_stream(req: &Request, log: &Arc<AuditLog>, users: &Arc<AuthStore>) -> Response {
517 use crate::history::Item;
518 if req.method != "GET" {
519 return Response::text(405, "method not allowed").header("Allow", "GET");
520 }
521 let err = |status: u16, code: &str, m: &str| {
522 Response::json(status, &json!({"error": code, "message": m}))
523 };
524 let Some(p) = users.principal_from_request(req) else {
525 return err(401, "unauthenticated", "sign in first");
526 };
527 let caller = Caller::User {
528 principal: Arc::new(p),
529 };
530 let org = param(req, "org").filter(|o| !o.is_empty());
531 let hvis = match history_visibility(&caller, org.as_deref()) {
532 Ok(v) => v,
533 Err(e) => return err(403, "forbidden", &e.to_string()),
534 };
535 let avis = visibility(&caller, org.as_deref()).ok();
536 let start = req
537 .header("last-event-id")
538 .map(String::from)
539 .or_else(|| param(req, "after"))
540 .and_then(|s| {
541 let (a, h) = s.split_once('.')?;
542 Some((a.parse::<i64>().ok()?, h.parse::<i64>().ok()?))
543 });
544 let (mut a_after, mut h_after) = match start {
545 Some(x) => x,
546 None => (log.head().unwrap_or(0), log.history_head().unwrap_or(0)),
547 };
548 let log = log.clone();
549 let mut seen = log.generation();
550 Response::stream(
551 200,
552 "text/event-stream",
553 Box::new(move |w: &mut dyn std::io::Write| {
554 loop {
555 let mut items: Vec<Item> = Vec::new();
556 let rows = log
557 .history_after(h_after, &hvis, 200)
558 .map_err(std::io::Error::other)?;
559 if let Some(l) = rows.last() {
560 h_after = l.id;
561 }
562 items.extend(
563 rows.into_iter()
564 .filter(|r| org.is_none() || r.org == org)
565 .map(Item::from_record),
566 );
567 if let Some(av) = &avis {
568 let q = Query {
569 org: org.clone(),
570 after: Some(a_after),
571 limit: Some(200),
572 ..Default::default()
573 };
574 let rows = log.list(&q, av).map_err(std::io::Error::other)?;
575 if let Some(l) = rows.last() {
576 a_after = l.id;
577 }
578 items.extend(rows.into_iter().map(Item::from_audit));
579 }
580 items.sort_by_key(|i| i.time);
581 for it in &items {
582 let data = serde_json::to_string(it).unwrap_or_default();
583 write!(
584 w,
585 "id: {a_after}.{h_after}\nevent: history\ndata: {data}\n\n"
586 )?;
587 }
588 if items.is_empty() {
589 w.write_all(b": keepalive\n\n")?;
590 }
591 w.flush()?;
592 seen = log.wait_change(seen, Duration::from_secs(15));
593 }
594 }),
595 )
596}
597
598pub fn audited_webhooks(inner: crate::server::Routes, log: Arc<AuditLog>) -> crate::server::Routes {
601 Arc::new(move |req: &Request| {
602 let r = inner(req)?;
603 let Some((org, app)) = req
604 .path
605 .strip_prefix("/api/v1/webhooks/")
606 .and_then(|p| p.split_once('/'))
607 else {
608 return Some(r);
609 };
610 if req.method != "POST" || r.status == 404 && crate::org::OrgId::new(org).is_err() {
611 return Some(r);
612 }
613 let h = |n: &str| req.header(n).is_some();
614 let provider = if h("x-hub-signature-256") {
615 "github"
616 } else if h("x-gitea-signature") || h("x-forgejo-signature") {
617 "gitea"
618 } else if h("x-gitlab-token") {
619 "gitlab"
620 } else {
621 "token"
622 };
623 let body: Value = serde_json::from_slice(&r.body).unwrap_or(Value::Null);
624 let outcome = match r.status {
625 202 => "ok",
626 200 if body.get("ignored").is_some() => "ignored",
627 200 => "ok",
628 401 => "unauthorized",
629 404 => "not_found",
630 400 => "invalid",
631 _ => "error",
632 };
633 let event = req
634 .header("x-github-event")
635 .or_else(|| req.header("x-gitea-event"))
636 .or_else(|| req.header("x-forgejo-event"))
637 .or_else(|| req.header("x-gitlab-event"));
638 let mut args = json!({"org": org, "app": app, "status": r.status});
639 if let Some(e) = event {
640 args["event"] = json!(e);
641 }
642 if let Some(d) = body.get("deployment") {
643 args["deployment"] = d.clone();
644 }
645 let details = safe_details(&args);
646 let e = NewEntry {
647 org: crate::org::OrgId::new(org).ok().map(|o| o.to_string()),
648 actor: Actor::webhook(provider),
649 origin: Origin {
650 surface: "webhook".into(),
651 ip: crate::auth::http::client_ip(req),
652 user_agent: req.header("user-agent").map(String::from),
653 request_id: req
654 .header("x-github-delivery")
655 .or_else(|| req.header("x-gitea-delivery"))
656 .or_else(|| req.header("x-gitlab-event-uuid"))
657 .map(String::from),
658 },
659 action: "webhook.deploy".into(),
660 target: Some(app.to_string()),
661 details,
662 outcome: outcome.into(),
663 };
664 if let Err(err) = log.append(e) {
665 eprintln!("isb serve: {err}");
666 }
667 Some(r)
668 })
669}
670
671#[cfg(test)]
672#[path = "audit_tests.rs"]
673mod tests;