Merge pull request 'Keep rotated log files for a set number of days' (#87) from feature/log-retention into main
This commit was merged in pull request #87.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -418,6 +418,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::Explanation
|
||||
| MethodObject::AuditEvent
|
||||
| MethodObject::AuditSettings
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 `<object>.<property>`: 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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user