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    "sandbox_start",
37    "sandbox_stop",
38];
39
40/// Argument keys whose scalar values may be kept: names and identifiers,
41/// never values. Anything else (`value`, `password`, `env`, `vars`,
42/// `compose`, `secrets`, `argv`, `stdin`, URLs) is dropped.
43const 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
89/// The target, in order of preference.
90const 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/// How a tool is judged.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub struct Class {
105    /// Only reads, and no secret material.
106    pub read_only: bool,
107    /// Hands out secret material.
108    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
126/// One call's class: [`class`], made a secret read when the tool names a
127/// boolean argument (annotation `isbSecretReadArg`) that the call sets,
128/// such as `database_get`'s `reveal`. Viewers and `read` tokens then get the
129/// tool without the values, and every reveal is recorded.
130pub 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
146/// May a token with `scopes` call `tool`? Empty scopes: anything the role allows.
147pub 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
157/// Who a caller is, for a row.
158pub 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
178/// The scalar values of whitelisted keys, each at most 128 characters.
179pub 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
206/// `ok`, or the error's code.
207pub 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
217/// The row's org: the one a call acts in; `None` for org-less calls.
218fn 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
231/// The row for a call, or `None` when it is not worth recording: reads are
232/// skipped unless `record_all`, but secret reads and refusals never are.
233pub 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    // A superadmin over HTTP is recorded whatever it does, reads included.
238    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    // The org tools act on the org they name.
244    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
260/// The dispatch hook: append, and never fail the call.
261pub 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
271/// What a caller may read of the log, for `audit_list` and the tail:
272/// platform admins and local callers everything; an org's owners and admins
273/// that org; nobody else anything.
274pub 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                // Without an org: every org this principal manages.
281                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
307/// `audit_list` and `audit_verify`.
308pub 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
371/// What of the history a caller may read: platform admins and local
372/// callers everything; anyone else the orgs they belong to (any role),
373/// never host-level rows.
374pub 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
395/// `history_query`.
396pub 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
445/// `GET /api/v1/audit/stream?org=ORG&after=ID`: new entries as server-sent
446/// events (`event: audit`), only those the caller may read. Signed in with a session or a token.
447pub 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
513/// `GET /api/v1/history/stream?org=ORG&after=AUDIT.HISTORY`: new timeline
514/// items (`event: history`), only what the caller may read; the event id is
515/// the cursor to resume from.
516fn 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
598/// Records every delivery to `/api/v1/webhooks/<org>/<app>` (actor
599/// `webhook:<provider>`): deployed, ignored or refused.
600pub 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;