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:
@@ -21,6 +21,8 @@ use types::{collection::Collection, id::Id};
|
||||
|
||||
pub trait JmapAuthorization {
|
||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>;
|
||||
/// inbuxa: AL-8: the account's own, or a delegate allowed to send as it.
|
||||
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self>;
|
||||
fn assert_has_jmap_permission(
|
||||
&self,
|
||||
request: &RequestMethod,
|
||||
@@ -31,6 +33,17 @@ pub trait JmapAuthorization {
|
||||
}
|
||||
|
||||
impl JmapAuthorization for AccessToken {
|
||||
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self> {
|
||||
if self
|
||||
.delegation(account_id.document_id())
|
||||
.is_some_and(|delegation| delegation.send_as)
|
||||
{
|
||||
Ok(self)
|
||||
} else {
|
||||
self.assert_is_member(account_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
|
||||
if self.is_member(account_id.document_id()) {
|
||||
Ok(self)
|
||||
@@ -81,6 +94,8 @@ impl JmapAuthorization for AccessToken {
|
||||
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
||||
Permission::SysAuditGet
|
||||
}
|
||||
// inbuxa: account lock (AL-12)
|
||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -199,6 +214,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysAuditExport,
|
||||
Permission::SysAuditExport,
|
||||
),
|
||||
// inbuxa: account lock (AL-12)
|
||||
SetRequestMethod::AccountLock(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysAccountLockCreate,
|
||||
Permission::SysAccountLockUpdate,
|
||||
Permission::SysAccountLockDestroy,
|
||||
),
|
||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -345,6 +368,7 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::AuditSettings
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
|
||||
@@ -143,15 +143,27 @@ impl RequestHandler for Server {
|
||||
| RequestMethod::Changes(_)
|
||||
| RequestMethod::QueryChanges(_)
|
||||
);
|
||||
if matches!(
|
||||
let is_write = matches!(
|
||||
call.method,
|
||||
RequestMethod::Set(_)
|
||||
| RequestMethod::Copy(_)
|
||||
| RequestMethod::ImportEmail(_)
|
||||
| RequestMethod::UploadBlob(_)
|
||||
) {
|
||||
);
|
||||
if is_write {
|
||||
has_written = true;
|
||||
}
|
||||
// inbuxa: AL-7: what a delegate makes in a locked account
|
||||
// may need the lock's grants
|
||||
let makes_containers = is_write
|
||||
&& matches!(
|
||||
call.name.obj,
|
||||
MethodObject::Mailbox
|
||||
| MethodObject::Calendar
|
||||
| MethodObject::AddressBook
|
||||
| MethodObject::FileNode
|
||||
);
|
||||
let call_name = call.name.as_str().into_owned();
|
||||
let presented = match &call.method {
|
||||
RequestMethod::Changes(changes) => match &changes.since_state {
|
||||
jmap_proto::types::state::State::Exact(change_id) => {
|
||||
@@ -189,7 +201,28 @@ impl RequestHandler for Server {
|
||||
};
|
||||
let (result, reached) = result;
|
||||
for account_id in reached {
|
||||
self.audit_foreign_access(access_token, account_id, false).await;
|
||||
// inbuxa: AL-9: a delegate's access, and what it
|
||||
// changes, are recorded; anyone else here impersonated
|
||||
if let Some(delegation) = access_token.delegation(account_id) {
|
||||
let access = delegation.access.as_str();
|
||||
self.audit_delegate(
|
||||
access_token,
|
||||
account_id,
|
||||
access,
|
||||
is_write.then_some(call_name.as_str()),
|
||||
result.as_ref().err(),
|
||||
)
|
||||
.await;
|
||||
if makes_containers
|
||||
&& result.is_ok()
|
||||
&& let Err(err) =
|
||||
email::inbuxa_lock::reconcile(self, account_id).await
|
||||
{
|
||||
trc::error!(err.details("Failed to grant a lock's delegates on new folders"));
|
||||
}
|
||||
} else {
|
||||
self.audit_foreign_access(access_token, account_id, false).await;
|
||||
}
|
||||
}
|
||||
match result
|
||||
{
|
||||
@@ -237,6 +270,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::AuditVerification(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AccountLock(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Explanation(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -354,13 +390,15 @@ impl RequestHandler for Server {
|
||||
}
|
||||
GetRequestMethod::Identity(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
// inbuxa: AL-8: a delegate may send as a locked account
|
||||
access_token.assert_can_send(req.account_id)?;
|
||||
|
||||
self.identity_get(*req).await?.into()
|
||||
}
|
||||
GetRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
// inbuxa: AL-8: a delegate may send as a locked account
|
||||
access_token.assert_can_send(req.account_id)?;
|
||||
|
||||
self.email_submission_get(*req).await?.into()
|
||||
}
|
||||
@@ -401,6 +439,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: account lock with delegation (AL-1)
|
||||
GetRequestMethod::AccountLock(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::account_lock::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-9)
|
||||
GetRequestMethod::AuditEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -526,7 +571,8 @@ impl RequestHandler for Server {
|
||||
}
|
||||
QueryRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
// inbuxa: AL-8: a delegate may send as a locked account
|
||||
access_token.assert_can_send(req.account_id)?;
|
||||
|
||||
self.email_submission_query(*req).await?.into()
|
||||
}
|
||||
@@ -631,7 +677,8 @@ impl RequestHandler for Server {
|
||||
}
|
||||
SetRequestMethod::EmailSubmission(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_is_member(req.account_id)?;
|
||||
// inbuxa: AL-8: a delegate may send as a locked account
|
||||
access_token.assert_can_send(req.account_id)?;
|
||||
|
||||
self.email_submission_set(*req, &session.instance, next_call)
|
||||
.await?
|
||||
@@ -665,6 +712,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)),
|
||||
)
|
||||
@@ -681,6 +729,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)),
|
||||
)
|
||||
@@ -697,6 +746,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::ai_limits::set(self, access_token, req)),
|
||||
)
|
||||
@@ -712,12 +762,41 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: account lock with delegation, recorded with its
|
||||
// reason (AL-1, AU-12)
|
||||
SetRequestMethod::AccountLock(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
req.create.as_ref().and_then(|create| {
|
||||
create.values().find_map(|value| {
|
||||
serde_json::to_value(value)
|
||||
.ok()?
|
||||
.get("reason")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
});
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::account_lock::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AuditExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||
@@ -747,6 +826,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)),
|
||||
)
|
||||
@@ -763,6 +843,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)),
|
||||
)
|
||||
@@ -840,6 +921,7 @@ impl RequestHandler for Server {
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
Some(object_type),
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(self.registry_set(object_type, req, access_token, session)),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::request::capability::{
|
||||
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, Session,
|
||||
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, InbuxaDelegatedCapabilities, DelegationInfo, Session,
|
||||
};
|
||||
use registry::schema::enums::Permission;
|
||||
use std::future::Future;
|
||||
@@ -116,11 +116,16 @@ impl SessionHandler for Server {
|
||||
continue;
|
||||
};
|
||||
|
||||
// inbuxa: AL-6, AL-7: a delegated locked account says so, and is
|
||||
// read-only at the read level
|
||||
let delegation = access_token.delegation(account_id).cloned();
|
||||
let account_id = Id::from(account_id);
|
||||
let mut account = Account {
|
||||
name: account.name().to_string(),
|
||||
is_personal: false,
|
||||
is_read_only: false,
|
||||
is_read_only: delegation
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.access == inbuxa_features::lock::Access::Read),
|
||||
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
|
||||
};
|
||||
for capability in access_token.account_capabilities() {
|
||||
@@ -132,6 +137,22 @@ impl SessionHandler for Server {
|
||||
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
|
||||
);
|
||||
}
|
||||
if let Some(delegation) = delegation {
|
||||
account.account_capabilities.append(
|
||||
Capability::Inbuxa,
|
||||
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||
delegation: DelegationInfo {
|
||||
locked: true,
|
||||
access: delegation.access.as_str(),
|
||||
send_as: delegation.send_as,
|
||||
until: delegation.until.map(|until| {
|
||||
jmap_proto::types::date::UTCDate::from_timestamp(until as i64)
|
||||
.to_string()
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
session.accounts.append(account_id, account);
|
||||
}
|
||||
|
||||
|
||||
@@ -423,6 +423,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AuditSettings
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -1141,7 +1143,20 @@ impl EmailSet for Server {
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
if !will_destroy.is_empty() {
|
||||
// inbuxa: AL-6: a delegate below full may move mail, never delete it
|
||||
if !will_destroy.is_empty()
|
||||
&& access_token
|
||||
.delegation(account_id)
|
||||
.is_some_and(|delegation| !delegation.access.may_destroy())
|
||||
{
|
||||
for destroy_id in will_destroy {
|
||||
response.not_destroyed.append(
|
||||
destroy_id,
|
||||
SetError::forbidden()
|
||||
.with_description("A delegate at this level can move mail but not delete it."),
|
||||
);
|
||||
}
|
||||
} else if !will_destroy.is_empty() {
|
||||
let email_ids = cache.email_document_ids();
|
||||
let can_destroy_message_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_messages(access_token, Acl::RemoveItems).into()
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:AccountLock` (audit-hold-lock spec, AL-1 to AL-12): locking an
|
||||
//! account, handing it to delegates, and unlocking it. The grants
|
||||
//! themselves are `email::inbuxa_lock`'s.
|
||||
|
||||
use common::{
|
||||
Server,
|
||||
auth::AccessToken,
|
||||
ipc::{BroadcastEvent, PushEvent},
|
||||
};
|
||||
use email::inbuxa_lock::apply_grants;
|
||||
use groupware::inbuxa_lock::invalidate;
|
||||
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_account_lock::{
|
||||
AccountLock, AccountLockProperty as P, AccountLockSetArguments, AccountLockValue,
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, AccountLockValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::AccountId,
|
||||
P::Name,
|
||||
P::Reason,
|
||||
P::LockedAt,
|
||||
P::LockedBy,
|
||||
P::Delegates,
|
||||
];
|
||||
|
||||
/// Whether the caller may lock, change or unlock `account_id` (AL-12): an
|
||||
/// administrator for an account in reach, never its own, never a group.
|
||||
async fn assert_reach(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
account_id: u32,
|
||||
) -> Result<(), SetError<P>> {
|
||||
if access_token.is_account_id(account_id) {
|
||||
return Err(SetError::forbidden().with_description("You can't lock your own account."));
|
||||
}
|
||||
let Ok(account) = server.account(account_id).await else {
|
||||
return Err(SetError::not_found());
|
||||
};
|
||||
if !account.is_user_account() {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(P::AccountId)
|
||||
.with_description("Only a person's account can be locked."));
|
||||
}
|
||||
match access_token.tenant_id() {
|
||||
// A tenant administrator reaches its own tenant's accounts only
|
||||
Some(tenant_id) if account.id_tenant != Some(tenant_id) => Err(SetError::not_found()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and checks the delegates asked for (AL-5, AL-6, AL-8).
|
||||
async fn parse_delegates(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
locked_id: u32,
|
||||
value: LValue,
|
||||
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||
let invalid = |why: String| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Delegates)
|
||||
.with_description(why)
|
||||
};
|
||||
let json: serde_json::Value = value.into();
|
||||
let Some(items) = json.as_array() else {
|
||||
return Err(invalid("delegates must be a list.".into()));
|
||||
};
|
||||
if items.len() > MAX_DELEGATES {
|
||||
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
||||
}
|
||||
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let account_id = item["accountId"]
|
||||
.as_str()
|
||||
.and_then(|id| Id::from_str(id).ok())
|
||||
.map(|id| id.document_id())
|
||||
.ok_or_else(|| invalid("Each delegate needs an accountId.".into()))?;
|
||||
let access = item["access"]
|
||||
.as_str()
|
||||
.and_then(Access::parse)
|
||||
.ok_or_else(|| invalid("access must be read, organize or full.".into()))?;
|
||||
let send_as = item["sendAs"].as_bool().unwrap_or(false);
|
||||
let until = match item.get("until").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|d| UTCDate::from_str(d).ok())
|
||||
.map(|d| d.timestamp().max(0) as u64)
|
||||
.ok_or_else(|| invalid("until must be a UTC date.".into()))?,
|
||||
),
|
||||
};
|
||||
if account_id == locked_id {
|
||||
return Err(invalid("An account can't be its own delegate.".into()));
|
||||
}
|
||||
if access_token.is_account_id(account_id) && access_token.tenant_id().is_some() {
|
||||
return Err(invalid(
|
||||
"Only a server administrator may make themselves a delegate.".into(),
|
||||
));
|
||||
}
|
||||
if send_as && access == Access::Read {
|
||||
return Err(invalid(
|
||||
"Sending as the account needs organize or full access: the message is made in its Drafts first."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let Ok(delegate) = server.account(account_id).await else {
|
||||
return Err(invalid(format!("No account {}.", Id::from(account_id))));
|
||||
};
|
||||
if !delegate.is_user_account() {
|
||||
return Err(invalid("A delegate must be a person, not a group.".into()));
|
||||
}
|
||||
// Delegates stay in the locked account's tenant, unless a server
|
||||
// administrator says otherwise (AL-5)
|
||||
if access_token.tenant_id().is_some() && delegate.id_tenant != locked_tenant {
|
||||
return Err(invalid("A delegate must be in the same organization.".into()));
|
||||
}
|
||||
if delegates.iter().any(|d| d.account_id == account_id) {
|
||||
return Err(invalid("A delegate is listed twice.".into()));
|
||||
}
|
||||
delegates.push(Delegate {
|
||||
account_id,
|
||||
access,
|
||||
send_as,
|
||||
until,
|
||||
});
|
||||
}
|
||||
Ok(delegates)
|
||||
}
|
||||
|
||||
/// Ends the account's open sessions, here and on every node (AL-3).
|
||||
async fn end_sessions(server: &Server, account_id: u32) {
|
||||
let _ = server
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.send(PushEvent::Revoke { account_id })
|
||||
.await;
|
||||
server
|
||||
.cluster_broadcast(BroadcastEvent::EndSessions(account_id))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn date(seconds: u64) -> LValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
||||
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
||||
P::Reason => Value::Str(lock.reason.clone().into()),
|
||||
P::LockedAt => date(lock.locked_at),
|
||||
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
||||
P::Delegates => {
|
||||
let mut items = Vec::with_capacity(lock.delegates.len());
|
||||
for delegate in &lock.delegates {
|
||||
let mut item = Map::with_capacity(5);
|
||||
item.insert_unchecked(
|
||||
Key::Borrowed("accountId"),
|
||||
Value::Str(Id::from(delegate.account_id).to_string().into()),
|
||||
);
|
||||
item.insert_unchecked(
|
||||
Key::Borrowed("name"),
|
||||
Value::Str(server.audit_account_name(delegate.account_id).await.into()),
|
||||
);
|
||||
item.insert_unchecked(
|
||||
Key::Borrowed("access"),
|
||||
Value::Str(Cow::Borrowed(delegate.access.as_str())),
|
||||
);
|
||||
item.insert_unchecked(Key::Borrowed("sendAs"), Value::Bool(delegate.send_as));
|
||||
item.insert_unchecked(
|
||||
Key::Borrowed("until"),
|
||||
delegate.until.map_or(Value::Null, date),
|
||||
);
|
||||
items.push(Value::Object(item));
|
||||
}
|
||||
Value::Array(items)
|
||||
}
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// Whether a lock is in the caller's reach: every lock at server level, the
|
||||
/// tenant's own inside one.
|
||||
async fn in_reach(server: &Server, access_token: &AccessToken, account_id: u32) -> bool {
|
||||
match access_token.tenant_id() {
|
||||
None => true,
|
||||
Some(tenant_id) => server
|
||||
.account(account_id)
|
||||
.await
|
||||
.is_ok_and(|a| a.id_tenant == Some(tenant_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inbuxa:AccountLock/get`: the locks in reach.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<AccountLock>,
|
||||
) -> trc::Result<GetResponse<AccountLock>> {
|
||||
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 data = server.store();
|
||||
match ids {
|
||||
None => {
|
||||
for current in lock::all(data).await? {
|
||||
if in_reach(server, access_token, current.account_id).await {
|
||||
response.list.push(to_value(server, ¤t, &properties).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match lock::get(data, id.document_id()).await? {
|
||||
Some(current) if in_reach(server, access_token, current.account_id).await => {
|
||||
response.list.push(to_value(server, ¤t, &properties).await);
|
||||
}
|
||||
_ => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn reason_of(reason: Option<&str>) -> Option<String> {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(|r| r.chars().take(500).collect())
|
||||
}
|
||||
|
||||
fn reason_required() -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Reason)
|
||||
.with_description("Say why: a reason is required and is kept in the audit log.")
|
||||
}
|
||||
|
||||
/// `inbuxa:AccountLock/set`: create locks, update changes delegates or the
|
||||
/// reason, destroy unlocks. The request layer records each.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, AccountLock>,
|
||||
) -> trc::Result<SetResponse<AccountLock>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let arguments: AccountLockSetArguments = std::mem::take(&mut request.arguments);
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
for (client_id, value) in request.unwrap_create() {
|
||||
let mut account_id = None;
|
||||
let mut reason = None;
|
||||
let mut delegates_value = None;
|
||||
let mut invalid = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, value) {
|
||||
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||
account_id = Some(id.document_id())
|
||||
}
|
||||
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||
_ => {
|
||||
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = invalid {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
let Some(account_id) = account_id else {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties().with_property(P::AccountId),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
||||
response.not_created.append(client_id, reason_required());
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
if lock::get(data, account_id).await?.is_some() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::already_exists().with_description("That account is already locked."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let delegates = match delegates_value {
|
||||
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
|
||||
Ok(delegates) => delegates,
|
||||
Err(error) => {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut created = Lock {
|
||||
account_id,
|
||||
reason,
|
||||
locked_at: now(),
|
||||
locked_by: actor.name.clone(),
|
||||
locked_by_id: actor.account_id,
|
||||
delegates,
|
||||
replaced: Vec::new(),
|
||||
};
|
||||
// The lock is written first: from here the account can't sign in,
|
||||
// whatever happens to the grants
|
||||
lock::set(data, &created, None).await?;
|
||||
created.replaced = apply_grants(server, account_id, None, Some(&created)).await?;
|
||||
lock::set(data, &created, Some(&created)).await?;
|
||||
invalidate(server, account_id, None, Some(&created)).await?;
|
||||
end_sessions(server, account_id).await;
|
||||
|
||||
let mut out = Map::with_capacity(1);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(AccountLockValue::Id(Id::from(account_id))),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
let account_id = id.document_id();
|
||||
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
let Some(current) = lock::get(data, account_id).await? else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
||||
response.not_updated.append(id, reason_required());
|
||||
continue;
|
||||
}
|
||||
let mut updated = current.clone();
|
||||
let mut invalid = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, value) {
|
||||
(Key::Property(P::Delegates), value) => {
|
||||
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
|
||||
Ok(delegates) => updated.delegates = delegates,
|
||||
Err(error) => {
|
||||
invalid = Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||
Some(r) => updated.reason = r,
|
||||
None => {
|
||||
invalid = Some(reason_required());
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = invalid {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
updated.replaced = apply_grants(server, account_id, Some(¤t), Some(&updated)).await?;
|
||||
lock::set(data, &updated, Some(¤t)).await?;
|
||||
invalidate(server, account_id, Some(¤t), Some(&updated)).await?;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
let account_id = id.document_id();
|
||||
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||
response.not_destroyed.append(id, error);
|
||||
continue;
|
||||
}
|
||||
let Some(current) = lock::get(data, account_id).await? else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
||||
response.not_destroyed.append(id, reason_required());
|
||||
continue;
|
||||
}
|
||||
// Grants go first: an unlocked account never keeps its delegates
|
||||
apply_grants(server, account_id, Some(¤t), None).await?;
|
||||
lock::remove(data, ¤t).await?;
|
||||
// Delegates lose the account on their next request: their tokens
|
||||
// are rebuilt without it, on every node
|
||||
invalidate(server, account_id, Some(¤t), None).await?;
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -47,10 +47,12 @@ pub async fn collect_access<F: Future>(f: F) -> (F::Output, Vec<u32>) {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Notes an account a method call is about to reach (AU-1.6).
|
||||
/// Notes an account a method call is about to reach (AU-1.6): through
|
||||
/// impersonation, or as a locked account's delegate (AL-9).
|
||||
pub fn note_access(account_id: u32, access_token: &AccessToken) {
|
||||
if !access_token.is_member_directly(account_id)
|
||||
&& access_token.has_permission(Permission::Impersonate)
|
||||
if access_token.delegation(account_id).is_some()
|
||||
|| (!access_token.is_member_directly(account_id)
|
||||
&& access_token.has_permission(Permission::Impersonate))
|
||||
{
|
||||
let _ = REACHED.try_with(|reached| {
|
||||
let mut reached = reached.borrow_mut();
|
||||
@@ -99,6 +101,7 @@ fn before_boxed<'a, T: JmapObject>(
|
||||
session: &'a HttpSessionData,
|
||||
object: &'a str,
|
||||
registry: Option<ObjectType>,
|
||||
reason: Option<String>,
|
||||
request: &'a SetRequest<'_, T>,
|
||||
) -> std::pin::Pin<Box<dyn Future<Output = trc::Result<Pending>> + Send + 'a>> {
|
||||
Box::pin(before(
|
||||
@@ -107,6 +110,7 @@ fn before_boxed<'a, T: JmapObject>(
|
||||
session,
|
||||
object,
|
||||
registry,
|
||||
reason,
|
||||
request,
|
||||
))
|
||||
}
|
||||
@@ -121,6 +125,7 @@ pub async fn recorded<'x, T, F, Fut>(
|
||||
session: &HttpSessionData,
|
||||
object: &str,
|
||||
registry: Option<ObjectType>,
|
||||
reason: Option<String>,
|
||||
request: SetRequest<'x, T>,
|
||||
method: F,
|
||||
) -> trc::Result<SetResponse<T>>
|
||||
@@ -135,7 +140,8 @@ where
|
||||
// Every inner future is boxed where it's made, never held in this
|
||||
// frame: a debug build's stack can't take a copy of registry_set's
|
||||
// state on top of the request's own
|
||||
let pending = before_boxed(server, access_token, session, object, registry, &request).await?;
|
||||
let pending =
|
||||
before_boxed(server, access_token, session, object, registry, reason, &request).await?;
|
||||
let result = scope::request(method(request)).await;
|
||||
after(server, pending, &result).await;
|
||||
result
|
||||
@@ -147,6 +153,7 @@ async fn before<T: JmapObject>(
|
||||
session: &HttpSessionData,
|
||||
object: &str,
|
||||
registry: Option<ObjectType>,
|
||||
reason: Option<String>,
|
||||
request: &SetRequest<'_, T>,
|
||||
) -> trc::Result<Pending> {
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
@@ -236,7 +243,7 @@ async fn before<T: JmapObject>(
|
||||
target,
|
||||
changes,
|
||||
details: None,
|
||||
reason: None,
|
||||
reason: reason.clone(),
|
||||
outcome: Outcome::Pending,
|
||||
};
|
||||
match server.audit_append(&record).await {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! `crates/features`; this module only speaks JMAP for them.
|
||||
|
||||
pub mod access;
|
||||
pub mod account_lock;
|
||||
pub mod audit;
|
||||
pub mod audit_log;
|
||||
pub mod ai_limits;
|
||||
|
||||
Reference in New Issue
Block a user