From 1d5a49409f43b2394a06e9fcc6f569a03babcfe5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 08:23:36 -0700 Subject: [PATCH] Keep rotated log files for a set number of days Personal-data catalog spec, default D1 (settled 2026-09-28): log files were never deleted. inbuxa:LogSettings.keepForDays says how many days rotated log files are kept; unset (null) keeps every file, as before, and a new install sets 30 days. It is a fork-owned setting, stored under T + l as audit retention is, not a field on x:TracerLog: that object is also stored inside x:Bootstrap with a field after it, so a new field would change x:Bootstrap's stored format. Server-level, with the tracers' permissions (sysTracerGet, sysTracerUpdate); changes are in the audit log, before and after. Log files are local, so every node deletes its own: hourly, and at once when the setting changes on that node. Only regular files named . in each enabled log tracer's directory, last changed more than the limit ago, are removed; the file being written is never that old, and nothing else in the directory is touched. Minimum one day. The catalog classifies inbuxa:LogSettings and points the log file's retention at it. Tested: unit tests for the file rule (only this log's old files; the current file, other files and directories stay) and a purge on disk; the system suite, which reads, sets, refuses zero, restores null and checks the audit records; fork checks. --- crates/common/src/manager/defaults.rs | 13 + crates/features/src/security/log_files.rs | 243 ++++++++++++++++++ crates/features/src/security/mod.rs | 1 + .../src/object/inbuxa_log_settings.rs | 153 +++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 7 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 14 + crates/jmap-proto/src/response/mod.rs | 14 + crates/jmap/src/api/auth.rs | 11 + crates/jmap/src/api/request.rs | 27 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/audit.rs | 4 + crates/jmap/src/inbuxa/log_settings.rs | 162 ++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/services/src/inbuxa_log_retention.rs | 62 +++++ crates/services/src/lib.rs | 4 + resources/privacy/catalog.toml | 9 +- tests/src/system/security.rs | 55 ++++ 21 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 crates/features/src/security/log_files.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_log_settings.rs create mode 100644 crates/jmap/src/inbuxa/log_settings.rs create mode 100644 crates/services/src/inbuxa_log_retention.rs diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index bd5e1d6..5f759b4 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -408,6 +408,19 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { ] { bp.registry.write(RegistryWrite::insert(&object)).await?; } + + // D1: rotated log files are kept 30 days (a fork-owned setting, + // since x:TracerLog is also stored inside x:Bootstrap) + use inbuxa_features::security::log_files; + if !log_files::is_set(&bp.data_store).await? { + log_files::set( + &bp.data_store, + &log_files::LogSettings { + keep_for_days: Some(log_files::NEW_INSTALL_KEEP_DAYS), + }, + ) + .await?; + } } if bp.registry.count_object(ObjectType::Role).await? == 0 { diff --git a/crates/features/src/security/log_files.rs b/crates/features/src/security/log_files.rs new file mode 100644 index 0000000..91f182a --- /dev/null +++ b/crates/features/src/security/log_files.rs @@ -0,0 +1,243 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:LogSettings`, how long rotated log files are kept (personal-data +//! catalog spec, default D1, settled 2026-09-28). Stored as JSON under `T` + +//! `l` in the fork's subspace, not on `x:TracerLog`: that object is also +//! stored inside `x:Bootstrap` with fields after it, so a new field there +//! would change `x:Bootstrap`'s stored format. +//! +//! Unset, files are kept as they always were: forever. A new install sets +//! 30 days. Each node deletes its own files, since log files are local. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use std::{ + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// The fewest days a limit may keep, so a typo can't empty the log directory +/// of what an incident needs. +pub const MIN_KEEP_DAYS: u64 = 1; + +/// The days a new install keeps (D1). +pub const NEW_INSTALL_KEEP_DAYS: u64 = 30; + +/// Rung when the settings change here, so this node purges at once; other +/// nodes read the settings again within the hour. +pub static CHANGED: tokio::sync::Notify = tokio::sync::Notify::const_new(); + +#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct LogSettings { + /// Rotated log files older than this many days are deleted; `None` + /// keeps them all. + pub keep_for_days: Option, +} + +/// The properties `inbuxa:LogSettings` has, as they appear over JMAP. +pub const PROPERTIES: &[&str] = &["keepForDays"]; + +impl LogSettings { + /// What's wrong with these values, naming the property. + pub fn check(&self) -> Result<(), (&'static str, String)> { + match self.keep_for_days { + Some(days) if days < MIN_KEEP_DAYS => Err(( + "keepForDays", + format!("must be at least {MIN_KEEP_DAYS}, or null to keep every file"), + )), + _ => Ok(()), + } + } +} + +fn key() -> ValueClass { + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: b"Tl".to_vec(), + }) +} + +struct Json(LogSettings); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// The settings in force; unset reads as keep everything. +pub async fn get(data: &Store) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key())) + .await + .caused_by(trc::location!())? + .map(|Json(settings)| settings) + .unwrap_or_default()) +} + +/// Whether anything was ever stored: a new install writes its default only +/// when nothing is there. +pub async fn is_set(data: &Store) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key())) + .await + .caused_by(trc::location!())? + .is_some()) +} + +/// Stores new settings. +pub async fn set(data: &Store, settings: &LogSettings) -> trc::Result<()> { + let bytes = serde_json::to_vec(settings).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// A file in a log directory: its path, name, and when it last changed. +pub struct LogFile { + pub path: PathBuf, + pub name: String, + pub modified: SystemTime, + pub is_file: bool, +} + +/// The files to delete: regular files named `.`, whose +/// last change is more than `keep` ago. The file being written changes all +/// the time, so it is never old enough; anything not named for this log is +/// never touched. +pub fn expired<'a>( + files: &'a [LogFile], + prefix: &str, + keep: Duration, + now: SystemTime, +) -> impl Iterator + 'a { + let lead = format!("{prefix}."); + files.iter().filter_map(move |file| { + (file.is_file + && file.name.starts_with(&lead) + && now + .duration_since(file.modified) + .is_ok_and(|age| age > keep)) + .then_some(file.path.as_path()) + }) +} + +/// Deletes this log's expired files in `dir`, returning how many went. +pub fn purge(dir: &Path, prefix: &str, keep: Duration) -> std::io::Result { + let mut files = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let meta = entry.metadata()?; + files.push(LogFile { + path: entry.path(), + name: entry.file_name().to_string_lossy().into_owned(), + modified: meta.modified()?, + is_file: meta.is_file(), + }); + } + let mut removed = 0; + for path in expired(&files, prefix, keep, SystemTime::now()) { + std::fs::remove_file(path)?; + removed += 1; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: Duration = Duration::from_secs(86_400); + + fn file(name: &str, age_days: u64, now: SystemTime) -> LogFile { + LogFile { + path: PathBuf::from(format!("/var/log/inbuxa/{name}")), + name: name.to_string(), + modified: now - DAY * age_days as u32, + is_file: true, + } + } + + #[test] + fn only_this_logs_old_files_go() { + let now = SystemTime::now(); + let files = [ + file("inbuxa.log.2026-08-01", 58, now), + file("inbuxa.log.2026-09-27", 1, now), + file("inbuxa.log", 0, now), + file("other.log.2026-01-01", 270, now), + file("inbuxa.logs.old", 90, now), + LogFile { + is_file: false, + ..file("inbuxa.log.dir", 90, now) + }, + ]; + let gone: Vec<_> = expired(&files, "inbuxa.log", 30 * DAY, now) + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(gone, vec!["inbuxa.log.2026-08-01"]); + } + + #[test] + fn unset_keeps_everything_and_zero_is_refused() { + assert_eq!(LogSettings::default().keep_for_days, None); + assert!(LogSettings::default().check().is_ok()); + let zero = LogSettings { + keep_for_days: Some(0), + }; + assert_eq!(zero.check().unwrap_err().0, "keepForDays"); + let json: LogSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(json, LogSettings::default()); + } + + #[test] + fn purge_deletes_on_disk() { + let dir = std::env::temp_dir().join(format!("inbuxa-log-purge-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let old = dir.join("inbuxa.log.2020-01-01"); + let new = dir.join("inbuxa.log.today"); + let other = dir.join("keep-me.txt"); + for path in [&old, &new, &other] { + std::fs::write(path, b"x").unwrap(); + } + let long_ago = SystemTime::now() - 60 * DAY; + std::fs::File::options() + .write(true) + .open(&old) + .unwrap() + .set_modified(long_ago) + .unwrap(); + std::fs::File::options() + .write(true) + .open(&other) + .unwrap() + .set_modified(long_ago) + .unwrap(); + + assert_eq!(purge(&dir, "inbuxa.log", 30 * DAY).unwrap(), 1); + assert!(!old.exists()); + assert!(new.exists()); + assert!(other.exists(), "a file not named for the log is never touched"); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index fb3e583..84ea44c 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -11,6 +11,7 @@ //! `legacy-protocols.md`. pub mod legacy_use; +pub mod log_files; pub mod listeners; pub mod protocol_policy; pub mod tenant_protocol_policy; diff --git a/crates/jmap-proto/src/object/inbuxa_log_settings.rs b/crates/jmap-proto/src/object/inbuxa_log_settings.rs new file mode 100644 index 0000000..b8f3b6f --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_log_settings.rs @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:LogSettings/get` and `/set` under `urn:inbuxa:jmap`: how long +//! rotated log files are kept (personal-data catalog spec, D1). A singleton, +//! id `singleton`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct LogSettings; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LogSettingsProperty { + Id, + KeepForDays, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LogSettingsValue { + Id(Id), +} + +impl Property for LogSettingsProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + LogSettingsProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + LogSettingsProperty::Id => "id", + LogSettingsProperty::KeepForDays => "keepForDays", + } + .into() + } +} + +impl LogSettingsProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => LogSettingsProperty::Id, + b"keepForDays" => LogSettingsProperty::KeepForDays, + ) + } +} + +impl FromStr for LogSettingsProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + LogSettingsProperty::parse(s).ok_or(()) + } +} + +impl Element for LogSettingsValue { + type Property = LogSettingsProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(LogSettingsProperty::Id) => { + Id::from_str(value).ok().map(LogSettingsValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + LogSettingsValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for LogSettings { + type Property = LogSettingsProperty; + + type Element = LogSettingsValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = LogSettingsProperty::Id; +} + +impl From for LogSettingsValue { + fn from(id: Id) -> Self { + LogSettingsValue::Id(id) + } +} + +impl JmapObjectId for LogSettingsValue { + fn as_id(&self) -> Option { + match self { + LogSettingsValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + LogSettingsValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = LogSettingsValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for LogSettingsProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 02a357a..87101a3 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -23,6 +23,7 @@ pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_account_lock; // inbuxa: account lock with delegation pub mod inbuxa_ai_limits; // inbuxa: AI spam classification +pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1 pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_hold_export; // inbuxa: legal hold exports diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 3b99b1f..b99117f 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -61,6 +61,9 @@ impl Response<'_> { GetResponseMethod::AiLimits(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::LogSettings(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::AuditEvent(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 84a34c7..f83a580 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -46,6 +46,7 @@ impl Response<'_> { GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, + GetRequestMethod::LogSettings(request) => request.resolve_references(self)?, GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?, GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, @@ -98,6 +99,9 @@ impl Response<'_> { SetRequestMethod::AiLimits(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::LogSettings(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::Explanation(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 24b7753..2339dc9 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -49,6 +49,7 @@ pub enum MethodObject { DeletedAccount, // inbuxa: AI call limits AiLimits, + LogSettings, // inbuxa: "Explain this" with the local model Explanation, // inbuxa: the audit log @@ -89,6 +90,7 @@ impl MethodObject { MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, + MethodObject::LogSettings => Capability::Inbuxa, MethodObject::Explanation => Capability::Inbuxa, MethodObject::AuditEvent | MethodObject::AuditSettings @@ -276,6 +278,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set", (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", + (MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get", + (MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set", (MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set", (MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get", (MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query", @@ -424,6 +428,8 @@ impl MethodName { "inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set), "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), + "inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get), + "inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set), "inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set), "inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get), "inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query), @@ -494,6 +500,7 @@ impl Display for MethodObject { MethodObject::MaskedEmail => "MaskedEmail", MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", + MethodObject::LogSettings => "inbuxa:LogSettings", MethodObject::Explanation => "inbuxa:Explanation", MethodObject::AuditEvent => "inbuxa:AuditEvent", MethodObject::AuditSettings => "inbuxa:AuditSettings", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 66ff11a..8759fce 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -116,6 +116,7 @@ pub enum GetRequestMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + LogSettings(Box>), AuditEvent(Box>), AuditSettings(Box>), AccountLock(Box>), @@ -148,6 +149,7 @@ pub enum SetRequestMethod<'x> { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + LogSettings(Box>), Explanation(Box>), AuditSettings(Box>), AuditExport(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index ac4c5ef..471986c 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -169,6 +169,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::LogSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LogSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)), Err(err) => RequestMethod::invalid(err), @@ -350,6 +357,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::LogSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LogSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index e13cebd..cfbbe9b 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -103,6 +103,7 @@ pub enum GetResponseMethod { MaskedEmail(GetResponse), DeletedAccount(GetResponse), AiLimits(GetResponse), + LogSettings(GetResponse), AuditEvent(GetResponse), AuditSettings(GetResponse), AccountLock(GetResponse), @@ -136,6 +137,7 @@ pub enum SetResponseMethod { MaskedEmail(Box>), DeletedAccount(Box>), AiLimits(Box>), + LogSettings(Box>), AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), @@ -349,12 +351,24 @@ impl<'x> From> for Respon } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::LogSettings(value)) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: SetResponse) -> Self { ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value))) } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::LogSettings(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: SetResponse) -> Self { ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value))) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index dce0434..f4ac407 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -90,6 +90,8 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, // inbuxa: AI call limits, with the classifier's permissions GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, + // inbuxa: log file retention, with the tracers' permissions + GetRequestMethod::LogSettings(_) => Permission::SysTracerGet, // inbuxa: the audit log (AU-9) GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => { Permission::SysAuditGet @@ -201,6 +203,14 @@ impl JmapAuthorization for AccessToken { Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate, ), + // inbuxa: log file retention, with the tracers' permissions + SetRequestMethod::LogSettings(s) => validate_set( + s, + self, + Permission::SysTracerUpdate, + Permission::SysTracerUpdate, + Permission::SysTracerUpdate, + ), // inbuxa: the audit log (AU-7, AU-9, AU-11) SetRequestMethod::AuditSettings(s) => validate_set( s, @@ -382,6 +392,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::MaskedEmail | MethodObject::DeletedAccount | MethodObject::AiLimits + | MethodObject::LogSettings | MethodObject::Explanation | MethodObject::AuditEvent | MethodObject::AuditSettings diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 8347e6d..d39ed42 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -261,6 +261,9 @@ impl RequestHandler for Server { SetResponseMethod::AiLimits(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::LogSettings(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AuditSettings(set_response) => { set_response.update_created_ids(&mut response); } @@ -445,6 +448,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:LogSettings/get + GetRequestMethod::LogSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::log_settings::get(self, access_token, *req) + .await? + .into() + } // inbuxa: account lock with delegation (AL-1) GetRequestMethod::AccountLock(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -769,6 +779,23 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:LogSettings/set + SetRequestMethod::LogSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| Box::pin(crate::inbuxa::log_settings::set(self, access_token, req)), + ) + .await? + .into() + } // inbuxa: the audit log (AU-7, AU-11, AU-6) SetRequestMethod::AuditSettings(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 9928c8b..e88d738 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -418,6 +418,7 @@ impl IntermediateChangesResponse { | MethodObject::MaskedEmail | MethodObject::DeletedAccount | MethodObject::AiLimits + | MethodObject::LogSettings | MethodObject::Explanation | MethodObject::AuditEvent | MethodObject::AuditSettings diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index 0926990..c089d7d 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -387,6 +387,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> O .await .ok() .map(|settings| serde_json::json!({"keepForDays": settings.keep_for_secs / 86_400})), + "inbuxa:LogSettings" => security::log_files::get(data) + .await + .ok() + .and_then(|settings| serde_json::to_value(settings).ok()), "inbuxa:AiLimits" => limits::get(data) .await .ok() diff --git a/crates/jmap/src/inbuxa/log_settings.rs b/crates/jmap/src/inbuxa/log_settings.rs new file mode 100644 index 0000000..e97c4bd --- /dev/null +++ b/crates/jmap/src/inbuxa/log_settings.rs @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:LogSettings/get` and `/set`: how long rotated log files are kept +//! (personal-data catalog spec, D1). Server-level: log files belong to the +//! server, not to a tenant. `null` restores the default, which keeps every +//! file. + +use common::{Server, auth::AccessToken}; +use inbuxa_features::security::log_files::{self, LogSettings as Settings}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_log_settings::{LogSettings, LogSettingsProperty as P, LogSettingsValue}, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use types::id::Id; + +type LValue = Value<'static, P, LogSettingsValue>; + +const ALL: &[P] = &[P::Id, P::KeepForDays]; + +fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("Log file settings are server-level.")) + } else { + Ok(()) + } +} + +fn to_value(settings: &Settings, properties: &[P]) -> LValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(LogSettingsValue::Id(Id::singleton())), + P::KeepForDays => settings + .keep_for_days + .map_or(Value::Null, |days| Value::Number(days.into())), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:LogSettings/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + assert_server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let settings = log_files::get(&server.core.storage.data).await?; + match ids { + None => response.list.push(to_value(&settings, &properties)), + Some(ids) => { + for id in ids { + if id.is_singleton() { + response.list.push(to_value(&settings, &properties)); + } else { + response.push_not_found(id); + } + } + } + } + Ok(response) +} + +fn apply( + settings: &mut Settings, + property: &P, + value: &Value<'_, P, LogSettingsValue>, +) -> Result<(), String> { + match property { + P::KeepForDays => match value { + Value::Null => settings.keep_for_days = None, + value => { + settings.keep_for_days = Some( + value + .as_u64() + .ok_or_else(|| "must be a whole number of days, or null".to_string())?, + ) + } + }, + P::Id => return Err("is immutable".to_string()), + } + Ok(()) +} + +/// `inbuxa:LogSettings/set`: updates the singleton. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, LogSettings>, +) -> trc::Result> { + assert_server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response.not_created.append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + let data = &server.core.storage.data; + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + let mut settings = log_files::get(data).await?; + let mut error = None; + for (key, value) in value.into_expanded_object() { + let Key::Property(property) = &key else { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + }; + if let Err(why) = apply(&mut settings, property, &value) { + error = Some( + SetError::invalid_properties() + .with_property(property.clone()) + .with_description(why), + ); + break; + } + } + if error.is_none() + && let Err((property, why)) = settings.check() + { + error = Some( + SetError::invalid_properties() + .with_property(property.parse::

().unwrap_or(P::Id)) + .with_description(format!("{property} {why}.")), + ); + } + match error { + Some(error) => response.not_updated.append(id, error), + None => { + log_files::set(data, &settings).await?; + // This node purges now; the others within the hour + log_files::CHANGED.notify_one(); + response.updated.append(id, None); + } + } + } + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index b1e984f..46623f5 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -15,6 +15,7 @@ pub mod hold_export_api; pub mod audit; pub mod audit_log; pub mod ai_limits; +pub mod log_settings; pub mod explanation; pub mod protocol_policy; pub mod tenant_protocol_policy; diff --git a/crates/services/src/inbuxa_log_retention.rs b/crates/services/src/inbuxa_log_retention.rs new file mode 100644 index 0000000..2f88c11 --- /dev/null +++ b/crates/services/src/inbuxa_log_retention.rs @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Deletes rotated log files past `inbuxa:LogSettings.keepForDays` +//! (personal-data catalog spec, D1). Log files are local, so every node +//! cleans its own: hourly, and at once when the settings change here. + +use common::{BuildServer, Inner, Server}; +use inbuxa_features::security::log_files; +use registry::schema::structs::Tracer; +use std::{path::PathBuf, sync::Arc, time::Duration}; + +const EVERY: Duration = Duration::from_secs(3600); + +pub fn spawn_log_retention(inner: Arc) { + tokio::spawn(async move { + loop { + let server = inner.build_server(); + if let Err(err) = purge(&server).await { + trc::error!(err.details("Failed to delete old log files")); + } + tokio::select! { + _ = tokio::time::sleep(EVERY) => {} + _ = log_files::CHANGED.notified() => {} + } + } + }); +} + +async fn purge(server: &Server) -> trc::Result<()> { + let Some(days) = log_files::get(&server.core.storage.data) + .await? + .keep_for_days + else { + return Ok(()); + }; + let keep = Duration::from_secs(days.max(log_files::MIN_KEEP_DAYS) * 86_400); + for tracer in server.registry().list::().await? { + let Tracer::Log(log) = tracer.object else { + continue; + }; + if !log.enable || log.path.is_empty() { + continue; + } + let (dir, prefix) = (PathBuf::from(&log.path), log.prefix.clone()); + let result = tokio::task::spawn_blocking(move || log_files::purge(&dir, &prefix, keep)) + .await + .map_err(|err| trc::EventType::Server(trc::ServerEvent::ThreadError).reason(err))?; + if let Err(err) = result { + trc::event!( + Telemetry(trc::TelemetryEvent::LogError), + Details = "Failed to delete old log files", + Path = log.path.clone(), + Reason = err.to_string(), + ); + } + } + Ok(()) +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index c1e76d9..37cc38b 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -25,6 +25,7 @@ use crate::task_manager::{manager::spawn_task_manager, scheduler::spawn_task_sch pub mod broadcast; // inbuxa: AL-5, delegations end at their date pub mod inbuxa_lock_expiry; +pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1 pub mod state_manager; pub mod task_manager; @@ -70,6 +71,9 @@ impl SpawnServices for IpcReceivers { // inbuxa: AL-5, end delegations at their `until` inbuxa_lock_expiry::spawn_lock_expiry(inner.clone()); + // inbuxa: personal-data catalog, D1: old log files go, per node + inbuxa_log_retention::spawn_log_retention(inner.clone()); + // Spawn task scheduler spawn_task_scheduler(inner); } diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index f647a70..6ab670a 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -161,6 +161,10 @@ recentLegacyUse = ["identifier", "metadata"] file = "inbuxa_ai_limits.rs" default = "none" +[object."inbuxa:LogSettings"] +file = "inbuxa_log_settings.rs" +default = "none" + # --------------------------------------------------------------------------- # Sources that are no object. Settings are `.`: a schema # field object, or one of inbuxa's own. @@ -202,11 +206,12 @@ categories = ["network", "identifier", "metadata"] whose = ["correspondent", "holder", "administrator"] where = ["log-file"] scope = "server" -retention = "unbounded" +# Unset, every file is kept; a new install keeps 30 days +retention = { setting = "inbuxa:LogSettings.keepForDays" } enabled_by = ["x:TracerLog.enable"] captures = ["x:TracerLog.level", "x:TracerLog.events", "x:EventTracingLevel.level"] leaves_host = false -written_by = ["crates/common/src/telemetry/tracers/log.rs"] +written_by = ["crates/common/src/telemetry/tracers/log.rs", "crates/services/src/inbuxa_log_retention.rs"] [source."console-and-journal"] categories = ["network", "identifier", "metadata"] diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index c107a33..e50ee91 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -236,6 +236,61 @@ pub async fn test(test: &mut TestServer) { "the expired ban's record is gone" ); + // inbuxa: personal-data catalog, D1: how long rotated log files are kept. + // Unset keeps every file; a value is days; zero is refused; null goes back + let using = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; + let account = admin.id_string().to_string(); + let log_get = || { + admin.jmap_request( + using, + json!([["inbuxa:LogSettings/get", {"accountId": account, "ids": null}, "0"]]), + ) + }; + let log_set = |keep: serde_json::Value| { + admin.jmap_request( + using, + json!([["inbuxa:LogSettings/set", {"accountId": account, + "update": {"singleton": {"keepForDays": keep}}}, "0"]]), + ) + }; + let got = log_get().await; + assert_eq!( + got.0.pointer("/methodResponses/0/1/list/0/keepForDays"), + Some(&serde_json::Value::Null), + "a server that never set it keeps every file: {}", + got.0 + ); + let set = log_set(json!(14)).await; + assert!(set.0.pointer("/methodResponses/0/1/updated/singleton").is_some(), "{}", set.0); + let got = log_get().await; + assert_eq!(got.0.pointer("/methodResponses/0/1/list/0/keepForDays"), Some(&json!(14))); + let refused = log_set(json!(0)).await; + assert_eq!( + refused.0.pointer("/methodResponses/0/1/notUpdated/singleton/type"), + Some(&json!("invalidProperties")), + "{}", + refused.0 + ); + log_set(serde_json::Value::Null).await; + let got = log_get().await; + assert_eq!( + got.0.pointer("/methodResponses/0/1/list/0/keepForDays"), + Some(&serde_json::Value::Null) + ); + // Each change is in the audit log, before and after + let query = admin + .jmap_request( + using, + json!([["inbuxa:AuditEvent/query", {"accountId": account, + "filter": {"targetKind": "inbuxa:LogSettings"}}, "0"]]), + ) + .await; + assert!( + query.0.pointer("/methodResponses/0/1/ids").and_then(|ids| ids.as_array()).is_some_and(|ids| ids.len() >= 2), + "log settings changes aren't recorded: {}", + query.0 + ); + // Make sure the IP remains unblocked after reload admin.registry_create_object(Action::ReloadBlockedIps).await; validate_password_with_ip( -- 2.54.0