Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +822 to +823
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=<seq>&limit=<n>`, 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
Expand Down
2 changes: 1 addition & 1 deletion crates/controller/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions crates/controller/migrations/0005_admin_audit_postgres.sql
Original file line number Diff line number Diff line change
@@ -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();
24 changes: 24 additions & 0 deletions crates/controller/migrations/0005_admin_audit_sqlite.sql
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +22 to +24
6 changes: 5 additions & 1 deletion crates/controller/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?),
Expand Down
Loading
Loading