diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index a111b77..d696272 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -312,7 +312,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: journals are the server's; administrators set them diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 7d653a9..49d38f0 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, @@ -54,6 +55,7 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysDlpReviewUpdate, Permission::SysJournalGet, Permission::SysJournalUpdate, + 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 7d91f9a..c7108ac 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_journal; // inbuxa: journaling pub mod inbuxa_held_message; // inbuxa: mail held for review pub mod inbuxa_hold_export; // inbuxa: legal hold exports diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 08a22b4..c6cd377 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::Journal(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 dd69481..d8f8686 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::Journal(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, GetRequestMethod::HoldExport(request) => request.resolve_references(self)?, @@ -132,6 +133,9 @@ impl Response<'_> { SetRequestMethod::MailRule(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::SecurityAcceptance(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::Journal(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 b42d028..d89d7c0 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, // inbuxa: journaling Journal, @@ -111,6 +113,7 @@ impl MethodObject { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::SecurityAcceptance | MethodObject::HeldMessage | MethodObject::Journal => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, @@ -310,6 +313,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::Journal) => "inbuxa:Journal/get", (MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set", (MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get", @@ -470,6 +475,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:Journal/get" => (MethodObject::Journal, MethodFunction::Get), "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get), @@ -546,6 +553,7 @@ impl Display for MethodObject { MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::MailRule => "inbuxa:MailRule", + MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::Journal => "inbuxa:Journal", MethodObject::HeldMessage => "inbuxa:HeldMessage", MethodObject::HoldExport => "inbuxa:HoldExport", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index b2ad5ef..48397a6 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>), Journal(Box>), HeldMessage(Box>), HoldExport(Box>), @@ -164,6 +165,9 @@ pub enum SetRequestMethod<'x> { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + SecurityAcceptance( + Box>, + ), Journal(Box>), HeldMessage(Box>), HoldExport(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 7c0f1b0..0d09910 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: journaling (MethodFunction::Get, MethodObject::Journal) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index d9e9e74..03f84ac 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), Journal(GetResponse), HeldMessage(GetResponse), HoldExport(GetResponse), @@ -151,6 +152,9 @@ pub enum SetResponseMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + SecurityAcceptance( + Box>, + ), Journal(Box>), HeldMessage(Box>), HoldExport(Box>), @@ -831,6 +835,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 73b7e36..710b6c9 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -119,6 +119,9 @@ impl JmapAuthorization for AccessToken { // inbuxa: journaling (JR-18) GetRequestMethod::Journal(_) => Permission::SysJournalGet, 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, @@ -298,6 +301,21 @@ impl JmapAuthorization for AccessToken { Permission::SysJournalUpdate, Permission::SysJournalUpdate, ), + // 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, @@ -460,6 +478,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::SecurityAcceptance | MethodObject::HeldMessage | MethodObject::Journal | MethodObject::ProtocolPolicy diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index d627d51..0bd8ffe 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::Journal(set_response) => { set_response.update_created_ids(&mut response); } @@ -515,6 +518,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: journaling GetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -993,6 +1003,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::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; let reason = req.arguments.reason.clone(); diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 5370409..3e50441 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::Journal | MethodObject::HeldMessage | MethodObject::ProtocolPolicy 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 5d2ab1b..92be663 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 journal; pub mod held_message; pub mod dlp_settings; 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 e0d5c98..e3d8bca 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1760,6 +1760,8 @@ pub enum Permission { SysJournalUpdate = 681, SysJournalSearch = 682, SysJournalExport = 683, + // inbuxa: the security to-do list, accepting an item + SysSecurityAccept = 684, 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 f8cba76..a130de3 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7101,6 +7101,7 @@ impl EnumImpl for Permission { b"sysJournalUpdate" => Permission::SysJournalUpdate, b"sysJournalSearch" => Permission::SysJournalSearch, b"sysJournalExport" => Permission::SysJournalExport, + b"sysSecurityAccept" => Permission::SysSecurityAccept, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7801,6 +7802,7 @@ impl EnumImpl for Permission { Permission::SysJournalUpdate => "sysJournalUpdate", Permission::SysJournalSearch => "sysJournalSearch", Permission::SysJournalExport => "sysJournalExport", + Permission::SysSecurityAccept => "sysSecurityAccept", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8494,6 +8496,7 @@ impl EnumImpl for Permission { 681 => Some(Permission::SysJournalUpdate), 682 => Some(Permission::SysJournalSearch), 683 => Some(Permission::SysJournalExport), + 684 => Some(Permission::SysSecurityAccept), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8938,7 +8941,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 684; + const COUNT: usize = 685; } 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 7461873..5f46cd7 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -125,6 +125,17 @@ name = ["content"] description = ["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 91d98fc..81207fe 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 8c25d1c..7c187f6 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -JGXu3u5TIsHK6jNasyQy1wVxK-2Ku-PSa5NgiJP61q0 \ No newline at end of file +0Ay1tm9k_D94V7sLFfK7pIxwt3QdfYK_VBNs-rLGjhs \ No newline at end of file diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index b181c64..1db96a2 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 journal; // inbuxa: journaling pub mod audit; // inbuxa: the audit log pub mod authorization; 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(); + } +}