Keep rotated log files for a set number of days
ci / fork-checks (pull_request) Successful in 2m28s
ci / build (pull_request) Successful in 3m47s

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
<prefix>.<something> 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.
This commit is contained in:
2026-09-28 08:23:36 -07:00
parent bdd97c5828
commit 1d5a49409f
21 changed files with 789 additions and 2 deletions
+13
View File
@@ -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 {
+243
View File
@@ -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<u64>,
}
/// 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<Self> {
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<LogSettings> {
Ok(data
.get_value::<Json>(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<bool> {
Ok(data
.get_value::<Json>(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 `<prefix>.<something>`, 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<Item = &'a Path> + '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<usize> {
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();
}
}
+1
View File
@@ -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;
@@ -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<Self> {
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<Self> {
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<Self, Self::Err> {
LogSettingsProperty::parse(s).ok_or(())
}
}
impl Element for LogSettingsValue {
type Property = LogSettingsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
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<Id> for LogSettingsValue {
fn from(id: Id) -> Self {
LogSettingsValue::Id(id)
}
}
impl JmapObjectId for LogSettingsValue {
fn as_id(&self) -> Option<Id> {
match self {
LogSettingsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
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<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -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
+3
View File
@@ -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)
}
@@ -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)?
}
+7
View File
@@ -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",
+2
View File
@@ -116,6 +116,7 @@ pub enum GetRequestMethod {
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
@@ -148,6 +149,7 @@ pub enum SetRequestMethod<'x> {
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
+14
View File
@@ -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),
+14
View File
@@ -103,6 +103,7 @@ pub enum GetResponseMethod {
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
@@ -136,6 +137,7 @@ pub enum SetResponseMethod {
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
@@ -349,12 +351,24 @@ impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for Respon
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::LogSettings(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::LogSettings(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
+11
View File
@@ -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
+27
View File
@@ -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)?;
+1
View File
@@ -418,6 +418,7 @@ impl IntermediateChangesResponse {
| MethodObject::MaskedEmail
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::LogSettings
| MethodObject::Explanation
| MethodObject::AuditEvent
| MethodObject::AuditSettings
+4
View File
@@ -387,6 +387,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> 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()
+162
View File
@@ -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<LogSettings>,
) -> trc::Result<GetResponse<LogSettings>> {
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<SetResponse<LogSettings>> {
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::<P>().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)
}
+1
View File
@@ -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;
@@ -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<Inner>) {
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::<Tracer>().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(())
}
+4
View File
@@ -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);
}