diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index 66e03f5..bd5e1d6 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -14,7 +14,7 @@ use aws_lc_rs::{ use registry::{ schema::{ enums::*, - prelude::{ObjectType, SocketAddr}, + prelude::{Object, ObjectType, SocketAddr}, structs::*, }, types::{duration::Duration, error::Error, list::List, map::Map}, @@ -388,6 +388,28 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { } } + // inbuxa: personal-data catalog, defaults D2, D3, D4 and D6 (settled + // 2026-09-28): privacy-leaning values, for new installs only. A server + // with roles is not new, and keeps its settings whether saved or left at + // the default. Each singleton is read, changed and written back whole, so + // anything already in it stays. + #[cfg(not(feature = "test_mode"))] + if bp.registry.count_object(ObjectType::Role).await? == 0 { + let mut security = bp.setting_infallible::().await; + let mut classifier = bp.setting_infallible::().await; + let mut pyzor = bp.setting_infallible::().await; + let mut retention = bp.setting_infallible::().await; + new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention); + for object in [ + Object::from(security), + classifier.into(), + pyzor.into(), + retention.into(), + ] { + bp.registry.write(RegistryWrite::insert(&object)).await?; + } + } + if bp.registry.count_object(ObjectType::Role).await? == 0 { let permissions = DefaultPermissions::default(); let mut role_ids = Vec::with_capacity(4); @@ -560,3 +582,81 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { Ok(()) } + +/// inbuxa: the new-install values of defaults D2, D3, D4 and D6 from the +/// personal-data catalog spec. Automatic IP bans expire after 30 days instead +/// of never; spam training samples are kept 90 days instead of 180; Pyzor, +/// which sends a digest of each message's text to a public server, is off; +/// delivery history is kept 14 days instead of 30. +fn new_install_privacy_defaults( + security: &mut Security, + classifier: &mut SpamClassifier, + pyzor: &mut SpamPyzor, + retention: &mut DataRetention, +) { + const DAY: u64 = 24 * 60 * 60 * 1000; + let ban_period = Some(Duration::from_millis(30 * DAY)); + security.auth_ban_period = ban_period; + security.abuse_ban_period = ban_period; + security.loiter_ban_period = ban_period; + security.scan_ban_period = ban_period; + classifier.hold_samples_for = Duration::from_millis(90 * DAY); + pyzor.enable = false; + retention.hold_traces_for = Some(Duration::from_millis(14 * DAY)); +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: u64 = 24 * 60 * 60 * 1000; + + #[test] + fn new_installs_get_the_privacy_defaults() { + let (mut security, mut classifier, mut pyzor, mut retention) = ( + Security::default(), + SpamClassifier::default(), + SpamPyzor::default(), + DataRetention::default(), + ); + // What an install gets without them: bans that never lift, 180-day + // samples, Pyzor on, 30-day traces. + assert_eq!(security.auth_ban_period, None); + assert!(pyzor.enable); + + new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention); + + for period in [ + security.auth_ban_period, + security.abuse_ban_period, + security.loiter_ban_period, + security.scan_ban_period, + ] { + assert_eq!(period.map(|p| p.into_inner().as_millis() as u64), Some(30 * DAY)); + } + assert_eq!(classifier.hold_samples_for.into_inner().as_millis() as u64, 90 * DAY); + assert!(!pyzor.enable); + assert_eq!( + retention.hold_traces_for.map(|p| p.into_inner().as_millis() as u64), + Some(14 * DAY) + ); + } + + #[test] + fn everything_else_in_the_settings_stays() { + let mut retention = DataRetention { + archive_deleted_items_for: Some(Duration::from_millis(7 * DAY)), + ..Default::default() + }; + let before = retention.clone(); + new_install_privacy_defaults( + &mut Security::default(), + &mut SpamClassifier::default(), + &mut SpamPyzor::default(), + &mut retention, + ); + assert_eq!(retention.archive_deleted_items_for, before.archive_deleted_items_for); + assert_eq!(retention.hold_metrics_for, before.hold_metrics_for); + assert_eq!(retention.expunge_trash_after, before.expunge_trash_after); + } +} diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index cdbadee..cdf10df 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -426,6 +426,37 @@ impl Server { } } +impl Server { + /// inbuxa: personal-data catalog, D2: removes bans whose period is over. + /// They already stop blocking when they expire, and go when settings are + /// next loaded; the daily clean-up makes sure a server that seldom + /// reloads doesn't keep them. + pub async fn purge_expired_blocked_ips(&self) -> trc::Result<()> { + let now = now() as i64; + let mut expired = Vec::new(); + for ip in self.registry().list::().await? { + if ip.object.expires_at.as_ref().is_some_and(|at| at.timestamp() <= now) { + let address = ip.object.address.clone(); + let object = Object { + inner: ip.object.into(), + revision: ip.revision, + }; + self.registry() + .write(RegistryWrite::delete_object(ip.id, &object)) + .await?; + expired.push(trc::Value::from(address.into_inner().0)); + } + } + if !expired.is_empty() { + trc::event!( + Security(trc::SecurityEvent::IpBlockExpired), + Details = expired + ); + } + Ok(()) + } +} + impl BlockedIps { pub async fn parse(bp: &mut Bootstrap) -> Self { let mut ips = Self::default(); diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 0b7fc8e..1ef2839 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -47353,7 +47353,9 @@ impl Default for WebHook { level: TracingLevel::Info, lossy: false, events: Default::default(), - events_policy: EventPolicy::Exclude, + // inbuxa: personal-data catalog, D7: a new webhook sends nothing + // until its events are chosen + events_policy: EventPolicy::Include, } } } diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index 9b69c2b..aa8d1d7 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -269,6 +269,11 @@ async fn store_maintenance( trc::error!(err.details("Failed to re-apply account locks")); } + // inbuxa: personal-data catalog, D2: bans past their period go + if let Err(err) = server.purge_expired_blocked_ips().await { + trc::error!(err.details("Failed to purge expired IP bans")); + } + // inbuxa: AU-7: audit records past their retention go; a // failure leaves them for the next run if let Err(err) = server.audit_purge().await { diff --git a/docs/spec/features/personal-data-catalog.md b/docs/spec/features/personal-data-catalog.md index d317279..e8416e3 100644 --- a/docs/spec/features/personal-data-catalog.md +++ b/docs/spec/features/personal-data-catalog.md @@ -151,7 +151,7 @@ These live in inbuxa's own key space (`SUBSPACE_INBUXA`) unless noted. | OAuth codes and tokens | credential | holder | `x:OidcProvider.*Expiry` | tokens are sealed and stateless (not stored); codes in the in-memory store with TTL | in-memory store | tenant | `http/src/auth/oauth/auth.rs`, `token.rs` | codes 10 min | | Rate-limit state | network, identifier (login names) | holder, correspondent | `x:Http.rateLimit*`, `x:Imap.maxRequestRate`, `x:Security.*BanRate` | the rate's period | in-memory store | server | `common/src/auth/rate_limit.rs`, `network/security.rs` | on | | Greylist | identifier (sender/recipient pairs, plain) | correspondent, holder | `x:SpamSettings.greylistFor` | that period | in-memory store | server | `smtp/src/inbound/rcpt.rs` | **off** | -| Automatic IP bans (`x:BlockedIp`) | network | correspondent, holder | `x:Security.authBanRate`, `abuseBanRate`, `loiterBanRate`, `scanBanRate` (on); `*BanPeriod` (**no default**) | **unbounded: a ban with no period never expires, and no purge of expired bans was found**; each ban is also an audit record | data store (registry) | server | `common/src/network/security.rs` `block_ip` | **collected, permanent** | +| Automatic IP bans (`x:BlockedIp`) | network | correspondent, holder | `x:Security.authBanRate`, `abuseBanRate`, `loiterBanRate`, `scanBanRate` (on); `*BanPeriod` (**no default**) | **unbounded: a ban with no period never expires**; an expired ban's record goes when settings next load; each ban is also an audit record | data store (registry) | server | `common/src/network/security.rs` `block_ip` | **collected, permanent** | | Allowed IPs | network | administrator's choice | manual; `expiresAt` | optional | data store | server | registry | none | ### 2.6 Spam filter and AI @@ -197,8 +197,10 @@ not a judgment; what to do about each is John's call. 2. **Log files are never deleted.** Daily rotation opens a new file; nothing removes old ones, and no logrotate configuration ships. At the default level every in-session line carries the client IP. -3. **Automatic IP bans are permanent.** No `*BanPeriod` has a default, and no - purge of expired `x:BlockedIp` records was found. +3. **Automatic IP bans are permanent.** No `*BanPeriod` has a default, so a + ban never expires. (Corrected 2026-09-28: a ban that does expire stops + blocking, and its record is deleted when settings are next loaded, in + `BlockedIps::parse`; the investigation missed that path.) 4. **Some records outlive the account.** Deleting an account doesn't clear inbuxa's own key space: legacy-protocol last use, account locks, masked address records and audit records stay (audit records by design). @@ -394,6 +396,17 @@ default; fails on a stale entry. Phase 3; existing servers keep their settings. D7 is also covered by the bug fix for finding 1 (Settled 6). +**As built (2026-09-28).** D2, D3, D4, D6 are written on first boot of a new +install only (no roles yet), each singleton read and written back whole +(`manager/defaults.rs`, `new_install_privacy_defaults`); expired bans are +also purged daily (`purge_expired_blocked_ips`). D7 changes the default for +webhooks created from now on; stored webhooks keep theirs (the registry +stores every field). **Held:** D1, because `x:TracerLog` is also stored +inside `x:Bootstrap` with fields after it, so adding a field changes that +object's stored format; a fork-owned setting is proposed instead, for John +to decide. D5, because the spam-rules loader it touches is being reworked +by the v0.16.24 import. + | # | Change | Trade-off | |---|---|---| | D1 | A **log retention** setting on `x:TracerLog` (delete rotated files older than N days), default 30 days for new installs | Needs code (a new field, so a schema edit); older logs gone for troubleshooting; operators wanting longer set it | diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index bc1c4ef..862bb88 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index cf8d413..083abdc 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ --jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM \ No newline at end of file +MiWRzsz0AHdRshG_8JimktRqBO_pHGAUBHFcfV5jwI4 \ No newline at end of file diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index 76a397b..c107a33 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -221,6 +221,21 @@ pub async fn test(test: &mut TestServer) { ) .await; + // inbuxa: personal-data catalog, D2: the daily clean-up removes the + // expired ban's record, without waiting for settings to reload + test.server.purge_expired_blocked_ips().await.unwrap(); + assert_eq!( + admin + .registry_query_ids( + ObjectType::BlockedIp, + [(Property::Address, "10.0.0.2")], + Vec::<&str>::new(), + ) + .await, + Vec::::new(), + "the expired ban's record is gone" + ); + // Make sure the IP remains unblocked after reload admin.registry_create_object(Action::ReloadBlockedIps).await; validate_password_with_ip(