From eea96e867426a5b3755fbb3882cc1355080aabf5 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 153468 -> 153488 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 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 91d98fc71905820cc21e96cc168856e1e4a97811..81207fe7559df470285078ae9dbd7f2f0e6f5ab6 100644 GIT binary patch delta 19862 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 (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(); + } +}