diff --git a/README.md b/README.md index b5062843..7f1b86da 100644 --- a/README.md +++ b/README.md @@ -817,6 +817,60 @@ requires a controller build with a compiled-in authentication provider; the standard executable rejects it at startup. Device enrollment is configured separately. See the [controller configuration schema](schema/controller-config.md). +### Management audit log + +The controller records every management mutation and every denied management +request. Each record has the actor (credential kind, issuer, subject and, when +the identity provider supplies them, email and name), the action (a permission +name such as `devices.delete`), the target, the outcome, the time, and the +request: method, path, peer address, `User-Agent`, and `X-Forwarded-For` as +received. A mutation produces two records: + +- `action.attempted` is written before the handler acts. If it cannot be + written, the request fails with `503 audit_unavailable` and nothing changes. +- `action.completed` carries the outcome and HTTP status. + +A mutating request that succeeds or fails with a server error without any +permission check is recorded with outcome `unaudited`. Allowed reads are not +recorded. Compiled-in authentication providers record their own sign-in, +sign-out and reauthentication events through `audit::AuditLog`. + +The device-facing fleet API records: + +- `action.attempted` and `action.completed` with action `devices.enroll`: a + signed-in user enrolled a device. The actor is that user and the target is the + new device, with the hostname it reported. The device is admitted only after + the attempt is recorded; if it cannot be, the daemon gets `unavailable`. +- `device.enrollment_denied`: an enrollment was refused after the user's ID + token was verified (nonce or token mismatch, rejected access token, invalid + certificate request). Earlier failures name nobody and are not recorded, + because anyone can cause them on the public fleet API. +- `device.certificate_renewed`: a device renewed its certificate. The actor has + kind `device` and the device ID as subject. It fails closed like enrollment. + +Records are written to two places: + +- **Stdout**, one JSON object per line, synchronously and separately from + operational logs (`LOG_FORMAT` does not affect them). Route lines whose + `schema` is `agentdesktop.audit.v1` to your SIEM with the cluster's log + shipper. +- **The `admin_audit_events` table**, shown on the controller's Audit page + (`GET /api/v1/audit?before=&limit=`, permission `audit.read`). The + table is append-only: triggers reject `UPDATE` and `DELETE` (and `TRUNCATE` on + PostgreSQL), and the controller never prunes it. + +Each stdout line also carries `stream: { "id": ..., "seq": ... }`. Every +controller process picks a random stream `id` at startup and numbers its lines +1, 2, 3, ... in the order it writes them. A number is used up even when its line +fails to write, so a gap within one stream means a line was lost on the way to +the SIEM; alert on such gaps in the SIEM. A restart starts a new stream at 1. +The record's own `seq` orders the Audit page and may have gaps, so it says +nothing about missing events. + +Someone with database access can drop the triggers and edit the table, so treat +the SIEM as the record of truth. On PostgreSQL, also revoke `UPDATE`, `DELETE` +and `TRUNCATE` on `admin_audit_events` from roles that do not need them. + ### Configuration status Each apply reports the outcome for every managed program, next to the diff --git a/crates/controller/Cargo.toml b/crates/controller/Cargo.toml index 5cb495a4..3acbeefc 100644 --- a/crates/controller/Cargo.toml +++ b/crates/controller/Cargo.toml @@ -36,7 +36,7 @@ tokio.workspace = true tokio-stream.workspace = true tonic.workspace = true tracing.workspace = true -time.workspace = true +time = { workspace = true, features = ["formatting"] } url.workspace = true uuid.workspace = true x509-parser.workspace = true diff --git a/crates/controller/migrations/0005_admin_audit_postgres.sql b/crates/controller/migrations/0005_admin_audit_postgres.sql new file mode 100644 index 00000000..10e7c5b3 --- /dev/null +++ b/crates/controller/migrations/0005_admin_audit_postgres.sql @@ -0,0 +1,29 @@ +-- Append-only management audit log (PostgreSQL). The triggers reject updates, +-- deletes and truncation. `seq` orders events for paging; the identity column +-- assigns it on insert. +CREATE TABLE admin_audit_events ( + seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + time_ms BIGINT NOT NULL, + event TEXT NOT NULL, + actor_subject TEXT NOT NULL, + action TEXT, + target_id TEXT, + outcome TEXT NOT NULL, + event_json TEXT NOT NULL +); + +CREATE INDEX admin_audit_events_time_idx ON admin_audit_events(time_ms); + +CREATE FUNCTION admin_audit_events_append_only() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'admin_audit_events is append-only'; END + $$; + +CREATE TRIGGER admin_audit_events_no_change + BEFORE UPDATE OR DELETE ON admin_audit_events + FOR EACH ROW EXECUTE FUNCTION admin_audit_events_append_only(); + +CREATE TRIGGER admin_audit_events_no_truncate + BEFORE TRUNCATE ON admin_audit_events + FOR EACH STATEMENT EXECUTE FUNCTION admin_audit_events_append_only(); diff --git a/crates/controller/migrations/0005_admin_audit_sqlite.sql b/crates/controller/migrations/0005_admin_audit_sqlite.sql new file mode 100644 index 00000000..087602b9 --- /dev/null +++ b/crates/controller/migrations/0005_admin_audit_sqlite.sql @@ -0,0 +1,24 @@ +-- Append-only management audit log (SQLite). The triggers reject updates and +-- deletes. `seq` orders events for paging: as an INTEGER PRIMARY KEY it is the +-- rowid, which SQLite assigns on insert. +CREATE TABLE admin_audit_events ( + seq INTEGER PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + time_ms BIGINT NOT NULL, + event TEXT NOT NULL, + actor_subject TEXT NOT NULL, + action TEXT, + target_id TEXT, + outcome TEXT NOT NULL, + event_json TEXT NOT NULL +); + +CREATE INDEX admin_audit_events_time_idx ON admin_audit_events(time_ms); + +CREATE TRIGGER admin_audit_events_no_update +BEFORE UPDATE ON admin_audit_events +BEGIN SELECT RAISE(ABORT, 'admin_audit_events is append-only'); END; + +CREATE TRIGGER admin_audit_events_no_delete +BEFORE DELETE ON admin_audit_events +BEGIN SELECT RAISE(ABORT, 'admin_audit_events is append-only'); END; diff --git a/crates/controller/src/application.rs b/crates/controller/src/application.rs index 6620999a..1c702e62 100644 --- a/crates/controller/src/application.rs +++ b/crates/controller/src/application.rs @@ -26,7 +26,11 @@ pub async fn run( extensions: management::Extensions, ) -> anyhow::Result<()> { // Fail before any network setup when the access mode has no provider. - management::auth(&config.admin_access, &extensions)?; + management::auth( + &config.admin_access, + &extensions, + crate::audit::AuditLog::new(database.clone()), + )?; let tls = config.tls.files(); let daemon_config = match &config.daemon_config { Some(daemon) => Some(daemon_config::load(&daemon.path, daemon.revision)?), diff --git a/crates/controller/src/audit.rs b/crates/controller/src/audit.rs new file mode 100644 index 00000000..91d59d6a --- /dev/null +++ b/crates/controller/src/audit.rs @@ -0,0 +1,551 @@ +//! Controller audit log, shared by the management API and the fleet API. +//! +//! Each event is appended to the append-only `admin_audit_events` table and then +//! written as one JSON line to stdout, where the deployment's log shipper forwards +//! it to a SIEM. Lines are recognised by `"schema": "agentdesktop.audit.v1"`. +//! +//! Each line also carries `stream: { id, seq }`. Every controller process picks a +//! random stream `id` at startup and numbers its lines 1, 2, 3, ... with `seq`, in +//! the order it writes them. A number is used up even when its line fails to +//! write, so a gap within one stream means a line was lost between the controller +//! and the SIEM. Detecting those gaps is the SIEM's job. A restart starts a new +//! stream at 1. +//! +//! The router records authorization denials and every mutation (an +//! `action.attempted` event before the handler acts and an `action.completed` +//! event with its outcome). Compiled-in authentication providers record their own +//! session events (sign-in, sign-out, reauthentication) with [`AuditLog::record`]. +//! The fleet API records device enrollment and certificate renewal. + +use std::{ + io::{self, Write}, + net::SocketAddr, +}; + +use anyhow::Context as _; +use axum::http::{HeaderMap, Method, Uri, header}; +use serde::Serialize; +use serde_json::{Map, Value}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::database::{ + Database, + audit::{AuditColumns, AuditRow}, +}; + +pub const SCHEMA: &str = "agentdesktop.audit.v1"; + +/// Every kind of audit event. The names are part of the `agentdesktop.audit.v1` +/// schema that SIEM rules match on, so adding or renaming one changes the schema. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventKind { + /// A permission check failed. Recorded for reads too: repeated denials are + /// the signal of probing or of a stolen low-privilege credential. + AuthorizationDenied, + /// Written before a mutation runs, and the request is refused if it cannot be + /// written (fail closed). It is the only trace of a change whose process dies + /// before `action.completed` is written. Only mutations produce it, and those + /// are rare operator actions, so it adds one line per change. + ActionAttempted, + /// The mutation's outcome (and HTTP status, for management requests), linked to + /// its attempt by `request.id`. + ActionCompleted, + /// Interactive sign-in. Recorded by providers, not by the controller. + AuthLogin, + AuthLogout, + /// A fresh interactive sign-in the user makes when a mutation requires recent + /// authentication (`/auth/login?reauthenticate=true`). Silent session or token + /// refreshes are not reauthentication and must not be recorded as such. + AuthReauthenticate, + /// An enrollment was refused after the user's ID token was verified, for + /// example a nonce or token subject mismatch or an invalid certificate + /// request. Failures before that name nobody and are not recorded, because + /// anyone can cause them on the public fleet API. + DeviceEnrollmentDenied, + /// A device renewed its certificate. Written before the certificate is + /// returned; if it cannot be written, the renewal is refused (fail closed). + DeviceCertificateRenewed, +} + +impl EventKind { + pub fn as_str(self) -> &'static str { + match self { + Self::AuthorizationDenied => "authorization.denied", + Self::ActionAttempted => "action.attempted", + Self::ActionCompleted => "action.completed", + Self::AuthLogin => "auth.login", + Self::AuthLogout => "auth.logout", + Self::AuthReauthenticate => "auth.reauthenticate", + Self::DeviceEnrollmentDenied => "device.enrollment_denied", + Self::DeviceCertificateRenewed => "device.certificate_renewed", + } + } +} + +impl Serialize for EventKind { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +const MAX_HEADER_LENGTH: usize = 512; +const MAX_PATH_LENGTH: usize = 2048; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Permission granted for a mutation that is about to run. + Authorized, + Denied, + Succeeded, + Failed, + NotFound, + /// A mutating request finished without any mutation permission check. + Unaudited, +} + +impl Outcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Authorized => "authorized", + Self::Denied => "denied", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::NotFound => "not_found", + Self::Unaudited => "unaudited", + } + } +} + +impl Serialize for Outcome { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Who acted, from verified credential evidence only. +/// +/// This is the audit schema's view of a +/// [`Principal`](crate::management::auth::authentication::Principal), not a copy of it. It keeps +/// a stable field layout for SIEM parsers, independent of internal refactors +/// (`Principal` is not `Serialize`). It also keeps only the identifying claims +/// (email, name) rather than the full verified claim snapshot, which would put +/// tokens' group lists and other personal data into every record. +#[derive(Clone, Debug, Serialize)] +pub struct Actor { + pub kind: &'static str, + pub issuer: String, + pub subject: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + pub claims_verified_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Actor { + /// A person who signed in with the enrollment identity provider; `claims` are + /// the verified ID token claims. + pub fn oidc_user( + issuer: &str, + subject: &str, + claims: &std::collections::BTreeMap, + ) -> Self { + let claim = |name: &str| claims.get(name).and_then(Value::as_str).map(str::to_owned); + Self { + kind: "human", + issuer: issuer.to_owned(), + subject: subject.to_owned(), + client_id: None, + claims_verified_at: OffsetDateTime::now_utc().unix_timestamp(), + email: claim("email"), + name: claim("name"), + } + } + + /// A device, identified by the certificate the controller's device CA issued it. + pub fn device(device_id: &str) -> Self { + Self { + kind: "device", + issuer: "device-ca".to_owned(), + subject: device_id.to_owned(), + client_id: None, + claims_verified_at: OffsetDateTime::now_utc().unix_timestamp(), + email: None, + name: None, + } + } +} + +/// What was acted on: a policy resource class and, unless the operation applies to +/// a collection, the concrete resource ID. +#[derive(Clone, Debug, Serialize)] +pub struct Target { + pub class: String, + pub id: Option, + /// Display name, e.g. the hostname a device reports for itself. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// The HTTP request an event belongs to. `forwarded_for` is recorded as received +/// and is only meaningful when a trusted proxy sets it. +#[derive(Clone, Debug, Serialize)] +pub struct RequestInfo { + pub id: String, + pub method: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub peer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forwarded_for: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_agent: Option, +} + +impl RequestInfo { + pub fn new(method: &Method, uri: &Uri, headers: &HeaderMap, peer: Option) -> Self { + let header = |name| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| truncate(value, MAX_HEADER_LENGTH)) + }; + Self { + id: uuid::Uuid::new_v4().to_string(), + method: method.to_string(), + path: truncate(uri.path(), MAX_PATH_LENGTH), + peer: peer.map(|peer| peer.to_string()), + forwarded_for: header(header::HeaderName::from_static("x-forwarded-for")), + user_agent: header(header::USER_AGENT), + } + } +} + +fn truncate(value: &str, limit: usize) -> String { + match value.char_indices().nth(limit) { + Some((end, _)) => value[..end].to_owned(), + None => value.to_owned(), + } +} + +/// One audit event. Build it with [`AuditEvent::new`] and the setters, then pass it +/// to [`AuditLog::record`]. +/// +/// Every record answers the audit questions: +/// - **Who**: `actor` (credential kind, issuer, subject, and email/name if known). +/// - **What**: `event` (kind of record) and `action` (the permission exercised). +/// - **Which**: `target` (resource class and, unless it is a collection, its ID). +/// - **When**: `time`, set by [`AuditLog::record`]; `seq` orders events. +/// - **Where from**: `request.peer`, `request.forwarded_for` and `request.user_agent`. +/// - **How**: `request.method` and `request.path`; `request.id` links the +/// attempted and completed records of one request. +/// - **Result and why**: `outcome`, `status` and `reason`. +#[derive(Clone, Debug)] +pub struct AuditEvent { + pub event: EventKind, + pub actor: Actor, + pub outcome: Outcome, + /// The operation: for management requests, the permission it required (e.g. + /// `devices.delete`); for device enrollment, `devices.enroll`. + pub action: Option, + pub target: Option, + /// Stable error code explaining a denial or failure. + pub reason: Option, + /// HTTP response status, for completed actions. + pub status: Option, + pub request: Option, +} + +impl AuditEvent { + pub fn new(event: EventKind, actor: Actor, outcome: Outcome) -> Self { + Self { + event, + actor, + outcome, + action: None, + target: None, + reason: None, + status: None, + request: None, + } + } + + pub fn action(mut self, action: impl Into) -> Self { + self.action = Some(action.into()); + self + } + + pub fn target(mut self, class: impl Into, id: Option<&str>) -> Self { + self.target = Some(Target { + class: class.into(), + id: id.map(str::to_owned), + name: None, + }); + self + } + + /// Sets the display name of the target set by [`AuditEvent::target`]. + pub fn target_name(mut self, name: impl Into) -> Self { + if let Some(target) = &mut self.target { + target.name = Some(name.into()); + } + self + } + + pub fn reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + pub fn status(mut self, status: u16) -> Self { + self.status = Some(status); + self + } + + pub fn request(mut self, request: RequestInfo) -> Self { + self.request = Some(request); + self + } +} + +/// The hashed form of an event, stored verbatim as `event_json`. +#[derive(Serialize)] +struct StoredEvent<'a> { + schema: &'static str, + id: &'a str, + time: String, + event: EventKind, + actor: &'a Actor, + action: Option<&'a str>, + target: Option<&'a Target>, + outcome: Outcome, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + request: Option<&'a RequestInfo>, +} + +/// Stored event plus its `seq`, as streamed and served by the API. +pub fn published(row: &AuditRow) -> anyhow::Result> { + let mut event: Map = + serde_json::from_str(&row.event_json).context("parse stored admin audit event")?; + event.insert("seq".into(), row.seq.into()); + Ok(event) +} + +/// Numbers the lines of one stream. The counter is held while the line is +/// written, so line order always matches number order. +struct Numbering { + id: String, + last: std::sync::Mutex, +} + +impl Numbering { + fn new() -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + last: std::sync::Mutex::new(0), + } + } + + /// Takes the next number, adds `stream` to `event` and writes it as one line. + /// The number is used up even if `write` fails, which leaves a visible gap. + fn write( + &self, + mut event: Map, + write: impl FnOnce(&[u8]) -> io::Result<()>, + ) -> anyhow::Result<()> { + let mut last = self + .last + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + *last += 1; + event.insert( + "stream".into(), + serde_json::json!({ "id": self.id, "seq": *last }), + ); + let mut line = serde_json::to_vec(&event).context("serialize admin audit stream line")?; + line.push(b'\n'); + write(&line).context("write admin audit stream") + } +} + +/// Every process writes one stdout stream, shared by all `AuditLog`s. +static STDOUT: std::sync::LazyLock = std::sync::LazyLock::new(Numbering::new); + +#[derive(Clone)] +enum Stream { + Stdout, + #[cfg(test)] + Memory( + std::sync::Arc, + std::sync::Arc>>, + ), + #[cfg(test)] + Broken(std::sync::Arc), +} + +impl Stream { + async fn write(&self, event: Map) -> anyhow::Result<()> { + match self { + // Written synchronously rather than through the lossy non-blocking + // tracing writer; one locked write keeps the line whole. + Self::Stdout => tokio::task::spawn_blocking(move || { + STDOUT.write(event, |line| { + let mut stdout = io::stdout().lock(); + stdout.write_all(line)?; + stdout.flush() + }) + }) + .await + .context("join admin audit stream writer")?, + #[cfg(test)] + Self::Memory(numbering, lines) => numbering.write(event, |line| { + lines + .lock() + .unwrap() + .push(String::from_utf8(line.to_vec()).unwrap()); + Ok(()) + }), + #[cfg(test)] + Self::Broken(numbering) => numbering.write(event, |_| { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "admin audit stream is closed", + )) + }), + } + } +} + +/// Durable audit sink shared by the router and compiled-in providers. Instances +/// built from the same database share one `seq` numbering. +#[derive(Clone)] +pub struct AuditLog { + database: Database, + stream: Stream, +} + +impl AuditLog { + pub fn new(database: Database) -> Self { + Self { + database, + stream: Stream::Stdout, + } + } + + #[cfg(test)] + pub(crate) fn in_memory( + database: Database, + ) -> (Self, std::sync::Arc>>) { + let lines = std::sync::Arc::default(); + ( + Self { + database, + stream: Stream::Memory( + std::sync::Arc::new(Numbering::new()), + std::sync::Arc::clone(&lines), + ), + }, + lines, + ) + } + + #[cfg(test)] + pub(crate) fn broken_stream(database: Database) -> Self { + Self { + database, + stream: Stream::Broken(std::sync::Arc::new(Numbering::new())), + } + } + + pub fn database(&self) -> &Database { + &self.database + } + + /// Persists the event, then streams it. An error means the event may not have + /// reached both sinks; callers about to act must not proceed. The work runs in + /// its own task, so a cancelled caller cannot leave a stored event unstreamed. + pub async fn record(&self, event: AuditEvent) -> anyhow::Result { + let log = self.clone(); + tokio::spawn(async move { log.write(event).await }) + .await + .context("join admin audit writer")? + } + + async fn write(&self, event: AuditEvent) -> anyhow::Result { + let id = uuid::Uuid::new_v4().to_string(); + let now = OffsetDateTime::now_utc(); + let time = now.format(&Rfc3339).context("format admin audit time")?; + let event_json = serde_json::to_string(&StoredEvent { + schema: SCHEMA, + id: &id, + time, + event: event.event, + actor: &event.actor, + action: event.action.as_deref(), + target: event.target.as_ref(), + outcome: event.outcome, + reason: event.reason.as_deref(), + status: event.status, + request: event.request.as_ref(), + }) + .context("serialize admin audit event")?; + let seq = self + .database + .append_audit_event( + &AuditColumns { + id: &id, + time_ms: (now.unix_timestamp_nanos() / 1_000_000) + .try_into() + .unwrap_or(i64::MAX), + event: event.event.as_str(), + actor_subject: &event.actor.subject, + action: event.action.as_deref(), + target_id: event + .target + .as_ref() + .and_then(|target| target.id.as_deref()), + outcome: event.outcome.as_str(), + }, + &event_json, + ) + .await?; + self.stream + .write(published(&AuditRow { seq, event_json })?) + .await?; + Ok(seq) + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use serde_json::{Map, Value, json}; + + use super::Numbering; + + #[test] + fn a_line_that_fails_to_write_leaves_a_gap_in_its_stream() { + let numbering = Numbering::new(); + let mut lines = Vec::new(); + let mut capture = |line: &[u8]| { + lines.push(serde_json::from_slice::(line).unwrap()); + Ok(()) + }; + numbering.write(Map::new(), &mut capture).unwrap(); + numbering + .write(Map::new(), |_| Err(io::Error::other("closed"))) + .unwrap_err(); + numbering.write(Map::new(), &mut capture).unwrap(); + assert_eq!(lines[0]["stream"], json!({ "id": numbering.id, "seq": 1 })); + assert_eq!(lines[1]["stream"], json!({ "id": numbering.id, "seq": 3 })); + // Each process has its own stream. + assert_ne!(Numbering::new().id, numbering.id); + } +} diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index bbc46403..463fd016 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -1,5 +1,9 @@ use anyhow::Context; -use sqlx::{AnyPool, any::AnyPoolOptions}; +use sqlx::{ + AnyPool, + any::AnyPoolOptions, + migrate::{Migration, Migrator}, +}; use std::{collections::BTreeMap, path::PathBuf}; use agentdesktop_core::model::{LocalModel, McpServer, ModelRuntime, Skill}; @@ -9,6 +13,8 @@ use agentdesktop_proto::fleet::{ use agentdesktop_usage::UsageStore; use serde::Serialize; +pub mod audit; + #[derive(Clone)] pub struct Database { pool: AnyPool, @@ -267,15 +273,42 @@ pub(crate) fn readable_name(idp_claims: &str) -> Option { .map(str::to_owned) } +/// Every file in `migrations/`. Most run on every database; see [`migrator`]. +static MIGRATIONS: Migrator = sqlx::migrate!(); + +/// Database-specific migration files end in `_sqlite` or `_postgres` (e.g. +/// `0005_admin_audit_sqlite.sql` and `0005_admin_audit_postgres.sql`, with the same +/// version) and run only on that database. All other files run on both. +fn migrator(url: &str) -> anyhow::Result { + let backend = if url.starts_with("postgres") { + "postgres" + } else if url.starts_with("sqlite") { + "sqlite" + } else { + anyhow::bail!("unsupported database URL; expected sqlite: or postgres:"); + }; + let applies = |migration: &&Migration| match ["sqlite", "postgres"] + .into_iter() + .find(|only| migration.description.ends_with(&format!(" {only}"))) + { + Some(only) => only == backend, + None => true, + }; + Ok(Migrator::with_migrations( + MIGRATIONS.iter().filter(applies).cloned().collect(), + )) +} + impl Database { pub async fn connect(url: &str) -> anyhow::Result { + let migrator = migrator(url)?; sqlx::any::install_default_drivers(); let pool = AnyPoolOptions::new() .max_connections(10) .connect(url) .await .with_context(|| format!("connect to database {url}"))?; - sqlx::migrate!() + migrator .run(&pool) .await .context("run database migrations")?; @@ -765,6 +798,37 @@ fn unix_time_seconds() -> i64 { mod tests { use std::{collections::BTreeMap, path::PathBuf}; + #[test] + fn each_database_runs_shared_and_its_own_migrations_once_per_version() { + for (url, own, other) in [ + ( + "sqlite://controller.db", + "admin audit sqlite", + "admin audit postgres", + ), + ( + "postgres://localhost/controller", + "admin audit postgres", + "admin audit sqlite", + ), + ] { + let migrator = super::migrator(url).unwrap(); + let descriptions: Vec<_> = migrator.iter().map(|m| m.description.as_ref()).collect(); + assert!(descriptions.contains(&"controller"), "{url}"); + assert!(descriptions.contains(&own), "{url}"); + assert!(!descriptions.contains(&other), "{url}"); + let mut versions: Vec<_> = migrator.iter().map(|m| m.version).collect(); + let count = versions.len(); + versions.dedup(); + assert_eq!( + versions.len(), + count, + "{url} has two migrations with one version" + ); + } + assert!(super::migrator("mysql://localhost/controller").is_err()); + } + use agentdesktop_proto::fleet::{ ConfigState, ConfigStatus, Discovery, Inventory, LocalModel, McpServer, ModelRuntime, ProgramState, ProgramStatus, Skill, TelemetryEvent, ToolUseEvent, telemetry_event, diff --git a/crates/controller/src/database/audit.rs b/crates/controller/src/database/audit.rs new file mode 100644 index 00000000..a4445802 --- /dev/null +++ b/crates/controller/src/database/audit.rs @@ -0,0 +1,233 @@ +//! Append-only storage for management audit events. +//! +//! The controller has no code that updates or deletes audit rows, and triggers +//! from the `0005_admin_audit_*` migrations reject `UPDATE` and `DELETE` (and +//! `TRUNCATE` on PostgreSQL), so a bug, a manual query or an SQL injection +//! elsewhere cannot edit the history. The table owner can still drop the +//! triggers; the copy streamed to the SIEM is the tamper-resistant record. +//! +//! `seq` orders events for the Audit page and its API. It is unique and +//! increasing but may have gaps (PostgreSQL identity values are not reused after +//! a failed insert), so it says nothing about missing events. Gap detection is +//! the SIEM's job, using the stream counter in `crate::audit`. + +use anyhow::Context; + +use super::Database; + +/// Indexed columns stored next to the event JSON. +pub struct AuditColumns<'a> { + pub id: &'a str, + pub time_ms: i64, + pub event: &'a str, + pub actor_subject: &'a str, + pub action: Option<&'a str>, + pub target_id: Option<&'a str>, + pub outcome: &'a str, +} + +#[derive(Clone, Debug)] +pub struct AuditRow { + pub seq: i64, + pub event_json: String, +} + +impl Database { + /// Appends one event and returns its `seq`, which the database assigns. + pub async fn append_audit_event( + &self, + columns: &AuditColumns<'_>, + event_json: &str, + ) -> anyhow::Result { + sqlx::query_scalar( + "INSERT INTO admin_audit_events + (id, time_ms, event, actor_subject, action, target_id, outcome, event_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING seq", + ) + .bind(columns.id) + .bind(columns.time_ms) + .bind(columns.event) + .bind(columns.actor_subject) + .bind(columns.action) + .bind(columns.target_id) + .bind(columns.outcome) + .bind(event_json) + .fetch_one(&self.pool) + .await + .context("append admin audit event") + } + + /// Newest events first, strictly older than `before` when given. + pub async fn list_audit_events( + &self, + before: Option, + limit: i64, + ) -> anyhow::Result> { + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT seq, event_json FROM admin_audit_events + WHERE seq < $1 ORDER BY seq DESC LIMIT $2", + ) + .bind(before.unwrap_or(i64::MAX)) + .bind(limit) + .fetch_all(&self.pool) + .await + .context("list admin audit events")?; + Ok(rows + .into_iter() + .map(|(seq, event_json)| AuditRow { seq, event_json }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::AuditColumns; + use crate::database::Database; + + async fn database() -> (Database, std::path::PathBuf) { + let path = std::env::temp_dir().join(format!("audit-log-{}.db", uuid::Uuid::new_v4())); + let database = Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .unwrap(); + (database, path) + } + + fn columns(id: &str) -> AuditColumns<'_> { + AuditColumns { + id, + time_ms: 1, + event: "action.attempted", + actor_subject: "alice", + action: Some("devices.delete"), + target_id: None, + outcome: "authorized", + } + } + + async fn stored_seqs(database: &Database) -> Vec { + sqlx::query_scalar("SELECT seq FROM admin_audit_events ORDER BY seq") + .fetch_all(database.pool()) + .await + .unwrap() + } + + /// Appends `count` events from concurrent tasks, alternating between databases + /// (standing in for controller replicas). + async fn append_concurrently(databases: &[Database], count: usize) { + let appends: Vec<_> = (0..count) + .map(|n| { + let database = databases[n % databases.len()].clone(); + tokio::spawn(async move { + let id = uuid::Uuid::new_v4().to_string(); + database + .append_audit_event(&columns(&id), &format!(r#"{{"n":{n}}}"#)) + .await + }) + }) + .collect(); + for append in appends { + append.await.unwrap().unwrap(); + } + } + + #[tokio::test] + async fn appends_number_events_in_order_and_list_newest_first() { + let (database, path) = database().await; + assert_eq!( + database + .append_audit_event(&columns("a"), r#"{"n":1}"#) + .await + .unwrap(), + 1 + ); + assert_eq!( + database + .append_audit_event(&columns("b"), r#"{"n":2}"#) + .await + .unwrap(), + 2 + ); + let listed = database.list_audit_events(None, 10).await.unwrap(); + assert_eq!(listed.iter().map(|row| row.seq).collect::>(), [2, 1]); + let older = database.list_audit_events(Some(2), 10).await.unwrap(); + assert_eq!(older.len(), 1); + assert_eq!(older[0].event_json, r#"{"n":1}"#); + std::fs::remove_file(path).unwrap(); + } + + /// Every append gets its own `seq`, and every event is stored. + async fn assert_unique_seqs(database: &Database, expected: usize) { + let mut seqs = stored_seqs(database).await; + assert_eq!(seqs.len(), expected); + seqs.dedup(); + assert_eq!(seqs.len(), expected, "duplicate seq"); + } + + #[tokio::test] + async fn concurrent_appends_get_unique_seqs() { + let (database, path) = database().await; + let replica = Database::connect(&format!("sqlite://{}", path.display())) + .await + .unwrap(); + append_concurrently(&[database.clone(), replica], 100).await; + assert_unique_seqs(&database, 100).await; + std::fs::remove_file(path).unwrap(); + } + + #[tokio::test] + async fn rows_cannot_be_updated_or_deleted() { + let (database, path) = database().await; + database + .append_audit_event(&columns("a"), r#"{"n":1}"#) + .await + .unwrap(); + for statement in [ + "UPDATE admin_audit_events SET outcome = 'denied'", + "DELETE FROM admin_audit_events", + ] { + let error = sqlx::query(statement) + .execute(database.pool()) + .await + .unwrap_err(); + assert!(error.to_string().contains("append-only"), "{error}"); + } + let listed = database.list_audit_events(None, 10).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].event_json, r#"{"n":1}"#); + std::fs::remove_file(path).unwrap(); + } + + /// Runs against a PostgreSQL database named by `AGENTDESKTOP_TEST_POSTGRES_URL`, + /// e.g. `postgres://postgres:test@127.0.0.1/postgres`. + #[tokio::test] + #[ignore = "needs AGENTDESKTOP_TEST_POSTGRES_URL"] + async fn postgres_log_is_append_only_with_unique_seqs() { + let url = std::env::var("AGENTDESKTOP_TEST_POSTGRES_URL") + .expect("AGENTDESKTOP_TEST_POSTGRES_URL names a PostgreSQL database"); + // A second connection runs the migrations again (a no-op), and stands in + // for another replica appending to the same table. + let replica = Database::connect(&url).await.unwrap(); + let database = Database::connect(&url).await.unwrap(); + let before = stored_seqs(&database).await; + let id = uuid::Uuid::new_v4().to_string(); + let seq = database + .append_audit_event(&columns(&id), r#"{"n":1}"#) + .await + .unwrap(); + assert!(before.iter().all(|&earlier| earlier < seq)); + for statement in [ + "UPDATE admin_audit_events SET outcome = 'denied'", + "DELETE FROM admin_audit_events", + "TRUNCATE admin_audit_events", + ] { + let error = sqlx::query(statement) + .execute(database.pool()) + .await + .unwrap_err(); + assert!(error.to_string().contains("append-only"), "{error}"); + } + append_concurrently(&[database.clone(), replica], 100).await; + assert_unique_seqs(&database, before.len() + 101).await; + } +} diff --git a/crates/controller/src/lib.rs b/crates/controller/src/lib.rs index 6e88b029..9acbdc3e 100644 --- a/crates/controller/src/lib.rs +++ b/crates/controller/src/lib.rs @@ -1,4 +1,5 @@ pub mod application; +pub mod audit; pub mod daemon_config; pub mod database; pub mod device_ca; diff --git a/crates/controller/src/management.rs b/crates/controller/src/management.rs index 0c47cf3a..82397ce6 100644 --- a/crates/controller/src/management.rs +++ b/crates/controller/src/management.rs @@ -27,12 +27,14 @@ pub mod auth; mod handlers; pub mod http; +use crate::audit::AuditLog; use auth::{ Auth, authorization::{self, AccessRequest, Verb}, }; use handlers::{ - api_not_found, daemon_config, delete_device, device, devices, overview, settings, usage_report, + api_not_found, audit_events, daemon_config, delete_device, device, devices, overview, settings, + usage_report, }; /// Embedded UI file lookup by path, e.g. `|path| Assets::get(path).map(|file| file.data)`. @@ -62,6 +64,7 @@ pub struct State { database: Database, daemon_config: DaemonConfigStore, settings: Settings, + audit: AuditLog, } #[derive(Clone, Serialize)] @@ -77,11 +80,18 @@ pub struct Settings { impl State { pub fn new(database: Database, daemon_config: DaemonConfigStore, settings: Settings) -> Self { Self { + audit: AuditLog::new(database.clone()), database, daemon_config, settings, } } + + #[cfg(test)] + pub(crate) fn with_audit(mut self, audit: AuditLog) -> Self { + self.audit = audit; + self + } } #[derive(RustEmbed)] @@ -92,13 +102,18 @@ struct Assets; /// Local access always uses the built-in loopback provider; remote (authenticated) /// access requires a compiled-in provider. Mismatches fail at startup rather than /// running a provider under the other mode's HTTP policy. -pub fn auth(access: &ControllerAdminAccess, extensions: &Extensions) -> anyhow::Result { +pub fn auth( + access: &ControllerAdminAccess, + extensions: &Extensions, + audit: AuditLog, +) -> anyhow::Result { match (access, &extensions.authentication) { - (ControllerAdminAccess::Local, None) => { - Ok(Auth::new(Arc::new(auth::providers::local::LocalAccess))) - } + (ControllerAdminAccess::Local, None) => Ok(Auth::new( + Arc::new(auth::providers::local::LocalAccess), + audit, + )), (ControllerAdminAccess::Authenticated(_), Some(provider)) => { - Ok(Auth::new(provider.clone())) + Ok(Auth::new(provider.clone(), audit)) } (ControllerAdminAccess::Authenticated(_), None) => anyhow::bail!( "adminAccess.mode authenticated requires a compiled-in authentication provider; this controller supports only local admin access" @@ -136,12 +151,16 @@ pub async fn serve_router( )?)); info!(listen = %address, "controller authenticated admin HTTPS listening"); axum_server::from_tcp_rustls(listener.into_std()?, tls)? - .serve(app.into_make_service()) + .serve(app.into_make_service_with_connect_info::()) .await .context("serve admin HTTPS")?; } else { info!(listen = %address, authenticated = matches!(access, ControllerAdminAccess::Authenticated(_)), "controller admin HTTP listening"); - axum::serve(listener, app).await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; } Ok(()) } @@ -180,7 +199,7 @@ pub fn router( access: &ControllerAdminAccess, extensions: Extensions, ) -> anyhow::Result { - let auth = auth(access, &extensions)?; + let auth = auth(access, &extensions, state.audit.clone())?; let policy = match access { ControllerAdminAccess::Local => http::Policy::Local, ControllerAdminAccess::Authenticated(admin) => http::Policy::Authenticated { @@ -206,6 +225,7 @@ pub fn router( ) .route("/api/v1/settings", api(get(settings))) .route("/api/v1/usage", api(get(usage_report))) + .route("/api/v1/audit", api(get(audit_events))) .route("/api", auth.authenticate_api(get(api_not_found))) .route("/api/{*path}", auth.authenticate_api(get(api_not_found))) .fallback(auth.authenticate_browser(get( diff --git a/crates/controller/src/management/auth.rs b/crates/controller/src/management/auth.rs index 1bf05a1c..efcee578 100644 --- a/crates/controller/src/management/auth.rs +++ b/crates/controller/src/management/auth.rs @@ -5,12 +5,18 @@ //! The built-in provider grants loopback-only local access; a compiled-in provider //! can supply remote authentication and its own permission decisions. -use std::sync::Arc; +use std::{ + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; use async_trait::async_trait; use axum::{ Extension, Json, Router, - extract::{FromRequestParts, Request, State}, + extract::{ConnectInfo, FromRequestParts, Request, State}, http::{HeaderMap, Method, StatusCode, header}, middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, @@ -18,7 +24,8 @@ use axum::{ }; use serde_json::json; -use authentication::Principal; +use crate::audit::{Actor, AuditEvent, AuditLog, EventKind, Outcome, RequestInfo, Target}; +use authentication::{Principal, PrincipalKind}; use authorization::AccessRequest; pub mod authentication; @@ -104,6 +111,17 @@ pub struct Context { provider: Arc, principal: Arc, headers: Arc, + audit: AuditLog, + request: Arc, +} + +/// Per-request audit state shared by `Context::require` and the middleware. +struct RequestAudit { + info: RequestInfo, + /// Mutations authorized (and recorded as attempted) during this request. + authorized: Mutex>, + /// Whether any mutation permission was checked, whatever the result. + mutation_checked: AtomicBool, } impl Context { @@ -111,28 +129,88 @@ impl Context { &self.principal } + /// Audit sink, for extension handlers that record events of their own. + pub fn audit(&self) -> &AuditLog { + &self.audit + } + + /// The audited request, to attach to extension-recorded events. + pub fn request_info(&self) -> &RequestInfo { + &self.request.info + } + /// Called by trusted authentication middleware, never constructed from request JSON. - pub fn new(provider: Arc, principal: Arc, headers: HeaderMap) -> Self { + pub fn new( + provider: Arc, + principal: Arc, + headers: HeaderMap, + audit: AuditLog, + request: RequestInfo, + ) -> Self { Self { provider, principal, headers: Arc::new(headers), + audit, + request: Arc::new(RequestAudit { + info: request, + authorized: Mutex::default(), + mutation_checked: AtomicBool::new(false), + }), } } /// Check before reading protected data or mutating state. `resource` is a domain /// identifier (e.g. a parsed device ID), not an HTTP path. `None` denotes a /// collection/global operation. + /// + /// Denials are audited. An allowed mutation is audited as attempted before this + /// returns; if that record cannot be written the mutation is refused (503). pub async fn require(&self, access: AccessRequest<'_>) -> Result<(), Error> { let result = self .provider .check(&self.principal, &self.headers, access) .await; - if let Err(error) = &result { - tracing::warn!(subject = %self.principal.subject, issuer = %self.principal.issuer, - credential_kind = ?self.principal.kind, resource_class = access.resource_class, - verb = access.verb.as_str(), reason = error.1, - "admin permission check denied"); + let mutation = access.verb.is_mutation(); + if mutation { + self.request.mutation_checked.store(true, Ordering::Relaxed); + } + let event = |kind: EventKind, outcome| { + AuditEvent::new(kind, Actor::from(&*self.principal), outcome) + .action(access.permission()) + .target(access.resource_class, access.resource) + .request(self.request.info.clone()) + }; + match &result { + Err(error) => { + let denied = event(EventKind::AuthorizationDenied, Outcome::Denied).reason(error.1); + if let Err(audit_error) = self.audit.record(denied).await { + tracing::error!(error = format!("{audit_error:#}"), subject = %self.principal.subject, + permission = access.permission(), reason = error.1, + "failed to record admin authorization denial"); + } + } + Ok(()) if mutation => { + if let Err(audit_error) = self + .audit + .record(event(EventKind::ActionAttempted, Outcome::Authorized)) + .await + { + tracing::error!(error = format!("{audit_error:#}"), subject = %self.principal.subject, + permission = access.permission(), + "refusing admin action that could not be audited"); + return Err(Error(StatusCode::SERVICE_UNAVAILABLE, "audit_unavailable")); + } + self.request.authorized.lock().unwrap().push(( + access.permission(), + Target { + class: access.resource_class.to_owned(), + id: access.resource.map(str::to_owned), + name: None, + }, + )); + } + Ok(()) => {} } result } @@ -157,11 +235,12 @@ impl FromRequestParts for Context { /// Router-facing facade over the compiled-in authentication provider. pub struct Auth { provider: Arc, + audit: AuditLog, } impl Auth { - pub fn new(provider: Arc) -> Self { - Self { provider } + pub fn new(provider: Arc, audit: AuditLog) -> Self { + Self { provider, audit } } pub fn routes(&self) -> Router { @@ -209,24 +288,119 @@ async fn authenticate_request( // queues on the database pool, which is shared with the fleet API, so device // traffic slows too. If that matters, give management its own small pool or a // limit no larger than its pool, rather than a global semaphore. - let context = Context::new(auth.provider, principal.clone(), request.headers().clone()); - request.extensions_mut().insert(context); - let method = request.method().clone(); - let path = request.uri().path().to_owned(); - let response = next.run(request).await; - if !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) { - tracing::info!( - subject = %principal.subject, - issuer = %principal.issuer, - credential_kind = ?principal.kind, - claims_verified_at = principal.claims_verified_at, - %method, - target = %path, - status = response.status().as_u16(), - "admin action" - ); + let peer = request + .extensions() + .get::>() + .map(|ConnectInfo(peer)| *peer); + let info = RequestInfo::new(request.method(), request.uri(), request.headers(), peer); + let context = Context::new( + auth.provider, + principal, + request.headers().clone(), + auth.audit, + info, + ); + let safe = matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ); + request.extensions_mut().insert(context.clone()); + if safe { + return next.run(request).await; + } + // Mutating requests run to completion in their own task, so a timeout or client + // disconnect cannot drop the handler between the mutation and its completion + // record. + let task = tokio::spawn({ + let context = context.clone(); + async move { + let response = next.run(request).await; + record_completion(&context, response.status()).await; + response + } + }); + match task.await { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, path = %context.request.info.path, "admin handler panicked"); + record_completion(&context, StatusCode::INTERNAL_SERVER_ERROR).await; + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Records the outcome of each mutation authorized during the request, or flags a +/// mutating request that never checked a mutation permission. The mutation has +/// already happened, so a failure to record is logged rather than returned. +async fn record_completion(context: &Context, status: StatusCode) { + let authorized = std::mem::take(&mut *context.request.authorized.lock().unwrap()); + let actor = || Actor::from(context.principal()); + let mut completions = Vec::new(); + if authorized.is_empty() { + // 4xx without a check: routing or extraction rejected the request (e.g. 405) + // before any handler logic ran. + if context.request.mutation_checked.load(Ordering::Relaxed) || status.is_client_error() { + return; + } + tracing::error!(method = %context.request.info.method, path = %context.request.info.path, + status = status.as_u16(), + "mutating admin request finished without a mutation permission check"); + completions.push(AuditEvent::new( + EventKind::ActionCompleted, + actor(), + Outcome::Unaudited, + )); + } else { + let outcome = if status.is_success() || status.is_redirection() { + Outcome::Succeeded + } else if status == StatusCode::NOT_FOUND { + Outcome::NotFound + } else { + Outcome::Failed + }; + for (action, target) in authorized { + let mut event = + AuditEvent::new(EventKind::ActionCompleted, actor(), outcome).action(action); + event.target = Some(target); + completions.push(event); + } + } + for event in completions { + let event = event + .status(status.as_u16()) + .request(context.request.info.clone()); + if let Err(error) = context.audit.record(event).await { + tracing::error!(error = format!("{error:#}"), subject = %context.principal.subject, + path = %context.request.info.path, status = status.as_u16(), + "failed to record admin action completion"); + } + } +} + +impl From<&Principal> for Actor { + fn from(principal: &Principal) -> Self { + let claim = |name: &str| { + principal + .claims + .get(name) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }; + let (kind, client_id) = match &principal.kind { + PrincipalKind::Local => ("local", None), + PrincipalKind::Human => ("human", None), + PrincipalKind::Service { client_id } => ("service", Some(client_id.clone())), + }; + Self { + kind, + issuer: principal.issuer.clone(), + subject: principal.subject.clone(), + client_id, + claims_verified_at: principal.claims_verified_at, + email: claim("email"), + name: claim("name"), + } } - response } #[cfg(test)] diff --git a/crates/controller/src/management/auth/authorization.rs b/crates/controller/src/management/auth/authorization.rs index bcc5d54e..b4bee825 100644 --- a/crates/controller/src/management/auth/authorization.rs +++ b/crates/controller/src/management/auth/authorization.rs @@ -62,6 +62,8 @@ impl AccessRequest<'_> { /// (see `AccessRequest::covered_by_admin_read`). pub const ADMIN: &str = "admin"; pub const DEVICES: &str = "devices"; +/// The management audit log. +pub const AUDIT: &str = "audit"; pub const DAEMON_CONFIG: &str = "daemon-config"; pub const SETTINGS: &str = "settings"; pub const USAGE: &str = "usage"; @@ -77,6 +79,7 @@ pub const OPERATIONS: &[(Verb, &str)] = &[ (Verb::Read, DAEMON_CONFIG), (Verb::Read, SETTINGS), (Verb::Read, USAGE), + (Verb::Read, AUDIT), ]; /// Permission names for `OPERATIONS`, as reported to the UI. diff --git a/crates/controller/src/management/auth/tests.rs b/crates/controller/src/management/auth/tests.rs index 84c36d04..66671448 100644 --- a/crates/controller/src/management/auth/tests.rs +++ b/crates/controller/src/management/auth/tests.rs @@ -1,5 +1,6 @@ use super::providers::local::LocalAccess; use super::*; +use crate::audit::{AuditLog, RequestInfo}; use crate::database::Database; use crate::management::http::is_loopback_host; use agentdesktop_core::config::ControllerAdminAccess; @@ -70,6 +71,7 @@ fn authenticated_access() -> ControllerAdminAccess { } fn state(database: Database) -> super::super::State { + let (audit, _) = AuditLog::in_memory(database.clone()); super::super::State::new( database, crate::daemon_config::DaemonConfigStore::new(None), @@ -82,6 +84,44 @@ fn state(database: Database) -> super::super::State { usage_enabled: false, }, ) + .with_audit(audit) +} + +/// Audit log on its own temporary database, streaming to memory. +struct TestAudit { + log: AuditLog, + lines: Arc>>, + path: std::path::PathBuf, +} + +impl TestAudit { + async fn new() -> Self { + let (database, path) = database().await; + let (log, lines) = AuditLog::in_memory(database); + Self { log, lines, path } + } + + fn events(&self) -> Vec { + stream_events(&self.lines) + } +} + +impl Drop for TestAudit { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn stream_events(lines: &std::sync::Mutex>) -> Vec { + lines + .lock() + .unwrap() + .iter() + .map(|line| { + assert!(line.ends_with('\n')); + serde_json::from_str(line).unwrap() + }) + .collect() } async fn database() -> (Database, std::path::PathBuf) { @@ -113,7 +153,8 @@ async fn handlers_own_operations_and_axum_selects_the_matching_handler() { auth.require(access).await?; Ok(access.permission()) } - let auth = Auth::new(Arc::new(LocalAccess)); + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); let app = Router::new().route( "/device", auth.authenticate_api(get(read)) @@ -193,7 +234,8 @@ async fn request(app: Router, path: &str, method: Method, headers: HeaderMap) -> #[tokio::test] async fn local_mode_keeps_ui_api_and_bootstrap_without_session_tables() { let (database, path) = database().await; - let auth = Auth::new(Arc::new(LocalAccess)); + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); let response = request(app(&auth), "/auth/session", Method::GET, HeaderMap::new()).await; assert_eq!(response.status(), StatusCode::OK); let info: serde_json::Value = @@ -243,7 +285,8 @@ async fn local_mode_keeps_ui_api_and_bootstrap_without_session_tables() { #[tokio::test] async fn local_mode_rejects_rebinding_and_cross_site_mutations() { - let auth = Auth::new(Arc::new(LocalAccess)); + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); // Every loopback address `adminListen` accepts in local mode, not just .1. for host in [ "localhost:8080", @@ -376,6 +419,7 @@ fn admin_read_covers_every_management_read_but_no_mutation() { (Verb::Read, "daemon-config", true), (Verb::Read, "settings", true), (Verb::Read, "usage", true), + (Verb::Read, "audit", true), (Verb::Delete, "devices", false), // Reads outside the catalog, such as an extension's policy, are not covered. (Verb::Read, "rbac.policy", false), @@ -498,7 +542,8 @@ async fn middleware_supplies_context_and_handlers_own_authorization() { async fn without_context(_auth: Context) -> StatusCode { panic!("a handler without authenticated context must not execute"); } - let auth = Auth::new(Arc::new(LocalAccess)); + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); let authenticated = Router::new().route( "/new-api", auth.authenticate_api(get( @@ -541,7 +586,14 @@ async fn middleware_supplies_context_and_handlers_own_authorization() { #[tokio::test] async fn request_context_shares_evidence_and_checks_borrowed_operation_and_resource() { let principal = Arc::new(Principal::local()); - let context = Context::new(Arc::new(LocalAccess), principal.clone(), HeaderMap::new()); + let audit = TestAudit::new().await; + let context = Context::new( + Arc::new(LocalAccess), + principal.clone(), + HeaderMap::new(), + audit.log.clone(), + RequestInfo::new(&Method::GET, &"/".parse().unwrap(), &HeaderMap::new(), None), + ); let extracted = context.clone(); assert!(Arc::ptr_eq(&context.principal, &extracted.principal)); assert!(Arc::ptr_eq(&context.headers, &extracted.headers)); @@ -564,7 +616,8 @@ async fn request_context_shares_evidence_and_checks_borrowed_operation_and_resou #[tokio::test] async fn read_only_routes_explicitly_deny_writes() { - let auth = Auth::new(Arc::new(LocalAccess)); + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); let router = Router::new().route( "/read-only", auth.authenticate_api(get(|| async { "data" })), @@ -618,7 +671,9 @@ impl Provider for TokenProvider { _headers: &HeaderMap, access: AccessRequest<'_>, ) -> Result<(), Error> { - if access.verb.is_mutation() && principal.subject != "admin" { + if (access.verb.is_mutation() || access.resource_class == authorization::AUDIT) + && principal.subject != "admin" + { return Err(Error(StatusCode::FORBIDDEN, "forbidden")); } Ok(()) @@ -810,3 +865,320 @@ async fn direct_tls_transport_serves_the_authenticated_router_over_https_only() std::fs::remove_dir_all(directory).unwrap(); std::fs::remove_file(path).unwrap(); } + +fn audited_router(database: Database, audit: AuditLog) -> Router { + super::super::router( + state(database).with_audit(audit), + None, + &authenticated_access(), + super::super::Extensions { + authentication: Some(Arc::new(TokenProvider)), + assets: Some(|_| None), + ..Default::default() + }, + ) + .unwrap() +} + +async fn enrolled_database() -> (Database, std::path::PathBuf) { + let (database, path) = database().await; + database + .enroll_device("device-1", "host", "https://id.example", "owner", None) + .await + .unwrap(); + (database, path) +} + +async fn json_body(response: Response) -> serde_json::Value { + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn device_deletion_is_audited_with_actor_target_and_outcome() { + let (database, path) = enrolled_database().await; + let (audit, lines) = AuditLog::in_memory(database.clone()); + let router = audited_router(database.clone(), audit); + let mut headers = bearer("admin"); + headers.insert(header::USER_AGENT, HeaderValue::from_static("audit-test")); + let response = request( + router.clone(), + "/api/v1/devices/device-1", + Method::DELETE, + headers.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(database.get_device("device-1").await.unwrap().is_none()); + + let events = stream_events(&lines); + let [attempted, completed] = &events[..] else { + panic!("expected attempted and completed events: {events:?}"); + }; + for event in [attempted, completed] { + assert_eq!(event["schema"], "agentdesktop.audit.v1"); + assert_eq!(event["actor"]["kind"], "human"); + assert_eq!(event["actor"]["issuer"], "https://id.example"); + assert_eq!(event["actor"]["subject"], "admin"); + assert_eq!(event["action"], "devices.delete"); + assert_eq!( + event["target"], + json!({ "class": "devices", "id": "device-1" }) + ); + assert_eq!(event["request"]["method"], "DELETE"); + assert_eq!(event["request"]["path"], "/api/v1/devices/device-1"); + assert_eq!(event["request"]["user_agent"], "audit-test"); + assert!(event["time"].as_str().unwrap().ends_with('Z')); + } + assert_eq!(attempted["request"]["id"], completed["request"]["id"]); + assert_eq!(attempted["event"], "action.attempted"); + assert_eq!(attempted["outcome"], "authorized"); + assert_eq!(completed["event"], "action.completed"); + assert_eq!(completed["outcome"], "succeeded"); + assert_eq!(completed["status"], 204); + // Streamed lines are numbered per stream, so the SIEM can spot a lost line. + assert_eq!(attempted["stream"]["seq"], 1); + assert_eq!(completed["stream"]["seq"], 2); + assert_eq!(attempted["stream"]["id"], completed["stream"]["id"]); + assert!(completed["seq"].as_i64().unwrap() > attempted["seq"].as_i64().unwrap()); + + let response = request(router, "/api/v1/devices/device-1", Method::DELETE, headers).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let events = stream_events(&lines); + let missing = events.last().unwrap(); + assert_eq!(missing["outcome"], "not_found"); + assert_eq!(missing["status"], 404); + std::fs::remove_file(path).unwrap(); +} + +#[tokio::test] +async fn denials_are_audited_and_the_action_does_not_run() { + let (database, path) = enrolled_database().await; + let (audit, lines) = AuditLog::in_memory(database.clone()); + let router = audited_router(database.clone(), audit); + let response = request( + router.clone(), + "/api/v1/devices/device-1", + Method::DELETE, + bearer("viewer"), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(database.get_device("device-1").await.unwrap().is_some()); + let response = request(router, "/api/v1/audit", Method::GET, bearer("viewer")).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let events = stream_events(&lines); + let [delete, read] = &events[..] else { + panic!("expected two denials: {events:?}"); + }; + for event in [delete, read] { + assert_eq!(event["event"], "authorization.denied"); + assert_eq!(event["outcome"], "denied"); + assert_eq!(event["reason"], "forbidden"); + assert_eq!(event["actor"]["subject"], "viewer"); + } + assert_eq!(delete["action"], "devices.delete"); + assert_eq!(delete["target"]["id"], "device-1"); + assert_eq!(read["action"], "audit.read"); + assert_eq!(read["target"], json!({ "class": "audit", "id": null })); + std::fs::remove_file(path).unwrap(); +} + +#[tokio::test] +async fn mutations_fail_closed_when_the_audit_log_is_unavailable() { + let (database, path) = enrolled_database().await; + let router = audited_router(database.clone(), AuditLog::broken_stream(database.clone())); + let response = request( + router, + "/api/v1/devices/device-1", + Method::DELETE, + bearer("admin"), + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(json_body(response).await["error"], "audit_unavailable"); + assert!(database.get_device("device-1").await.unwrap().is_some()); + + let (audit, lines) = AuditLog::in_memory(database.clone()); + sqlx::raw_sql("DROP TABLE admin_audit_events") + .execute(database.pool()) + .await + .unwrap(); + let response = request( + audited_router(database.clone(), audit), + "/api/v1/devices/device-1", + Method::DELETE, + bearer("admin"), + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(database.get_device("device-1").await.unwrap().is_some()); + assert!(lines.lock().unwrap().is_empty()); + std::fs::remove_file(path).unwrap(); +} + +#[tokio::test] +async fn mutations_without_a_permission_check_are_flagged() { + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); + let router = Router::new() + .route( + "/unchecked", + auth.authenticate_api(axum::routing::post(|| async { "changed" })), + ) + .route( + "/read-only", + auth.authenticate_api(get(|| async { "data" })), + ); + let response = request(router.clone(), "/unchecked", Method::POST, HeaderMap::new()).await; + assert_eq!(response.status(), StatusCode::OK); + let events = audit.events(); + let [unchecked] = &events[..] else { + panic!("expected one event: {events:?}"); + }; + assert_eq!(unchecked["event"], "action.completed"); + assert_eq!(unchecked["outcome"], "unaudited"); + assert_eq!(unchecked["action"], serde_json::Value::Null); + assert_eq!(unchecked["status"], 200); + assert_eq!(unchecked["actor"]["kind"], "local"); + assert_eq!(unchecked["request"]["path"], "/unchecked"); + + let response = request(router, "/read-only", Method::POST, HeaderMap::new()).await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(audit.events().len(), 1); +} + +#[tokio::test] +async fn audit_api_pages_newest_first() { + let (database, path) = database().await; + let (audit, _) = AuditLog::in_memory(database.clone()); + let router = audited_router(database.clone(), audit); + for device in ["a", "b", "c"] { + let response = request( + router.clone(), + &format!("/api/v1/devices/{device}"), + Method::DELETE, + bearer("admin"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + let seqs = |page: &serde_json::Value| -> Vec { + page["events"] + .as_array() + .unwrap() + .iter() + .map(|event| event["seq"].as_i64().unwrap()) + .collect() + }; + let page = json_body( + request( + router.clone(), + "/api/v1/audit?limit=4", + Method::GET, + bearer("admin"), + ) + .await, + ) + .await; + assert_eq!(seqs(&page), [6, 5, 4, 3]); + assert_eq!(page["next_before"], 3); + assert_eq!(page["events"][0]["target"]["id"], "c"); + assert_eq!(page["events"][0]["schema"], "agentdesktop.audit.v1"); + let page = json_body( + request( + router.clone(), + "/api/v1/audit?before=3&limit=4", + Method::GET, + bearer("admin"), + ) + .await, + ) + .await; + assert_eq!(seqs(&page), [2, 1]); + assert_eq!(page["next_before"], serde_json::Value::Null); + // Allowed reads, including of the audit log itself, are not recorded. + let page = + json_body(request(router, "/api/v1/audit", Method::GET, bearer("admin")).await).await; + assert_eq!(seqs(&page), [6, 5, 4, 3, 2, 1]); + std::fs::remove_file(path).unwrap(); +} + +#[tokio::test] +async fn served_requests_record_the_peer_address() { + let (database, path) = database().await; + let (audit, lines) = AuditLog::in_memory(database.clone()); + let router = super::super::router( + state(database).with_audit(audit), + None, + &ControllerAdminAccess::Local, + super::super::Extensions::default(), + ) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::super::serve_router( + listener, + router, + ControllerAdminAccess::Local, + )); + let response = reqwest::Client::new() + .delete(format!("http://{address}/api/v1/devices/missing")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let events = stream_events(&lines); + assert_eq!(events.len(), 2); + for event in &events { + assert!( + event["request"]["peer"] + .as_str() + .unwrap() + .starts_with("127.0.0.1:"), + "{event}" + ); + assert_eq!(event["actor"]["kind"], "local"); + } + server.abort(); + std::fs::remove_file(path).unwrap(); +} + +#[tokio::test] +async fn completion_is_recorded_when_the_client_goes_away() { + let audit = TestAudit::new().await; + let auth = Auth::new(Arc::new(LocalAccess), audit.log.clone()); + let router = Router::new().route( + "/slow", + auth.authenticate_api(axum::routing::delete(|auth: Context| async move { + auth.require(AccessRequest { + verb: authorization::Verb::Delete, + resource_class: "devices", + resource: Some("device-1"), + }) + .await?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + Ok::<_, Error>(StatusCode::NO_CONTENT) + })), + ); + let abandoned = tokio::time::timeout( + std::time::Duration::from_millis(30), + request(router, "/slow", Method::DELETE, HeaderMap::new()), + ) + .await; + assert!(abandoned.is_err(), "the client must give up first"); + for _ in 0..200 { + if audit.events().len() == 2 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let events = audit.events(); + let [attempted, completed] = &events[..] else { + panic!("expected attempted and completed events: {events:?}"); + }; + assert_eq!(attempted["outcome"], "authorized"); + assert_eq!(completed["outcome"], "succeeded"); + assert_eq!(completed["status"], 204); + assert_eq!(completed["target"]["id"], "device-1"); +} diff --git a/crates/controller/src/management/handlers.rs b/crates/controller/src/management/handlers.rs index b41f42fb..58ec934f 100644 --- a/crates/controller/src/management/handlers.rs +++ b/crates/controller/src/management/handlers.rs @@ -9,7 +9,8 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use tracing::info; use super::{ @@ -187,6 +188,56 @@ pub(super) async fn daemon_config( Ok(Json(ActiveDaemonConfig { config })) } +const AUDIT_PAGE_DEFAULT: i64 = 100; +const AUDIT_PAGE_MAX: i64 = 200; + +#[derive(Deserialize)] +pub(super) struct AuditQuery { + /// Return events with a smaller `seq` than this, for paging back in time. + before: Option, + limit: Option, +} + +#[derive(Serialize)] +pub(super) struct AuditPage { + events: Vec>, + /// Pass as `before` to fetch the next older page; absent on the last page. + next_before: Option, +} + +pub(super) async fn audit_events( + State(state): State, + auth: Context, + Query(query): Query, +) -> Result, HandlerError> { + auth.require(AccessRequest { + verb: Verb::Read, + resource_class: authorization::AUDIT, + resource: None, + }) + .await?; + let limit = query + .limit + .unwrap_or(AUDIT_PAGE_DEFAULT) + .clamp(1, AUDIT_PAGE_MAX); + let rows = state + .database + .list_audit_events(query.before, limit) + .await?; + let next_before = rows + .last() + .filter(|row| rows.len() as i64 == limit && row.seq > 1) + .map(|row| row.seq); + let events = rows + .iter() + .map(crate::audit::published) + .collect::>()?; + Ok(Json(AuditPage { + events, + next_before, + })) +} + fn unix_time_seconds() -> i64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) diff --git a/crates/controller/src/oidc.rs b/crates/controller/src/oidc.rs index f6c8672d..9a276922 100644 --- a/crates/controller/src/oidc.rs +++ b/crates/controller/src/oidc.rs @@ -61,6 +61,7 @@ struct PendingEnrollment { expires_at: Instant, } +#[derive(Clone, Debug)] pub struct CompletedEnrollment { pub hostname: String, pub issuer: String, @@ -68,6 +69,27 @@ pub struct CompletedEnrollment { pub idp_claims: BTreeMap, } +/// Why [`OidcProvider::complete`] refused an enrollment. +#[derive(Debug)] +pub struct EnrollmentRejected { + /// The sign-in, once its ID token was verified. `None` when the attempt failed + /// before that, so nobody verified can be named. + pub verified: Option, + /// Stable code for the audit log. + pub reason: &'static str, + pub error: anyhow::Error, +} + +impl From for EnrollmentRejected { + fn from(error: anyhow::Error) -> Self { + Self { + verified: None, + reason: "invalid_enrollment", + error, + } + } +} + /// Stable identity authenticated by one OIDC provider. #[derive(Clone, Debug, Eq, PartialEq)] pub struct OidcPrincipal { @@ -320,7 +342,7 @@ impl OidcProvider { &self, request: CompleteEnrollmentRequest, access_token: &str, - ) -> anyhow::Result { + ) -> Result { let pending = self .inner .pending @@ -329,10 +351,10 @@ impl OidcProvider { .remove(&request.enrollment_id) .context("unknown or already completed enrollment")?; if pending.expires_at <= Instant::now() { - bail!("enrollment expired"); + return Err(anyhow::anyhow!("enrollment expired").into()); } if request.id_token.is_empty() || access_token.is_empty() { - bail!("ID token and access token are required"); + return Err(anyhow::anyhow!("ID token and access token are required").into()); } let header = decode_header(&request.id_token).context("decode ID token header")?; let kid = header.kid.context("ID token has no key ID")?; @@ -345,27 +367,48 @@ impl OidcProvider { let claims = decode::(&request.id_token, &key, &validation) .context("validate OIDC ID token")? .claims; + + let mut idp_claims = claims.additional; + idp_claims.insert("sub".to_owned(), claims.sub.clone().into()); + let verified = CompletedEnrollment { + hostname: pending.hostname, + issuer: self.inner.issuer.clone(), + subject: claims.sub.clone(), + idp_claims: idp_claims.clone(), + }; + let reject = |reason, error| EnrollmentRejected { + verified: Some(verified.clone()), + reason, + error, + }; if let Some(expected) = &pending.nonce && claims.nonce.as_ref() != Some(expected) { - bail!("OIDC nonce mismatch"); + return Err(reject( + "nonce_mismatch", + anyhow::anyhow!("OIDC nonce mismatch"), + )); } - let access_principal = self.authenticate_access_token(access_token).await?; + let access_principal = self + .authenticate_access_token(access_token) + .await + .map_err(|error| reject("access_token_rejected", error))?; if access_principal.subject != claims.sub { - bail!("access token and ID token subjects do not match"); + return Err(reject( + "token_subject_mismatch", + anyhow::anyhow!("access token and ID token subjects do not match"), + )); } - let mut idp_claims = claims.additional; - idp_claims.insert("sub".to_owned(), claims.sub.clone().into()); if let Some(nonce) = claims.nonce { idp_claims.insert("nonce".to_owned(), nonce.into()); } Ok(CompletedEnrollment { - hostname: pending.hostname, issuer: access_principal.issuer, subject: access_principal.subject, idp_claims, + ..verified }) } diff --git a/crates/controller/src/service.rs b/crates/controller/src/service.rs index 6fe3570b..6af2ed73 100644 --- a/crates/controller/src/service.rs +++ b/crates/controller/src/service.rs @@ -19,13 +19,21 @@ use agentdesktop_core::config::{LlmGatewayAuthentication, ProgramsConfig}; use agentdesktop_usage::UsageError; use crate::{ + audit::{Actor, AuditEvent, AuditLog, EventKind, Outcome, RequestInfo}, daemon_config::DaemonConfigStore, database::{Database, DevicePrincipal}, device_ca::DeviceCertificateIssuer, gateway_jwt::GatewayJwtIssuer, - oidc::{OidcPrincipal, OidcProvider}, + management::auth::authorization::DEVICES, + oidc::{CompletedEnrollment, OidcPrincipal, OidcProvider}, }; +/// gRPC method paths, recorded as `request.path` in audit events. +const COMPLETE_ENROLLMENT: &str = "/agentdesktop.fleet.v1.FleetAgent/CompleteEnrollment"; +const RENEW_DEVICE_CERTIFICATE: &str = "/agentdesktop.fleet.v1.FleetAgent/RenewDeviceCertificate"; +/// The `action` of enrollment's attempted and completed audit events. +const ENROLL_DEVICE: &str = "devices.enroll"; + #[derive(Clone)] pub struct FleetAgentService { oidc: Option, @@ -34,6 +42,7 @@ pub struct FleetAgentService { gateway_jwt_issuer: Option, device_certificate_issuer: Option, usage_enabled: bool, + audit: AuditLog, } impl FleetAgentService { @@ -46,6 +55,7 @@ impl FleetAgentService { ) -> Self { Self { oidc, + audit: AuditLog::new(database.clone()), database, daemon_config, gateway_jwt_issuer, @@ -54,6 +64,12 @@ impl FleetAgentService { } } + #[cfg(test)] + fn with_audit(mut self, audit: AuditLog) -> Self { + self.audit = audit; + self + } + /// Tells devices that the controller collects gateway usage. pub fn with_usage_collection(mut self, enabled: bool) -> Self { self.usage_enabled = enabled; @@ -96,41 +112,35 @@ impl FleetAgent for FleetAgentService { .as_ref() .ok_or_else(|| Status::failed_precondition("OIDC enrollment is disabled"))?; let access_token = bearer_credential(request.metadata())?.to_owned(); + let info = grpc_request_info(&request, COMPLETE_ENROLLMENT); let request = request.into_inner(); let csr_der = request.certificate_signing_request_der.clone(); - let completed = oidc - .complete(request, &access_token) - .await - .map_err(invalid_enrollment)?; - self.enroll_device( - &completed.hostname, - &completed.issuer, - &completed.subject, - Some(&completed.idp_claims), - &csr_der, - ) - .await + let completed = match oidc.complete(request, &access_token).await { + Ok(completed) => completed, + Err(rejected) => { + if let Some(verified) = &rejected.verified { + self.record_enrollment_denied(verified, rejected.reason, &info) + .await; + } + return Err(invalid_enrollment(rejected.error)); + } + }; + self.enroll_device(&completed, &csr_der, info).await } async fn renew_device_certificate( &self, request: Request, ) -> Result, Status> { + let info = grpc_request_info(&request, RENEW_DEVICE_CERTIFICATE); let device_id = self.authenticate_device(&request).await?; - let issuer = self.device_certificate_issuer.as_ref().ok_or_else(|| { - Status::failed_precondition("device certificate issuance is disabled") - })?; - let issued = issuer - .issue( - &device_id, - &request.into_inner().certificate_signing_request_der, - ) - .map_err(invalid_csr)?; - info!(device_id, "renewed device certificate"); - Ok(Response::new(DeviceCertificateResponse { - client_certificate_pem: issued.chain_pem, - expires_at_unix_seconds: issued.expires_at_unix_seconds, - })) + self.renew_certificate( + &device_id, + &request.into_inner().certificate_signing_request_der, + info, + ) + .await + .map(Response::new) } async fn get_llm_gateway_credential( @@ -408,34 +418,81 @@ impl FleetAgentService { async fn enroll_device( &self, - hostname: &str, - enrolled_by_issuer: &str, - enrolled_by_subject: &str, - idp_claims: Option<&std::collections::BTreeMap>, + completed: &CompletedEnrollment, csr_der: &[u8], + info: RequestInfo, ) -> Result, Status> { let device_id = Uuid::new_v4().to_string(); - let issued_certificate = self - .device_certificate_issuer - .as_ref() - .ok_or_else(|| Status::failed_precondition("device certificate issuance is disabled"))? - .issue(&device_id, csr_der) - .map_err(invalid_csr)?; - self.database - .enroll_device( - &device_id, - hostname, - enrolled_by_issuer, - enrolled_by_subject, - idp_claims, - ) + let issuer = self.device_certificate_issuer.as_ref().ok_or_else(|| { + Status::failed_precondition("device certificate issuance is disabled") + })?; + let issued_certificate = match issuer.issue(&device_id, csr_der) { + Ok(issued) => issued, + Err(error) => { + self.record_enrollment_denied(completed, "invalid_certificate_request", &info) + .await; + return Err(invalid_csr(error)); + } + }; + let actor = Actor::oidc_user(&completed.issuer, &completed.subject, &completed.idp_claims); + let event = |kind, outcome| { + AuditEvent::new(kind, actor.clone(), outcome) + .action(ENROLL_DEVICE) + .target(DEVICES, Some(&device_id)) + .target_name(&completed.hostname) + .request(info.clone()) + }; + // Nothing is admitted until the attempt is recorded (fail closed). + if let Err(error) = self + .audit + .record(event(EventKind::ActionAttempted, Outcome::Authorized)) .await - .map_err(internal)?; + { + error!(error = format!("{error:#}"), %device_id, + "refusing device enrollment that could not be audited"); + return Err(audit_unavailable()); + } + // The insert and its completion record run in their own task, so a client + // that disconnects cannot drop the handler between them. + let stored = tokio::spawn({ + let database = self.database.clone(); + let audit = self.audit.clone(); + let completed = completed.clone(); + let device_id = device_id.clone(); + let completion = event(EventKind::ActionCompleted, Outcome::Succeeded); + async move { + let stored = database + .enroll_device( + &device_id, + &completed.hostname, + &completed.issuer, + &completed.subject, + Some(&completed.idp_claims), + ) + .await; + let completion = AuditEvent { + outcome: if stored.is_ok() { + Outcome::Succeeded + } else { + Outcome::Failed + }, + ..completion + }; + if let Err(error) = audit.record(completion).await { + error!(error = format!("{error:#}"), %device_id, + "failed to record device enrollment completion"); + } + stored + } + }) + .await + .map_err(|error| internal(error.into()))?; + stored.map_err(internal)?; info!( %device_id, - hostname, - enrolled_by_issuer, - enrolled_by_subject, + hostname = completed.hostname, + enrolled_by_issuer = completed.issuer, + enrolled_by_subject = completed.subject, "enrolled device" ); @@ -445,6 +502,79 @@ impl FleetAgentService { client_certificate_expires_at_unix_seconds: issued_certificate.expires_at_unix_seconds, })) } + + /// Records a refused enrollment of a verified user. Best effort: the + /// enrollment is refused either way. + async fn record_enrollment_denied( + &self, + attempt: &CompletedEnrollment, + reason: &'static str, + info: &RequestInfo, + ) { + let denied = AuditEvent::new( + EventKind::DeviceEnrollmentDenied, + Actor::oidc_user(&attempt.issuer, &attempt.subject, &attempt.idp_claims), + Outcome::Denied, + ) + .target(DEVICES, None) + .target_name(&attempt.hostname) + .reason(reason) + .request(info.clone()); + if let Err(error) = self.audit.record(denied).await { + error!( + error = format!("{error:#}"), + subject = attempt.subject, + reason, + "failed to record denied device enrollment" + ); + } + } + + /// Issues a renewed certificate to an authenticated device, failing closed if + /// the renewal cannot be audited. + async fn renew_certificate( + &self, + device_id: &str, + csr_der: &[u8], + info: RequestInfo, + ) -> Result { + let issuer = self.device_certificate_issuer.as_ref().ok_or_else(|| { + Status::failed_precondition("device certificate issuance is disabled") + })?; + let issued = issuer.issue(device_id, csr_der).map_err(invalid_csr)?; + let renewed = AuditEvent::new( + EventKind::DeviceCertificateRenewed, + Actor::device(device_id), + Outcome::Succeeded, + ) + .target(DEVICES, Some(device_id)) + .request(info); + if let Err(error) = self.audit.record(renewed).await { + error!( + error = format!("{error:#}"), + device_id, "refusing certificate renewal that could not be audited" + ); + return Err(audit_unavailable()); + } + info!(device_id, "renewed device certificate"); + Ok(DeviceCertificateResponse { + client_certificate_pem: issued.chain_pem, + expires_at_unix_seconds: issued.expires_at_unix_seconds, + }) + } +} + +fn grpc_request_info(request: &Request, path: &'static str) -> RequestInfo { + RequestInfo::new( + &axum::http::Method::POST, + &axum::http::Uri::from_static(path), + request.metadata().as_ref(), + request.remote_addr(), + ) +} + +fn audit_unavailable() -> Status { + Status::unavailable("audit_unavailable") } fn oidc_principals_match(authenticated: &OidcPrincipal, enrolled: &DevicePrincipal) -> bool { @@ -626,9 +756,225 @@ mod tests { use tonic::Code; use super::{ - FleetAgentService, device_id_from_certificate, subscription_is_configured_for_client, + COMPLETE_ENROLLMENT, FleetAgentService, RENEW_DEVICE_CERTIFICATE, + device_id_from_certificate, subscription_is_configured_for_client, }; - use crate::{daemon_config::DaemonConfigStore, database::Database, oidc::OidcPrincipal}; + use crate::{ + audit::{AuditLog, RequestInfo}, + daemon_config::DaemonConfigStore, + database::Database, + device_ca::DeviceCertificateIssuer, + oidc::{CompletedEnrollment, OidcPrincipal}, + }; + + async fn audit_database(name: &str) -> Database { + let path = std::env::temp_dir().join(format!( + "agentdesktop-{name}-{}-{}.db", + std::process::id(), + rand::random::() + )); + Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("connect database") + } + + fn certificate_issuer() -> DeviceCertificateIssuer { + use rcgen::{BasicConstraints, IsCa, KeyUsagePurpose}; + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + ]; + let ca = ca_params.self_signed(&ca_key).unwrap(); + DeviceCertificateIssuer::from_pem(ca.pem(), &ca_key.serialize_pem()).unwrap() + } + + fn certificate_request() -> Vec { + CertificateParams::new(vec!["ignored.example".to_owned()]) + .unwrap() + .serialize_request(&KeyPair::generate().unwrap()) + .unwrap() + .der() + .to_vec() + } + + fn signed_in_user() -> CompletedEnrollment { + CompletedEnrollment { + hostname: "dev-mac".into(), + issuer: "https://id.example".into(), + subject: "user-1".into(), + idp_claims: [("email".to_owned(), "dev@example.com".into())].into(), + } + } + + fn grpc_request(path: &'static str) -> RequestInfo { + RequestInfo::new( + &axum::http::Method::POST, + &axum::http::Uri::from_static(path), + &axum::http::HeaderMap::new(), + None, + ) + } + + fn audited_service( + database: &Database, + ) -> ( + FleetAgentService, + std::sync::Arc>>, + ) { + let (audit, lines) = AuditLog::in_memory(database.clone()); + let service = FleetAgentService::new( + None, + database.clone(), + DaemonConfigStore::new(None), + None, + Some(certificate_issuer()), + ) + .with_audit(audit); + (service, lines) + } + + fn streamed(lines: &std::sync::Mutex>) -> Vec { + lines + .lock() + .unwrap() + .iter() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[tokio::test] + async fn enrollment_is_audited_with_the_signed_in_user_and_new_device() { + let database = audit_database("audit-enroll").await; + let (service, lines) = audited_service(&database); + let device_id = service + .enroll_device( + &signed_in_user(), + &certificate_request(), + grpc_request(COMPLETE_ENROLLMENT), + ) + .await + .unwrap() + .into_inner() + .device_id; + let events = streamed(&lines); + let [attempted, completed] = &events[..] else { + panic!("expected attempted and completed events: {events:?}"); + }; + assert_eq!(attempted["event"], "action.attempted"); + assert_eq!(attempted["outcome"], "authorized"); + assert_eq!(completed["event"], "action.completed"); + assert_eq!(completed["outcome"], "succeeded"); + assert_eq!(attempted["request"]["id"], completed["request"]["id"]); + for event in [attempted, completed] { + assert_eq!(event["action"], "devices.enroll"); + assert_eq!(event["actor"]["kind"], "human"); + assert_eq!(event["actor"]["issuer"], "https://id.example"); + assert_eq!(event["actor"]["subject"], "user-1"); + assert_eq!(event["actor"]["email"], "dev@example.com"); + assert_eq!( + event["target"], + serde_json::json!({ "class": "devices", "id": device_id, "name": "dev-mac" }) + ); + assert_eq!(event["request"]["path"], COMPLETE_ENROLLMENT); + } + assert!(database.get_device(&device_id).await.unwrap().is_some()); + } + + #[tokio::test] + async fn enrollment_fails_closed_when_it_cannot_be_audited() { + let database = audit_database("audit-enroll-closed").await; + let service = FleetAgentService::new( + None, + database.clone(), + DaemonConfigStore::new(None), + None, + Some(certificate_issuer()), + ) + .with_audit(AuditLog::broken_stream(database.clone())); + let status = service + .enroll_device( + &signed_in_user(), + &certificate_request(), + grpc_request(COMPLETE_ENROLLMENT), + ) + .await + .expect_err("an unaudited enrollment must be refused"); + assert_eq!(status.code(), Code::Unavailable); + assert!(database.list_devices().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn a_rejected_certificate_request_is_audited_as_a_denied_enrollment() { + let database = audit_database("audit-enroll-denied").await; + let (service, lines) = audited_service(&database); + let status = service + .enroll_device( + &signed_in_user(), + b"not a certificate request", + grpc_request(COMPLETE_ENROLLMENT), + ) + .await + .expect_err("an invalid certificate request must be refused"); + assert_eq!(status.code(), Code::InvalidArgument); + let events = streamed(&lines); + let [denied] = &events[..] else { + panic!("expected one event: {events:?}"); + }; + assert_eq!(denied["event"], "device.enrollment_denied"); + assert_eq!(denied["outcome"], "denied"); + assert_eq!(denied["reason"], "invalid_certificate_request"); + assert_eq!(denied["actor"]["subject"], "user-1"); + assert_eq!( + denied["target"], + serde_json::json!({ "class": "devices", "id": null, "name": "dev-mac" }) + ); + assert!(database.list_devices().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn certificate_renewal_is_audited_with_the_device_as_actor() { + let database = audit_database("audit-renew").await; + let (service, lines) = audited_service(&database); + let device_id = "7ca03414-bb20-4c80-98ef-7b0538b988ba"; + service + .renew_certificate( + device_id, + &certificate_request(), + grpc_request(RENEW_DEVICE_CERTIFICATE), + ) + .await + .unwrap(); + let events = streamed(&lines); + let [renewed] = &events[..] else { + panic!("expected one event: {events:?}"); + }; + assert_eq!(renewed["event"], "device.certificate_renewed"); + assert_eq!(renewed["actor"]["kind"], "device"); + assert_eq!(renewed["actor"]["subject"], device_id); + assert_eq!(renewed["target"]["id"], device_id); + assert_eq!(renewed["request"]["path"], RENEW_DEVICE_CERTIFICATE); + + let unaudited = FleetAgentService::new( + None, + database.clone(), + DaemonConfigStore::new(None), + None, + Some(certificate_issuer()), + ) + .with_audit(AuditLog::broken_stream(database.clone())); + let status = unaudited + .renew_certificate( + device_id, + &certificate_request(), + grpc_request(RENEW_DEVICE_CERTIFICATE), + ) + .await + .expect_err("an unaudited renewal must be refused"); + assert_eq!(status.code(), Code::Unavailable); + } #[test] fn subscription_billing_requires_server_configured_program_auth() { diff --git a/frontend/controller/src/App.tsx b/frontend/controller/src/App.tsx index 6e5b66ab..deea1cf4 100644 --- a/frontend/controller/src/App.tsx +++ b/frontend/controller/src/App.tsx @@ -10,6 +10,7 @@ import type { ControllerExtension } from "./extensions"; import { navigate, usePath } from "./router"; import type { ActiveDaemonConfig, + AuditPage as AuditPageData, ControllerSettings, Device, DeviceDetail, @@ -18,6 +19,7 @@ import type { UsageReport, } from "./types"; import { type UsageFilters, usageDimensions } from "./usage"; +import { AuditView } from "./views/AuditView"; import { ConfigurationView } from "./views/ConfigurationView"; import { DevicesView } from "./views/DevicesView"; import { DeviceView } from "./views/DeviceView"; @@ -108,6 +110,7 @@ function ControllerRoute({ } if (path === "/configuration") return ; if (path === "/usage") return ; + if (path === "/audit") return ; if (path === "/settings") return ; return ; } @@ -290,6 +293,47 @@ function UsagePage() { ); } +function AuditPage() { + const { request } = useAuth(); + const first = useApi("/api/v1/audit"); + const [older, setOlder] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + const nextBefore = older ? older.next_before : first.data?.next_before; + + async function loadMore() { + if (nextBefore == null || loadingMore) return; + setLoadingMore(true); + setLoadMoreError(null); + try { + const response = await request(`/api/v1/audit?before=${nextBefore}`); + const page = (await response.json()) as AuditPageData; + setOlder((current) => ({ + events: [...(current?.events ?? []), ...page.events], + next_before: page.next_before, + })); + } catch (error) { + setLoadMoreError( + error instanceof Error ? error.message : "Could not load older events", + ); + } finally { + setLoadingMore(false); + } + } + + return ( + + ); +} + function ConfigurationPage() { const query = useApi("/api/v1/daemon-config"); return ; diff --git a/frontend/controller/src/components/ControllerShell.tsx b/frontend/controller/src/components/ControllerShell.tsx index b8d81cd7..9f203020 100644 --- a/frontend/controller/src/components/ControllerShell.tsx +++ b/frontend/controller/src/components/ControllerShell.tsx @@ -7,6 +7,7 @@ import { Gauge, Laptop, RefreshCw, + ScrollText, Settings, SlidersHorizontal, } from "lucide-react"; @@ -15,11 +16,22 @@ import type { ControllerExtension } from "../extensions"; import { Link } from "../router"; import type { AdminSession } from "../types"; -const navigation = [ +const navigation: Array<{ + href: string; + label: string; + icon: typeof Gauge; + permission?: string; +}> = [ { href: "/", label: "Overview", icon: Gauge }, { href: "/devices", label: "Devices", icon: Laptop }, { href: "/usage", label: "Usage", icon: ChartColumn }, { href: "/configuration", label: "Configuration", icon: SlidersHorizontal }, + { + href: "/audit", + label: "Audit", + icon: ScrollText, + permission: "audit.read", + }, { href: "/settings", label: "Settings", icon: Settings }, ]; @@ -41,7 +53,13 @@ export function ControllerShell({ const { theme } = useTheme(); const [loggingOut, setLoggingOut] = useState(false); const items = [ - ...navigation, + // Sessions that do not report permissions keep the full navigation. + ...navigation.filter( + (item) => + !item.permission || + !session?.permissions || + session.permissions.includes(item.permission), + ), ...extensions.map((extension) => ({ href: extension.path, label: extension.label, diff --git a/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 461da2c2..61244897 100644 --- a/frontend/controller/src/stories/fixtures.ts +++ b/frontend/controller/src/stories/fixtures.ts @@ -6,6 +6,7 @@ import { } from "@agentdesktop/ui"; import type { + AuditEvent, ControllerSettings, DaemonConfigDocument, Device, @@ -705,3 +706,104 @@ export const sandboxDaemonConfig: DaemonConfigDocument = { codex: {}, }, }; + +const auditRequest = { + id: "5b0f3c1e-7a2d-4f53-9a1e-0c6c2f1b9d01", + method: "DELETE", + path: `/api/v1/devices/${linuxDevice.id}`, + peer: "10.0.4.17:51822", + user_agent: "Mozilla/5.0", +}; + +const operator = { + kind: "human" as const, + issuer: "https://login.example.com", + subject: "00u1operator", + claims_verified_at: now - 300, + email: "ops.lead@example.com", +}; + +export const auditEvents: AuditEvent[] = [ + { + schema: "agentdesktop.audit.v1", + id: "e5", + seq: 5, + time: new Date((now - 30) * 1000).toISOString(), + event: "action.completed", + actor: { + kind: "human", + issuer: "https://login.example.com", + subject: "00u1developer", + claims_verified_at: now - 30, + email: "developer@example.com", + }, + action: "devices.enroll", + target: { class: "devices", id: macDevice.id, name: macDevice.hostname }, + outcome: "succeeded", + request: { + id: "2f6c1d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f", + method: "POST", + path: "/agentdesktop.fleet.v1.FleetAgent/CompleteEnrollment", + peer: "203.0.113.24:50412", + }, + }, + { + schema: "agentdesktop.audit.v1", + id: "e4", + seq: 4, + time: new Date((now - 60) * 1000).toISOString(), + event: "authorization.denied", + actor: { + kind: "human", + issuer: "https://login.example.com", + subject: "00u1viewer", + claims_verified_at: now - 900, + email: "sam.okafor@example.com", + }, + action: "devices.delete", + target: { class: "devices", id: macDevice.id }, + outcome: "denied", + reason: "forbidden", + request: { + ...auditRequest, + id: "8c7d2b4a-1f0e-4d6b-a3c5-9e8f7a6b5c4d", + path: `/api/v1/devices/${macDevice.id}`, + }, + }, + { + schema: "agentdesktop.audit.v1", + id: "e3", + seq: 3, + time: new Date((now - 3600) * 1000).toISOString(), + event: "action.completed", + actor: operator, + action: "devices.delete", + target: { class: "devices", id: linuxDevice.id }, + outcome: "succeeded", + status: 204, + request: auditRequest, + }, + { + schema: "agentdesktop.audit.v1", + id: "e2", + seq: 2, + time: new Date((now - 3601) * 1000).toISOString(), + event: "action.attempted", + actor: operator, + action: "devices.delete", + target: { class: "devices", id: linuxDevice.id }, + outcome: "authorized", + request: auditRequest, + }, + { + schema: "agentdesktop.audit.v1", + id: "e1", + seq: 1, + time: new Date((now - 7200) * 1000).toISOString(), + event: "auth.login", + actor: operator, + action: null, + target: null, + outcome: "succeeded", + }, +]; diff --git a/frontend/controller/src/styles.css b/frontend/controller/src/styles.css index cb098810..3d85ba5c 100644 --- a/frontend/controller/src/styles.css +++ b/frontend/controller/src/styles.css @@ -1618,6 +1618,45 @@ code { } } +.audit-table .icon-button:hover { + color: var(--ink); + background: var(--hover); +} + +.audit-detail-row, +.audit-detail-row:hover { + cursor: default; + background: var(--surface); +} + +tbody tr.audit-detail-row td { + height: auto; + padding: 0; + white-space: normal; +} + +.audit-detail-row pre { + margin: 0; + padding: 14px 18px; + max-height: 360px; + overflow: auto; + color: var(--ink); + background: var(--code-surface); + font-size: 12px; + line-height: 1.6; +} + +.audit-load-more { + align-self: center; +} + +.audit-load-error { + margin: 0; + color: var(--red); + font-size: 12px; + text-align: center; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 4b9527f6..a7184e7b 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -178,3 +178,47 @@ export type DaemonConfigDocument = { export type ActiveDaemonConfig = { config: DaemonConfigDocument | null; }; + +export type AuditOutcome = + | "authorized" + | "denied" + | "succeeded" + | "failed" + | "not_found" + | "unaudited"; + +/** One `agentdesktop.audit.v1` event, as streamed to the SIEM. */ +export type AuditEvent = { + schema: string; + id: string; + seq: number; + time: string; + event: string; + actor: { + kind: "local" | "human" | "service" | "device"; + issuer: string; + subject: string; + client_id?: string; + claims_verified_at: number; + email?: string; + name?: string; + }; + action: string | null; + target: { class: string; id: string | null; name?: string } | null; + outcome: AuditOutcome; + reason?: string; + status?: number; + request?: { + id: string; + method: string; + path: string; + peer?: string; + forwarded_for?: string; + user_agent?: string; + }; +}; + +export type AuditPage = { + events: AuditEvent[]; + next_before: number | null; +}; diff --git a/frontend/controller/src/views/AuditView.stories.tsx b/frontend/controller/src/views/AuditView.stories.tsx new file mode 100644 index 00000000..0cf35e27 --- /dev/null +++ b/frontend/controller/src/views/AuditView.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent } from "storybook/test"; + +import { ControllerStoryFrame } from "../stories/ControllerStoryFrame"; +import { auditEvents } from "../stories/fixtures"; +import { AuditView } from "./AuditView"; + +const meta = { + title: "Controller/Audit", + component: AuditView, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { layout: "fullscreen" }, + tags: ["test"], + args: { events: auditEvents, onLoadMore: fn() }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText("Permission denied")).toBeVisible(); + await expect(canvas.getByText("Denied")).toBeVisible(); + await expect(canvas.getByText("sam.okafor@example.com")).toBeVisible(); + await expect(canvas.getByText("Signed in")).toBeVisible(); + await expect(canvas.getByText("devices.enroll")).toBeVisible(); + }, +}; + +export const ShowsTheFullRecord: Story = { + play: async ({ canvas }) => { + await userEvent.click( + canvas.getByRole("button", { name: "Show details for event 3" }), + ); + await expect(canvas.getByText(/"status": 204/)).toBeVisible(); + await userEvent.click( + canvas.getByRole("button", { name: "Hide details for event 3" }), + ); + await expect(canvas.queryByText(/"status": 204/)).not.toBeInTheDocument(); + }, +}; + +export const LoadsOlderEvents: Story = { + args: { hasMore: true }, + play: async ({ args, canvas }) => { + await userEvent.click( + canvas.getByRole("button", { name: "Load older events" }), + ); + await expect(args.onLoadMore).toHaveBeenCalledOnce(); + }, +}; + +export const LoadingOlderEvents: Story = { + args: { hasMore: true, loadingMore: true }, +}; + +export const Empty: Story = { + args: { events: [] }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No audit events yet")).toBeVisible(); + }, +}; + +export const Loading: Story = { + args: { events: [], loading: true }, +}; + +export const LoadError: Story = { + args: { + events: [], + error: "You do not have permission to view the audit log.", + }, +}; + +export const MobileReflow: Story = { + globals: { viewport: { value: "mobile", isRotated: false } }, + play: async ({ canvasElement }) => { + const documentElement = canvasElement.ownerDocument.documentElement; + const viewportWidth = canvasElement.ownerDocument.defaultView?.innerWidth; + await expect(documentElement.scrollWidth).toBeLessThanOrEqual( + viewportWidth ?? 0, + ); + }, +}; diff --git a/frontend/controller/src/views/AuditView.tsx b/frontend/controller/src/views/AuditView.tsx new file mode 100644 index 00000000..69e1bba9 --- /dev/null +++ b/frontend/controller/src/views/AuditView.tsx @@ -0,0 +1,217 @@ +import { ChevronDown, ChevronRight, ScrollText } from "lucide-react"; +import { Fragment, useState } from "react"; + +import { ErrorState, PageSkeleton } from "../components/ViewStates"; +import type { AuditEvent, AuditOutcome } from "../types"; + +const outcomeTones: Record = { + authorized: "neutral", + succeeded: "success", + not_found: "warning", + denied: "danger", + failed: "danger", + unaudited: "danger", +}; + +const outcomeLabels: Record = { + authorized: "Authorized", + succeeded: "Succeeded", + not_found: "Not found", + denied: "Denied", + failed: "Failed", + unaudited: "Unaudited", +}; + +const eventLabels: Record = { + "authorization.denied": "Permission denied", + "action.attempted": "Action started", + "action.completed": "Action finished", + "auth.login": "Signed in", + "auth.logout": "Signed out", + "auth.reauthenticate": "Reauthenticated", + "device.enrollment_denied": "Enrollment denied", + "device.certificate_renewed": "Certificate renewed", +}; + +export function AuditView({ + events, + error = null, + loading = false, + hasMore = false, + loadingMore = false, + loadMoreError = null, + onLoadMore, +}: { + events: AuditEvent[]; + error?: string | null; + loading?: boolean; + hasMore?: boolean; + loadingMore?: boolean; + loadMoreError?: string | null; + onLoadMore?: () => void; +}) { + return ( +
+
+
+

