Lock accounts: keep receiving mail, no sign-in, hand to delegates
A locked account can't sign in (it fails as a wrong password does), its sessions end on every node, refresh tokens stop working, and its Sieve scripts forward and reply to nothing. Mail keeps arriving. Delegates get real ACL grants on the account's mailboxes, calendars, address books and files at read, organize or full, with the rights they replaced restored on unlock. Folders made later are granted after the create and in a daily sweep. Organize delegates can't destroy; send-as needs organize or full. The JMAP session marks delegated accounts in urn:inbuxa:jmap. New inbuxa:AccountLock object with get/set, permissions 665-668, and a Compliance > Locked Accounts entry in the schema. Lock, unlock and delegate changes need a reason and are audited; delegate access and writes are audited too (audit-hold-lock spec AL-1 to AL-12).
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Account lock with delegation acceptance tests, from
|
||||
//! `inbuxa-drafts/specs/audit-hold-lock.md` (AL-1 to AL-12; tests 10 to 15
|
||||
//! of its list). Each check names the requirement or test number.
|
||||
|
||||
use crate::{
|
||||
jmap::mail::submission::{
|
||||
MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server,
|
||||
},
|
||||
utils::{
|
||||
account::Account,
|
||||
dns::DnsCache,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
smtp::SmtpConnection,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaProtocol,
|
||||
structs::{
|
||||
Expression, ExpressionMatch, Imap, MtaOutboundStrategy, MtaRoute, MtaRouteRelay,
|
||||
MtaStageAuth,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
"urn:inbuxa:jmap",
|
||||
];
|
||||
|
||||
const OWNER_SECRET: &str = "owner-secret-4471";
|
||||
const DELEGATE_SECRET: &str = "delegate-secret-9902";
|
||||
|
||||
impl Account {
|
||||
async fn call(&self, method: &str, arguments: Value) -> (String, Value) {
|
||||
let response = self.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 lock_set(&self, arguments: Value) -> Value {
|
||||
let mut arguments = arguments;
|
||||
arguments["accountId"] = self.id_string().into();
|
||||
let (name, response) = self.call("inbuxa:AccountLock/set", arguments).await;
|
||||
assert_eq!(name, "inbuxa:AccountLock/set", "{response}");
|
||||
response
|
||||
}
|
||||
|
||||
async fn session_status(&self) -> u16 {
|
||||
self.http_get_raw(&format!("{}/jmap/session", self.base_url()), None)
|
||||
.await
|
||||
.status
|
||||
}
|
||||
}
|
||||
|
||||
fn message(from: &str, subject: &str) -> String {
|
||||
format!("From: {from}\r\nTo: [email protected]\r\nSubject: {subject}\r\n\r\nHello.\r\n")
|
||||
}
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running account lock tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let owner = admin
|
||||
.create_user_account("[email protected]", OWNER_SECRET, "Owner", &[], vec![])
|
||||
.await;
|
||||
let delegate = admin
|
||||
.create_user_account("[email protected]", DELEGATE_SECRET, "Delegate", &[], vec![])
|
||||
.await;
|
||||
let owner_id = owner.id_string().to_string();
|
||||
|
||||
// Mail leaving the server goes to the mock
|
||||
let (mut smtp_rx, _smtp_settings) = spawn_mock_smtp_server();
|
||||
test.server.ipv4_add(
|
||||
"localhost",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(60),
|
||||
);
|
||||
|
||||
// The owner set a vacation reply before leaving
|
||||
owner
|
||||
.jmap_client()
|
||||
.await
|
||||
.vacation_response_enable("Away", "I'm away.".into(), None::<String>)
|
||||
.await
|
||||
.unwrap();
|
||||
// Control: before the lock, the vacation reply goes out, so its absence
|
||||
// later means the lock stopped it
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&message("[email protected]", "Before the lock"),
|
||||
)
|
||||
.await;
|
||||
assert_message_delivery(
|
||||
&mut smtp_rx,
|
||||
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Away"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let right_password = owner.session_status().await;
|
||||
assert_eq!(right_password, 200, "the owner can sign in before the lock");
|
||||
let wrong = Account::new("[email protected]", "wrong-password", &[], "", owner.id());
|
||||
let wrong_password = wrong.session_status().await;
|
||||
|
||||
// AU-12: no lock without a reason
|
||||
let response = admin
|
||||
.lock_set(json!({"create": {"l": {"accountId": owner_id,
|
||||
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["l"]["type"], "invalidProperties",
|
||||
"AU-12: {response}"
|
||||
);
|
||||
|
||||
// AL-12: nobody locks themselves
|
||||
let response = admin
|
||||
.lock_set(json!({"create": {"l": {"accountId": admin.id_string(), "reason": "test"}}}))
|
||||
.await;
|
||||
assert_eq!(response["notCreated"]["l"]["type"], "forbidden", "AL-12: {response}");
|
||||
|
||||
// AL-8: send-as needs more than read
|
||||
let response = admin
|
||||
.lock_set(json!({"create": {"l": {"accountId": owner_id, "reason": "Left",
|
||||
"delegates": [{"accountId": delegate.id_string(), "access": "read", "sendAs": true}]}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["l"]["type"], "invalidProperties",
|
||||
"AL-8: {response}"
|
||||
);
|
||||
|
||||
// Lock, with a read-only delegate (AL-1)
|
||||
let response = admin
|
||||
.lock_set(json!({"create": {"l": {"accountId": owner_id,
|
||||
"reason": "Left the company; mail to be reviewed",
|
||||
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
|
||||
.await;
|
||||
assert_eq!(response["created"]["l"]["id"], owner_id.as_str(), "AL-1: {response}");
|
||||
|
||||
// AL-2: the right password fails as a wrong one does
|
||||
let right_password = owner.session_status().await;
|
||||
assert_ne!(right_password, 200, "AL-2: a locked account signed in");
|
||||
assert_eq!(
|
||||
right_password, wrong_password,
|
||||
"AL-2: the right password is told apart from a wrong one"
|
||||
);
|
||||
|
||||
// Test 11, AL-4: mail keeps arriving, and nothing is sent: no vacation
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&message("[email protected]", "Quarterly report"),
|
||||
)
|
||||
.await;
|
||||
expect_nothing(&mut smtp_rx).await;
|
||||
|
||||
// AL-7: the delegate sees the account, read-only, marked as delegated
|
||||
let session = delegate.jmap_session_object().await.0;
|
||||
let entry = &session["accounts"][owner_id.as_str()];
|
||||
assert_eq!(entry["isPersonal"], false, "AL-7: {session}");
|
||||
assert_eq!(entry["isReadOnly"], true, "AL-6: {entry}");
|
||||
let delegation = &entry["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
|
||||
assert_eq!(delegation["locked"], true, "AL-7: {entry}");
|
||||
assert_eq!(delegation["access"], "read", "AL-7");
|
||||
assert_eq!(delegation["sendAs"], false, "AL-7");
|
||||
|
||||
// The delegate reads the mail that arrived
|
||||
let (_, found) = delegate
|
||||
.call(
|
||||
"Email/query",
|
||||
json!({"accountId": owner_id, "filter": {"text": "Quarterly"}}),
|
||||
)
|
||||
.await;
|
||||
let email_id = found["ids"][0]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("AL-4: the mail didn't arrive: {found}"))
|
||||
.to_string();
|
||||
|
||||
// Test 12, AL-6: read means nothing changes, not even $seen
|
||||
let (_, response) = delegate
|
||||
.call(
|
||||
"Email/set",
|
||||
json!({"accountId": owner_id, "update": {email_id.as_str(): {"keywords/$seen": true}}}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response["notUpdated"][email_id.as_str()].is_object(),
|
||||
"test 12: a read delegate changed a keyword: {response}"
|
||||
);
|
||||
|
||||
// AU-12: changing delegates needs a reason
|
||||
let response = admin
|
||||
.lock_set(json!({"update": {owner_id.as_str(): {"delegates": [
|
||||
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][owner_id.as_str()]["type"], "invalidProperties",
|
||||
"AU-12: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.lock_set(json!({"reason": "Manager files the mail",
|
||||
"update": {owner_id.as_str(): {"delegates": [
|
||||
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
|
||||
.await;
|
||||
assert!(
|
||||
response["updated"].get(owner_id.as_str()).is_some(),
|
||||
"AL-5: {response}"
|
||||
);
|
||||
|
||||
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
|
||||
// never deletes it
|
||||
let (_, mailboxes) = delegate
|
||||
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
|
||||
.await;
|
||||
let inbox = mailboxes["list"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["role"] == "inbox")
|
||||
.unwrap_or_else(|| panic!("AL-7: no inbox seen: {mailboxes}"))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (_, created) = delegate
|
||||
.call(
|
||||
"Mailbox/set",
|
||||
json!({"accountId": owner_id, "create": {"f": {"name": "Reviewed", "parentId": inbox}}}),
|
||||
)
|
||||
.await;
|
||||
let folder = created["created"]["f"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("AL-6: organize couldn't make a folder: {created}"))
|
||||
.to_string();
|
||||
let (_, mailboxes) = delegate
|
||||
.call("Mailbox/get", json!({"accountId": owner_id, "ids": [folder]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
mailboxes["list"].as_array().map(Vec::len),
|
||||
Some(1),
|
||||
"AL-7: the delegate can't see the folder it made: {mailboxes}"
|
||||
);
|
||||
let (_, moved) = delegate
|
||||
.call(
|
||||
"Email/set",
|
||||
json!({"accountId": owner_id, "update": {email_id.as_str(): {
|
||||
"mailboxIds": {folder.as_str(): true}}}}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
moved["updated"].get(email_id.as_str()).is_some(),
|
||||
"test 12: organize couldn't move mail: {moved}"
|
||||
);
|
||||
let (_, destroyed) = delegate
|
||||
.call(
|
||||
"Email/set",
|
||||
json!({"accountId": owner_id, "destroy": [email_id]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
destroyed["notDestroyed"][email_id.as_str()]["type"], "forbidden",
|
||||
"test 12: organize deleted mail: {destroyed}"
|
||||
);
|
||||
|
||||
// AL-8: no sending without send-as
|
||||
let (name, _) = delegate
|
||||
.call("Identity/get", json!({"accountId": owner_id, "ids": null}))
|
||||
.await;
|
||||
assert_eq!(name, "error", "AL-8: identities of a locked account without send-as");
|
||||
|
||||
// Test 11, AL-4: a Sieve reject is kept instead, and nobody is answered
|
||||
admin
|
||||
.jmap_client()
|
||||
.await
|
||||
.set_default_account_id(owner_id.clone())
|
||||
.sieve_script_create(
|
||||
"rejector",
|
||||
"require \"reject\";\r\nreject \"Not here.\";\r\n",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&message("[email protected]", "Invoice 77"),
|
||||
)
|
||||
.await;
|
||||
expect_nothing(&mut smtp_rx).await;
|
||||
let (_, kept) = delegate
|
||||
.call(
|
||||
"Email/query",
|
||||
json!({"accountId": owner_id, "filter": {"text": "Invoice"}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
kept["ids"].as_array().map(Vec::len),
|
||||
Some(1),
|
||||
"AL-4: the rejected message wasn't kept: {kept}"
|
||||
);
|
||||
|
||||
// AL-10: unlocking needs a reason, then restores everything
|
||||
let response = admin
|
||||
.lock_set(json!({"destroy": [owner_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notDestroyed"][owner_id.as_str()]["type"], "invalidProperties",
|
||||
"AU-12: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.lock_set(json!({"reason": "Review done", "destroy": [owner_id]}))
|
||||
.await;
|
||||
assert_eq!(response["destroyed"][0], owner_id.as_str(), "AL-10: {response}");
|
||||
assert_eq!(owner.session_status().await, 200, "AL-10: the owner can sign in");
|
||||
let session = delegate.jmap_session_object().await.0;
|
||||
assert!(
|
||||
session["accounts"].get(owner_id.as_str()).is_none(),
|
||||
"test 15: the delegate kept the account: {session}"
|
||||
);
|
||||
|
||||
// AL-9, AU-12: every step is recorded, with its reason
|
||||
let (_, query) = admin
|
||||
.call(
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:AccountLock"}}),
|
||||
)
|
||||
.await;
|
||||
let (_, records) = admin
|
||||
.call(
|
||||
"inbuxa:AuditEvent/get",
|
||||
json!({"accountId": admin.id_string(), "ids": query["ids"]}),
|
||||
)
|
||||
.await;
|
||||
let reasons = records["list"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["outcome"]["status"] == "success")
|
||||
.filter_map(|r| r["reason"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
for reason in [
|
||||
"Left the company; mail to be reviewed",
|
||||
"Manager files the mail",
|
||||
"Review done",
|
||||
] {
|
||||
assert!(reasons.contains(&reason), "AU-12: {reason} missing from {reasons:?}");
|
||||
}
|
||||
let (_, query) = admin
|
||||
.call(
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"accountId": admin.id_string(),
|
||||
"filter": {"actorId": delegate.id_string(), "accountId": owner_id}}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
||||
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
||||
#[ignore]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn account_lock_tests() {
|
||||
let mut test = TestServerBuilder::new("account_lock_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.create_admin_account("[email protected]").await;
|
||||
admin
|
||||
.registry_create_object(Imap {
|
||||
allow_plain_text_auth: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaStageAuth {
|
||||
require: Expression {
|
||||
else_: "false".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
route: Expression {
|
||||
match_: List::from_iter([
|
||||
ExpressionMatch {
|
||||
if_: "rcpt_domain == 'example.com'".into(),
|
||||
then: "'local'".into(),
|
||||
},
|
||||
ExpressionMatch {
|
||||
if_: "rcpt_domain == 'remote.org'".into(),
|
||||
then: "'mock-smtp'".into(),
|
||||
},
|
||||
]),
|
||||
else_: "'mx'".to_string(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
|
||||
address: "127.0.0.1".into(),
|
||||
port: 9999,
|
||||
allow_invalid_certs: true,
|
||||
implicit_tls: false,
|
||||
name: "mock-smtp".into(),
|
||||
protocol: MtaProtocol::Smtp,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod authentication;
|
||||
pub mod ai;
|
||||
pub mod ai_calibration;
|
||||
pub mod ai_explain;
|
||||
pub mod account_lock; // inbuxa: account lock with delegation
|
||||
pub mod audit; // inbuxa: the audit log
|
||||
pub mod authorization;
|
||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||
|
||||
Reference in New Issue
Block a user