Files
inbuxa-server/crates/common/src/privacy.rs
T
jcoffey-dev b20b09f81a
ci / fork-checks (pull_request) Successful in 1m3s
ci / build (pull_request) Successful in 1h11m16s
New installs start with the hashed-address blocklist off, and DNSBL zones read right
Personal-data catalog spec, default D5 (settled 2026-09-28; built after
the v0.16.24 import's spam-rules loader landed). msbl.org's EBL is sent
a SHA-1 of every email address it's asked about. A new install's first
boot now leaves a note, and the rules update, once the bundled rules
are in, switches STWT_MSBL_EBL_EMAIL off and forgets the note, so it
happens once; the loader keeps that switch through later updates. An
existing server has no note and keeps every blocklist as it is.

Also fixes the data inventory's DNSBL endpoints: a zone is an
expression (`ip_reverse + '.zen.spamhaus.org'`, conditional branches,
`hash(email, 'sha1') + '.ebl.msbl.org'`), and the zone names are now
the quoted literals that start with a dot, from every branch, rather
than the expression's text.

Tested: unit test for the zone rule; the compliance system test (no
note, no change; the inventory lists ebl.msbl.org, not a hash; with the
note the blocklist goes off; the note works once); the system suite;
fork checks.
2026-09-28 10:21:15 -07:00

435 lines
17 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The live facts the personal-data catalog is evaluated against
//! (personal-data catalog spec, §6): which sources are switched on, what
//! bounds each one's retention, which stores and endpoints are elsewhere.
//! Read from the registry on each request, so every node answers alike.
use crate::Server;
use inbuxa_features::privacy::{
self, Days, Inventory, LiveFacts, is_loopback,
snapshot::{self, Snapshot, Trigger},
};
use registry::schema::{
prelude::Object,
structs::{
AiModel, BlobStore, DataRetention, DataStore, InMemoryStore, Jmap, MtaHook, MtaMilter,
MtaRoute, Search, SearchStore, SpamClassifier, SpamClassifierModel, SpamDnsblServer,
SpamLlm, SpamPyzor, Tracer, TracingStore, WebHook,
},
};
use registry::types::duration::Duration;
use serde_json::Value;
use types::id::Id;
/// The objects [`Server::privacy_facts`] reads: a write to one may change
/// the inventory.
pub const INVENTORY_OBJECTS: &[&str] = &[
"x:DataRetention",
"x:SpamClassifier",
"x:Jmap",
"x:TracingStore",
"x:Search",
"x:Tracer",
"x:WebHook",
"x:AiModel",
"x:SpamLlm",
"x:SpamDnsblServer",
"x:SpamPyzor",
"x:MtaMilter",
"x:MtaHook",
"x:MtaRoute",
"x:DataStore",
"x:BlobStore",
"x:SearchStore",
"x:InMemoryStore",
"inbuxa:AuditSettings",
"inbuxa:LogSettings",
"inbuxa:AiLimits",
];
/// A store or endpoint object's type and host, from its JSON: local types
/// stay on the host.
fn remote_host(value: &Value) -> Option<String> {
let kind = value.get("@type").and_then(Value::as_str).unwrap_or_default();
if matches!(kind, "" | "RocksDb" | "Sqlite" | "FileSystem" | "Default" | "Disabled") {
return None;
}
for key in ["host", "url", "endpoint", "address", "hostname"] {
if let Some(host) = value.get(key).and_then(Value::as_str).filter(|h| !h.is_empty()) {
return Some(host.to_string());
}
}
// A list of URLs, as an array or as a map keyed by URL
match value.get("urls") {
Some(Value::Array(urls)) => {
if let Some(url) = urls.first().and_then(Value::as_str) {
return Some(url.to_string());
}
}
Some(Value::Object(urls)) => {
if let Some(url) = urls.keys().next() {
return Some(url.clone());
}
}
_ => {}
}
Some(kind.to_string())
}
fn days(duration: Option<&Duration>) -> Days {
match duration {
Some(d) => Days::Days(d.into_inner().as_secs().div_ceil(86_400)),
None => Days::Unbounded,
}
}
/// The zones a DNSBL's zone expression can query: each quoted literal that
/// starts with a dot, in any branch (`ip_reverse + '.zen.spamhaus.org'`).
fn zone_hosts(value: &Value) -> Vec<String> {
let mut hosts = Vec::new();
let mut texts = Vec::new();
fn collect<'a>(value: &'a Value, texts: &mut Vec<&'a str>) {
match value {
Value::String(s) => texts.push(s),
Value::Array(items) => items.iter().for_each(|v| collect(v, texts)),
Value::Object(map) => map.values().for_each(|v| collect(v, texts)),
_ => {}
}
}
collect(value, &mut texts);
for text in texts {
for literal in text.split('\'').skip(1).step_by(2) {
if let Some(zone) = literal.strip_prefix('.')
&& zone.contains('.')
&& !hosts.iter().any(|h| h == zone)
{
hosts.push(zone.to_string());
}
}
}
hosts
}
impl Server {
async fn singleton<T: registry::types::ObjectImpl + From<Object> + Default>(&self) -> trc::Result<T> {
Ok(self.registry().object::<T>(Id::singleton()).await?.unwrap_or_default())
}
/// The facts the catalog is evaluated against, from the live settings.
pub async fn privacy_facts(&self) -> trc::Result<LiveFacts> {
let mut facts = LiveFacts::default();
let data = &self.core.storage.data;
let endpoint = |facts: &mut LiveFacts, id: &str, url: String| {
if !url.is_empty() && !is_loopback(&url) {
facts.endpoints.entry(id.to_string()).or_default().push(url);
}
};
// Retention
let retention = self.singleton::<DataRetention>().await?;
for (name, value) in [
("x:DataRetention.holdTracesFor", &retention.hold_traces_for),
("x:DataRetention.holdMetricsFor", &retention.hold_metrics_for),
("x:DataRetention.holdMtaReportsFor", &retention.hold_mta_reports_for),
("x:DataRetention.archiveDeletedItemsFor", &retention.archive_deleted_items_for),
("x:DataRetention.archiveDeletedAccountsFor", &retention.archive_deleted_accounts_for),
("x:DataRetention.expungeTrashAfter", &retention.expunge_trash_after),
("x:DataRetention.expungeSubmissionsAfter", &retention.expunge_submissions_after),
] {
facts.durations.insert(name.into(), days(value.as_ref()));
}
let classifier = self.singleton::<SpamClassifier>().await?;
facts.durations.insert(
"x:SpamClassifier.holdSamplesFor".into(),
days(Some(&classifier.hold_samples_for)),
);
let jmap = self.singleton::<Jmap>().await?;
facts
.durations
.insert("x:Jmap.uploadTtl".into(), days(Some(&jmap.upload_ttl)));
let audit = inbuxa_features::audit::log::settings(data).await?;
facts.durations.insert(
"inbuxa:AuditSettings.keepForDays".into(),
Days::Days(audit.keep_for_secs.div_ceil(86_400)),
);
let logs = inbuxa_features::security::log_files::get(data).await?;
facts.durations.insert(
"inbuxa:LogSettings.keepForDays".into(),
logs.keep_for_days.map_or(Days::Unbounded, Days::Days),
);
// What's switched on
let tracing = self.singleton::<TracingStore>().await?;
let tracing_on = !matches!(tracing, TracingStore::Disabled);
let search = self.singleton::<Search>().await?;
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
facts.collected.insert(id.into(), tracing_on);
}
facts
.collected
.insert("trace-index".into(), tracing_on && search.index_telemetry);
facts.collected.insert(
"full-text-index".into(),
search.index_email || search.index_calendar || search.index_contacts,
);
let archive_on = retention.archive_deleted_items_for.is_some();
for id in [
"x:ArchivedEmail",
"x:ArchivedFileNode",
"x:ArchivedCalendarEvent",
"x:ArchivedContactCard",
"x:ArchivedSieveScript",
] {
facts.collected.insert(id.into(), archive_on);
}
facts.collected.insert(
"inbuxa:DeletedAccount".into(),
retention.archive_deleted_accounts_for.is_some(),
);
let reports_on = retention.hold_mta_reports_for.is_some();
for id in [
"x:ArfExternalReport",
"x:ArfFeedbackReport",
"x:DmarcExternalReport",
"x:DmarcReport",
"x:DmarcReportRecord",
"x:TlsExternalReport",
"x:TlsReport",
"x:TlsFailureDetails",
] {
facts.collected.insert(id.into(), reports_on);
}
let classifier_on = !matches!(classifier.model, SpamClassifierModel::Disabled);
facts
.collected
.insert("x:SpamTrainingSample".into(), classifier_on);
facts
.collected
.insert("spam-trainer-state".into(), classifier_on);
// Tracers
let (mut log_on, mut console_on, mut otel_on) = (false, false, false);
for tracer in self.registry().list::<Tracer>().await? {
match tracer.object {
Tracer::Log(t) => log_on |= t.enable,
Tracer::Stdout(t) => console_on |= t.enable,
Tracer::Journal(t) => console_on |= t.enable,
Tracer::OtelHttp(t) if t.enable => {
otel_on = true;
endpoint(&mut facts, "otel-tracer", t.endpoint);
}
Tracer::OtelGrpc(t) if t.enable => {
otel_on = true;
endpoint(&mut facts, "otel-tracer", t.endpoint.unwrap_or_default());
}
_ => {}
}
}
facts.collected.insert("log-file".into(), log_on);
facts.collected.insert("x:Log".into(), log_on);
facts.collected.insert("console-and-journal".into(), console_on);
facts.collected.insert("otel-tracer".into(), otel_on);
// Webhooks
let mut hooks_on = false;
for hook in self.registry().list::<WebHook>().await? {
if hook.object.enable {
hooks_on = true;
endpoint(&mut facts, "webhooks", hook.object.url);
}
}
facts.collected.insert("webhooks".into(), hooks_on);
// AI: the classifier's model, and Explain's
let models = self.registry().list::<AiModel>().await?;
let model_url = |id: Id| {
models
.iter()
.find(|m| Id::from(m.id.id()) == id)
.map(|m| m.object.url.clone())
};
let llm_on = match self.singleton::<SpamLlm>().await? {
SpamLlm::Enable(props) => {
if let Some(url) = model_url(props.model_id) {
endpoint(&mut facts, "spam-llm", url);
}
true
}
SpamLlm::Disable => false,
};
facts.collected.insert("spam-llm".into(), llm_on);
let limits = self.ai_limits().await;
let explain = self.ai_explain_model(&limits).await;
if let Some((_, model)) = &explain {
endpoint(&mut facts, "inbuxa:Explanation", model.url.clone());
}
facts
.collected
.insert("explain-cache".into(), explain.is_some());
facts
.collected
.insert("inbuxa:Explanation".into(), explain.is_some());
// Spam lookups off the host
let mut dnsbl_on = false;
for server in self.registry().list::<SpamDnsblServer>().await? {
let value = serde_json::to_value(&server.object).unwrap_or_default();
if value.get("enable").and_then(Value::as_bool).unwrap_or(false) {
dnsbl_on = true;
for zone in value.get("zone").map(zone_hosts).unwrap_or_default() {
endpoint(&mut facts, "spam-dnsbl", zone);
}
}
}
facts.collected.insert("spam-dnsbl".into(), dnsbl_on);
let pyzor = self.singleton::<SpamPyzor>().await?;
if pyzor.enable {
endpoint(&mut facts, "spam-pyzor", format!("{}:{}", pyzor.host, pyzor.port));
}
facts.collected.insert("spam-pyzor".into(), pyzor.enable);
// Mail handed to others
let mut hooks = false;
for milter in self.registry().list::<MtaMilter>().await? {
hooks = true;
endpoint(
&mut facts,
"mta-milter-and-hooks",
format!("{}:{}", milter.object.hostname, milter.object.port),
);
}
for hook in self.registry().list::<MtaHook>().await? {
hooks = true;
endpoint(&mut facts, "mta-milter-and-hooks", hook.object.url);
}
facts.collected.insert("mta-milter-and-hooks".into(), hooks);
let mut relays = false;
for route in self.registry().list::<MtaRoute>().await? {
if let MtaRoute::Relay(relay) = route.object {
relays = true;
endpoint(&mut facts, "relay", format!("{}:{}", relay.address, relay.port));
}
}
facts.collected.insert("relay".into(), relays);
// Stores elsewhere
let stores = [
("data-store", serde_json::to_value(self.singleton::<DataStore>().await.ok()).unwrap_or_default()),
("blob-store", serde_json::to_value(self.singleton::<BlobStore>().await?).unwrap_or_default()),
("search-store", serde_json::to_value(self.singleton::<SearchStore>().await?).unwrap_or_default()),
("in-memory-store", serde_json::to_value(self.singleton::<InMemoryStore>().await?).unwrap_or_default()),
];
for (place, value) in stores {
if let Some(host) = remote_host(&value) {
facts.remote_stores.insert(place.into(), host);
}
}
if let Some(host) = remote_host(&serde_json::to_value(&tracing).unwrap_or_default()) {
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
endpoint(&mut facts, id, host.clone());
}
}
Ok(facts)
}
/// The server's inventory, or a tenant's slice of it.
pub async fn data_inventory(&self, tenant_only: bool) -> trc::Result<Inventory> {
let facts = self.privacy_facts().await?;
Ok(privacy::evaluate(privacy::catalog(), &facts, tenant_only))
}
/// Records a snapshot of the server's inventory if it differs from the
/// newest one, or if there is none: the history shows when what the
/// server holds changed, not a copy a day. Returns whether it recorded.
pub async fn inventory_snapshot(&self, trigger: Trigger) -> trc::Result<bool> {
let data = &self.core.storage.data;
let inventory = self.data_inventory(false).await?;
if let Some(latest) = snapshot::latest(data).await?
&& let Some(previous) = snapshot::get(data, latest).await?
&& previous.inventory == inventory
{
return Ok(false);
}
snapshot::record(
data,
&Snapshot {
taken_at: store::write::now(),
trigger,
summary: inventory.summary(),
inventory,
},
)
.await?;
Ok(true)
}
/// A snapshot after a registry write, when the object is one the
/// inventory reads. Failures are logged: a snapshot is history, not
/// worth failing the write over.
pub async fn inventory_snapshot_after(&self, object: &str) {
if !INVENTORY_OBJECTS.contains(&object) {
return;
}
if let Err(err) = self
.inventory_snapshot(Trigger::SettingChanged {
setting: object.to_string(),
})
.await
{
trc::error!(err.details("Failed to record an inventory snapshot"));
}
}
/// Removes snapshots past the audit log's retention (settled
/// 2026-09-28: snapshots are kept as long as audit records).
pub async fn purge_inventory_snapshots(&self) -> trc::Result<usize> {
let data = &self.core.storage.data;
let keep = inbuxa_features::audit::log::settings(data).await?.keep_for_secs;
snapshot::purge(data, store::write::now().saturating_sub(keep)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn local_stores_stay_and_others_name_their_host() {
assert_eq!(remote_host(&json!({"@type": "RocksDb", "path": "/var/lib"})), None);
assert_eq!(remote_host(&json!({"@type": "Default"})), None);
assert_eq!(
remote_host(&json!({"@type": "PostgreSql", "host": "db.example.net"})),
Some("db.example.net".into())
);
assert_eq!(
remote_host(&json!({"@type": "ElasticSearch", "url": "https://es.example.net:9200"})),
Some("https://es.example.net:9200".into())
);
assert_eq!(remote_host(&json!({"@type": "S3", "bucket": "mail"})), Some("S3".into()));
}
#[test]
fn zones_come_from_every_branch() {
let zone = json!({"else": "false", "match": {"0": {"if": "location == 'tcp'",
"then": "ip_reverse + '.rep.mailspike.net'"}}});
assert_eq!(zone_hosts(&zone), vec!["rep.mailspike.net"]);
let zone = json!({"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}});
assert_eq!(zone_hosts(&zone), vec!["ebl.msbl.org"], "not 'sha1'");
assert!(zone_hosts(&json!({"else": "false"})).is_empty());
}
#[test]
fn days_round_up() {
assert_eq!(days(Some(&Duration::from_millis(86_400_000))), Days::Days(1));
assert_eq!(days(Some(&Duration::from_millis(3_600_000))), Days::Days(1));
assert_eq!(days(None), Days::Unbounded);
}
}