Security to-do list: accepted items, kept on the server
This commit is contained in:
@@ -312,7 +312,10 @@ impl Default for DefaultPermissions {
|
|||||||
| Permission::SysDlpPolicyGet
|
| Permission::SysDlpPolicyGet
|
||||||
| Permission::SysDlpPolicyUpdate
|
| Permission::SysDlpPolicyUpdate
|
||||||
| Permission::SysDlpReviewGet
|
| Permission::SysDlpReviewGet
|
||||||
| Permission::SysDlpReviewUpdate => {
|
| Permission::SysDlpReviewUpdate
|
||||||
|
// inbuxa: every security check is server-wide (security
|
||||||
|
// to-do list spec)
|
||||||
|
| Permission::SysSecurityAccept => {
|
||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
}
|
}
|
||||||
// inbuxa: journals are the server's; administrators set them
|
// inbuxa: journals are the server's; administrators set them
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ use types::id::Id;
|
|||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (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
|
/// 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] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -54,6 +55,7 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysDlpReviewUpdate,
|
Permission::SysDlpReviewUpdate,
|
||||||
Permission::SysJournalGet,
|
Permission::SysJournalGet,
|
||||||
Permission::SysJournalUpdate,
|
Permission::SysJournalUpdate,
|
||||||
|
Permission::SysSecurityAccept,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the server-level Compliance Officer role once it exists:
|
/// Granted to the server-level Compliance Officer role once it exists:
|
||||||
|
|||||||
@@ -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<String>) -> 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::<u32>().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>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
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<Acceptance> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
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<ValueClass> {
|
||||||
|
ValueKey::from(class(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Acceptance>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Acceptance>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(acceptance)| acceptance))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every acceptance, oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Acceptance>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(acceptance)) = Json::<Acceptance>::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<Created> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
//! `legacy-protocols.md`.
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod acceptance;
|
||||||
pub mod legacy_use;
|
pub mod legacy_use;
|
||||||
pub mod log_files;
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
|
|||||||
@@ -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<Self> {
|
||||||
|
// 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<Self> {
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
SecurityAcceptanceProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for SecurityAcceptanceValue {
|
||||||
|
type Property = SecurityAcceptanceProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
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<Id> for SecurityAcceptanceValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
SecurityAcceptanceValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SecurityAcceptanceValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
SecurityAcceptanceValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
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<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
|
|||||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||||
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
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_journal; // inbuxa: journaling
|
||||||
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
||||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::MailRule(response) => {
|
GetResponseMethod::MailRule(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::SecurityAcceptance(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Journal(response) => {
|
GetResponseMethod::Journal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::MailRule(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::Journal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||||
@@ -132,6 +133,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::MailRule(request) => {
|
SetRequestMethod::MailRule(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::SecurityAcceptance(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::Journal(request) => {
|
SetRequestMethod::Journal(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub enum MethodObject {
|
|||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
// inbuxa: DLP and mail flow rules
|
// inbuxa: DLP and mail flow rules
|
||||||
MailRule,
|
MailRule,
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
SecurityAcceptance,
|
||||||
HeldMessage,
|
HeldMessage,
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
Journal,
|
Journal,
|
||||||
@@ -111,6 +113,7 @@ impl MethodObject {
|
|||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::Journal => Capability::Inbuxa,
|
| MethodObject::Journal => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
@@ -310,6 +313,8 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||||
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
|
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
|
||||||
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
(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::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||||
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
||||||
(MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get",
|
(MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get",
|
||||||
@@ -470,6 +475,8 @@ impl MethodName {
|
|||||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||||
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
|
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
|
||||||
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
"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/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||||
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
||||||
"inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get),
|
"inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get),
|
||||||
@@ -546,6 +553,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||||
MethodObject::MailRule => "inbuxa:MailRule",
|
MethodObject::MailRule => "inbuxa:MailRule",
|
||||||
|
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||||
MethodObject::Journal => "inbuxa:Journal",
|
MethodObject::Journal => "inbuxa:Journal",
|
||||||
MethodObject::HeldMessage => "inbuxa:HeldMessage",
|
MethodObject::HeldMessage => "inbuxa:HeldMessage",
|
||||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ pub enum GetRequestMethod {
|
|||||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||||
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||||
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
@@ -164,6 +165,9 @@ pub enum SetRequestMethod<'x> {
|
|||||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
|
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
|
),
|
||||||
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||||
HeldMessage(Box<SetRequest<'x, crate::object::inbuxa_held_message::HeldMessage>>),
|
HeldMessage(Box<SetRequest<'x, crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
|
|||||||
@@ -653,6 +653,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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
|
// inbuxa: journaling
|
||||||
(MethodFunction::Get, MethodObject::Journal) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::Journal) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)),
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ pub enum GetResponseMethod {
|
|||||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||||
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||||
|
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||||
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||||
@@ -151,6 +152,9 @@ pub enum SetResponseMethod {
|
|||||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
|
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
|
),
|
||||||
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||||
HeldMessage(Box<SetResponse<crate::object::inbuxa_held_message::HeldMessage>>),
|
HeldMessage(Box<SetResponse<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
@@ -831,6 +835,23 @@ impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::SecurityAcceptance(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::SecurityAcceptance(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::MailRule(value))
|
ResponseMethod::Get(GetResponseMethod::MailRule(value))
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// inbuxa: journaling (JR-18)
|
// inbuxa: journaling (JR-18)
|
||||||
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
|
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
|
||||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
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
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -298,6 +301,21 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysJournalUpdate,
|
Permission::SysJournalUpdate,
|
||||||
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
|
// inbuxa: LH-12, exporting held data
|
||||||
SetRequestMethod::HoldExport(s) => validate_set(
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -460,6 +478,7 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
|
|||||||
@@ -285,6 +285,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::MailRule(set_response) => {
|
SetResponseMethod::MailRule(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::Journal(set_response) => {
|
SetResponseMethod::Journal(set_response) => {
|
||||||
set_response.update_created_ids(&mut 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)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into()
|
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
|
// inbuxa: journaling
|
||||||
GetRequestMethod::Journal(mut req) => {
|
GetRequestMethod::Journal(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -993,6 +1003,29 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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) => {
|
SetRequestMethod::Journal(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
let reason = req.arguments.reason.clone();
|
let reason = req.arguments.reason.clone();
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ async fn before<T: JmapObject>(
|
|||||||
|
|
||||||
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
||||||
for id in 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();
|
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||||
if let Some(before) = &before {
|
if let Some(before) = &before {
|
||||||
described.name = full_name(server, object, before, described.name).await;
|
described.name = full_name(server, object, before, described.name).await;
|
||||||
@@ -434,6 +438,26 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
|||||||
}
|
}
|
||||||
MaybeInvalid::Invalid(_) => None,
|
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 {
|
"inbuxa:TenantProtocolPolicy" => match id {
|
||||||
MaybeInvalid::Value(id) => {
|
MaybeInvalid::Value(id) => {
|
||||||
security::tenant_protocol_policy::get(data, id.document_id())
|
security::tenant_protocol_policy::get(data, id.document_id())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod access;
|
|||||||
pub mod account_lock;
|
pub mod account_lock;
|
||||||
pub mod legal_hold;
|
pub mod legal_hold;
|
||||||
pub mod mail_rule;
|
pub mod mail_rule;
|
||||||
|
pub mod security_acceptance;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod held_message;
|
pub mod held_message;
|
||||||
pub mod dlp_settings;
|
pub mod dlp_settings;
|
||||||
|
|||||||
@@ -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<Acceptance, SetError<P>> {
|
||||||
|
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::<P>().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> {
|
||||||
|
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<SecurityAcceptance>,
|
||||||
|
) -> trc::Result<GetResponse<SecurityAcceptance>> {
|
||||||
|
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<SetResponse<SecurityAcceptance>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1760,6 +1760,8 @@ pub enum Permission {
|
|||||||
SysJournalUpdate = 681,
|
SysJournalUpdate = 681,
|
||||||
SysJournalSearch = 682,
|
SysJournalSearch = 682,
|
||||||
SysJournalExport = 683,
|
SysJournalExport = 683,
|
||||||
|
// inbuxa: the security to-do list, accepting an item
|
||||||
|
SysSecurityAccept = 684,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7101,6 +7101,7 @@ impl EnumImpl for Permission {
|
|||||||
b"sysJournalUpdate" => Permission::SysJournalUpdate,
|
b"sysJournalUpdate" => Permission::SysJournalUpdate,
|
||||||
b"sysJournalSearch" => Permission::SysJournalSearch,
|
b"sysJournalSearch" => Permission::SysJournalSearch,
|
||||||
b"sysJournalExport" => Permission::SysJournalExport,
|
b"sysJournalExport" => Permission::SysJournalExport,
|
||||||
|
b"sysSecurityAccept" => Permission::SysSecurityAccept,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7801,6 +7802,7 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysJournalUpdate => "sysJournalUpdate",
|
Permission::SysJournalUpdate => "sysJournalUpdate",
|
||||||
Permission::SysJournalSearch => "sysJournalSearch",
|
Permission::SysJournalSearch => "sysJournalSearch",
|
||||||
Permission::SysJournalExport => "sysJournalExport",
|
Permission::SysJournalExport => "sysJournalExport",
|
||||||
|
Permission::SysSecurityAccept => "sysSecurityAccept",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8494,6 +8496,7 @@ impl EnumImpl for Permission {
|
|||||||
681 => Some(Permission::SysJournalUpdate),
|
681 => Some(Permission::SysJournalUpdate),
|
||||||
682 => Some(Permission::SysJournalSearch),
|
682 => Some(Permission::SysJournalSearch),
|
||||||
683 => Some(Permission::SysJournalExport),
|
683 => Some(Permission::SysJournalExport),
|
||||||
|
684 => Some(Permission::SysSecurityAccept),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
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 {
|
impl serde::Serialize for Permission {
|
||||||
|
|||||||
@@ -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`. |
|
| 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. |
|
| — | 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
|
## 5. The web front ends
|
||||||
|
|
||||||
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ name = ["content"]
|
|||||||
description = ["content"]
|
description = ["content"]
|
||||||
createdBy = ["identifier"]
|
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"]
|
[object."inbuxa:LegalHold"]
|
||||||
file = "inbuxa_legal_hold.rs"
|
file = "inbuxa_legal_hold.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
JGXu3u5TIsHK6jNasyQy1wVxK-2Ku-PSa5NgiJP61q0
|
0Ay1tm9k_D94V7sLFfK7pIxwt3QdfYK_VBNs-rLGjhs
|
||||||
@@ -15,6 +15,7 @@ pub mod account_lock; // inbuxa: account lock with delegation
|
|||||||
pub mod legal_hold; // inbuxa: legal hold
|
pub mod legal_hold; // inbuxa: legal hold
|
||||||
pub mod compliance; // inbuxa: the compliance roles
|
pub mod compliance; // inbuxa: the compliance roles
|
||||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
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 journal; // inbuxa: journaling
|
||||||
pub mod audit; // inbuxa: the audit log
|
pub mod audit; // inbuxa: the audit log
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
|
|||||||
@@ -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<Value> {
|
||||||
|
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("[email protected]");
|
||||||
|
|
||||||
|
// 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"], "[email protected]",
|
||||||
|
"{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(
|
||||||
|
"[email protected]",
|
||||||
|
"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"] == "[email protected]"),
|
||||||
|
"{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("[email protected]").await;
|
||||||
|
test.insert_account(admin);
|
||||||
|
self::test(&mut test).await;
|
||||||
|
if test.is_reset() {
|
||||||
|
test.temp_dir.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user