Lock accounts: keep receiving mail, no sign-in, hand to delegates
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 8m47s

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:
2026-09-27 14:46:06 -07:00
parent ebf2fe11d9
commit 447229f871
46 changed files with 2573 additions and 29 deletions
+75
View File
@@ -35,6 +35,7 @@ const KIND_ACCOUNT_ACCESS: u8 = 0;
const KIND_BLOB_ACCESS: u8 = 1;
const KIND_SIGN_IN: u8 = 2;
const KIND_SIGN_IN_FAILED: u8 = 3;
const KIND_DELEGATE_ACCESS: u8 = 4;
/// The permissions that make an account an administrator for AU-1.4: every
/// `sys*` permission a plain user doesn't get by default, and impersonation.
@@ -369,6 +370,80 @@ impl Server {
}));
}
/// AL-9: a delegate reaching a locked account: its access once an hour,
/// and every change it makes there, one record per method call.
pub async fn audit_delegate(
&self,
token: &AccessToken,
locked_id: u32,
access: &str,
write: Option<&str>,
error: Option<&trc::Error>,
) {
let first = self.audit().first_access_this_hour(
token.account_id(),
locked_id,
KIND_DELEGATE_ACCESS,
now(),
);
if !first && write.is_none() {
return;
}
let actor = self.audit_actor(token).await;
let target = Target {
kind: "account".into(),
id: Some(Id::from(locked_id).to_string()),
name: Some(self.audit_account_name(locked_id).await),
account_id: Some(locked_id),
tenant_id: self
.account(locked_id)
.await
.ok()
.and_then(|account| account.id_tenant),
};
let mut records = Vec::new();
if first {
records.push(Record {
at: ms(),
actor: actor.clone(),
via: token.origin().cloned(),
remote_ip: None,
action: Action::AccountAccess,
target: target.clone(),
changes: vec![],
details: Some(format!("As a delegate ({access})")),
reason: None,
outcome: Outcome::success(),
});
}
if let Some(method) = write {
records.push(Record {
at: ms(),
actor,
via: token.origin().cloned(),
remote_ip: None,
action: Action::Update,
target,
changes: vec![],
details: Some(format!("{method} as a delegate ({access})")),
reason: None,
outcome: match error {
None => Outcome::success(),
Some(err) => Outcome::refused(
"error",
err.value_as_str(trc::Key::Details).map(str::to_string),
),
},
});
}
for record in records {
if !self.audit_note(record).await && first {
self.audit()
.forget_access(token.account_id(), locked_id, KIND_DELEGATE_ACCESS);
}
}
}
/// AU-7: removes entries past the retention period.
pub async fn audit_purge(&self) -> trc::Result<usize> {
let settings = log::settings(self.store()).await?;
+69 -1
View File
@@ -43,6 +43,27 @@ impl Server {
revision: u64,
revision_account: u64,
) -> trc::Result<AccessTokenInner> {
// inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every
// change to a lock invalidates the tokens it touches.
let locked = inbuxa_features::lock::get(self.store(), account_id)
.await
.caused_by(trc::location!())?
.is_some();
let now_secs = now();
let delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id)
.await
.caused_by(trc::location!())?
.into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation {
account_id: locked_id,
access: delegate.access,
send_as: delegate.send_as,
until: delegate.until,
})
.collect();
match account {
Account::User(account) => {
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
@@ -202,6 +223,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version,
@@ -211,7 +234,15 @@ impl Server {
access_to: access_to.into_boxed_slice(),
scopes: []
.into_iter()
.chain(credential_scopes)
.chain(credential_scopes.into_iter().map(|mut scope| {
// inbuxa: AL-2: no credential of a locked
// account authenticates; receiving mail isn't
// signing in, so EmailReceive stays
if locked {
scope.permissions.clear(Permission::Authenticate as usize);
}
scope
}))
.collect::<Box<[AccessScope]>>(),
}
.update_size())
@@ -245,6 +276,8 @@ impl Server {
.upload_max_concurrent
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
delegations: delegations.clone(),
revision,
revision_account,
credential_version: 0,
@@ -591,6 +624,8 @@ impl AccessToken {
revision: old_inner.revision,
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
locked: old_inner.locked,
delegations: old_inner.delegations.clone(),
};
access_token = AccessToken {
@@ -775,6 +810,30 @@ impl AccessToken {
}
}
/// inbuxa: AL-2: the account is locked.
pub fn is_locked(&self) -> bool {
self.inner.locked
}
/// inbuxa: AL-5: this account's delegation into a locked account, if it
/// has one that hasn't ended.
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
let now = now();
self.inner
.delegations
.iter()
.find(|d| d.account_id == account_id && d.until.is_none_or(|until| until > now))
}
/// inbuxa: AL-5: every current delegation this account holds.
pub fn delegations(&self) -> impl Iterator<Item = &super::Delegation> {
let now = now();
self.inner
.delegations
.iter()
.filter(move |d| d.until.is_none_or(|until| until > now))
}
/// inbuxa: how this session signed in (AU-5).
pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> {
self.origin.as_deref()
@@ -828,6 +887,8 @@ impl AccessToken {
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
delegations: Default::default(),
}),
}
}
@@ -838,6 +899,11 @@ impl AccessToken {
}
impl AccessTokenInner {
/// inbuxa: AL-2: the account is locked.
pub fn is_locked(&self) -> bool {
self.locked
}
/// inbuxa: SCIM-27: the account's own effective permission, from its
/// roles, its own settings and its tenant, before a credential narrows it
pub fn account_has_permission(&self, permission: Permission) -> bool {
@@ -881,6 +947,8 @@ impl AccessTokenInner {
revision_account: Default::default(),
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
delegations: Default::default(),
}
}
+13
View File
@@ -44,6 +44,19 @@ impl Server {
pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
match Box::pin(self.route_auth_request(req))
.await
// inbuxa: AL-2: a locked account fails as a wrong password does,
// so the right password learns nothing; master and recovery
// sign-ins as it fail the same way
.and_then(|token| {
if token.is_locked() {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountId, token.account_id())
.reason("Account is locked"))
} else {
Ok(token)
}
})
.and_then(|token| token.assert_has_permission(Permission::Authenticate))
{
Ok(token) => {
+15
View File
@@ -150,6 +150,21 @@ pub struct AccessTokenInner {
pub(crate) revision: u64,
pub(crate) credential_version: u64,
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
pub(crate) locked: bool,
// inbuxa: AL-5: locked accounts handed to this one
pub(crate) delegations: Box<[Delegation]>,
}
/// inbuxa: a locked account this one may open, and how (AL-5, AL-6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delegation {
/// The locked account.
pub account_id: u32,
pub access: inbuxa_features::lock::Access,
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
}
#[derive(Debug, Default, Hash, Clone)]
+9
View File
@@ -275,6 +275,15 @@ impl Default for DefaultPermissions {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: AL-12: tenant administrators lock and delegate
// within their tenant
Permission::SysAccountLockGet
| Permission::SysAccountLockCreate
| Permission::SysAccountLockUpdate
| Permission::SysAccountLockDestroy => {
default.superuser.push(permission);
default.tenant.push(permission);
}
permission => {
let name = permission.as_str();
if name.starts_with("jmap")
+2
View File
@@ -86,6 +86,8 @@ pub enum BroadcastEvent {
CacheInvalidateNegative,
MtaQueueStatus { is_running: bool },
QueueRefresh,
// inbuxa: AL-3: end an account's open sessions on every node
EndSessions(u32),
}
#[derive(Debug, Clone, Copy)]
@@ -36,11 +36,23 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAuditSettingsUpdate,
Permission::SysAccountLockGet,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
];
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9).
const TENANT_GRANTS: &[Permission] = &[Permission::SysAuditGet, Permission::SysAuditExport];
/// the tenant's audit log (AU-9), and locking and delegating its accounts
/// (AL-12).
const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAccountLockGet,
Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Audience {