Audit log

+

+ Management changes, denied requests and device enrollments, newest + first. The same events are streamed to your SIEM. +

+
+
+
+ {loading ? ( + + ) : error ? ( + + ) : events.length ? ( + + ) : ( +
+ +

No audit events yet

+

Management changes and denied requests will appear here.

+
+ )} +
+ {loadMoreError && ( +

+ {loadMoreError} +

+ )} + {hasMore && !loading && !error && ( + + )} +
+ ); +} + +function AuditTable({ events }: { events: AuditEvent[] }) { + const [expanded, setExpanded] = useState(null); + return ( +
+ + + + + + + + + + + + + {events.map((event) => { + const open = expanded === event.id; + const toggle = () => setExpanded(open ? null : event.id); + return ( + + + + + + + + + + {open && ( + + + + )} + + ); + })} + +
TimeActorEventTargetOutcome + Details +
+
+ {formatAuditTime(event.time)} + #{event.seq} +
+
+
+ {actorLabel(event)} + {event.actor.kind} +
+
+
+ {eventLabels[event.event] ?? event.event} + {event.action ?? event.request?.path ?? "—"} +
+
+ {event.target ? ( +
+ + {event.target.name ?? event.target.id ?? "All"} + + {event.target.class} +
+ ) : ( + "—" + )} +
+ + {outcomeLabels[event.outcome] ?? event.outcome} + + + +
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to focus this scrollable record. */} +
+                        {JSON.stringify(event, null, 2)}
+                      
+
+
+ ); +} + +function actorLabel(event: AuditEvent) { + if (event.actor.kind === "local") return "Local access"; + if (event.actor.kind === "device") + return `Device ${event.actor.subject.slice(0, 8)}`; + return event.actor.email ?? event.actor.name ?? event.actor.subject; +} + +function formatAuditTime(time: string) { + const date = new Date(time); + if (Number.isNaN(date.getTime())) return time; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }).format(date); +}