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,199 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:AccountLock/get` and `/set` under `urn:inbuxa:jmap`: an account
|
||||
//! locked, and the people it is handed to (audit-hold-lock spec, AL-1 to
|
||||
//! AL-12). A lock's id is the locked account's id. Creating one locks the
|
||||
//! account, updating changes its delegates, destroying unlocks it. The set
|
||||
//! call's `reason` argument says why, for the audit log (AU-12); creating
|
||||
//! takes it as a property.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AccountLock;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AccountLockProperty {
|
||||
Id,
|
||||
/// The locked account (on create; afterwards the same as `id`).
|
||||
AccountId,
|
||||
Name,
|
||||
Reason,
|
||||
LockedAt,
|
||||
LockedBy,
|
||||
Delegates,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AccountLockValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for AccountLockProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside a delegate stay plain keys
|
||||
match parent {
|
||||
None => AccountLockProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
AccountLockProperty::Id => "id",
|
||||
AccountLockProperty::AccountId => "accountId",
|
||||
AccountLockProperty::Name => "name",
|
||||
AccountLockProperty::Reason => "reason",
|
||||
AccountLockProperty::LockedAt => "lockedAt",
|
||||
AccountLockProperty::LockedBy => "lockedBy",
|
||||
AccountLockProperty::Delegates => "delegates",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountLockProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => AccountLockProperty::Id,
|
||||
b"accountId" => AccountLockProperty::AccountId,
|
||||
b"name" => AccountLockProperty::Name,
|
||||
b"reason" => AccountLockProperty::Reason,
|
||||
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||
b"delegates" => AccountLockProperty::Delegates,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AccountLockProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
AccountLockProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for AccountLockValue {
|
||||
type Property = AccountLockProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(AccountLockProperty::Id | AccountLockProperty::AccountId) => {
|
||||
Id::from_str(value).ok().map(AccountLockValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
AccountLockValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own arguments: why (AU-12).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AccountLockSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for AccountLockSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for AccountLock {
|
||||
type Property = AccountLockProperty;
|
||||
|
||||
type Element = AccountLockValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = AccountLockSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = AccountLockProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for AccountLockValue {
|
||||
fn from(id: Id) -> Self {
|
||||
AccountLockValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for AccountLockValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
AccountLockValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
AccountLockValue::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 = AccountLockValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for AccountLockProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod contact;
|
||||
pub mod email;
|
||||
pub mod email_submission;
|
||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||
|
||||
@@ -67,6 +67,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::AuditSettings(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::AccountLock(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
@@ -107,6 +108,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::AuditVerification(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AccountLock(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -133,9 +133,31 @@ pub enum Capabilities {
|
||||
FileNode(FileNodeCapabilities),
|
||||
WebPush(WebPushCapabilities),
|
||||
Inbuxa(InbuxaAccountCapabilities),
|
||||
// inbuxa: AL-7
|
||||
InbuxaDelegated(InbuxaDelegatedCapabilities),
|
||||
Empty(EmptyCapabilities),
|
||||
}
|
||||
|
||||
/// inbuxa: `urn:inbuxa:jmap` on a locked account delegated to the signed-in
|
||||
/// principal (audit-hold-lock spec, AL-7), so a client can tell it from an
|
||||
/// ordinary share without guessing from `isReadOnly`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct InbuxaDelegatedCapabilities {
|
||||
pub delegation: DelegationInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DelegationInfo {
|
||||
/// Always true: only locked accounts are delegated.
|
||||
pub locked: bool,
|
||||
/// `read`, `organize` or `full`.
|
||||
pub access: &'static str,
|
||||
#[serde(rename(serialize = "sendAs"))]
|
||||
pub send_as: bool,
|
||||
/// When the delegation ends, if it does (UTC).
|
||||
pub until: Option<String>,
|
||||
}
|
||||
|
||||
/// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct InbuxaAccountCapabilities {
|
||||
|
||||
@@ -56,6 +56,8 @@ pub enum MethodObject {
|
||||
AuditSettings,
|
||||
AuditExport,
|
||||
AuditVerification,
|
||||
// inbuxa: account lock with delegation
|
||||
AccountLock,
|
||||
ProtocolPolicy,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
@@ -88,7 +90,8 @@ impl MethodObject {
|
||||
MethodObject::AuditEvent
|
||||
| MethodObject::AuditSettings
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification => Capability::Inbuxa,
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -274,6 +277,8 @@ impl MethodName {
|
||||
(MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get",
|
||||
(MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/set",
|
||||
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
||||
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||
"inbuxa:AuditVerification/set"
|
||||
}
|
||||
@@ -416,6 +421,8 @@ impl MethodName {
|
||||
"inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get),
|
||||
"inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, MethodFunction::Set),
|
||||
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
||||
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
|
||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||
@@ -479,6 +486,7 @@ impl Display for MethodObject {
|
||||
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
||||
MethodObject::AuditExport => "inbuxa:AuditExport",
|
||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
|
||||
@@ -118,6 +118,7 @@ pub enum GetRequestMethod {
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
@@ -149,6 +150,7 @@ pub enum SetRequestMethod<'x> {
|
||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
|
||||
@@ -551,6 +551,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: account lock with delegation
|
||||
(MethodFunction::Get, MethodObject::AccountLock) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AccountLock(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AccountLock(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: the audit log
|
||||
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
||||
|
||||
@@ -105,6 +105,7 @@ pub enum GetResponseMethod {
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
@@ -136,6 +137,7 @@ pub enum SetResponseMethod {
|
||||
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -750,3 +752,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_audit::AuditVerification>> for R
|
||||
ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: account lock with delegation
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AccountLock(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user