Skip to main content

isb_daemon/daemon/
audit.rs

1//! The daemon's side of the audit log ([`crate::audit`]): which calls are
2//! recorded and what of them is kept, the tool classes that viewers and
3//! token scopes are judged by, the `audit_list` and `audit_verify` tools, the
4//! live tail (`GET /api/v1/audit/stream`) and webhook deliveries.
5//!
6//! Recording happens at the dispatch hook ([`crate::server::mcp::Audit`]),
7//! so every tool is covered, whoever registered it.
8
9use 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
21/// Read-only tools that hand out secret material. Always recorded, refused
22/// to viewers and to `read`/`deploy` tokens. A tool can also say so itself
23/// with the annotation `"isbSecretRead": true`.
24pub const SECRET_READS: &[&str] = &["secret_get", "secret_resolve", "app_webhook"];
25
26/// What the `deploy` scope adds to `read`.
27pub 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
38/// Argument keys whose scalar values may be kept: names and identifiers,
39/// never values. Anything else (`value`, `password`, `env`, `vars`,
40/// `compose`, `secrets`, `argv`, `stdin`, URLs) is dropped.
41const 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
83/// The target, in order of preference.
84const 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/// How a tool is judged.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub struct Class {
99    /// Only reads, and no secret material.
100    pub read_only: bool,
101    /// Hands out secret material.
102    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
120/// One call's class: [`class`], made a secret read when the tool names a
121/// boolean argument (annotation `isbSecretReadArg`) that the call sets,
122/// such as `database_get`'s `reveal`. Viewers and `read` tokens then get the
123/// tool without the values, and every reveal is recorded.
124pub 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
140/// May a token with `scopes` call `tool`? Empty scopes: anything the role allows.
141pub 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
151/// Who a caller is, for a row.
152pub 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
172/// The scalar values of whitelisted keys, each at most 128 characters.
173pub 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
200/// `ok`, or the error's code.
201pub 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
211/// The row's org: the one a call acts in; `None` for org-less calls.
212fn 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
225/// The row for a call, or `None` when it is not worth recording: reads are
226/// skipped unless `record_all`, but secret reads and refusals never are.
227pub 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    // A superadmin over HTTP is recorded whatever it does, reads included.
232    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    // The org tools act on the org they name.
238    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
254/// The dispatch hook: append, and never fail the call.
255pub 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
265/// What a caller may read of the log, for `audit_list` and the tail:
266/// platform admins and local callers everything; an org's owners and admins
267/// that org; nobody else anything.
268pub 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                // Without an org: every org this principal manages.
275                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
301/// `audit_list` and `audit_verify`.
302pub 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
365/// What of the history a caller may read: platform admins and local
366/// callers everything; anyone else the orgs they belong to (any role),
367/// never host-level rows.
368pub 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
389/// `history_query`.
390pub 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
439/// `GET /api/v1/audit/stream?org=ORG&after=ID`: new entries as server-sent
440/// events (`event: audit`), only those the caller may read. Signed in with a session or a token.
441pub 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
507/// `GET /api/v1/history/stream?org=ORG&after=AUDIT.HISTORY`: new timeline
508/// items (`event: history`), only what the caller may read; the event id is
509/// the cursor to resume from.
510fn 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
592/// Records every delivery to `/api/v1/webhooks/<org>/<app>` (actor
593/// `webhook:<provider>`): deployed, ignored or refused.
594pub 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    /// An endpoint with the daemon's authorizer and audit hook over a few
703    /// stand-in tools, and callers picked by `Authorization: Bearer NAME`.
704    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        // A write by an agent's token over REST.
844        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        // A deploy with secrets in every place they can hide.
871        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        // A secret read is recorded; a plain read is not.
891        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        // Refusals are recorded with their code: a viewer writing, a read
912        // token reading a secret, someone outside the org.
913        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        // A refusal on an org-bound endpoint is filed under that org.
934        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        // The local CLI over the socket.
950        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        // Not a byte of any value in the database or its WAL.
959        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        // An org admin: their org only, with or without naming it.
1005        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        // Members, viewers and tokens below admin: refused.
1010        for who in ["member", "viewer", "agent"] {
1011            let r = list(who, json!({"org": "acme"}));
1012            assert_eq!(r["isError"], true, "{who}");
1013        }
1014        // A platform admin: everything, platform-level rows included.
1015        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        // audit_verify is for platform admins.
1022        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}