From 4c53da59470c8f3b9428658e9e3a303ff90ae8e2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 20:26:09 -0700 Subject: [PATCH] Security to-do list: accepted items, kept on the server --- crates/common/src/auth/permissions.rs | 5 +- .../common/src/manager/granted_permissions.rs | 4 +- crates/features/src/security/acceptance.rs | 280 ++++++++++++++++++ crates/features/src/security/mod.rs | 1 + .../src/object/inbuxa_security_acceptance.rs | 173 +++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 8 + crates/jmap-proto/src/request/mod.rs | 4 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 21 ++ crates/jmap/src/api/auth.rs | 19 ++ crates/jmap/src/api/request.rs | 33 +++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/audit.rs | 26 +- crates/jmap/src/inbuxa/mod.rs | 1 + crates/jmap/src/inbuxa/security_acceptance.rs | 257 ++++++++++++++++ crates/registry/src/schema/enums.rs | 2 + crates/registry/src/schema/enums_impl.rs | 5 +- docs/spec/SPEC.md | 2 + resources/privacy/catalog.toml | 11 + resources/schema/schema.json.gz | Bin 153429 -> 153452 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/mod.rs | 1 + tests/src/system/security_acceptances.rs | 233 +++++++++++++++ 26 files changed, 1107 insertions(+), 5 deletions(-) create mode 100644 crates/features/src/security/acceptance.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_security_acceptance.rs create mode 100644 crates/jmap/src/inbuxa/security_acceptance.rs create mode 100644 tests/src/system/security_acceptances.rs diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 04a98be..e29bfc2 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -304,7 +304,10 @@ impl Default for DefaultPermissions { | Permission::SysDlpPolicyGet | Permission::SysDlpPolicyUpdate | Permission::SysDlpReviewGet - | Permission::SysDlpReviewUpdate => { + | Permission::SysDlpReviewUpdate + // inbuxa: every security check is server-wide (security + // to-do list spec) + | Permission::SysSecurityAccept => { default.superuser.push(permission); } // inbuxa: AL-12: tenant administrators lock and delegate diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index fac45e4..26fa287 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -31,7 +31,8 @@ use types::id::Id; /// Granted to the default administrator roles: "Explain this" /// (ai-explain spec, EX-4: superuser by default), the audit log, account /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and -/// the data inventory (personal-data catalog spec). +/// the data inventory (personal-data catalog spec), and accepting security +/// to-do items (security to-do list spec). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -52,6 +53,7 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysDlpPolicyUpdate, Permission::SysDlpReviewGet, Permission::SysDlpReviewUpdate, + Permission::SysSecurityAccept, ]; /// Granted to the server-level Compliance Officer role once it exists: diff --git a/crates/features/src/security/acceptance.rs b/crates/features/src/security/acceptance.rs new file mode 100644 index 0000000..6448f2d --- /dev/null +++ b/crates/features/src/security/acceptance.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Accepted security to-do items (security to-do list spec, SS-23 to SS-26). +//! +//! The console runs the checks; the server only keeps what an administrator +//! accepted, so every administrator sees the same accepted risks. An +//! acceptance names the check, what within it (a domain, a certificate…), +//! the value the check saw, and why. It holds only while the check still +//! sees that value, which the console compares. Acceptances are created and +//! removed, never edited. +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts +//! with `Q`, then one byte for the kind: +//! +//! - `a` + acceptance id (u32): the acceptance, as JSON. +//! +//! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so +//! they're read whole. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; + +const FEATURE: u8 = b'Q'; +const KIND_ACCEPTANCE: u8 = b'a'; +const CREATE_ATTEMPTS: usize = 5; + +pub const MAX_ACCEPTANCES: usize = 200; +/// The checks are SS-1 to SS-18; a few spare for checks added later. +const MAX_CHECK: u32 = 40; +const MAX_SUBJECT: usize = 255; +const MAX_VALUE_BYTES: usize = 4096; +const MAX_NOTE: usize = 500; + +#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Acceptance { + #[serde(default)] + pub id: u32, + /// Which check: `SS-1`, `SS-2`… + pub check: String, + /// What within the check: empty for a server-wide setting, else the + /// domain, strategy or certificate it names. + #[serde(default)] + pub subject: String, + /// The value the check saw when it was accepted. + #[serde(default)] + pub accepted_value: serde_json::Value, + /// Why. Required. + pub note: String, + #[serde(default)] + pub accepted_by: String, + /// Seconds since the epoch. + #[serde(default)] + pub accepted_at: u64, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Invalid { + pub property: &'static str, + pub reason: String, +} + +fn invalid(property: &'static str, reason: impl Into) -> Invalid { + Invalid { + property, + reason: reason.into(), + } +} + +impl Acceptance { + /// What an administrator sends is checked whole before it's kept. + pub fn validate(&self) -> Result<(), Invalid> { + let check_ok = self + .check + .strip_prefix("SS-") + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=MAX_CHECK).contains(&n)); + if !check_ok { + return Err(invalid("check", "A check is named SS-1, SS-2 and so on.")); + } + if self.subject.chars().count() > MAX_SUBJECT { + return Err(invalid( + "subject", + format!("At most {MAX_SUBJECT} characters."), + )); + } + let value_bytes = serde_json::to_vec(&self.accepted_value) + .map(|v| v.len()) + .unwrap_or(usize::MAX); + if value_bytes > MAX_VALUE_BYTES { + return Err(invalid( + "acceptedValue", + format!("At most {MAX_VALUE_BYTES} bytes."), + )); + } + let note = self.note.trim(); + if note.is_empty() { + return Err(invalid("note", "Say why this is accepted.")); + } + if note.chars().count() > MAX_NOTE { + return Err(invalid("note", format!("At most {MAX_NOTE} characters."))); + } + Ok(()) + } +} + +// --- Storage -------------------------------------------------------------- + +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize a security acceptance") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid security acceptance") + .reason(err) + }) + } +} + +fn class(id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(KIND_ACCEPTANCE); + key.extend_from_slice(&id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(id: u32) -> ValueKey { + ValueKey::from(class(id)) +} + +pub async fn get(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::>(key(id)) + .await + .caused_by(trc::location!())? + .map(|Json(acceptance)| acceptance)) +} + +/// Every acceptance, oldest first. +pub async fn all(data: &Store) -> trc::Result> { + let mut out = Vec::new(); + data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| { + if let Ok(Json(acceptance)) = Json::::deserialize(value) { + out.push(acceptance); + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + out.sort_by_key(|a| a.id); + Ok(out) +} + +pub enum Created { + Id(u32), + /// There are already [`MAX_ACCEPTANCES`]. + Full, +} + +/// Keeps a new acceptance under the next free id. Two nodes creating at +/// once can't take the same id: the key must be absent. +pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result { + let mut attempt = 0; + loop { + attempt += 1; + let existing = all(data).await?; + if existing.len() >= MAX_ACCEPTANCES { + return Ok(Created::Full); + } + let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1; + let stored = Acceptance { + id, + ..acceptance.clone() + }; + let mut batch = BatchBuilder::new(); + batch.assert_value(class(id), AssertValue::None); + batch.set(class(id), Json(&stored).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => return Ok(Created::Id(id)), + Err(err) + if attempt < CREATE_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(class(id)); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn acceptance() -> Acceptance { + Acceptance { + id: 0, + check: "SS-1".into(), + subject: String::new(), + accepted_value: serde_json::json!(true), + note: "Old clients on the LAN; closed by 2027.".into(), + accepted_by: String::new(), + accepted_at: 0, + } + } + + #[test] + fn a_note_is_required() { + assert!(acceptance().validate().is_ok()); + let blank = Acceptance { + note: " ".into(), + ..acceptance() + }; + assert_eq!(blank.validate().unwrap_err().property, "note"); + let long = Acceptance { + note: "x".repeat(501), + ..acceptance() + }; + assert_eq!(long.validate().unwrap_err().property, "note"); + } + + #[test] + fn only_named_checks() { + for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] { + let a = Acceptance { + check: bad.into(), + ..acceptance() + }; + assert_eq!(a.validate().unwrap_err().property, "check", "{bad}"); + } + } + + #[test] + fn subject_and_value_are_bounded() { + let a = Acceptance { + subject: "d".repeat(256), + ..acceptance() + }; + assert_eq!(a.validate().unwrap_err().property, "subject"); + let a = Acceptance { + accepted_value: serde_json::json!("v".repeat(4096)), + ..acceptance() + }; + assert_eq!(a.validate().unwrap_err().property, "acceptedValue"); + } +} diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index 84ea44c..7bf8b9b 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -10,6 +10,7 @@ //! ships. The legacy-protocols switch is INBUXA's own design, specified in //! `legacy-protocols.md`. +pub mod acceptance; pub mod legacy_use; pub mod log_files; pub mod listeners; diff --git a/crates/jmap-proto/src/object/inbuxa_security_acceptance.rs b/crates/jmap-proto/src/object/inbuxa_security_acceptance.rs new file mode 100644 index 0000000..e3d2b77 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_security_acceptance.rs @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SecurityAcceptance/get` and `/set` under `urn:inbuxa:jmap`: the +//! security to-do items an administrator accepted, with why (security +//! to-do list spec, SS-23 to SS-26). Created and destroyed, never updated. + +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 SecurityAcceptance; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecurityAcceptanceProperty { + Id, + /// `SS-1` to `SS-18`. + Check, + Subject, + AcceptedValue, + Note, + AcceptedBy, + AcceptedAt, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecurityAcceptanceValue { + Id(Id), +} + +impl Property for SecurityAcceptanceProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside acceptedValue stay plain keys + match parent { + None => SecurityAcceptanceProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SecurityAcceptanceProperty::Id => "id", + SecurityAcceptanceProperty::Check => "check", + SecurityAcceptanceProperty::Subject => "subject", + SecurityAcceptanceProperty::AcceptedValue => "acceptedValue", + SecurityAcceptanceProperty::Note => "note", + SecurityAcceptanceProperty::AcceptedBy => "acceptedBy", + SecurityAcceptanceProperty::AcceptedAt => "acceptedAt", + } + .into() + } +} + +impl SecurityAcceptanceProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => SecurityAcceptanceProperty::Id, + b"check" => SecurityAcceptanceProperty::Check, + b"subject" => SecurityAcceptanceProperty::Subject, + b"acceptedValue" => SecurityAcceptanceProperty::AcceptedValue, + b"note" => SecurityAcceptanceProperty::Note, + b"acceptedBy" => SecurityAcceptanceProperty::AcceptedBy, + b"acceptedAt" => SecurityAcceptanceProperty::AcceptedAt, + ) + } +} + +impl FromStr for SecurityAcceptanceProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + SecurityAcceptanceProperty::parse(s).ok_or(()) + } +} + +impl Element for SecurityAcceptanceValue { + type Property = SecurityAcceptanceProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(SecurityAcceptanceProperty::Id) => { + Id::from_str(value).ok().map(SecurityAcceptanceValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SecurityAcceptanceValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for SecurityAcceptance { + type Property = SecurityAcceptanceProperty; + + type Element = SecurityAcceptanceValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = SecurityAcceptanceProperty::Id; +} + +impl From for SecurityAcceptanceValue { + fn from(id: Id) -> Self { + SecurityAcceptanceValue::Id(id) + } +} + +impl JmapObjectId for SecurityAcceptanceValue { + fn as_id(&self) -> Option { + match self { + SecurityAcceptanceValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + SecurityAcceptanceValue::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 = SecurityAcceptanceValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for SecurityAcceptanceProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 003cf17..4caa0b4 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -30,6 +30,7 @@ pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules +pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items pub mod inbuxa_held_message; // inbuxa: mail held for review pub mod inbuxa_hold_export; // inbuxa: legal hold exports pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 216d826..04077ad 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -88,6 +88,9 @@ impl Response<'_> { GetResponseMethod::MailRule(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::SecurityAcceptance(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::HeldMessage(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index b4a3ed3..92ce08e 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -55,6 +55,7 @@ impl Response<'_> { GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::MailRule(request) => request.resolve_references(self)?, + GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, GetRequestMethod::HoldExport(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, @@ -131,6 +132,9 @@ impl Response<'_> { SetRequestMethod::MailRule(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::SecurityAcceptance(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::HeldMessage(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 99d5258..e57a763 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -68,6 +68,8 @@ pub enum MethodObject { ProtocolPolicy, // inbuxa: DLP and mail flow rules MailRule, + // inbuxa: accepted security to-do items + SecurityAcceptance, HeldMessage, TenantProtocolPolicy, } @@ -109,6 +111,7 @@ impl MethodObject { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::SecurityAcceptance | MethodObject::HeldMessage => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, @@ -307,6 +310,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get", (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", + (MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get", + (MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set", (MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get", (MethodFunction::Set, MethodObject::HeldMessage) => "inbuxa:HeldMessage/set", (MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get", @@ -465,6 +470,8 @@ impl MethodName { "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get), "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), + "inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get), + "inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set), "inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get), "inbuxa:HeldMessage/set" => (MethodObject::HeldMessage, MethodFunction::Set), "inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get), @@ -539,6 +546,7 @@ impl Display for MethodObject { MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::MailRule => "inbuxa:MailRule", + MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::HeldMessage => "inbuxa:HeldMessage", MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 0923c14..b0e3ce7 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -125,6 +125,7 @@ pub enum GetRequestMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + SecurityAcceptance(Box>), HeldMessage(Box>), HoldExport(Box>), ProtocolPolicy(Box>), @@ -163,6 +164,9 @@ pub enum SetRequestMethod<'x> { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + SecurityAcceptance( + Box>, + ), HeldMessage(Box>), HoldExport(Box>), ProtocolPolicy(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 4d067db..bd29ce9 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -653,6 +653,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: accepted security to-do items + (MethodFunction::Get, MethodObject::SecurityAcceptance) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::SecurityAcceptance(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::SecurityAcceptance) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::SecurityAcceptance(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: legal hold (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 6320886..a554da7 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -112,6 +112,7 @@ pub enum GetResponseMethod { AccountLock(GetResponse), LegalHold(GetResponse), MailRule(GetResponse), + SecurityAcceptance(GetResponse), HeldMessage(GetResponse), HoldExport(GetResponse), ProtocolPolicy(GetResponse), @@ -150,6 +151,9 @@ pub enum SetResponseMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + SecurityAcceptance( + Box>, + ), HeldMessage(Box>), HoldExport(Box>), Explanation(Box>), @@ -829,6 +833,23 @@ impl<'x> From> for } } +// inbuxa: accepted security to-do items +impl<'x> From> + for ResponseMethod<'x> +{ + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::SecurityAcceptance(value)) + } +} + +impl<'x> From> + for ResponseMethod<'x> +{ + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::SecurityAcceptance(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::MailRule(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 83af147..e666792 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -117,6 +117,9 @@ impl JmapAuthorization for AccessToken { } } GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport, + // inbuxa: accepted security items are read by whoever may + // see the server's security settings + GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -288,6 +291,21 @@ impl JmapAuthorization for AccessToken { .details("You are not authorized to change mail rules")) } } + // inbuxa: accepting a security to-do item, or removing + // an acceptance; nothing is ever edited + SetRequestMethod::SecurityAcceptance(s) => { + if s.update.as_ref().is_some_and(|u| !u.is_empty()) { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("An acceptance is replaced, not edited")) + } else if self.has_permission(Permission::SysSecurityAccept) { + Ok(()) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to accept security items")) + } + } // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -450,6 +468,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::SecurityAcceptance | MethodObject::HeldMessage | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3dbc901..ad97449 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -285,6 +285,9 @@ impl RequestHandler for Server { SetResponseMethod::MailRule(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::SecurityAcceptance(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::HeldMessage(set_response) => { set_response.update_created_ids(&mut response); } @@ -512,6 +515,13 @@ impl RequestHandler for Server { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into() } + // inbuxa: accepted security to-do items + GetRequestMethod::SecurityAcceptance(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::security_acceptance::get(self, access_token, *req) + .await? + .into() + } // inbuxa: the audit log (AU-9) GetRequestMethod::AuditEvent(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -985,6 +995,29 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: SS-26, every acceptance made or removed is in the + // audit log + SetRequestMethod::SecurityAcceptance(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::security_acceptance::set( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } SetRequestMethod::AuditExport(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit_log::export_set(self, access_token, session, *req) diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 343ef12..ae15f87 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -431,6 +431,7 @@ impl IntermediateChangesResponse { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::SecurityAcceptance | MethodObject::HeldMessage | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index c089d7d..8370804 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -217,7 +217,11 @@ async fn before( if let Some(MaybeResultReference::Value(destroy)) = &request.destroy { for id in destroy { - let before = stored(server, registry, id).await; + // inbuxa: a fork object is named from its own store, as an update is + let before = match registry { + Some(_) => stored(server, registry, id).await, + None => fork_current(server, object, id).await, + }; let mut described = before.as_ref().map(diff::describe).unwrap_or_default(); if let Some(before) = &before { described.name = full_name(server, object, before, described.name).await; @@ -434,6 +438,26 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> O } MaybeInvalid::Invalid(_) => None, }, + // SS-26: an acceptance named by its check and subject + "inbuxa:SecurityAcceptance" => match id { + MaybeInvalid::Value(id) => { + let acceptance = + security::acceptance::get(data, u32::try_from(id.id()).ok()?) + .await + .ok()??; + let name = match acceptance.subject.as_str() { + "" => acceptance.check.clone(), + subject => format!("{} {subject}", acceptance.check), + }; + Some(serde_json::json!({ + "name": name, + "check": acceptance.check, + "subject": acceptance.subject, + "note": acceptance.note, + })) + } + MaybeInvalid::Invalid(_) => None, + }, "inbuxa:TenantProtocolPolicy" => match id { MaybeInvalid::Value(id) => { security::tenant_protocol_policy::get(data, id.document_id()) diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 9e978f1..dd52a23 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -11,6 +11,7 @@ pub mod access; pub mod account_lock; pub mod legal_hold; pub mod mail_rule; +pub mod security_acceptance; pub mod held_message; pub mod dlp_settings; pub mod hold_export; diff --git a/crates/jmap/src/inbuxa/security_acceptance.rs b/crates/jmap/src/inbuxa/security_acceptance.rs new file mode 100644 index 0000000..fa48c34 --- /dev/null +++ b/crates/jmap/src/inbuxa/security_acceptance.rs @@ -0,0 +1,257 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26): +//! the security to-do items an administrator accepted, with why, so every +//! administrator sees the same accepted risks. Created and destroyed, never +//! updated (the request gate refuses an update). Seeing them needs what the +//! security page needs; changing them needs `sysSecurityAccept`. Every +//! check is server-wide, so nobody in a tenant reaches them. The request +//! layer records every change in the audit log (SS-26). + +use common::{Server, auth::AccessToken}; +use inbuxa_features::security::acceptance::{self, Acceptance, Created}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_security_acceptance::{ + SecurityAcceptance, SecurityAcceptanceProperty as P, SecurityAcceptanceValue, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +type RValue = Value<'static, P, SecurityAcceptanceValue>; + +const ALL: &[P] = &[ + P::Id, + P::Check, + P::Subject, + P::AcceptedValue, + P::Note, + P::AcceptedBy, + P::AcceptedAt, +]; + +/// Properties the server sets; a client that sends them is refused. +const SERVER_SET: &[P] = &[P::Id, P::AcceptedBy, P::AcceptedAt]; + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("Security checks are the server's.")) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> RValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn to_value(acceptance: &Acceptance, properties: &[P]) -> RValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(SecurityAcceptanceValue::Id(Id::from(acceptance.id))), + P::Check => Value::Str(acceptance.check.clone().into()), + P::Subject => Value::Str(acceptance.subject.clone().into()), + P::AcceptedValue => json_to_value(acceptance.accepted_value.clone()), + P::Note => Value::Str(acceptance.note.clone().into()), + P::AcceptedBy => Value::Str(acceptance.accepted_by.clone().into()), + P::AcceptedAt => Value::Str( + UTCDate::from_timestamp(acceptance.accepted_at as i64) + .to_string() + .into(), + ), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// An acceptance as sent, checked whole. +fn parse(value: Value<'_, P, SecurityAcceptanceValue>) -> Result> { + let mut map = serde_json::Map::new(); + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(p) if SERVER_SET.contains(p) => { + return Err(SetError::invalid_properties() + .with_property(p.clone()) + .with_description("The server sets this.")); + } + Key::Property(p) => { + map.insert(p.to_cow().into_owned(), value.into()); + } + _ => { + return Err(SetError::invalid_properties().with_property(key.clone().into_owned())); + } + } + } + let acceptance: Acceptance = + serde_json::from_value(serde_json::Value::Object(map)).map_err(|err| { + SetError::invalid_properties() + .with_description(format!("Not a valid acceptance: {err}")) + })?; + acceptance.validate().map_err(|invalid| { + let property = invalid.property.parse::

().unwrap_or(P::Note); + SetError::invalid_properties() + .with_property(property) + .with_description(invalid.reason) + })?; + Ok(Acceptance { + note: acceptance.note.trim().to_string(), + ..acceptance + }) +} + +fn acceptance_id(id: Id) -> Option { + u32::try_from(id.id()).ok() +} + +/// `inbuxa:SecurityAcceptance/get`: every acceptance, oldest first. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let all = acceptance::all(server.store()).await?; + match ids { + None => { + response.list = all.iter().map(|a| to_value(a, &properties)).collect(); + } + Some(ids) => { + for id in ids { + match acceptance_id(id).and_then(|id| all.iter().find(|a| a.id == id)) { + Some(a) => response.list.push(to_value(a, &properties)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:SecurityAcceptance/set`: accept an item, or remove an acceptance. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, SecurityAcceptance>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + for (client_id, value) in request.unwrap_create() { + let parsed = match parse(value) { + Ok(parsed) => parsed, + Err(error) => { + response.not_created.append(client_id, error); + continue; + } + }; + let accepted = Acceptance { + accepted_by: actor.name.clone(), + accepted_at: now(), + ..parsed + }; + match acceptance::create(data, &accepted).await? { + Created::Id(id) => { + let mut out = Map::with_capacity(3); + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(SecurityAcceptanceValue::Id(Id::from(id))), + ); + out.insert_unchecked( + Key::Property(P::AcceptedBy), + Value::Str(accepted.accepted_by.clone().into()), + ); + out.insert_unchecked( + Key::Property(P::AcceptedAt), + Value::Str( + UTCDate::from_timestamp(accepted.accepted_at as i64) + .to_string() + .into(), + ), + ); + response.created.insert(client_id, Value::Object(out)); + } + Created::Full => { + response.not_created.append( + client_id, + SetError::over_quota().with_description(format!( + "There are already {} acceptances. Remove some first.", + acceptance::MAX_ACCEPTANCES + )), + ); + } + } + } + + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("An acceptance is replaced, not edited."), + ); + } + + for id in request.unwrap_destroy().into_valid() { + let found = match acceptance_id(id) { + Some(acceptance_id) => acceptance::get(data, acceptance_id).await?, + None => None, + }; + match found { + Some(found) => { + acceptance::delete(data, found.id).await?; + response.destroyed.push(id); + } + None => response.not_destroyed.append(id, SetError::not_found()), + } + } + + Ok(response) +} diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index f578905..b465227 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1755,6 +1755,8 @@ pub enum Permission { SysDlpPolicyUpdate = 677, SysDlpReviewGet = 678, SysDlpReviewUpdate = 679, + // inbuxa: the security to-do list, accepting an item + SysSecurityAccept = 680, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index ed72ffe..5f85ea4 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7097,6 +7097,7 @@ impl EnumImpl for Permission { b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate, b"sysDlpReviewGet" => Permission::SysDlpReviewGet, b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate, + b"sysSecurityAccept" => Permission::SysSecurityAccept, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7793,6 +7794,7 @@ impl EnumImpl for Permission { Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate", Permission::SysDlpReviewGet => "sysDlpReviewGet", Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate", + Permission::SysSecurityAccept => "sysSecurityAccept", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8482,6 +8484,7 @@ impl EnumImpl for Permission { 677 => Some(Permission::SysDlpPolicyUpdate), 678 => Some(Permission::SysDlpReviewGet), 679 => Some(Permission::SysDlpReviewUpdate), + 680 => Some(Permission::SysSecurityAccept), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8926,7 +8929,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 680; + const COUNT: usize = 681; } impl serde::Serialize for Permission { diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index dffbf7a..b417873 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -362,6 +362,8 @@ is written. | 9 | Per-domain directories | A domain signs in against its own LDAP, SQL or OIDC directory | Added 2026-09-18. Signing in through an OIDC provider as the server's directory is already AGPL; only the per-domain choice is Enterprise. Built 2026-09-19 in `crates/common/src/auth` and `crates/directory`; status in `features/per-domain-directories.md`. | | — | Seat limits, license keys | Nothing: there's no license | Removed, not rebuilt. | +Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts `specs/security-score.md`). The console runs its checks; the server's part is `inbuxa:SecurityAcceptance`, the accepted items (`crates/jmap/src/inbuxa/security_acceptance.rs`), and the `sysSecurityAccept` permission. + ## 5. The web front ends **Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs, diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index b6a6b9a..98aa443 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -113,6 +113,17 @@ exceptions = ["contact", "content"] actions = ["contact", "content"] createdBy = ["identifier"] +[object."inbuxa:SecurityAcceptance"] +file = "inbuxa_security_acceptance.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."inbuxa:SecurityAcceptance".properties] +note = ["content"] +acceptedBy = ["identifier"] + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 85a0a525d9bd3dba6b5f5750b6bde5be1ac229b7..51cbef461c6bf47c41f35f907496e7347326f206 100644 GIT binary patch delta 17895 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 delta 17903 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 Y(WTt~K(SNTr7idW3wq3+v7h!105!-zV*mgE diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index dfb1703..728421a 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -bLr7o49yK6CtWpENWq5zjgKrbCSzAXW3DZfNAEpnXEE \ No newline at end of file +-Qr56jJ7QowRD0wYXigExwHILIn1MXCgO4TBSAUHk8w \ No newline at end of file diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 4c51b14..4e66099 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -15,6 +15,7 @@ pub mod account_lock; // inbuxa: account lock with delegation pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules +pub mod security_acceptances; // inbuxa: accepted security to-do items pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once diff --git a/tests/src/system/security_acceptances.rs b/tests/src/system/security_acceptances.rs new file mode 100644 index 0000000..272563c --- /dev/null +++ b/tests/src/system/security_acceptances.rs @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26): +//! an accepted item is kept with who, when and why, a note is required, +//! nothing is edited, only administrators may accept, and every acceptance +//! made or removed is in the audit log. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use serde_json::{Value, json}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +async fn list(account: &Account) -> Vec { + let (name, response) = call( + account, + "inbuxa:SecurityAcceptance/get", + json!({"ids": null}), + ) + .await; + assert_eq!(name, "inbuxa:SecurityAcceptance/get", "{response}"); + response["list"].as_array().unwrap().clone() +} + +pub async fn test(test: &mut TestServer) { + println!("Running security acceptance tests..."); + let admin = test.account("admin@example.com"); + + // Accepted, with the server's who and when + let (_, response) = call( + &admin, + "inbuxa:SecurityAcceptance/set", + json!({"create": { + "plain": { + "check": "SS-1", + "subject": "", + "acceptedValue": true, + "note": " Old scanners on the LAN; replaced in March. " + }, + "relay": { + "check": "SS-2", + "acceptedValue": {"match": {}, "else": "is_local_ip(remote_ip)"}, + "note": "The office printer relays through us." + } + }}), + ) + .await; + let plain_id = response["created"]["plain"]["id"] + .as_str() + .unwrap_or_else(|| panic!("accepted: {response}")) + .to_string(); + assert_eq!( + response["created"]["plain"]["acceptedBy"], "admin@example.com", + "{response}" + ); + let relay_id = response["created"]["relay"]["id"] + .as_str() + .unwrap() + .to_string(); + + let all = list(&admin).await; + assert_eq!(all.len(), 2, "{all:?}"); + let plain = all.iter().find(|a| a["id"] == plain_id.as_str()).unwrap(); + assert_eq!(plain["check"], "SS-1"); + assert_eq!(plain["subject"], ""); + assert_eq!(plain["acceptedValue"], true); + assert_eq!(plain["note"], "Old scanners on the LAN; replaced in March."); + assert!( + plain["acceptedAt"] + .as_str() + .is_some_and(|d| d.ends_with('Z')), + "{plain}" + ); + let relay = all.iter().find(|a| a["id"] == relay_id.as_str()).unwrap(); + assert_eq!(relay["acceptedValue"]["else"], "is_local_ip(remote_ip)"); + + // A note is required, the check must be one of ours, and what the + // server sets can't be sent + let (_, response) = call( + &admin, + "inbuxa:SecurityAcceptance/set", + json!({"create": { + "nonote": {"check": "SS-1", "acceptedValue": true, "note": " "}, + "nocheck": {"check": "SS-99", "acceptedValue": true, "note": "x"}, + "by": {"check": "SS-1", "acceptedValue": true, "note": "x", "acceptedBy": "someone"} + }}), + ) + .await; + assert_eq!( + response["notCreated"]["nonote"]["properties"][0], "note", + "{response}" + ); + assert_eq!( + response["notCreated"]["nocheck"]["properties"][0], "check", + "{response}" + ); + assert_eq!( + response["notCreated"]["by"]["properties"][0], "acceptedBy", + "{response}" + ); + assert_eq!(list(&admin).await.len(), 2); + + // Replaced, never edited + let (name, response) = call( + &admin, + "inbuxa:SecurityAcceptance/set", + json!({"update": {plain_id.as_str(): {"note": "changed"}}}), + ) + .await; + assert_eq!(name, "error", "an acceptance was edited: {response}"); + + // Only administrators: someone without the permissions neither sees + // nor accepts + let user = admin + .create_user_account( + "security-user@example.com", + "user-secret-8812", + "User", + &[], + vec![], + ) + .await; + let (name, response) = call( + &user, + "inbuxa:SecurityAcceptance/set", + json!({"create": {"x": {"check": "SS-1", "acceptedValue": true, "note": "mine"}}}), + ) + .await; + assert_eq!(name, "error", "a user accepted an item: {response}"); + let (name, response) = call(&user, "inbuxa:SecurityAcceptance/get", json!({"ids": null})).await; + assert_eq!(name, "error", "a user read acceptances: {response}"); + + // Removed + let (_, response) = call( + &admin, + "inbuxa:SecurityAcceptance/set", + json!({"destroy": [plain_id, "zzzzzz"]}), + ) + .await; + assert_eq!(response["destroyed"], json!([plain_id]), "{response}"); + assert!( + response["notDestroyed"].get("zzzzzz").is_some(), + "{response}" + ); + let remaining = list(&admin).await; + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["check"], "SS-2"); + + // SS-26: accepted and removed are both in the audit log, with who and + // what + let (_, response) = call( + &admin, + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:SecurityAcceptance"}}), + ) + .await; + let ids = response["ids"].clone(); + let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await; + let events = response["list"].as_array().unwrap(); + let created = events + .iter() + .filter(|e| e["action"] == "create" && e["outcome"]["status"] == "success") + .count(); + assert_eq!(created, 2, "{response}"); + let removed = events + .iter() + .find(|e| e["action"] == "destroy" && e["outcome"]["status"] == "success") + .unwrap_or_else(|| panic!("no removal recorded: {response}")); + assert_eq!(removed["target"]["name"], "SS-1", "{removed}"); + assert!( + events + .iter() + .all(|e| e["actor"]["name"] == "admin@example.com"), + "{response}" + ); + assert!( + response.to_string().contains("Old scanners on the LAN"), + "the note isn't in the record: {response}" + ); + + // Cleared for the tests that follow + call( + &admin, + "inbuxa:SecurityAcceptance/set", + json!({"destroy": [relay_id]}), + ) + .await; +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn security_acceptance_tests() { + let mut test = TestServerBuilder::new("security_acceptance_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +}