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
+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;