New-install privacy defaults, and expired bans purged daily #85

Merged
jcoffey-dev merged 1 commits from feature/new-install-privacy-defaults into main 2026-09-28 14:51:26 +00:00
8 changed files with 172 additions and 6 deletions
+101 -1
View File
@@ -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::<Security>().await;
let mut classifier = bp.setting_infallible::<SpamClassifier>().await;
let mut pyzor = bp.setting_infallible::<SpamPyzor>().await;
let mut retention = bp.setting_infallible::<DataRetention>().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);
}
}
+31
View File
@@ -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::<BlockedIp>().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();
+3 -1
View File
@@ -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,
}
}
}
@@ -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 {
+16 -3
View File
@@ -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 |
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM
MiWRzsz0AHdRshG_8JimktRqBO_pHGAUBHFcfV5jwI4
+15
View File
@@ -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::<Id>::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(