Files
windmill/backend/windmill-audit/src/instance_config.rs
T
afbbc0b1b2 feat: audit declarative instance config writes (#11684)
* feat: audit declarative instance config writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep early logs, base url and license plan for declarative writers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: require an observer on every config apply, drop license loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: export only audit events from the declarative subcommands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to d6a6cdd343e7744ea49c610f9534c8013c8ffc2a

This commit updates the EE repository reference after PR #872 was merged in windmill-ee-private.

Previous ee-repo-ref: fc51c31bafb5975694fffe1da51f7b41fbf127a6

New ee-repo-ref: d6a6cdd343e7744ea49c610f9534c8013c8ffc2a

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-12 16:37:05 +02:00

149 lines
4.6 KiB
Rust

//! Audit entries for writes to instance settings and worker group configs, whichever way
//! they arrive: an API request, or a declarative sync that no user request stands behind.
use std::collections::HashMap;
use windmill_common::{
audit::AuditAuthor, error::Result, instance_config::ConfigWriteObserver,
users::SUPERADMIN_SYNC_EMAIL, DB,
};
use crate::{
audit_oss::{audit_log, AuditAuthorable},
ActionKind,
};
/// The declarative writer behind an entry, recorded as its `source` parameter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeclarativeSource {
/// `windmill sync-config <file>`
SyncConfig,
/// The Kubernetes operator's ConfigMap sync.
Operator,
}
impl DeclarativeSource {
pub fn as_str(&self) -> &'static str {
match self {
DeclarativeSource::SyncConfig => "sync-config",
DeclarativeSource::Operator => "operator",
}
}
}
/// Who a declarative sync's writes are attributed to. It runs on direct database
/// credentials, so there is no user to name.
pub fn declarative_sync_author() -> AuditAuthor {
AuditAuthor {
username: "instance_config_sync".to_string(),
email: SUPERADMIN_SYNC_EMAIL.to_string(),
username_override: None,
token_prefix: None,
}
}
fn parameters<'a>(
value: Option<&'a str>,
source: Option<DeclarativeSource>,
) -> Option<HashMap<&'a str, &'a str>> {
let parameters: HashMap<&str, &str> = value
.map(|v| ("value", v))
.into_iter()
.chain(source.map(|s| ("source", s.as_str())))
.collect();
(!parameters.is_empty()).then_some(parameters)
}
/// A setting is recorded by name, with its value only when that is a boolean or a number.
/// Any other shape can carry a credential (a registry URL, a webhook, a header map), and no
/// list of secret-bearing settings stays complete as settings are added.
///
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
/// `author` may change instance settings before performing the write this records.
pub async fn audit_setting_write(
db: &DB,
author: &impl AuditAuthorable,
key: &str,
value: Option<&serde_json::Value>,
source: Option<DeclarativeSource>,
) -> Result<()> {
let deleted = match value {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(s)) => s.trim().is_empty(),
Some(_) => false,
};
let recorded = match value {
Some(v @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_))) => {
Some(v.to_string())
}
_ => None,
};
audit_log(
db,
author,
if deleted {
"global_settings.delete"
} else {
"global_settings.update"
},
if deleted {
ActionKind::Delete
} else {
ActionKind::Update
},
"global",
Some(key),
parameters(recorded.as_deref(), source),
)
.await
}
/// A worker group config is recorded by name only: its env vars and init script are free
/// text that can carry a credential.
///
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
/// `author` may change worker group configs before performing the write this records.
pub async fn audit_worker_config_write(
db: &DB,
author: &impl AuditAuthorable,
group: &str,
deleted: bool,
source: Option<DeclarativeSource>,
) -> Result<()> {
let (operation, action_kind) = if deleted {
("worker_config.delete", ActionKind::Delete)
} else {
("worker_config.update", ActionKind::Update)
};
audit_log(
db,
author,
operation,
action_kind,
"global",
Some(&format!("worker__{group}")),
parameters(None, source),
)
.await
}
/// Audits each write of an instance config apply as `author`'s.
///
/// AUTHORIZATION: as [`audit_setting_write`], for every write it is told of.
pub struct InstanceConfigAuditor<'a, A> {
pub db: &'a DB,
pub author: &'a A,
/// `None` for a write made through the API.
pub source: Option<DeclarativeSource>,
}
impl<A: AuditAuthorable + Sync> ConfigWriteObserver for InstanceConfigAuditor<'_, A> {
async fn setting_written(&self, key: &str, value: Option<&serde_json::Value>) -> Result<()> {
audit_setting_write(self.db, self.author, key, value, self.source).await
}
async fn worker_config_written(&self, group: &str, deleted: bool) -> Result<()> {
audit_worker_config_write(self.db, self.author, group, deleted, self.source).await
}
}