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,7 @@
|
||||
pub mod ai;
|
||||
pub mod audit;
|
||||
pub mod branding;
|
||||
pub mod lock;
|
||||
pub mod masked_email;
|
||||
pub mod security;
|
||||
pub mod tenancy;
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Account lock with delegation (audit-hold-lock spec, AL-1 to AL-12).
|
||||
//!
|
||||
//! A locked account keeps receiving mail but can't sign in, by any means,
|
||||
//! and sends nothing on its own. Delegates open it as a separate account,
|
||||
//! through real ACL grants on its containers (the sharing every protocol
|
||||
//! already honors), at a level the administrator chose.
|
||||
//!
|
||||
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||
//! with `K`, then one byte for the kind:
|
||||
//!
|
||||
//! - `l` + account: the lock, as JSON.
|
||||
//! - `d` + delegate + account: an index, so a delegate's access token can
|
||||
//! find the accounts delegated to it with one scan.
|
||||
//!
|
||||
//! Numbers are big-endian. Nothing is cached in memory: the access token is
|
||||
//! the cache, built from these keys and invalidated on every change.
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::Collection,
|
||||
};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
const FEATURE: u8 = b'K';
|
||||
const KIND_LOCK: u8 = b'l';
|
||||
const KIND_DELEGATE: u8 = b'd';
|
||||
|
||||
/// Most delegates one lock may have (AL-5).
|
||||
pub const MAX_DELEGATES: usize = 10;
|
||||
|
||||
/// What a delegate may do in the locked account (AL-6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Access {
|
||||
/// See and download everything; change nothing, not even `$seen`.
|
||||
Read,
|
||||
/// Read, set keywords, move mail and create and rename folders; never
|
||||
/// destroy.
|
||||
Organize,
|
||||
/// Everything the owner could do. Deletions are still kept under a hold.
|
||||
Full,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Access::Read => "read",
|
||||
Access::Organize => "organize",
|
||||
Access::Full => "full",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"read" => Some(Access::Read),
|
||||
"organize" => Some(Access::Organize),
|
||||
"full" => Some(Access::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a delegate at this level may destroy anything.
|
||||
pub fn may_destroy(&self) -> bool {
|
||||
matches!(self, Access::Full)
|
||||
}
|
||||
|
||||
/// The rights granted on one container. `is_trash` marks a mailbox with
|
||||
/// the Trash or Junk role: an organizing delegate may read it, but not
|
||||
/// move mail into it, since mail there is destroyed in time.
|
||||
pub fn grants(&self, collection: Collection, is_trash: bool) -> Bitmap<Acl> {
|
||||
let read = [Acl::Read, Acl::ReadItems];
|
||||
let rights: &[Acl] = match (self, collection) {
|
||||
(Access::Read, _) => &read,
|
||||
(Access::Organize, Collection::Mailbox) if is_trash => &read,
|
||||
(Access::Organize, Collection::Mailbox) => &[
|
||||
Acl::Read,
|
||||
Acl::ReadItems,
|
||||
Acl::Modify,
|
||||
Acl::AddItems,
|
||||
Acl::ModifyItems,
|
||||
Acl::RemoveItems,
|
||||
Acl::CreateChild,
|
||||
],
|
||||
// Calendars, address books and files have no "move": organizing
|
||||
// there is adding and changing, never removing
|
||||
(Access::Organize, _) => &[
|
||||
Acl::Read,
|
||||
Acl::ReadItems,
|
||||
Acl::AddItems,
|
||||
Acl::ModifyItems,
|
||||
Acl::CreateChild,
|
||||
],
|
||||
(Access::Full, _) => &[
|
||||
Acl::Read,
|
||||
Acl::Modify,
|
||||
Acl::Delete,
|
||||
Acl::ReadItems,
|
||||
Acl::AddItems,
|
||||
Acl::ModifyItems,
|
||||
Acl::RemoveItems,
|
||||
Acl::CreateChild,
|
||||
Acl::Submit,
|
||||
Acl::ModifyItemsOwn,
|
||||
Acl::ModifyPrivateProperties,
|
||||
Acl::ModifyRSVP,
|
||||
Acl::SchedulingReadFreeBusy,
|
||||
Acl::SchedulingInvite,
|
||||
Acl::SchedulingReply,
|
||||
],
|
||||
};
|
||||
Bitmap::from_iter(rights.iter().copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// One person the locked account is handed to (AL-5).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delegate {
|
||||
pub account_id: u32,
|
||||
pub access: Access,
|
||||
/// May send from the locked account's identities (AL-8). Needs
|
||||
/// `organize` or `full`: a message is made in its Drafts first.
|
||||
#[serde(default)]
|
||||
pub send_as: bool,
|
||||
/// Seconds since the epoch; the delegation ends then on its own.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub until: Option<u64>,
|
||||
}
|
||||
|
||||
impl Delegate {
|
||||
pub fn is_current(&self, now: u64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
}
|
||||
}
|
||||
|
||||
/// A delegate's rights a lock replaced on one container, put back when the
|
||||
/// lock or that delegation ends (AL-10). A container with no entry had no
|
||||
/// grant for that delegate before.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Replaced {
|
||||
pub collection: u8,
|
||||
pub document_id: u32,
|
||||
pub delegate: u32,
|
||||
/// The rights as a bitmap's raw value.
|
||||
pub rights: u64,
|
||||
}
|
||||
|
||||
/// An account's lock (AL-1).
|
||||
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Lock {
|
||||
pub account_id: u32,
|
||||
pub reason: String,
|
||||
/// Seconds since the epoch.
|
||||
pub locked_at: u64,
|
||||
pub locked_by: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub locked_by_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub delegates: Vec<Delegate>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replaced: Vec<Replaced>,
|
||||
}
|
||||
|
||||
impl Lock {
|
||||
pub fn delegate(&self, account_id: u32) -> Option<&Delegate> {
|
||||
self.delegates.iter().find(|d| d.account_id == account_id)
|
||||
}
|
||||
|
||||
/// The grants a new container of this account gets: one per current
|
||||
/// delegate (AL-7, containers made later).
|
||||
pub fn grants_for_new(
|
||||
&self,
|
||||
collection: Collection,
|
||||
is_trash: bool,
|
||||
now: u64,
|
||||
) -> Vec<(u32, Bitmap<Acl>)> {
|
||||
self.delegates
|
||||
.iter()
|
||||
.filter(|d| d.is_current(now))
|
||||
.map(|d| (d.account_id, d.access.grants(collection, is_trash)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One container's ACL as a lock change leaves it (AL-7, AL-10).
|
||||
///
|
||||
/// Delegates in `new` get their level's rights. The first time a delegate
|
||||
/// is given a container, whatever it had there before is noted in
|
||||
/// `replaced`; entries `old` already noted are carried over. Delegates only
|
||||
/// in `old` get back what they had before, or nothing. Returns the new ACL
|
||||
/// when it differs from `current`.
|
||||
pub fn merge_grants(
|
||||
current: &[AclGrant],
|
||||
collection: Collection,
|
||||
document_id: u32,
|
||||
is_trash: bool,
|
||||
old: Option<&Lock>,
|
||||
new: Option<&Lock>,
|
||||
now: u64,
|
||||
replaced: &mut Vec<Replaced>,
|
||||
) -> Option<Vec<AclGrant>> {
|
||||
let mut acls = current.to_vec();
|
||||
let collection_id = collection as u8;
|
||||
let noted = |lock: &Lock, delegate: u32| {
|
||||
lock.replaced
|
||||
.iter()
|
||||
.find(|r| {
|
||||
r.collection == collection_id && r.document_id == document_id && r.delegate == delegate
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
let is_current = |lock: Option<&Lock>, delegate: u32| {
|
||||
lock.and_then(|lock| lock.delegate(delegate))
|
||||
.is_some_and(|d| d.is_current(now))
|
||||
};
|
||||
let set = |acls: &mut Vec<AclGrant>, account_id: u32, grants: Bitmap<Acl>| {
|
||||
acls.retain(|a| a.account_id != account_id);
|
||||
if !grants.is_empty() {
|
||||
acls.push(AclGrant { account_id, grants });
|
||||
}
|
||||
};
|
||||
|
||||
// Delegations that ended get back what they had
|
||||
if let Some(old) = old {
|
||||
for delegate in &old.delegates {
|
||||
if is_current(new, delegate.account_id) {
|
||||
continue;
|
||||
}
|
||||
let before = noted(old, delegate.account_id)
|
||||
.map(|r| Bitmap::from(r.rights))
|
||||
.unwrap_or_default();
|
||||
set(&mut acls, delegate.account_id, before);
|
||||
}
|
||||
}
|
||||
|
||||
// Current delegations get their level
|
||||
if let Some(new) = new {
|
||||
for delegate in new.delegates.iter().filter(|d| d.is_current(now)) {
|
||||
let had = old.and_then(|old| {
|
||||
is_current(Some(old), delegate.account_id)
|
||||
.then(|| noted(old, delegate.account_id))
|
||||
.flatten()
|
||||
});
|
||||
match had {
|
||||
Some(entry) => replaced.push(entry),
|
||||
None if !is_current(old, delegate.account_id) => {
|
||||
if let Some(existing) = current.iter().find(|a| a.account_id == delegate.account_id) {
|
||||
replaced.push(Replaced {
|
||||
collection: collection_id,
|
||||
document_id,
|
||||
delegate: delegate.account_id,
|
||||
rights: existing.grants.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
set(
|
||||
&mut acls,
|
||||
delegate.account_id,
|
||||
delegate.access.grants(collection, is_trash),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let sorted = |acls: &[AclGrant]| {
|
||||
let mut v = acls.iter().map(|a| (a.account_id, u64::from(a.grants))).collect::<Vec<_>>();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
(sorted(&acls) != sorted(current)).then_some(acls)
|
||||
}
|
||||
|
||||
struct Json<T>(T);
|
||||
|
||||
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||
serde_json::to_vec(&self.0).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to serialize account lock")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.details("Invalid account lock")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(kind: u8, parts: &[u32]) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(2 + parts.len() * 4);
|
||||
key.push(FEATURE);
|
||||
key.push(kind);
|
||||
for part in parts {
|
||||
key.extend_from_slice(&part.to_be_bytes());
|
||||
}
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(kind: u8, parts: &[u32]) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(kind, parts))
|
||||
}
|
||||
|
||||
/// The numbers after the kind byte, from the key's tail (the iterator may or
|
||||
/// may not hand back the subspace byte).
|
||||
fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option<Vec<u32>> {
|
||||
let len = 2 + parts * 4;
|
||||
let tail = key.get(key.len().checked_sub(len)?..)?;
|
||||
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
|
||||
Some(
|
||||
tail[2..]
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| u32::from_be_bytes(chunk.try_into().unwrap()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// An account's lock, if it is locked.
|
||||
pub async fn get(data: &Store, account_id: u32) -> trc::Result<Option<Lock>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Lock>>(key(KIND_LOCK, &[account_id]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(lock)| lock))
|
||||
}
|
||||
|
||||
/// Every lock, for the console's list.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
||||
let mut locks = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(key(KIND_LOCK, &[0]), key(KIND_LOCK, &[u32::MAX])),
|
||||
|_, value| {
|
||||
if let Ok(Json(lock)) = Json::<Lock>::deserialize(value) {
|
||||
locks.push(lock);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(locks)
|
||||
}
|
||||
|
||||
/// The accounts delegated to `delegate`, with its delegation in each.
|
||||
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
||||
let mut locked = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_DELEGATE, &[delegate, 0]),
|
||||
key(KIND_DELEGATE, &[delegate, u32::MAX]),
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_DELEGATE, 2) {
|
||||
locked.push(parts[1]);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut delegations = Vec::with_capacity(locked.len());
|
||||
for account_id in locked {
|
||||
if let Some(lock) = get(data, account_id).await?
|
||||
&& let Some(delegation) = lock.delegate(delegate)
|
||||
{
|
||||
delegations.push((account_id, delegation.clone()));
|
||||
}
|
||||
}
|
||||
Ok(delegations)
|
||||
}
|
||||
|
||||
/// Writes a lock, keeping the delegate index in step with `previous`.
|
||||
pub async fn set(data: &Store, lock: &Lock, previous: Option<&Lock>) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
if let Some(previous) = previous {
|
||||
for delegate in &previous.delegates {
|
||||
if lock.delegate(delegate.account_id).is_none() {
|
||||
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
for delegate in &lock.delegates {
|
||||
batch.set(
|
||||
class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]),
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
batch.set(class(KIND_LOCK, &[lock.account_id]), Json(lock).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Removes a lock and its delegate index.
|
||||
pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
for delegate in &lock.delegates {
|
||||
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
|
||||
}
|
||||
batch.clear(class(KIND_LOCK, &[lock.account_id]));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_read_back() {
|
||||
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(parse_key(&any.key, KIND_DELEGATE, 2), Some(vec![7, 9]));
|
||||
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||
with_subspace.extend_from_slice(&any.key);
|
||||
assert_eq!(parse_key(&with_subspace, KIND_DELEGATE, 2), Some(vec![7, 9]));
|
||||
assert_eq!(parse_key(&any.key, KIND_LOCK, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_grant_what_they_say() {
|
||||
let read = Access::Read.grants(Collection::Mailbox, false);
|
||||
assert!(read.contains(Acl::ReadItems));
|
||||
assert!(!read.contains(Acl::ModifyItems), "read can't set $seen");
|
||||
assert!(!read.contains(Acl::RemoveItems));
|
||||
|
||||
let organize = Access::Organize.grants(Collection::Mailbox, false);
|
||||
assert!(organize.contains(Acl::RemoveItems), "moving needs it");
|
||||
assert!(!organize.contains(Acl::Delete));
|
||||
assert!(!organize.contains(Acl::Submit));
|
||||
let trash = Access::Organize.grants(Collection::Mailbox, true);
|
||||
assert!(!trash.contains(Acl::AddItems), "nothing moved into Trash");
|
||||
let calendar = Access::Organize.grants(Collection::Calendar, false);
|
||||
assert!(!calendar.contains(Acl::RemoveItems));
|
||||
|
||||
let full = Access::Full.grants(Collection::Mailbox, false);
|
||||
assert!(full.contains(Acl::Delete) && full.contains(Acl::RemoveItems));
|
||||
assert!(!full.contains(Acl::Share), "a delegate can't pass it on");
|
||||
assert!(Access::Full.may_destroy() && !Access::Organize.may_destroy());
|
||||
}
|
||||
|
||||
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||
Lock {
|
||||
account_id: 1,
|
||||
reason: "r".into(),
|
||||
locked_at: 0,
|
||||
locked_by: "admin".into(),
|
||||
locked_by_id: None,
|
||||
delegates,
|
||||
replaced,
|
||||
}
|
||||
}
|
||||
|
||||
fn delegate(account_id: u32, access: Access) -> Delegate {
|
||||
Delegate {
|
||||
account_id,
|
||||
access,
|
||||
send_as: false,
|
||||
until: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_are_added_and_restored() {
|
||||
let read = Access::Read.grants(Collection::Mailbox, false);
|
||||
let full = Access::Full.grants(Collection::Mailbox, false);
|
||||
// Delegate 2 already had a share here; delegate 3 had nothing
|
||||
let earlier: Bitmap<Acl> = Bitmap::from_iter([Acl::Read]);
|
||||
let current = vec![AclGrant {
|
||||
account_id: 2,
|
||||
grants: earlier,
|
||||
}];
|
||||
let lock = lock_with(
|
||||
vec![delegate(2, Access::Full), delegate(3, Access::Read)],
|
||||
vec![],
|
||||
);
|
||||
let mut replaced = Vec::new();
|
||||
let acls = merge_grants(¤t, Collection::Mailbox, 5, false, None, Some(&lock), 0, &mut replaced)
|
||||
.unwrap();
|
||||
assert!(acls.contains(&AclGrant { account_id: 2, grants: full }));
|
||||
assert!(acls.contains(&AclGrant { account_id: 3, grants: read }));
|
||||
assert_eq!(replaced.len(), 1, "only 2 had rights to put back");
|
||||
assert_eq!(replaced[0].rights, u64::from(earlier));
|
||||
|
||||
// Running it again changes nothing and keeps the note
|
||||
let locked = Lock { replaced: replaced.clone(), ..lock.clone() };
|
||||
let mut again = Vec::new();
|
||||
assert!(merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&locked), 0, &mut again).is_none());
|
||||
assert_eq!(again, replaced);
|
||||
|
||||
// Unlocking puts 2's share back and removes 3
|
||||
let mut none = Vec::new();
|
||||
let back = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), None, 0, &mut none).unwrap();
|
||||
assert_eq!(back, vec![AclGrant { account_id: 2, grants: earlier }]);
|
||||
|
||||
// Ending one delegation keeps the other
|
||||
let fewer = lock_with(vec![delegate(3, Access::Read)], vec![]);
|
||||
let mut kept = Vec::new();
|
||||
let after = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&fewer), 0, &mut kept).unwrap();
|
||||
assert!(after.contains(&AclGrant { account_id: 2, grants: earlier }));
|
||||
assert!(after.contains(&AclGrant { account_id: 3, grants: read }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_delegations_grant_nothing() {
|
||||
let lock = Lock {
|
||||
account_id: 1,
|
||||
reason: "Left the company".into(),
|
||||
locked_at: 100,
|
||||
locked_by: "admin".into(),
|
||||
locked_by_id: None,
|
||||
delegates: vec![
|
||||
Delegate {
|
||||
account_id: 2,
|
||||
access: Access::Read,
|
||||
send_as: false,
|
||||
until: Some(200),
|
||||
},
|
||||
Delegate {
|
||||
account_id: 3,
|
||||
access: Access::Full,
|
||||
send_as: true,
|
||||
until: None,
|
||||
},
|
||||
],
|
||||
replaced: vec![],
|
||||
};
|
||||
let grants = lock.grants_for_new(Collection::Mailbox, false, 300);
|
||||
assert_eq!(grants.len(), 1);
|
||||
assert_eq!(grants[0].0, 3);
|
||||
let json = serde_json::to_string(&lock).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Lock>(&json).unwrap(), lock);
|
||||
assert!(json.contains("\"access\":\"full\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user