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:
@@ -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?;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user