From 447229f87182b23da32cbcf86476771d10e4a0d3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 14:31:54 -0700 Subject: [PATCH] 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). --- crates/common/src/audit.rs | 75 +++ crates/common/src/auth/access_token.rs | 70 ++- crates/common/src/auth/authentication.rs | 13 + crates/common/src/auth/mod.rs | 15 + crates/common/src/auth/permissions.rs | 9 + crates/common/src/ipc.rs | 2 + .../common/src/manager/granted_permissions.rs | 16 +- crates/dav/src/file/mkcol.rs | 10 + crates/dav/src/file/update.rs | 10 + crates/email/src/inbuxa_lock.rs | 128 ++++ crates/email/src/lib.rs | 1 + crates/email/src/sieve/ingest.rs | 23 + crates/features/src/lib.rs | 1 + crates/features/src/lock/mod.rs | 566 ++++++++++++++++++ crates/groupware/src/inbuxa_lock.rs | 221 +++++++ crates/groupware/src/lib.rs | 1 + crates/http/src/auth/oauth/token.rs | 15 +- crates/imap/src/core/mod.rs | 9 + crates/imap/src/op/create.rs | 10 + crates/imap/src/op/expunge.rs | 30 +- .../src/object/inbuxa_account_lock.rs | 199 ++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/capability.rs | 22 + crates/jmap-proto/src/request/method.rs | 10 +- crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 15 + crates/jmap/src/api/auth.rs | 24 + crates/jmap/src/api/request.rs | 96 ++- crates/jmap/src/api/session.rs | 25 +- crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/email/set.rs | 17 +- crates/jmap/src/inbuxa/account_lock.rs | 437 ++++++++++++++ crates/jmap/src/inbuxa/audit.rs | 17 +- crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 5 + crates/registry/src/schema/enums_impl.rs | 14 +- crates/services/src/broadcast/mod.rs | 12 + crates/services/src/broadcast/subscriber.rs | 12 + .../services/src/task_manager/maintenance.rs | 6 + resources/schema/schema.json.gz | Bin 151134 -> 151198 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/account_lock.rs | 436 ++++++++++++++ tests/src/system/mod.rs | 1 + 46 files changed, 2573 insertions(+), 29 deletions(-) create mode 100644 crates/email/src/inbuxa_lock.rs create mode 100644 crates/features/src/lock/mod.rs create mode 100644 crates/groupware/src/inbuxa_lock.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_account_lock.rs create mode 100644 crates/jmap/src/inbuxa/account_lock.rs create mode 100644 tests/src/system/account_lock.rs diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 2a19a79..5eac3aa 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -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 { let settings = log::settings(self.store()).await?; diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index f6fcefe..408746c 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -43,6 +43,27 @@ impl Server { revision: u64, revision_account: u64, ) -> trc::Result { + // 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::>(), } .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 { + 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(), } } diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 045a2ec..bf82ee4 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -44,6 +44,19 @@ impl Server { pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result { 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) => { diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 3bd2710..87cf335 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -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, } #[derive(Debug, Default, Hash, Clone)] diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 433ff0a..1f09e87 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -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") diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index 75bd155..c79f935 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -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)] diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 0043d4b..62b1f6c 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -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 { diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs index e9d9249..f0bf2e5 100644 --- a/crates/dav/src/file/mkcol.rs +++ b/crates/dav/src/file/mkcol.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::proppatch::FilePropPatchRequestHandler; @@ -131,6 +133,14 @@ impl FileMkColRequestHandler for Server { let etag = batch.etag(); self.commit_batch(batch).await.caused_by(trc::location!())?; + // inbuxa: AL-7: a folder a delegate makes in a locked account gets + // the lock's grants + if account_id != access_token.account_id() + && let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await + { + trc::error!(err.details("Failed to grant a lock's delegates on a new folder")); + } + if let Some(prop_stat) = return_prop_stat { Ok(HttpResponse::new(StatusCode::CREATED) .with_xml_body( diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs index 85fffe9..121abf2 100644 --- a/crates/dav/src/file/update.rs +++ b/crates/dav/src/file/update.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -299,6 +301,14 @@ impl FileUpdateRequestHandler for Server { let etag = batch.etag(); self.commit_batch(batch).await.caused_by(trc::location!())?; + // inbuxa: AL-7: a top-level file a delegate adds to a locked + // account gets the lock's grants + if account_id != access_token.account_id() + && let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await + { + trc::error!(err.details("Failed to grant a lock's delegates on a new file")); + } + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } } diff --git a/crates/email/src/inbuxa_lock.rs b/crates/email/src/inbuxa_lock.rs new file mode 100644 index 0000000..22425fc --- /dev/null +++ b/crates/email/src/inbuxa_lock.rs @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: a locked account's grants, whole (audit-hold-lock spec, AL-7, +//! AL-10): its mailboxes here, and its calendars, address books and files +//! through `groupware::inbuxa_lock`. +//! +//! A delegate's access is real ACL grants on the locked account's +//! containers, the sharing IMAP, DAV and JMAP already honor, so a delegate +//! sees the account as a shared one everywhere. The lock notes what each +//! delegate had on a container before, so ending a delegation or the lock +//! puts it back. Idempotent: run again, it grants on containers made since +//! and changes nothing else. + +use crate::{cache::MessageCacheFetch, mailbox::Mailbox}; +use common::{Server, storage::index::ObjectIndexBuilder}; +use groupware::inbuxa_lock::{apply_dav_grants, invalidate, same_replaced}; +use inbuxa_features::lock::{self, Lock, Replaced}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{collection::Collection, special_use::SpecialUse}; + +/// Grants a lock's delegates their rights on every container of the locked +/// account, and takes away those of delegations that ended. Returns what the +/// lock now has to remember. +pub async fn apply_grants( + server: &Server, + account_id: u32, + old: Option<&Lock>, + new: Option<&Lock>, +) -> trc::Result> { + let now = now(); + let mut replaced = Vec::new(); + let mut batch = BatchBuilder::new(); + + let cache = server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + for mailbox in cache.mailboxes.items.iter() { + // Mail in Trash and Junk is destroyed in time: an organizing + // delegate may look, not move mail in + let is_trash = matches!(mailbox.role, SpecialUse::Trash | SpecialUse::Junk); + let current = mailbox.acls.to_vec(); + let Some(acls) = lock::merge_grants( + ¤t, + Collection::Mailbox, + mailbox.document_id, + is_trash, + old, + new, + now, + &mut replaced, + ) else { + continue; + }; + let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + mailbox.document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let current = archive + .into_deserialized::() + .caused_by(trc::location!())?; + let mut changed = current.inner.clone(); + changed.acls = acls; + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox.document_id) + .custom( + ObjectIndexBuilder::new() + .with_changes(changed) + .with_current(current), + ) + .caused_by(trc::location!())?; + } + + apply_dav_grants(server, account_id, old, new, now, &mut replaced, &mut batch).await?; + + if !batch.is_empty() { + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + Ok(replaced) +} + +/// Re-applies the lock on `account_id`, if any, so containers made since get +/// its grants: after a delegate creates something there, and daily. +pub async fn reconcile(server: &Server, account_id: u32) -> trc::Result<()> { + let data = server.store(); + let Some(current) = lock::get(data, account_id).await? else { + return Ok(()); + }; + let replaced = apply_grants(server, account_id, Some(¤t), Some(¤t)).await?; + if !same_replaced(&replaced, ¤t.replaced) { + let updated = Lock { + replaced, + ..current.clone() + }; + lock::set(data, &updated, Some(¤t)).await?; + } + invalidate(server, account_id, Some(¤t), Some(¤t)).await +} + +/// Re-applies every lock: the daily sweep, for containers made by the server +/// itself (a Sieve `fileinto :create`) rather than by a delegate. +pub async fn reconcile_all(server: &Server) -> trc::Result<()> { + for current in lock::all(server.store()).await? { + reconcile(server, current.account_id).await?; + } + Ok(()) +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index ab6cc62..fe3c046 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -14,6 +14,7 @@ pub mod cache; pub mod identity; +pub mod inbuxa_lock; // inbuxa: account lock grants pub mod mailbox; pub mod message; pub mod push; diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index ef13c36..1a7c2c7 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -287,6 +287,18 @@ impl SieveScriptIngest for Server { do_discard = true; input = true.into(); } + // inbuxa: AL-4: a locked account answers no sender, so a + // rejection is kept instead; sieve has already cleared + // the implicit keep, so it is filed here + Event::Reject { .. } if access_token.is_locked() => { + if let Some(message) = messages.get_mut(0) + && !message.file_into.contains(&INBOX_ID) + { + message.file_into.push(INBOX_ID); + } + do_deliver = true; + input = true.into(); + } Event::Reject { reason, .. } => { reject_reason = reason.into(); do_discard = true; @@ -388,6 +400,17 @@ impl SieveScriptIngest for Server { } input = true.into(); } + // inbuxa: AL-4: a locked account sends nothing on its + // own: no redirect, vacation reply or notification. An + // unsent redirect leaves the message to be kept. + Event::SendMessage { .. } if access_token.is_locked() => { + trc::event!( + Sieve(SieveEvent::ActionReject), + Details = "Account is locked: nothing is sent", + SpanId = session_id + ); + input = true.into(); + } Event::SendMessage { recipient, message_id, diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 2d3ac72..617ec59 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -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; diff --git a/crates/features/src/lock/mod.rs b/crates/features/src/lock/mod.rs new file mode 100644 index 0000000..b19e4ff --- /dev/null +++ b/crates/features/src/lock/mod.rs @@ -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 { + 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 { + 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, +} + +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, + #[serde(default)] + pub delegates: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaced: Vec, +} + +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)> { + 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, +) -> Option> { + 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, account_id: u32, grants: Bitmap| { + 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::>(); + v.sort(); + v + }; + (sorted(&acls) != sorted(current)).then_some(acls) +} + +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize account lock") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + 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 { + 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> { + 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> { + Ok(data + .get_value::>(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> { + 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::::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> { + 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, replaced: Vec) -> 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 = 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::(&json).unwrap(), lock); + assert!(json.contains("\"access\":\"full\"")); + } +} diff --git a/crates/groupware/src/inbuxa_lock.rs b/crates/groupware/src/inbuxa_lock.rs new file mode 100644 index 0000000..e699bfb --- /dev/null +++ b/crates/groupware/src/inbuxa_lock.rs @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: a locked account's grants on its calendars, address books, file +//! folders and top-level files (audit-hold-lock spec, AL-7, AL-10). The +//! mailbox half, and the whole, are in `email::inbuxa_lock`; this half is +//! here so DAV, which sees only these, can grant on what it creates. + +use crate::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode}; +use common::{ + DavResourceMetadata, Server, + auth::AccountTenantIds, + cache::invalidate::CacheInvalidationBuilder, + ipc::CacheInvalidation, +}; +use inbuxa_features::lock::{self, Lock, Replaced}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::collection::{Collection, SyncCollection}; + +/// The collections this half covers. +pub const DAV_COLLECTIONS: [Collection; 3] = [ + Collection::Calendar, + Collection::AddressBook, + Collection::FileNode, +]; + +/// Who a lock's grant changes are recorded as having been made by: the +/// locked account itself, as the server acting for it. +pub async fn changed_by(server: &Server, account_id: u32) -> AccountTenantIds { + AccountTenantIds { + account_id, + tenant_id: server.account(account_id).await.ok().and_then(|a| a.id_tenant), + } +} + +/// Grants on calendars, address books, file folders and top-level files, +/// into `batch`, with what they replaced into `replaced`. +#[allow(clippy::too_many_arguments)] +pub async fn apply_dav_grants( + server: &Server, + account_id: u32, + old: Option<&Lock>, + new: Option<&Lock>, + now: u64, + replaced: &mut Vec, + batch: &mut BatchBuilder, +) -> trc::Result<()> { + let changed_by = changed_by(server, account_id).await; + for (sync, collection) in [ + (SyncCollection::Calendar, Collection::Calendar), + (SyncCollection::AddressBook, Collection::AddressBook), + (SyncCollection::FileNode, Collection::FileNode), + ] { + let resources = server + .fetch_dav_resources(account_id, account_id, sync) + .await + .caused_by(trc::location!())?; + for resource in &resources.resources { + // A folder covers what's in it; a file outside any folder + // needs its own grant + let top_level_file = matches!( + &resource.data, + DavResourceMetadata::File { + parent_id: None, + .. + } + ); + if !resource.is_container() && !top_level_file { + continue; + } + let Some(current) = resource.acls() else { + continue; + }; + let Some(acls) = lock::merge_grants( + current, + collection, + resource.document_id, + false, + old, + new, + now, + replaced, + ) else { + continue; + }; + let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + collection, + resource.document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + match collection { + Collection::Calendar => { + let current = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .caused_by(trc::location!())?; + changed.acls = acls; + changed + .update(changed_by, current, account_id, resource.document_id, batch) + .caused_by(trc::location!())?; + } + Collection::AddressBook => { + let current = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .caused_by(trc::location!())?; + changed.acls = acls; + changed + .update(changed_by, current, account_id, resource.document_id, batch) + .caused_by(trc::location!())?; + } + _ => { + let current = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .caused_by(trc::location!())?; + changed.acls = acls; + changed + .update( + changed_by, + current, + account_id, + resource.document_id, + false, + batch, + ) + .caused_by(trc::location!())?; + } + } + } + } + Ok(()) +} + +/// Every token a lock change touches is rebuilt on its next use, on every +/// node: the locked account's and each delegate's, before and after. +pub async fn invalidate( + server: &Server, + account_id: u32, + old: Option<&Lock>, + new: Option<&Lock>, +) -> trc::Result<()> { + let mut builder = CacheInvalidationBuilder::default(); + builder.invalidate(CacheInvalidation::AccessToken(account_id)); + for delegate in old.into_iter().chain(new).flat_map(|l| &l.delegates) { + builder.invalidate(CacheInvalidation::AccessToken(delegate.account_id)); + } + server.invalidate_caches(builder).await +} + +/// Whether two lists of replaced rights say the same, in any order. +pub fn same_replaced(a: &[Replaced], b: &[Replaced]) -> bool { + let key = |r: &Replaced| (r.collection, r.document_id, r.delegate, r.rights); + let mut a = a.iter().map(key).collect::>(); + let mut b = b.iter().map(key).collect::>(); + a.sort(); + b.sort(); + a == b +} + +/// Grants the lock on `account_id`, if any, on calendars, address books and +/// files made since. For DAV, after a delegate creates one there. +pub async fn reconcile_dav(server: &Server, account_id: u32) -> trc::Result<()> { + let data = server.store(); + let Some(current) = lock::get(data, account_id).await? else { + return Ok(()); + }; + // Mailbox entries aren't this half's to change + let mut replaced = current + .replaced + .iter() + .filter(|r| !DAV_COLLECTIONS.iter().any(|c| *c as u8 == r.collection)) + .cloned() + .collect::>(); + let mut batch = BatchBuilder::new(); + apply_dav_grants( + server, + account_id, + Some(¤t), + Some(¤t), + now(), + &mut replaced, + &mut batch, + ) + .await?; + if batch.is_empty() { + return Ok(()); + } + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + if !same_replaced(&replaced, ¤t.replaced) { + let updated = Lock { + replaced, + ..current.clone() + }; + lock::set(data, &updated, Some(¤t)).await?; + } + invalidate(server, account_id, Some(¤t), Some(¤t)).await +} diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index 18554ef..e9c91af 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -23,6 +23,7 @@ pub mod calendar; pub mod contact; pub mod file; pub mod inbuxa; // inbuxa: undelete notes +pub mod inbuxa_lock; // inbuxa: account lock grants pub mod scheduling; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 08e9038..a191f5d 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -239,10 +239,20 @@ impl TokenHandler for Server { .validate_access_token(GrantType::RefreshToken.into(), refresh_token) .await { + // inbuxa: AL-2: a locked account gets no new tokens + Ok(token_info) + if self + .access_token(token_info.account_id) + .await + .is_ok_and(|token| token.is_locked()) => + { + TokenResponse::error(ErrorType::InvalidGrant) + } Ok(token_info) => self .issue_token( token_info.account_id, - "", + // inbuxa: AU-5: the client travels in the refresh token + token_info.claims.as_deref().unwrap_or_default(), issuer, None, None, @@ -342,7 +352,8 @@ impl TokenHandler for Server { account_id, account_name, self.core.oauth.oauth_expiry_refresh_token, - None, + // inbuxa: AU-5: so a refreshed access token still names it + Some(client_id), credential_version.into(), ) .await? diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs index 174bb45..7c41c02 100644 --- a/crates/imap/src/core/mod.rs +++ b/crates/imap/src/core/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use ahash::AHashMap; @@ -198,6 +200,13 @@ impl SessionData { .access_token(self.account_id) .await .and_then(|inner| { + // inbuxa: AL-3: a session opened before its account was + // locked is refused from its next command + if inner.is_locked() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Account is locked")); + } AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr) }) .caused_by(trc::location!()) diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 08c5275..8858168 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -141,6 +143,14 @@ impl SessionData { .await .imap_ctx(&arguments.tag, trc::location!())?; + // inbuxa: AL-7: a folder a delegate makes in a locked account gets + // the lock's grants, so the delegate can see it + if params.account_id != self.account_id + && let Err(err) = email::inbuxa_lock::reconcile(&self.server, params.account_id).await + { + trc::error!(err.details("Failed to grant a lock's delegates on a new folder")); + } + trc::event!( Imap(trc::ImapEvent::CreateMailbox), SpanId = self.session_id, diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index b497fb4..918f177 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -45,14 +45,22 @@ impl Session { let (data, mailbox) = self.state.select_data(); // Validate ACL - if !data - .check_mailbox_acl( - mailbox.id.account_id, - mailbox.id.mailbox_id, - Acl::RemoveItems, - ) + // inbuxa: AL-6: a delegate below full may move mail, never delete it + let may_destroy = data + .refresh_access_token() .await .imap_ctx(&request.tag, trc::location!())? + .delegation(mailbox.id.account_id) + .is_none_or(|delegation| delegation.access.may_destroy()); + if !may_destroy + || !data + .check_mailbox_acl( + mailbox.id.account_id, + mailbox.id.mailbox_id, + Acl::RemoveItems, + ) + .await + .imap_ctx(&request.tag, trc::location!())? { return Err(trc::ImapEvent::Error .into_err() @@ -143,6 +151,16 @@ impl SessionData { ) -> trc::Result> { // Obtain message ids let account_id = mailbox.id.account_id; + // inbuxa: AL-6: nothing is deleted for a delegate below full (CLOSE + // expunges quietly, so it deletes nothing, quietly) + if self + .refresh_access_token() + .await? + .delegation(account_id) + .is_some_and(|delegation| !delegation.access.may_destroy()) + { + return Ok(None); + } let mut deleted_ids = RoaringBitmap::from_iter( self.server .get_cached_messages(account_id) diff --git a/crates/jmap-proto/src/object/inbuxa_account_lock.rs b/crates/jmap-proto/src/object/inbuxa_account_lock.rs new file mode 100644 index 0000000..6634263 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_account_lock.rs @@ -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 { + // 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 { + 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 { + AccountLockProperty::parse(s).ok_or(()) + } +} + +impl Element for AccountLockValue { + type Property = AccountLockProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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, +} + +impl<'de> DeserializeArguments<'de> for AccountLockSetArguments { + fn deserialize_argument(&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::()?; + } + 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 for AccountLockValue { + fn from(id: Id) -> Self { + AccountLockValue::Id(id) + } +} + +impl JmapObjectId for AccountLockValue { + fn as_id(&self) -> Option { + match self { + AccountLockValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index a7e26bc..e838bb8 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -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 diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 736c8fa..5d78608 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -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) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 6026692..633231e 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -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)? } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 7fb8787..66909b7 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -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, +} + /// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account. #[derive(Debug, Clone, serde::Serialize)] pub struct InbuxaAccountCapabilities { diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index f47c1bf..b404836 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -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) => { diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 6325cbf..47e3ee2 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -118,6 +118,7 @@ pub enum GetRequestMethod { AiLimits(Box>), AuditEvent(Box>), AuditSettings(Box>), + AccountLock(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -149,6 +150,7 @@ pub enum SetRequestMethod<'x> { AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), + AccountLock(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index b7e06fe..a909ddf 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -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)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 014979c..1fe6925 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -105,6 +105,7 @@ pub enum GetResponseMethod { AiLimits(GetResponse), AuditEvent(GetResponse), AuditSettings(GetResponse), + AccountLock(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( GetResponse, @@ -136,6 +137,7 @@ pub enum SetResponseMethod { AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), + AccountLock(Box>), Explanation(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -750,3 +752,16 @@ impl<'x> From> for R ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value))) } } + +// inbuxa: account lock with delegation +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::AccountLock(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value))) + } +} diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 93b63d0..eb26e5e 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -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 diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 4915b2c..e2803f4 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -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)), ) diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 4dd09b9..f74feb3 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -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); } diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 5b3389e..537cd0c 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -423,6 +423,7 @@ impl IntermediateChangesResponse { | MethodObject::AuditSettings | MethodObject::AuditExport | MethodObject::AuditVerification + | MethodObject::AccountLock | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 7120b70..b18182e 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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() diff --git a/crates/jmap/src/inbuxa/account_lock.rs b/crates/jmap/src/inbuxa/account_lock.rs new file mode 100644 index 0000000..4f5479c --- /dev/null +++ b/crates/jmap/src/inbuxa/account_lock.rs @@ -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

> { + 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, SetError

> { + 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 = 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, +) -> trc::Result> { + 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 { + reason + .map(str::trim) + .filter(|r| !r.is_empty()) + .map(|r| r.chars().take(500).collect()) +} + +fn reason_required() -> SetError

{ + 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> { + 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) +} diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index de0be04..2fc61fa 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -47,10 +47,12 @@ pub async fn collect_access(f: F) -> (F::Output, Vec) { .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, + reason: Option, request: &'a SetRequest<'_, T>, ) -> std::pin::Pin> + 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, + reason: Option, request: SetRequest<'x, T>, method: F, ) -> trc::Result> @@ -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( session: &HttpSessionData, object: &str, registry: Option, + reason: Option, request: &SetRequest<'_, T>, ) -> trc::Result { let actor = server.audit_actor(access_token).await; @@ -236,7 +243,7 @@ async fn before( target, changes, details: None, - reason: None, + reason: reason.clone(), outcome: Outcome::Pending, }; match server.audit_append(&record).await { diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 1f62fec..95b9e26 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -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; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 2ba8a41..c75ac84 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1734,6 +1734,11 @@ pub enum Permission { SysAuditGet = 662, SysAuditExport = 663, SysAuditSettingsUpdate = 664, + // inbuxa: account lock with delegation (audit-hold-lock spec, AL-12) + SysAccountLockGet = 665, + SysAccountLockCreate = 666, + SysAccountLockUpdate = 667, + SysAccountLockDestroy = 668, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index acb5d87..922135c 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7076,6 +7076,10 @@ impl EnumImpl for Permission { b"sysAuditGet" => Permission::SysAuditGet, b"sysAuditExport" => Permission::SysAuditExport, b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate, + b"sysAccountLockGet" => Permission::SysAccountLockGet, + b"sysAccountLockCreate" => Permission::SysAccountLockCreate, + b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate, + b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7757,6 +7761,10 @@ impl EnumImpl for Permission { Permission::SysAuditGet => "sysAuditGet", Permission::SysAuditExport => "sysAuditExport", Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate", + Permission::SysAccountLockGet => "sysAccountLockGet", + Permission::SysAccountLockCreate => "sysAccountLockCreate", + Permission::SysAccountLockUpdate => "sysAccountLockUpdate", + Permission::SysAccountLockDestroy => "sysAccountLockDestroy", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8431,6 +8439,10 @@ impl EnumImpl for Permission { 662 => Some(Permission::SysAuditGet), 663 => Some(Permission::SysAuditExport), 664 => Some(Permission::SysAuditSettingsUpdate), + 665 => Some(Permission::SysAccountLockGet), + 666 => Some(Permission::SysAccountLockCreate), + 667 => Some(Permission::SysAccountLockUpdate), + 668 => Some(Permission::SysAccountLockDestroy), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8875,7 +8887,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 665; + const COUNT: usize = 669; } impl serde::Serialize for Permission { diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index 108b413..0c859f0 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::ipc::{ @@ -139,6 +141,11 @@ impl BroadcastBatch> { BroadcastEvent::QueueRefresh => { serialized.push(12u8); } + // inbuxa: AL-3 + BroadcastEvent::EndSessions(account_id) => { + serialized.push(13u8); + let _ = serialized.write_leb128(*account_id); + } } } serialized @@ -272,6 +279,11 @@ where 10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })), 11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })), 12 => Ok(Some(BroadcastEvent::QueueRefresh)), + // inbuxa: AL-3 + 13 => { + let account_id = self.messages.next_leb128().ok_or(())?; + Ok(Some(BroadcastEvent::EndSessions(account_id))) + } _ => Err(()), } } else { diff --git a/crates/services/src/broadcast/subscriber.rs b/crates/services/src/broadcast/subscriber.rs index ad5bb3e..2ca21f9 100644 --- a/crates/services/src/broadcast/subscriber.rs +++ b/crates/services/src/broadcast/subscriber.rs @@ -180,6 +180,15 @@ pub fn spawn_broadcast_subscriber(inner: Arc, mut shutdown_rx: watch::Rec .send(QueueEvent::Paused(!is_running)) .await; } + // inbuxa: AL-3: sessions an account has + // open here end too + BroadcastEvent::EndSessions(account_id) => { + let _ = inner + .ipc + .push_tx + .send(PushEvent::Revoke { account_id }) + .await; + } BroadcastEvent::QueueRefresh => { if inner.shared_core.load().network.roles.outbound_mta { let _ = inner @@ -266,6 +275,9 @@ fn log_event(event: &BroadcastEvent) -> trc::Value { BroadcastEvent::PushServerUpdate(account_id) => { trc::Value::Array(vec!["PushServerUpdate".into(), (*account_id).into()]) } + BroadcastEvent::EndSessions(account_id) => { + trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()]) + } BroadcastEvent::RegistryChange(change) => match change { RegistryChange::Insert(id) => trc::Value::Array(vec![ "RegistryInsert".into(), diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index 89eb916..9b69c2b 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -263,6 +263,12 @@ async fn store_maintenance( } } + // inbuxa: AL-7: locks' grants reach folders the server made on + // its own (a Sieve fileinto :create) + if let Err(err) = email::inbuxa_lock::reconcile_all(server).await { + trc::error!(err.details("Failed to re-apply account locks")); + } + // inbuxa: AU-7: audit records past their retention go; a // failure leaves them for the next run if let Err(err) = server.audit_purge().await { diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index c7264e87fb81e44b591c4b3fa191906ac1b92130..a855484b8428e51c75d8fa8c4b203629937c4059 100644 GIT binary patch delta 25395 zcmV)0K+eD3oC%(t34pW#kLQ1JBVDg0>FXNOe~wpnV#bX zZ4^LX!g4Ls-_W*w^TxxGVi{+#{fWc5rw!K>onrV?vh>=m7=+HU_n*DVBM3_a|WWT<)pZkD>C!&GMg=)I4=AouTk${z$rf2Bv$xYxGWtw6v7U77%i-vTg)Xt_=mbyV_!CUc9aXpQO+s_EPZ8k!@ss@FCh~ zS`QvM+NxeEW_xR)P-nQ_FdFHMoaJIOi9vr+9zm8n5S;UzQP*GMtzpu5;W2rXRIQ&2 z$U}sl?5odk@*XYkTD%<*Y}m<1;5O;jn;W)zwICktYix^%;{CV&#{d zahN1PwL(l>p;-waeU3-^J&d&K(EKX3{oo!%$bfay6WM9e1|q8fX>B@%IMtlNxp03o z5zT3ZQNd){%CO9@Qn!-DhFIvUU_7N@AGNcFs30ej{EuWR>0bGjwA%6mD6=jh}=9DHk&fr6H+fHaxk%@eu4a!6vy^`eP2_drCwEl?bE zN8pDjvlgD~eRx4{jjkkxyC#C4>&j5tI7{TUB5SReyUr5v-91&&&2P?ndB=Z&iIwlI zRc#a80j2IOR_z(mMZ*hQMh}jrP?7nO2z-W2oah`ysOCrWBr!ahvefYhr*3(h5Bbm1be1G^8pg2|I?%Fsyq?Oyrr`);mpUIp*_gmBD#pFr~5_X&`|QOS|z z&PXnVy}NY*$f0Q0f&GhecHD`$rPPN026z&a$K!lRv8Y3&i$oSy3c^NpJ_^M4LukG( z8?{?dTj~Z?85j;gNKqKQ^A^8WmhK7@`a{jVxQ?LSZ4$*kMaE(pzH?!Cb$98*Q!`^2 z1N|0!0O-3gT9>=)0VjX6x#mbfp}yomq-K>9gc^f5G|S90y-Olr554FjUGeJ2H!1cu zg~96^dk}Y;=)X;72XI@(ms|F(k23^2wZ07#9@ttz)Fn_fa?v>&9#mY;2eFWYm4!o2 zI2*{oN){D9*AtYLERMNK4*-6d&|{YmRQ^U)C6#G z{-ZP_Eff!_LaGr28J59CrB4jlceMud@Qxb{(Ly&Q5E z!xBQT6zt8g>LP!z+C%T4{Y&-^N%i%w!7YHuzaKmXB$Nk2n7ruAV^0N2_m7^5V0dS9 z3G%0RPH(n;14QAJ8yp?mNCQNHS@ z1C`7O!t%@x?*uBGn7pH?lXD{K$v3L+M63qq)2O=>VH$jPTeh_Gbw@hzZQ;1YeESUV z^hUoeq?vzj8`8e^+A{SW_U|y{4s!Aka>90Oa*$=5A$O2@oScZX357m(b0!o$jeJ$< zw6Sd&jkavsY6~Dt4dwE(y-D}3{IQHw2OM%`hFA3viq2lWOIsj@WADs19=MSOCA;wf z(PHr9P(%0uLdR)g4B#b-@)Vo&a3+j)BOCBkZ8v{xb{5rF@YaL4QCRPA;oP*&qzw!S zGKgxI40JaGPZ=tU1aOwLS3aaEg9J$<(#nmIyR$v%uWFSqqgR?%sP=vwt4_mWNGm0I zo1vcSMd-~umb;e2Q1lR%FdN^(6I1vM)^_6Cs-pdP(z69Nt34*{8h}}^>rRkFMAHnc z+p197eTj}FGKJAkGBh4_wqAlmCy(x}*;po-VPH_DCu~Yw zdGM~xiixyoO)c*sw@iXlbRNlRA>F@Vmx1pAD}N|DXR(fQ0ef|kM`CdHcgsBJ4Qrq9XXXPpDX>bTnL?OmBPwu$cg>tEIB3A~UR{ zD$^=o`7pFNEu9EB?kn3XTCP_<=JcBg4&%nF{U##9@FMs4j?*5@?C20b>q-8`u4pqG zwn|9%$%+9B{kHCQZipWoumgoZv`Y-X`SCL?WUZ`JhdUs zxoM9TGE2j>n~F*k3*mCj?_PwVA};#E@7GfXF%4_WcR2GtSgt=}D&F>{?}8dwNq@|j zNW{#&zz`3E2aZP;Z)2RJ#IOWOA~s6^reBNPyo&{BOwi4{kd#Z$n5YOqKxAbqFWjc^ zilj8fuKjnXZ>&30`Jf62dV%0NFco#lU&Kn{7+{7+fEJ^27Pzv4YTUZ$pJHkvQeiWi zuh(ugOc#J&Tu2CC%k=b{iH3h1C1guw+0tqcjkN0}Z60)a6Lj2eb> z06kE^Tv6U>-bAyF;%&uq2u&=mVvoXyeGWRtWnBRMrss)*{0z6oWlGnp-(`BR9*YKS z-9e49NgwF+gyv;El?tOx|EQITGW_q~m}ssc2@@ss9A;7{On-t&ehW^$ z26+umyXJ?7T7BZ@PfM3erx;&hpQ7PDP|4*>%$>Re;ovvFc#_?xc-}GAD{zfk^^>m2 zYixTLp53s?{{64xV3~#a^#>It(kSrbk@eu1peIMx+hV^m9KT9bJh0BuOx8erY4ZR2 zi!~v>{F_00`Rb)j{LLU9{eOC76aQ`ye>r+>5s!X0VJ|JxOB2*4nMHqXlU^C5-)xfU zmG$yhoA!%Ad-ckuS=I74n`T#wlNY;MFgIBBZK{K7pJJdz(Q(|~eAgE49ok(5FHk>P z{c%bksOaN%_cZ65b7jgj1N~MF2gc^QRzrY{D1KzfRM?g(c{4-D7=PPrYbi=VV|c%p zLC?0Pkj&Mr#JIbwyG>oSZIQjptmm)bZ3BE1*jo8?l4b3Kv72W*YZooTInS)gOFYD7 zv5A-Q94<@wKJ~mj0(;r19ULbssuK~V$eg?I&OoJF#wu{HY}==Z1j0f9B_jjrC~j}H zp4R0dh&^Wysu~6!{C~oY=-O(K$JcjI10R01?qwcdqavY%YJ>^LntlZHYn%Dkp82)K z{QA1I-Y65eBF%!NT)!$VnQw3f2=yo{HavudM-PbqxUTdc)X>q52+MI>!PdmA0aPqI z#tT-#Z1RO5OEh6$DQy0nO8sA>ma(Fo+1H%zohHtr z=G6Hf-lTTig1I}yP#>TdX<{#DK0b3d6@^lsM0%}wu3=!u2Mg?y(X-SEjrH2KtanW5 zsdKWh$KnkUMIo;rH>ZE)Y6tEMt<{wuLBH6U9r?p~a{Au#dH|MX>aZM7O%|~1XuA8{ zMtUa`9W>2qA!=3|wW;=1CtonIfaiU>gKY>{@P@##Z1NjWfkT_G&LN=Yv=7Z`KD1ZE zi>62ROLRKP_~Jq+noRA3OfWO$vdjs~Zt<|XH#ey_GQrF#tgnAEinftScp0~H?qAm( zY6xKV_(xLb&BI>Y8KL}(f405~>Y>`LMC+4HTUWvf>_->~(;`nB20%Vdp2=tw$fIozGAGg;>K&Ku zG`H@%WugpFH=W5lP$%XfDE%Okkez!l^i1$I&}@?y_o#oqNY(Wesxj=RN0n3HEscos zW{<<5H#r?#2pbkb2Har)IwKN~uzlu%M5N_uxkpu`wd#USL73h`Fr`*pf}$3)`B7*F zp>e84>fpg&dUz8QEV6|{$6QRY3Ffvj>M5rE1K*Ne$tG%ugbEEog%p92(+}rO@dd!f z`J{e-PkldiX+IZeJ|ofyS49EOp{qkC^sdM+fL#SV_AQaI9b{mE=&#U<7(R5PZas4& zY!d%bGyO5!sdoU*xQ%y_5mD!FBEzd^S!7=f_aJezk6vDC7f6f28R1BnELii zHEb)c5(uC&&4)dW+sx3kRDt1bJwtr7@1O)bQ5CXou=TbL&Bf z>^@^KBb~)7eka^@f?Y%|UCOV=GteYVbQMdms9qr^+lc(dbzp%OolQBjN9S_1=PJC> zZabhImep{Vm1URA_W?eCMfXa);?Q=)c-7(9m?E6)AoCIe5M^z!r%THg)xJq1B^sk^ zxzMu}?oiwP2&&Kz<%-ug#5GoQmsEgx-0a`Ok9JTyzsw-lP?6m=8gHcYj^c=ZGw)r& zBPKP&Z`{)=4(5UL#eg3_%MBlkoA0cT#7%w9C!SoWi22Z{y11Et(JGFxYL#A*Bj|@r zo#rP-U}NUf5Zs4U9yM-Nkp5twd{oRsNPlhiM2t>2>~IBB@Fo)xMMm=+uG**Lj?`^D zuuvz?#Wp=yztTBW20KpVw%#}F4am_^sGN5NbHoTVpg+iXGqi3s0j3KReRQQ8pvOlW zQyZabh#X)CqK@=`ff%$Ks0p|(NyD4io#u+%$o8nC&EF$Ow0(~nJ>?qc5etZ&>%%`1 zp3u#jp32vObFe$6wIjZsqy`J9DWI;leb{cJT>d&5S0;>u#A*-5k;c>oUIcO580?(V zU%iuoHp>;GyX$eH+f-mibsB*?+RYW-Xcr&Q4$Eq|>!HqnlB>cKP)Z8rr}r>{lK!)e z&~!!|Z4l)`JSjzRFd0;RG6$*+mo>z5m4_3ICCnD*y&HL4!l|(8*uA}Fno0%t8~Adt zUuoyN1!ggB-ZP-1+t2(=RF5-1GCIgD^Z-)iNRNdZ8SX(CxM_y_p!OL2#e?m$lza&~ zmiu-h23B-`gP6Ec4Pty`bRw|9N9O|<`bmWdPYz}kjdmw1`vIiL?i33*(w$;q{4<%| zEaNkp0Xn8?vAb!6#s&RmuHYsu*!j`#2CidPS>(*5L8CiR|AD9>py5aRzX3bi`7O}H zGal~teT9tn`60_bjL61v~JkHK;Bbh)Vr3MPUlJXF1W4^qsf!T~!I`F5@7r@RI1Oiq6#wjHc^ftnh_ z3YevH#UTMc@`H5K;N^5!I?8Ss`3v(y4>I9>y7=y>5wOU;B(iT1UG0e zn`(F$3B4RVSV+=$32*=$5R>~NUCTRSFYeTR&uV5WS^LQLxYqh0j?*2z=a?Dy97ALY z(RlEDSXMUN)8(_34?%y1c9vbSpSW?S|EOGM-8|X5h}p1<=!>)f_mK=nJ8PLH8n1fH zm`*SR5stthQ^YLFJP$=r+&~LbR3;lXh{7Nd9@ObGrGnRI+za#4&zgXWu)L`V;2`~> z###z|TERx10^R6%PX(^1TdT4}G>ug-duM2YO^dAWFmxCN9CLrT1YrlzaUf`4V#=v4 zrlI-^5roOvju_URdez_t;@A)z8V7R-vsPZC2SlAOz$1(zf_VgwIN;#ShnDmBCXF(= zU5Hn(U9ceBQ!k69^2zNCY8k+i+ib(aI zrZU_hNsE7(FDuZ76R6lob7c+V0Tt~i9fSIS{EPvOfP-Np1K0b&U^@+Y#0B)rCWlGQ zvPps|TgJpExUgH5x%A@dHn9Mih7oW#-3Xl8)yc=K@bw4Be2O5-%|;kh-}i=*qrq+<+{83yXD!>nqPu~`S5PWtG^+ePm46xuM`SwJmt@%<#fS5_G!Zm@PS6Zhx( zd+7A0Pn6CeT&0f?5_Wkk;|bZZ?I38=$@Bx4-TnbZfA|GxemyQ$Eap_E(p#i%4A;_##`-pw}>A&%dNAZ{(@m7IJDxuyl?dN5)FCdt8#go zJ`nLOe_I3nG&w4N`2b^%L2&PahXE%JSSyJBfN{Ta7L=UShI$+Bbc6NOaitn&>#eM- zQ5@&g+ZV3C9PWj2EycoOgcG8`({*K^e}{%6MT$X#s1y&}fUYp&F+sz(0q}&^zo^ox zc((oz95NdTWzAr$;Hy(%`*HBFu#Cze=S;8Bf4~O~j!4{*ArgraF;FZdW56(IVUJcH1bKVMn`B78uU+q3ypUU?%=UrJZ-3t*K5QmCj}TE;l)7%oO~c^ zaGyaSnm1ju_mx0yx$lANCwS0vFXHtR500h}Tw#UmL5+ueF6cH}Lv1SGH8b7_Cc^;? zJI1wm{6wDfl5{8En{k`bz`O=^NDZ1K2O0#$LaL2Vmn8uLjRGOZm$U%_Dt|@1$fHo? zTN6-aVBh!PK3WQp(pCrOFS``;Ow`MRd@**=Z%h3u>J!(gbA4jNk;^u@CsJ5`IbXR_ zCp-Vv#N>irt-mje@r>AYG1V0yXJH(gpbiJ^eHet)0E?rR?l2Uo6c$QyjX=>FVX+k3 z2$(x1vry`;D+r_`w7H#7_J4J&w)<#otcNXyBy~5S+7%WHtmR|!s*xfgL{!gGkEEBdYSpAkr?*L+U;rgX#8k9#M967EeAE3_(L- zL#$iXd5c#*#f%KNEfcFS*SaRsUC|ul2}Eotvk)LgyJs8JuFwE~FCb@!e zViZ#&;HlCk(*>QrfUTEb0|F#}!Pi+6P4$j%*$Q~b(zP%KFc}#A$_71U=@w zAhe<>i0)Ee5T=4DN8OIXu;Wbdivx;W$XuVv&%v zy*NzWP?4Z+GjWiAD6d5puqlJ5qNenoU79lD5EPIf%z4Q~>}0~?jKhn_S`;cqmlEox zPzD#nqugUK_X7Vuzq$8#})!A0S4GofzTM=)Zt5{D#Swus7-eAXfc?tt7yfetTM z#MNET9uIs-g^t4yx_UdqroNxdLp}|^4M-*Vteu@Kj zCn+zioToydMTbTrG>Vuas6bGpwM?oXk~WtYRkv3hFl{h@FR*U0IAEs9yvTi<#X*xs z^8%|@8;8BETw8WbVfMW83vWP+l1$YCJ5GbTWKUmONv_QM3J-wlVM$S#-wsXZ_U zQ-O*g2al{OylxF_-bry_iI{B1@lH~FrV{>9)*d_ zR~SsWU^rTT(38P4gA5*z*M`(VnClm)x^Mlud+!%igzNP zuZe89XKErF^qHE-27R_BvSFX4i4K^fkv)16rPvX(^g&-J8!)@6ipMOtms^#MaO5j| zMr<}zL?+_^rP-XbE4i90qjuWu4Vn*(?n*p(s?$e6(p`xMQWZV|lG&AbFm;EI042K; z52Wk{?g2pYF1(k52Ld~P=4j_sy;v2H9?wr01E+TI#BYM9tE}f5$BD?$I2!uzC}*Dw zaOQF9=v0PWbw>`3=gL8mF~p#$AruEl3d{iNl1Bm3QN(cX18TgZ!~mYRZSa0%1N4yWcrL~e&Z@BPU&y0>E7=%GWLCzYq-DCD zqYL3E=?)kYCDKBm4on0@jgv4ADHg?1#S7!mVL^fPs0bsuqk-bc!N8%+w&D9Y3?Sxf z9h4n!Ec*Otbk=`(}#*W}3|h+&5bkFxPB8yGn<4~k|-g(Ti$DwG0yz|*(kAt~Q@Qydg9tZk>fzqC()&8Arzsk|p+}%|69b}Wr2r0{iWnR{2?cPNnTG-D=bQi%nP?as zHN^x_f@&0hCFt9SDTMLOTpZ5mmbZrqZB0$E19BFGglAr5JXyHJo?{r3Z0$_ODwW%S zHa#f@mR{uX$oeAhAaGhAJh;BlI|!V)*yGXri@k&3$ps$|t}gfj&^o4zvTAdCoJwWd zQB%etX(O_zx((xjX#=vry8YsTnZ{$0`?iaRCJo1b0;_iO!=Cn!0|ciCuXe-3LOqoD7cMa)zPk zu9Jb%Jv0o(Y&;pPe&ZPiBYRHM7X-nFa|de>$?1jYotYkfB80UX8xy=#3Y=m8wg z0KIFy7U%&aYJc9f9s_h91zv4giw91|rqZSrkybH2r+xkQ|O;Hsg6~!T_<^3Bf=lzGqc_8FSZz?KW#VAd|)&ru?J5FB_4;QF^N5p zIx6uvBoma_gV~2A9tR~+i9L`iF0mI-OyH|a8H(1uR#xvw(tBk|2thIT%M4n7!8{Jg zTr@L){q^NIAa~=;AP!f~X`x5ch4+byeWUAq5YvY{IRg#;W+>O9`oPtD*qfl z^55$mFW+AYwXu9O(ANf1e!NLzP)2PGG)TxEz<`R+RTvPS&VdK*!x@u2V;C_x8bc~! zg&R{OHG(Us@fm<(GBpmjN$Zi|HjQK%L5RrB2#$EAS3LbWlddjV8{Ut9H1cIwA%YJ} zYnj8o5Rvhxe@DJ?>smvI7m68M2>Nm{1IE}vP`us>A_|hRfS{1Q{X-NaXZt`g2g`>j zP-^u+A+^ne7w|ne8y?ib@bRGNW_QqIJJ-SAc&3guKs{5p8UUZEQw@O6)};o>@9t3W zI)i$YAs^{V_>RM}XZ5y!uLT&>p;h{)bF0(7(g zR*g@UJLDRLiOkU`*ex+xy=Bc+YsQRH%hOY-&o(uLLQkeXL(~uob2{}}orZA83Dsj_ z>cfbh-(u#T11Gf@$h*`z`9K0=UzGZUsX37=uPuqZ5Y%wUh0r!bUI@lS$i?WbgS;Tb z7|4ZCc0eA08lp>oKqb%9?T%P~i!^3`A=5|E=oW(k)LV>5OxlMSOx>FzF`1191KMvi zB2md+gTYjL4G-ddyk4-`-N!2N2yV=q)ij zuuRs>)jZ%MbU=l$H;U}YzJ~!8l2&xDyg>s%rZu!pGoBlh*?}gXg!^ErcH~znk4~uMr~9Wmc~h$_rZ zt9TrFBQ6<#5f{%zi$@mqoC0q!!vok=e=c;By9B<~U90XJG*spJp z0?I+Ho^vG_&TG2jC1rf75sv%SGKaCU62@#Us)7TXE>I|y_!KumzLC##_sH9UH>uza zZ~Eeks6ph2!WHybSTT9UpvoGJ1eK>L(4|Eo%WcFsHkvu|!5JR4^of!j7W559dfk3o0M%>%z}=H(@4@HmX3 zs#UyyXIKMje0_}q6yw)vVUfnPu}o(XXgf0?x}^g+w6z%=UA_Pg)8-6N-|7J*(((+B zYI`1~Xr3=7E&==gaH|CJ`-80#$ma;PN6^M2bnwJSviZIdM9RQUpDTdO_$EN)PSVFv&c0*|I2%2N0mgGP4nxBeK;njtQ1C?&co5PXSsc}W z9m7zhTrAX3!~hiSlPs3*lwmNpTe48HUk*V3`tdJtKH26XJV0}|-vQ?T-Gk4{O$K&X z2B=Dbv+-^aGxH-952p3&zy_yvbYj~s@)G2~CMZdqh$X+78kzZnV4x3V9fLA97!)pq4q z_|s?}ZEh~uj8AI*`34qtM@Xa%mg=r3+V5y(qN{&cYF|Vr_{w+~R3|i^AHAaSv99C`AojLepvi57 z+R@JTT;#;9D;?g+MAspq63EmJ6xhyOe_Y{hV4HCn!i+TC5_Z^|)Z>-h`D8(A@aak_)lH)dxJ!a{j5&? z?Dt4C*#DX0t+&B06O!x2XN(7;-mWkuKBjcm`d2Myl!zTrn*Yep@8EmpUM1w8f-T9T z5lQk*c5)pgJ#(xjc{Cz<6tTj~#a+0gX_JVvJS(gtbDOsXdFdv(6RFn8PdXXc$34k> zV$iIk60!D>{1+89f98$^p-n`A>QbF6s+M|t&bW7 z()iB6Lmo0f)xp|j>uaw6f;fAPpYFgh%y`Ax+CRdvy#+0kKr@SErCXarcsQhS*+nX4 zKEtStVq!R1n1vaV-&jZKUQcUkHC5st7gjd*HrIA9e;E3TQFk+>_Qiu{qQc0sg^9)& zM690K$_?w2;P^Hs=4=naW-KefcREStW(nteNQ&3sH9{n$!jqtfD#eu4lNDk_zE_6z zg>(z|zedJU)Av2i<0vfu>lSiE)B0a8<6<4>6k_<`mx1y7<@Cn9l}LRC5VVi<9XGo26d4JoU<9f=fZ02d{B$7HQWe zoT{z&f-qG$|H>-Ee;ubg*zy+;w86e@lea3?b(>5|@Jh|vx+%g0l*=6d$^0t$Yp>mu zs@=r2(*A)*lM;(Piz$hdHr}y`;>ul`JVUfzo+1ecHM3F5^znGm5=-|4q-Vdzx)9T8X0Ki4t=v|coedJzrqUV_u!F-- z?(739ycn9CimY7Dz&QIRwY&8;w|1R9e-Evgnq2!2%*rId-4Vl}E2@SUITf_d-gUyd zgCnrD*Pm0Fnuwb>u@v+$c5R94tUcL)n-h>zq*V?_jMkaXZdJ0pb%r^OmtiI?N0Obn zjAhWC?)t@XHe68KUv`dSw<00Aq`oK_;T5jbILRrYzvAjODey-92d4$((|@)oe;PFw zd!g0@j0^HOV7H(r>3N@sYeEYx@dM0IEqI667k7^-(dm3pNeP7gsV3OF_MF57_FPrD zYB)*J0&_{nt^$&+GhnO(uslemHB96Q&Or7WE7ueI8AV7*PQQxCkYMj}yvddijF6=a z&|(XSPx$8d)8jk)(69v>WHS{$e=bz&QjwCWxXHASzUa2Y$jV3!LD3yt#ex#Gvj*y1 zrM?%-iuk@gHQ`q3^?iZfvG#^4IV&8O*?f~eI*JpR!sH~~yZDn;1V7+zYXrV}lp2g8 z)c(etyq86{qA^%tNCBZC18Qwe^>A0&fv*_kX;-VqS-Ad+ci3osWYrdUf77@H(0n`i zGB*-gA(#a~&1$26z%k7=gHd||3xm>}e&+h1#UndX;3;t(fAD_&plN+SX&!bKV@2>+vkNCQEnS&QY-DKEdg{JY71B2z?0tw1xEu;C+Ty0f+}&P?sa+1>5M1dbVB zp)VH6e@mB890DJIW!U#qT4NSrk+**{r2jU71nsP-%`&X*T;Be630r_b$2%TjmkL11}{TYVHrghCMBlXoMarVO-!Ah zkF&(Z1jplFf#-*qRhRJq1tSeS)!D6wDFd@p8jNL-H@m8T5!0bns%2%Fj7))lT?(@^ zh)D2nP&Q;h(=<$~V2($FOBn}Z>FDx&ML@}eieU+;q8L$v-0?Im&Ak!+GOaOOGdue- zR=D69IUqiHt`G4kaTlIhM}6y{f9~$7>2~@JJ?QjhhR`rXw;HfCH8myH-&GQqqik^a+WC8!kVs>8YHu*jgvu$GazNJ!4AV&ynLs& zrWV|+Pct{C;2_>uXL|f?ew4A<8|?T@#q5tRa_>wXNQ_yVJU^YRe#1nJp$6Qj%3CqK@{ER^i}~;L7)gib@_g)_qm! zCXq9L2aH2|yly~8$5)p0u!eHLuJQcz;`*o4^NZ^QR5@jf3P|UmbY|atdT=hMvy0^H z_WF{==}K{wacu8JmW4g#Kow>Bv%qflq>C~*J^yh587lTaJnuD?VM6sM=xMEk%pVt+ zAhIrvi^StumeoM|^;A9pZ=M}t``JPGiw;eH2a@WZc_SX=h9h1`bjuwC^;hS;COqQ% zhTfb7o5}Xgvly&=V78%9LzIJ^G0bF~i_UOK>-~!otw|G#?EZzNrGE#UWe5FE|TKBYn zaNV;oo6;x)Z(K$)rmQ!Xg3|^;jZs+oJ}*e;(MxbpjxN(;j5ZHo3L%R%I#Q8WjgPb! zajJ5+yMZ;$y{BfXa<>z!pSr$wK=OC^P(nz4dNU)hDTpDUo@W^A`|NWJ004Ww2RRE; zc{s&9;CV>8qM>ydF35{L=vI{GqFbncx`frzT>L)^w?K?K!jhp{n=eYcJBK8ovji?wpQjv})Iu#PfJ~6u2Ah$Bb6o5mh1QMYpT@w%3 zKBUDG^l5E215Mt0OAmTG5EG%(1M8@!_p2K4W&h<~e=y+P9$NrFXxtLeVg zms{29E&5yNceF@{_`0ibj|69iH!0|G?(c0~nIeTH!hKrjvyKV_R)(UU|d&|x&eigBN>yISnvJK?mdTbK28x>24? z=6+nH$gnl(mo!JORv5eR;DUQ4B)Y5D>$hUcGZwGEgm&Kgb@{u&f;fiBWclmxUD=Y%+(`R&rs1E4k6;{#A zJmOf;fUqbsC%OeSC7!u|?y85(+7PP2EWfM|vm%xljyw61S1W}GAQ*l>2m?GCxX*yS zyqn5L_Id>~YzZDba5w-wxice41rSawqa6bPjCXu;;;=AV@pAGcq3=CKwe$uEV0oAn zIk~Mz%(r#6M-mTe&SG#xmCxKI=u;uiJZdT+G4SYI@ZC$X>~F(=CrQi$K6Ir&4hBchu4jElMamsm0Q@`N&6szV_h3SQn*KChA|qPB%hy(|IRr+efz^)tkW6OY zR5~Y6uDhHQ5b3PI=dNG{Fr5(qJ$>3S0y3QuC}6JJ+xNH*g%PULZ{N~K2!6rI8AL|3 z`wZQ@Oh)m|#Q6~qhSIWusG)55a7&#e{%&>!9+=F!tb_i7DBz;J0 zDj<_N_WD!Se44{<(lUq{$O&jP6qynKoG-vGo9S~sg%PUYQFNvjR5}$nEj~@tykq8$ z(;|@+Ds)z7SqnNFD%4Y`&|6ek*>}q>g$kX+c;|Y5xJsu2zgL^IEVBkWf5Ix~`ZxS|&5> z`2x(~`B}uEF@n{W6REZ=a|cHOrW`7r6Ij4Jgcg6CP*39o8yLl$kVvNjm(aosW?EC} zoWQPsYgqx2&I!P_rsD)eIwOD!FgLz5ff1?JDXiTa?lV1!6Dx6tlX6muLWQny!e1~A zqX~=%Y+hia&wQt+FhV)}Waf31&IrKV_^{kmZOaG^G%9%ArBFvzx2~A_Gl^962F}{m z5`AZT<-U5zfqi zF~&LqTd%6c7@p(_0{sqK*nJR2as)vx(oeT}^*NlKi!_l;L8wbm%vImQNSY@I1mF7f z7LJsev%qtVNzWLBggt8;&Y9#G0VzNI-r1*CjwumLqfi%J-i$&CrXbW^2m0!fOlh7V z(0f=q(~*v@d03#%)2lmAPj0$$Y3s9p35gSH^M~qzFR&)DVh?QY+6OjsZ_*806*Ao? z@aYW(`>NB%Cz|nyH zv1jn|JDBDqnH4XWUC9ax0xJSnRct5K6jsQ;wgy+!<8+^uC0{g2;f44~O%XVe4g6hK zoQ$IILNKFk*zM0i;{@|=m9B6-sIzaJu%IW3&H=zauK0A0j-_4A0U$lmbRNL7kqB%b zj=|~}mwdh%k(dY~E4tsL`JWws+mbzv6Rh!~jcrUt;e@(89%6Gin2e@yr6d?;ik(IX01_3RFNpfQ4PF$9BWbrLk+%JjB^(Ddno z6H^pc2qHW9zJ@gK*KmpZbl26~s1U?N5Lr=QLuv{q)PYZ00oR{gQy4^E6ifwD_NYD3 zc)@-8XJ=W0P#P<^PfPxPbArH#oP)CUKK;tbXbLOz$bUx>I1z9Y0!P)N;AUQf$cTao zO%AB~|EQ$lb|oxZ0PbB`eGR|}azOkNmh6(8qf?U~ z33-xZ$MMN!`j9=QArMIq0YDrD+_8lkY2XO}fw~MfjLQIw=J11mjP#a3FXItJLI2VKQ)CD@+ft@svK}6&Fo89o4|;?gTp+89N5Dp251+^ok)Ny z1O|$9@Q}**X@KM6z+hy4nq6@KKuKm-tWYfs*z(U@n;Jw$6trJADYttEjTaI)5eVAA z31BkEjwj1pAOtxs*kf!@;>5g) z)PVXFNVqG{GG0v4SV7QE@E;QdRs^#Cfca;|dLk?OHD6YLzST@pz3bQ|wh*s@>Pehf zRWA7xiKg&E0Rkia+^{y+*`60uG@l?EQ&m1mN%V+<9c|$e6eN!r+^~Y(q(@K@8PTiM zHQ})doJjWN(?2VAlMbXrkT@}T)o0B%f~Ign&b^_T%(8<~#wKMYB!w4hH=n>H2K0%b z0R@}%4G(~U+bpgze=vNafIX>OV2W!D467Xuv#h4q!ox<8j2vXwu?>gG4EyzG*oCdR z3e^-w=qAOT8zZ9#yvVxD>$WS`yj)LZg=%-OL!9D)2uAAmX$_w zBFUUMMu_0*S`k>0HGe&>2&_m`6XXn|dS$Cb(^-HD4hdbs3s;Gxae^3| zs9Efr&U%W2C3E7N9KB$<>AELzVmW`&DbqQD7L;}PxC|^A+-7IqCI+1c04v3^;bkR> z7kihttw&6fc(IFa$==O^m?ZIHcN=h7@O)0uctNSre`tA0P2@zsjeCe;+i`5D4iOVf7yaO9GBG4O!3wRSGaBi7;F(aHOos6)Iw zJP@ca{)9W(+=DTYed6wYAE9{!mzN#f%6cRfkr%y5i>=*r35^%Ltzg==y&ou%7Zp!~ zeY&Z9buH-0P35iU`M&z%`LZPOVt3%$Rq$exf5eM@fSc>;ftOP>Q&3Pq2Uf245Gr5+ zRtR=fJwi2wn;dO7ZIM2WFcuK6drw$pDq-F_HV;E0VTD_*eNA{}Dq;S!wGa72BEwoO zGKLpZ33Ibq!(Dt!*mXK1@Br6_*4w}ifrx-x5R-YC>N3Ac*P9hZ*U6N9;{-Xek}3N` ze@A&AxNMZ`2fI6($&?+O`E1WCGnuewa3N0WT%{6bFo=K1XjW7*VaKcXoN&ujm-!f7 z_*>gN%StB#u=r7Kkw{sqe32Fn9S;VD5h~Yz+Cfts0u=$R!UHR!2}GnSs;#YTFq2*O z6L8KYg_enw6*i$uTN`er6M>E612QBMf7ZMp?JCj;BbaMaPLW7i!7iqx(?y@|3YpvqfD|W|>NuL0`SeKV9bKhDd<2dXp!DM8(SW zr*{?YT1ZF)fr>n?DEA|sxn?!&>l!kwbuwXxnuXZxWQyt2U4h@xLy$I;PNwX8e{c$_ z8Ge;c1i&GvakZH?!4-LzG6j%{lywWw`LuLs3>`ygcM$fP)Q3uSnc)#vFw!B=I+?J8r`JRGt<0Dy$f9wAO}@x$UQUx-vFk16 z7iomi4(vRnxr{d(xDovX0oBNiE+~nh5FxZ(096Lb%RnF^dpt#?v>Oiue^=xW7<{Bp z$V9@5_PS+Cju(A85!ip*7hoJrT6!S3BA^*xS9Gb=$%H-Mrz=vaD%EA4r|y-YkxH52 zgiz!RyGo}5Xt|q~D=MK0L?o-|8Y2@4D@?2F4c#gA=|li#Eig)|$!=*N5D^GUS7r}Y zv1bN=O>{-?QcxC=%&An$e~hPrvZd@Qoe1D-fenRM$%H+F0~KjFl}eex!T<r0n3{ zkLg5UgD#VtWpUxJQIJF(Jf$~f9?u=$VFh(l8lFe zKtu{~@O~t!9+%{b2_-0P8=XwqOJ-J5sf0O$%@U|gOV`qq5~(Nz9iYQPC!6eODTF$y z>9@*pQgSGq&>Jwp?d&PvC2?ZlK9t(gMjQ$+w9DXbxwbu-Oll&jobXwi)fFqKD7=tY z#wYSSjD^e8QTiFge?$^F(Mzxj&ug|WHHj0u&GrwUQ^)F*5;;++h<%$?aNWRuS88H4 z0}Ii9I)~f4k{8D(BMUb$!9Y#nN_@`?N|GrCCwSOxp?>c8&Xlpzc>xS~oZH=o5?RqV zb>6sPBq-4%ioruao#(dB*`X(b&H@~xnxnQm1SRsKaPOD%f0L276Kzu5lx^Xml!wGf4&29DO3ndEXA`@&IseERQM*v z$A*}?&;%+nhtS9P-Y5h$Wio0}sqjC$hl-Js$tF9#4T`bkz!Dy6*sgg(BZ9JuXYQ=S z5Ur;%EEnHnCsM?z)d5fK4tP4Zh;x_t%jt>3AW}@G^?qkPg)DxX47oLK#ZQymE`NG^ zX?;Yce^93X_A_OJG=}jd!}yYA{jK469 zzi^DdGK{}+jDKSo|Hd)?#xVZIG5(!l{5!)qVcK!RHRFV7#mP&Sk%N56W3r$;>Ji6! z#InBPSYNTMuQ^t()hFEd+0S!dXFtw;oBcG?f8i!vhnp}RZo+lA3De;wT!))59d5#P zxCztYCR~SOItT3<5PqQik}o$dZ=dc6ygzm)!uQ8yB7A>$>@q=$%T%HfcVjI;cDf_* z?_JS3ydueD>uQKB4z@J74y~-tMAu^)yh6^KusT?zeE9JN`nPK1O9QQuwm?3QcqGIY- zr85Gbp+kMP?Y;?#5vyR{Ypr*i`E*83MuK%`RvY5?F6sK|9wFj<%HGy)=j z<`W{7VYZ!7%~Z-P?wA>DmCgy^r3Bm+Rh$ssNeCJzSf`(}M^;Qxc%hb?M>K&E$+!1# z3j<*_7^#8A2tHK1&rHp!bVi`M=enXyX4qSBh1ygFv#A?MRO}7{hl0M23TXl(0{1&5 zQxcWV3p8wv^%N@fz5u(B>XDlu1`;C;_H6?Lqhcnzo=@33;}_{v;2k^z*WnE^l^IA>>@wYCR6?Y3%mr9?WDO^vD4Y-s zme!t12#FKhL-2eyZ<0VoZZn7%UojP+38si`2{m&}pk_^-T3*#E!C;|rrI>h9zo$Fv zJMy=|@a{|c8SJdBQ{s?jFv8;)d-G%RxA{DvrKP`rw6*k?){wlMyTrd;o;VC5#d>`Z zW5R$p%;yyENi*vOLWjhQ%`MAghd@Q%!s~JN#gjuYM6UkbV1pkPX>xo%y|d^t#bg2< z$Y*o3sRV=N=GN4i;V+Xozx(aRszIG%GRcdjpWsDy>)l#2nPZ25UCvZUNQ~GUctHZL z&0If!O_t1v!}JG{CAW;Erm#YGToXH;5s(*BjD#X^BKK*_6-cIY0_ZOdmS1~LsHZW4 zXJ7zc-B*m5rf@>}=bV$kWRATn(|h}^CPHGwAlg1mB=)*RX{_LOk+U_fDVz`l;RIhY zSVId=(4g|dTV9IoLa-Nr02`JfnUEMU5#GRm4lYGVjM%%h4qN%?u(Z zTHzwXiAe${!YmJz#t2@fpSzmtyNbYy)DXO@NgGy5(>TF=^(Ezomm)ACSFrMPZ7kC{ zfxP2tP9$^e6}xSbg*BM7Vrgi^Ycp;TBwh@_;+jQA~BiNb(ebG1Lv z7(wt-uyJS)_f2KQzA5%?#=E~jFRAegyod$L()}o`6%-5x_&7B$kvWV|2 z;yde=WX2bSbpyeG*VZ+JN-$qEw=Q0tOV{^vh&6!rK_*L|<}$*=leyKW%~XfE&c9Wq z3#$@sR%*y)B-*UF`cL{1)ak}X%84F5Lig6`RR_cT6q`<**R%%OiKGQB!YpaV{m zonSXJ=)BQ>H=WGy<_uq1$xoeCb|ky23XzpS@+FsmIszttESV}Rgg@+x)-5Ys-`6cr zuc~(5?RKaFGV^zvZL2m@Z>>Xa;O*kFYLiB?CiA=L?Hv@r%-|A?>TyDaBmFrzlU?Ru zfZke)=i`AR*#HX)4%mWQebIw^`gI4|^_342YB5tBygU>Zc34g8g~(-ATE%3bJc1d* z3eJdCd6wpXMQ3aMrE)7VzLX67$-NS=`*%7k1rOWdutmZB0nQ#sMNqieqrmIp!2I;E&g!j@nsgkL(33 zd6aC)%NIvv5%jXYk{^K>8rcVUCn0kO@Vu?<5Dzi|i)tUQ-^A;W@HZuW!2#phX?ss(5vbL-9 zJ>+x?53nhU!F=6j#d+JBXT@tUrc3TH#3?*~h_z4tdVY6z^Mfn%w%#oRGV9x$;~)Bx zLN~sEYAVdKX3i|5XPqU4j;i7J|F-!1%jHXJH~%HIPBw5gUCI$N$h`jL)z6lhZ(iQp z7kJ`lFYxl0-+q2&PpH|VGh5+A{T@VCud|h9bKGKMn&V#m?nVBV9UUz*QXSXeVPVt@ z2)x%VCT8Q?GR4Z5Ejv0gcCvY#0XlM+FP`Ld5rhxMuWz z;75&)Gne4xzeC#|3~_2Q`}a+46tI$8G-MZ_z_wXA;fPMMdx(o?Ed=nY7;;HLYpTW_ zshBxWOv4Q$`HijlX}Vce5sp{L@vr~+BkZZ0&7y*&Mqt7|6!w&`k$cQD(?aTcNLu#HZQd3_ zSqaZi6baS=_FuR$hy4AeQ$HJ6hnL7zfEo#YG&bXzmCi+wB1Z0x+s%8~#HG#8w$_9Cn5Y>mh=`NfD3s_2mG!2xZu26b0@Ay9`Pqq$lFnYNSM->eo4d951sS zw!nRj!%XW8@PfWcR-HJHU{QAv$fa7nz%1@S@e3xR@G&@KXNZi};o^tgWUxu5B}MGW zf~Fn%{j}mVzJHoH#r{z-t(wwJsx#DZd{_NU%};~QWUuXuaR}ca{T$Y+0j0g6HrHsZ8`${-s2_;sBpM!2MzagezbSP@=xzETp z1dYYn6ShXEh=UQ5XOdS>qO}?YPwU!PX&R}Kr?daOet(0d2d&%k0m!5YFZKKYAB(An zt)9zb=PI07F$dyId1g;pxQeND3t|s`u^*_q>Qm^dHNU)rQ!<477M)7n^#ZE!mirAdX_BUnfqy3*tE~o5OeUk z%AVL6P=EA!GJzektz-lSm4zJyu+Byo4bdToZ%>4NJa$5KCTh|@pOhofKmP-AB)aR9 zawNLze?X2z?|o8^MDKmB9Pz7@=u*cL+$q6Glv8HqI8`U(F_Rj*7zj7wOvv)`=bpPwD9J^VGgaSt0K+p(qOLlP0|gjRO=>kEh3gEf>V>*&d)kETKOFATE$ySA z6lylM?t#si-qK^Z0q8#P(d~0*@Z@rOdr_Py{di+$5v-rqMHV@b;CVFhw%$aZEncj^ z|8=Q;NTUd?V}4pU(sRGeZZF@TO~GZLP40^nb<}VL+jOo@0&gszy_9JC@%&|ktWIuk z?ncU&ra}TNTa~|l6dt|+1P{cz-EKvDnZ0au`4rvxIY*LNU^IGpw(e^!)_S95-C6ZX zbg}+JYL2MV2Uo=jn2%)A#^X74Kh_E%&TGj$le(?p`{E!cw-_NUaKDFrj&q;Pmn}mA zD}OJ-=Of(>zOQ&6WO_zVC{@oQG^YdUfLMpz8Zi z({mr3$+^@bKC^oIxwj*zbhdMfh9Z+T``Sik*HCyKJUDOFlKXu*c!FMjMBi-ou+byE z^sDr5BRBjm($YBZMlgI^(GktD-4!EL$AA5T@!h@BEFSi6!2#P;nshb^MD}zF#Dju<6FpHP3L;@WFq?gS^0waI=Qi8Gb z_y$oJ;=RC`5e|~+>RGyf0b(T>(!+5CF~J4_BS}E-5!%5TbnZ~mgBjpK;~b?v!g)V) z3f%1uE$$aYdG7KG(B~}h8<@Yi28I#3nm}yC$1JkUpc`5ICp9sC>WL-gn)Yc${m*LN z0}9K}tfET`yW8YKeko>h#abMH+DJC%R#BcOj*!TCRW$3f6>q#3M-?xkhlGlkj70(% zf5F$bs+15s_b>k#?4-3#r=eC8;FFa>L37S4)Akf1eeEiI=HaIS;D2KA0RsJ!VQ*l3 z13ayC06NFwbI6x)H~NRn-jBUmtg_m)v+(sFD*Y#6S}R(dlYR>qI6JSbp!$XJz8CjN z=mJA96!AiP!mtQ;@L(2`cbV)L6E?)If6%SA0A@w5#U<8pfsf)5Q9+b6>^>qK?Nj(nK@BHIGW~}(X|p~mQ;Qk&V!d8r(ZF)Lt4n31V*X2=di1=Xkw+Ow`XL2 zsqbCse}(&!XsqulEIKScy|AwIzXA<=y}*M;ijTOqd-f`1q!8GH66NtT-q$pvw?yC! zNG1<-7Yq~uzW~p1Nezx|_W=(ie+e_lAC%I6j z36&3CM0;>=$wa{-7Th^7uDXKGjR@*5hjfMJWbl2q2SrwSc6K1g(v!h3RH?qw1wnCL zRrvA*gEH-{jugr_5$DHQ`Q;W$jz?YMtZnUU{RUj9VRtgwR3cQ3vexVCe~qj1!FE{N zqbrZak?J18NLm?{eyKWVKPmJ+r~d8)O^t}8_7sAq`w>vxlP_uY`*NhXN7hO(4SRB3 z2q)Y?etRn~BJ~sg=^4Bb_+lAMUkUEDIT(>XHiiE#t@}pjzHLr7XupFU;7|BRk-a&+ z>6(pKSP;FU5FTKTb4}}&e+Q06as;*t%81cIm~GQt@WUt553UTRrjLPTb$*rn@AzKr zjC^j|lq1~@l${Z4DQHA*&=qXO440F#nGfMPyz;j~zbZcMiua`8VmfHo=qOTfLS?F3QrbeR!Zs!irN|Qe;B4mao~t2kLEmt zA`%F$!%0PGix-MQmovJ30HXp3@d9tSv#r-d{e-_XqrWTS=Z`T8MYkJ=(~SPH2Q8RC zlHHk$)w2x+51fQm0E94P&t^dS=b~c>lSHadCI*Jw)Ur%7&nHmFHRwyr_Q^|?gY9oH zr4PxOb8oLI@t*)^e<#QN)I|>a`D)qILVHAU5O~t&*9ap-cWDDH(7}!`PyKW6bh*u9 zN&taxh7y1(r_CZymdb=GqM*`H3#NyZj?+t=WFcKK`Ad6xesLX!INhk{aQNDKe|H|1 zpX&FP`AKd%yGYJ%uP?(OsL=}Oq>jO&^lqdax656i9!{orf77u1i4Nhw*CU*%OKpJX z${WP7o!YE-Ss6PD_5H;Cnu5>_ft3;+gcCC5I7=onjkKW*^2AnL6Q^E?D>B9#$ku!2 zLmH%K3Ft37W@Z2gK?v@{m0e__K9aLA1K)UF&~7^j2HKBtlwLumPsbdRX3{)E`$e-wkDkGAeD&cm=-{-XnPohDu%iE;^lAQ`$i_{qR?7D3gs)<=f%G}}0! zH1p|8;{pk?ppMtfpo4tUaxUe^Pu-}>=JeOm@?N!GEW-STXd&mj`&emSDHxsm`4!1~ zQ@YY4O?8$RZPbO(WzXn#3`q&jp~*Rz*>JL$9zLmgf0b0DdkC<(ftNewEu6hqGDwGA z2C2-*JE6eo61|6H&3YZ&o~WM!3UrC@#Z3CYt!pI$wxqWu_z*@ z9HAcxe<9RL{SnNtzFi&5Fm`1))YIEssVk7?ckiZiR z?W;!Yy0|%>Z&DRRR;zoeLTXP&Do_j$PX|TXXwMwJY>z9I99q7Wi@te*zN}Uex?y=) zf0Kx7-Cdov%$kSTlFj7u&ukd8ZVV5ZqR>?S=EB~s3FJ+3jASbWgA5uZP}9RDlD;#o zVl-jiWpwn;=^X5(vU2`lE>@)W*e#<*k@vavSv;v(d>&KDyk3<4ow;Czp@x#_4h(dp zy3};H-tEBCyj+P#>utP&pT&Fw52&uJ^?wIj5czd7{M#bdy5*FmH z$%5v!jCF};(NJF)PF~)vA$Yr^e>yoWJmE@4#@h`Xgu!@Y<;g%#_IJ_UF40^K(-aX1 zH#2s|g;z$!M9X$i6@ZD02Wo&QF18!!eJ8(_HrL^Pd(8E2V_Rw>$!)p~WNlNqsb0jA z8+I_vUe7?2J0rE7VbDILatxqh0_-3fWB0f#C~Q5vLiQNiKrZax2twVX4hfMD|_y6NQdPDx69ouLZ8H@-<0pLJsCYH_QOwaLx zHVU9GVY!y+Z)n@TdE?>8XBlXcb^zGWoIIe>+#{fWc5rw!K>onrV?vh>=m7=+x0e;@ z0cL+8d9>U_n*DhFOqfO5WWWAwKlcF%&qM>23)OU8%|lIxA^|HyLpQ_Tu-QO*NmhW) z1+QL|4NUiX*XW%PX=y2yEg} zv>rTiv{k)S%=Xqoq0VrN6%O#L6!@ z<1k5pYK54%LbDP;`Vx=ydl+feq4`y6`@ubkkOAwYC$iI`4MbJ}(%N(iajH3kbK!qx zBAU|*qk_q@m0_7*rEVpQ4YANy!FWo+K5AzTQ9({5`5(zt(!KI4X|?4CP-b0z1Zm{w z2e_4VuRLe99xskDWl@d45!m91MFDOYzgg?#Ir!El0|hNt0ckS7nqQf5?tzGUTc9}T zj=&F5W-UC|`|yh18eK^WcTEI8*Oj5PahAwyMb=s`cbz5TyL+mlo8O%E@{WH46D!|a ztJ)^G14`XntlBfAi-s4rj2;|Kp(4v85%>a`IMF4FP|c5)Nn&^kWvSy2Dpk4$Q~Wi; zddk-`pt50+VYdJs7Mx4M=)zr8i#j^_0a6wDAY{BunhY)~*49o|CDPhf?GW&0Dg-Az zvgK%1X-HL05_SxiVOaN!+M9nOlm$VRW)bqbm)DYNhJFx6X2l&*RJE|j6EYP0di4}c zRW7f)rKQ1>4m?AM%0=#gI#3ONG>W^cc5i(5eK*%PZ-V!ILOABWPat{j`vgefsN~3U zXCxQG-rc$YQffng13Zbz<8eNuSkxiXMIs9;1>r_I9|dCjAv9l? zjoK}!Ep>yc3=9V#q$rHud5d2wOLv6{{h{VwTt`svHi=@NB4aTP-?=cny1Vq@shKg1 zfqn}<0Q6lL{g=Dz0VjWpx#mbfp}yomq-K>9gc^f5G|S90y-Olr554FjUGeJ2w<-2E zg~96^dk}Y;=<_DC1Guf?%Po7?#~Fg1T6x2S2ewuabqN%WTy&0x2Njp|K`i88W#Nz$ zE(S8Nl0}8j^#o-li({_R1At#9^w{Nt)$DI&N-}t6va;qr#kYSeN#%lIiK7D^%!pgC zy|SaXLV6<8LDl7GKa^Oz0sZ=l`qfEcNoAT|XaPOTwW$LPt=bYKX4&SoiVY9Oou;Lz z|0vB!3&lffImB@&;;mNA>O-U_a~p~|mRs`VOm2(Q1G$uj14qBA5n&`6u67)0?f|08u#Q21my>(g0B)`9{SyY=G#4~nWP`^CGk9H7#hC^&Ge8gpa6rJ}Gm-*2Fx#ON*EeVXj0q*Jt zDwz?4<(VDc2~;>SWk*q`7a$=iRB_O;iRsqe6Vhaq>6lZTKKwquinEaMEhgUsXPM5Ik9^s$>Wq3CHOSEbX& zwq-QhvT3U=fG{jwjFeJzz zs$DYB-3&Zss4Nn|S<+tlkfsb0B#lTbH%9Kx_N2e6RlbZ~XtpOyoFBW%5G_u=rUN9z8nbR_}551;##aFPALu0Wc>__ttDIlguzMsL~TQC9XVp zS7yaT+O(#Y_mKNdf>U%J$!Q_ozhakz?*S`+_;k)<9pwV{>Y|Lq;#}UWy7uP6&+Zl6 zJf)(Mh^rbUoL-PLOPni5NGo@!{SQ9SEWKRD|QvD1!=&uo_O+>X+Ht5-8X;I$>b8le)~jrGIBV7 zCpQEY_X)$tJv2;NZ!z6p%tnMAr(IM8ANL6rtCWt0i<9Y%PXIO(V0E=rHdSPXl~iR~ z<@-JiEl$5q1RVE$+Z*~_Z+y(@HxV4hjaU0kM1a$QawhZChBW7< zJyysp4byHaDoree%Qe4y5r&Gm=nKDJ&o_u^SX;isnfJkR{Si~~wl{qj)WAxAV!lKo zX6^-sco;lzJhFHj;~XW1B}fvnSpqQqTI7~pEI?y|ZrO#TTzbYtMF0XKD^q#lHicIt zr73ppzdLYLJ?NB};`^f(X*j9FmRFq{ME zfdb}=@=o(6nr#$sE1pAWVsRCF6h7>8&@nFS0_ZnAPZZ=AxHT?Qx?cS*(}VR`G+^rv zYK%?#K&K}(FYBpP7;XATtxS~RfB(ira}5P|ADTF!;X+TCD52*tlR9C45=`n6j_bHxtjP(j!qgMT-Yw{Y~ z-i2p3Y_fm<>p1w$!un_s&e2TPKzwcT|N4tH zA-?{bL3{n?wN3oZARhgHdSny-ZV-Pt`q?5L{ba&kTBO$|s7*3I{b!r>#vuJ>lT5Fy z*T34dUkuutH#W_xmcQ9FyIP#G*wuoi!K!al9bEeq11*Y<m~gD=M=<|vGymE%|7T(-2gH9|SNacX=;%g-<+!b2Yhu;_DwZAN z1?$6X@`WHvG+|#UZ2p`|{hy&fqq-b}+FsY!LNSn9l~U=KB=i9ke-#F_i4KUiK5z_LsomgA|(0+t<3cc0rx z?`5KcrdcgS&1$1I)xPTF3kDYOyia$q4FL<@5IB}iegi6SX!F%M1k{}Np*hWm_G)<1 z^vHgRPA3^TE`*}V)IP`rvrsO}oUrT`5374~lX@c)%#yMYEAst(Kr4 zDhqI7#7ARNP@B7X{A%R^gUo7^ZT7TD+fY7mj*ZOeH#;Y=+_mft(|dTn6yS`!@D5J6 zlDU~y_xB>Fe+I7cMO0QWn1;X{bzZrdzW&L|71TquTZvYdOy1f%3;PKM!nDZKh5?XIlV>s-1@dT{gUpFEhkD0lJI$^8 zZkZ?p)J`$L1>(+ zkve$rmmb~(1&eH<&@mTNY=XIMjCzV`|G>9oSF(v3BB4S)V#w%5-FoIm*d#tv zGyO5!sdoU*t^UPONAA#dcuylkHHO;+Xx)$F-8K;2(V0s%lRh?J&O53)FQ#W z#QWd9w22Zl_DdgO720!2duS7y_-XZSfjm02KkfMQ^KxgH0NO zJhaf#7)M!Zcxz&`L-nQgAVhYbF_@9g;uXIW?mEFPB9|`Z*W(#z5+=HeC0JCi5R+|0 z9Qooput1A0rkvTMOF7ze72asK9ncOdYPieFZTqmK5l(iH zc?kiCvNqV$rDcn1-=vWejnTDS=-CQ)sO^3PRp^Iu#p@g58Y{X>D!@E$_V3|GJE&b= zW{_*B$nF}AH_~}WaYVnF_paa(lbYc-?r9YV^T7FHz>lBhhRovTJ1dj8sn5yc*&h`# z9~xB`H#2`)pChbVrB~z#`XN)NnZ*cf%zPSx`ug#u_(Fung zu3!q@WFn%-Xqm%R`+VGyx{U`G>cqL&rU&a+I)}<&$BEq5`-Z&%IXViJ^R8fy7=Z@# zM;UL1)~zPMbYY^8u5<(R_-JElBUBBM1MEQ5kv@MAgLVTo0oNsIcoVzRT#*~u9(A<& zd*q0=?@^uFkyJK2ABKIUUSU^nyb+zrob{pU2ucL8g z!Z=8*_Fx=oOkLnb5VwuN&ME!XI~i!RTrs-49w)j@1!h#I5xAq>T;Yv&@d53yqK3O3 z>MVb`Dm($Dq)>i(4-+WqKiddRXT;G4Q7*)jQUnK+LDeTqpxSU*Lp)b`IKf!L?B{%N zBacfs6;>U)xA&W-Qo;Qeat`(@?R>YuBF4>o26S}$nb}13I5U&cL2jW3kRnHVEZoR& z55mArGu#KY$KWp>Y@em%OVF|0w-Yh2q8oq2#EohYBa_jIzy=?k4_xSHeMES2Fso>^ zJ6XjKAVqekSh$hy6bs{@$?RqspV17^F;$D*O(Qfe=r?l(H)+AnkA63B9ka?JXC@6A z-GTZHqK1HmAMO7J>}cn=Ko2i?xZC#?GTP^dBKt5Rd;2)hNIxG3@b_?|;>Wx9d;>QT zO|Oj0zOaf8xvjfF@4?W;jc!$%M^wKg8IE>PE~D{NDVxoAd|bHi(rO%6OVZC8MUB-^ zJ_DD)`T;)|$Y^}C=43FwT_f<%{Fsp~n^j%W-AR3y7W)Ase=>?$RJ|EJx9=Ui%6dz1 z;0QZ(*$wCW5}$v-I6Q1U4t;oZHqRQY&&=zfQ5?<#h7}klg0`=0qppB=1OMnfFoPYh z$ffy2UJrA*j_Y69>>{9JwX)B}AJf%F;U0tI4uXoY-rKJb}Jv4TCdha8%_Vf^@mR z&DR_IiPND>_`v6`HwkXgUN+V6E)seJw z?QyO3K^&($de1R4?m33Y5~A_o`LL{PxTni!D<6Ukf9)*0Vn1=?PXAH4%(`W=cM-E; z7tw#x0^CP3811ZOnrOW0F=INx5JWfvgG>>#DDylNJ#hmqNKu(=*dPjnM0ilAFO&*i zpK&kDOFwG@D#G%nB7lSRhZ<`s@M#4bc?xu+<2@C)qHe9q645kP!Q!2v1vdR;eTSjL zDBzgGex%8J*YV7I7F$dv+5j5Nw0FnPWk0e>}ed?)<_R8RK5zi_1{D6BHI-S zR@KZ}nqiGVOgpIiAu^s=CH{iN`$7CzXYdah(FOhiL-N&M3B2xuGz(3^jPQYM{nNC@ ze|F)d^)A7BS66UvXtb}q^}sn_t^mr6*Y&#d>?hCzkL)MJ3paE>fiTWcm*R3@>LLx} z8=Xr?K|bWGf*|xAw<~ivdkl|Rs@2VTU2jpiAXapP# z8yUFX2L{_|C?hVQPn#SjHOnRmrfeA#pWwo7RhH6=tJ}l^WEw`m-E<>xYF8&8v%=RO z9P=rHC^s8nP<`JUMv{xg7?M~}Mv#s%NM;zQw+^$aO~z&&bUNvy8*dl6&r@h`FJ}R@ zz(xK^ey^-DMBHHQVkYj-b$;mdrcad4AY7%75E6EIEaM5;vF#ve)5-KBm*V~bMSsW) zqyDkBKqj^`_iAwWtTPyYskgUeG(sQhWoqv)hcaW-HUkag@S*dnv7O*HCnh%8=JXB8 zn!>?$N7V_Mh(biqCJc=2X`f0pzFY_7#A8%5JQoD#6J@e5KgL__c(;fdoaNS8Q0HJ6 z2@b7zFYgg_+SzZ~v`aV^D<#Rw-vfv4-rKK~94M?NVA4Wd#! za09x+h{psC-v+=FUjL#>tK!-EGdN^663UvvSix7P!uI3fVPP4SLCKk3qkn-98XS?h zBSRz-C1RjhNXCF+(8fjxS155%38F?0o5(K?4IZ|9Bb{E$J{=`!+#lcg; zIOND|Fb)tg5%eIT;aZH%0e@~$#>RgUIsC^zF*Nc?sYXX=5gPQ*fD4Ux4({NwUOaE8 zkJoF&C?_8W~^VNe46tiiK1gS(hpS z0*wNp#Fx1N0xEwbUgS|I@~sJ|GO+J^a33uNNNKBMe}PJD)U1PD5!An+tyY#j4G%)D z*H|32RE43)$FWe7YXpj3uCrK*Z3N7nf>EYg3ec{tUpV-U$V^N8yEV-RT< z=OJ|;kHK_%I*%y3I*TWt#)Tl6umRMq>byNGpB_dA+=hr%hihFE>8@ywamOt-P+15N zqr5LoRu7`|lM^>|Avnn<iNLq(Dfzy^^VU}zs;kyI}j0izv(1ydh80><kQ}=;$ zs}7k;YnBV9Y68&;e`c|C9go4$s%GKr0qlokDxJma>wOH6R6h%+TEGXk%OU!_(*id$ zI8N@F2}KcD3|jddjGj$GNVMKLBt4ackeDjxp!#_vghgtbLsFB74`;_84S~>~*O*#2 zseVXW|6Wwx2ywu)IlRESP2zx=*6||uO%w-B8p{i;S}Y7(f3V(027#HZJbQH;kZUv^ zQ%_n^uw0Yzup=i68S|#zjkaMe z)5pL_3vzg>f9WD2NyBlFs>LE9X?t;)x}hRL-Dct-|4?3fEMQXxPeo1XJ-akz#33jk zKbWtQiP$NG#TkbeQM4#jjHV>iO`!rVhDWH!V8+W81tt|&-@DK5fnH{x-NU`iKD&o| zseN`2_!9eUPy)1g4Y&(-49(9X(4BP>wivk^9YKW`5o;@D;kP01#A9VG0flYlsnTLEDejAWXMqW%c zAH~7aD_9?_u8t^JIyrf9_52hE>`qc%SUFFHK#LBIB52esMNol{NNbr?KO}7~FRE^@ zIAGdfe_mkSVsXGslX;Q*Hj9HMjphYbtu_vOTe-H|n8NIpG1U)2_s9fA?~ublbZ1O} z^u9O@#O#L&QokDxLy=uD0aANl4yFPXK@J{BRe0SR*u0bCz!EXpj^p9O_F$HUo7C_I zSp*5FHD?sucmd-v`5DVGCBe+zmtcp{L&se;i+L10ZBgA z2dO`O9Fh(yAEq8!5uony@*paTT zlWDJNdQu3EbSw{|>Q`}~^z(U8RTpufv~zh-b>E7Eb-R}bRK>IKLZXGl%PE-{xbIgp ze=(rnuV`XGUqaQyz<#f?NkEEs5nrx}Y`7O{A{+FDn#cxyu_m%%U!;i+n4^(BdJ?7B z5wr9`Ig|~U-BiV67Tn9N%0@V@6+R<28!DoZae&fn&e@e*&6N>1?e+%E2S#@#9z50Q zBOvLn!~>~5J_3^2m3T09hmQayyAltim#+r`1p*Gbm$nB2JAYnimsGvb6p$V-PZ$HI zcJMrJf~Tvj=NiX}D9|_>`l~4CmJ4uRaq8$)h8T564vk;RL6I@Ups67g2S`4c0n}d} z1xQB~gQy2p9HcwE7(h9?SU7ax#-lUvhI)zc)@~%9uVoFW@s1J$c;2?b`;iUML$c$g z7(+O#!n%JUkAJRHV<3@58H19R>2`@OgkzvPU`SL%3xNz23ZlkI7>9fo#ZjLZ#-YQ4 z0_jl^Msi03#gT)7Lz!*E_i-37%hx(6JKo4agz^&naqIo4J`9}Hwt_RP$FlGuiWZ0Z z`$!#q34G!2QFRnTxTu^PE=cGdOvw-!XcUHy01uKL2Y(?jIt)BudN72*n6cnN>qkTg zjtmG7m>L#dD12=immC;3b`vOqa!_^~VQW(Tkfb^MsH#CCfl1T&fmP!~0@G&lBkP8W zgm# z8)T0IeZ;_EFH-9Q=>@A^;Jsk|3%nO?1cCTHEr9_DoXJWCC7{z34OY-2(7A~L(X&zj zhfYNdj-G@9ILyq$0QGZD0EtXA433&&0w_T>ihmOH?ZXtp_+~B+XLQTk!-Te`CfETb z3qr!PrZRpiTw>2LY)7_sCS#S#Z9toz6az~y@_1x@k#`U{tq&etU+5hK&Rp#A=>5gs zLGa{)j|W#5d;w@3!$euNxjjy$GVQ1-H`(XINDFW;|hOqg80X@J)OcJr6=fpqwTs%4JcBPH`DC;0jx(-E9-6jA+ zK8b>8eUl%Ab_t52yF&oRZGH+Oo8AL4D;E$A1ma1ECpE^JK9}6kMDhg=e^c4Gaed%m zbcm~7wXHkF=v3M-T1^>6B;AAuRb3?>mw!|QkE<#r9+!3|9$0s%cx1PK@wl>&1yC9W8cY$AUkh{Wqm99!>6RFWa~psGleO=Ix`p9IY|y)wHA3&& z%!k03pm(j$20ehoSfF>U&jdYy!x^A=t=9rQfJE)jyVhfX&ZEF9C2R4Z$JkWbv?8)7 z#-sGla6MYwNE^KnoeNm}rNTFb4B>xr^+O^>Ww7pdC`eQOst%Y)u#-wm2aeh{X4z$}a=& z1+`uV>Iu2j6}(GX)em@!o0aaVQm69PI)W00W6gFbqG7rd0m|C& zP*B5;Cjyi==%J{lQBMRcXWc_V2MZq#@_LBbZ8wh@r<*pBMfF3HCi0`IW{LzR&EyAG zO%(}Do63)@n=2C9Z7x5sY=5#KY`$9ZL4WN!t6O092AGr`6e$SBAgh@hG;$z8on z=HsXB2AU6yh9vgj>7c~pkTfQ-2U15R9*1Ot5_>TFu*Bn_Br357QpF|q0*VQIbtyy9 zy4T9;-9&n?ED0ef=6;z$>o1tc0hx0r!7Tugh?7$kKwN7+Qp)jXYuhnS?hn!G7CZ;}&==m*X?m2K$i-EjLos$nFF!n{MPnen$x$@eQ z$O}OYhg=A4GvtL}OoUvF-a5z&LX3f22xSN40jMFm1b@^rnwb$?<-pA_&o85h^5|7}<49j?Q)a z`67+;NEYdS7{)7^BK020BVdeIG6m~Bl1IQeuVjigcqEU&QLkhQrah8bD0$$H=`B(t zGTN3FV1Gn6iGT%bxQ7`ix4xcK%MQQEnz@<>e1s0D5cWoq9oY{sz(UfB?v)ql7%C+5 zWZc5b;OGanm+uF=@>a}h;o?egj_o+r*+g)KaIUJhe-e8Pt~7Z)pfh-GTHQkU;qtrL z4fh%$0$pbHIs>axc*?gS2!g1>?6iu1jBhvS3KB^Pc_1Ezgp%nR#w88%|%sk zVABN(r4pZ>CdfDPS?V5nJMbnIyje|Od=WK>98tJ}9t$fbuNYKWqmiKUGzGe}C}i9* z-hT`BNPK+d^%jgEp3=gLIz7HTHRFFnuMz^{DfLvvD*W5ht+h$%~at4pXD5_e;dw+&CpvKqNC_piOofZ~pOdHE|7J;@i1EO0x zfJ0lG!O`Crz+u{)0qR>lfJ9oJ!BK6`qZG}{#l$6G-yd$3Kz@I)RRZ}Ep;ig#_Xb)8 zz?X}JOTb>FVO}~4E`fZZhI!ctxc&Yu=4{dyP3&2d;6_ZL2cYBj-;cK?` zSFH!fQ@M4Zc0EXJ{np+JW>HWBHD=h;O1`imjtLeqv|=DXWs8&aF;udrYyoGZ$1uQn zX~tn_cmhb=un`KL6oCgJy^+OH-G4C*MZSxL8a^=qMf)U+r8{L9%gk_Z5Me7@=rO~ zP32>7uyz!-9>@f&B~={TJy7_86Z{!K;6Esa&88RPLQc!ML zWNB$V*aW{B2`sm=AD=;@WM^asQ-hO_Z?cW}G!eiNoy!~JVZzbahl zLFV}-XEFNX3k&ztue-e7lk&Hg;_U8XvPc^Y+9RBvH?&0MV4Vi8l?+sH#tt|Dg$eYLxY5L zUsUOeTJWP+G(OgqJON^Fs|A|eMyMU_V$VfM+`7`?olJBc5-Nd0?LdL;%=O0=-UhZA zS0F4%(=B0V-Q0IYvDX!mSu^k*)G!1QulHx5qK9{VxV-Oy1fS9>*1Uh%{{TZ9*18(+ zK1?)R6w?wLW)y9wS$i~S7>y_@3l=h%#oKCG6=pTTOCP%<^MrXizd14sfeddPhBp!u zZS37@KgsUXuCSh;kSW3>->PkR@vbs20c3*W1N`|LvH!gV_tL6#^ZC%6!ZP~W5bv5v z21%3GW+syDh+#Xj**`Kj63&>(;f50(;daz@pxCTK5sG#TIzJKz>!bj$RqH^C-8+vKpq8#j%kU7%t+_lW?bi-r*3C&Ql_(?R9E}u8*6jE&Re)Gx?bYew?v}F zKHWBc_7sU0`+U87?PYI}XtAHwsh|BGi3a;WQ@r&y*kwX;z4(IhK-Ak6ro^X|&U&9x zIE~~9JNaq;BR_xtk;~6C<{-khDN4EhZV;i}?^DXok4nThO@2z*aigSs!^e<13Q8Th zQl2kj2bAVN^7A|Rp1D^E`KMq@@@PbocQkDhah7L= zb!2YywjeLvBzGd!I{8T_1N*oqnNJLwbyOnO9+Ll}qNaZx)f2ev6V7XqYOtlSwT7`v z3p~{XbK~2lHF;wQhC8GtC#Zum&&c_TaDJGbm^CPmq@)7j^^Ap8^mh=hX$KZ0BH75| z0jzeIBkp@lHrPu0Y_Hj_oI`z?FCToYnq<~*y-v&go7*5-;zg#yG|&b4)ha;50nt3VaSn zNrPZYfHnYc7K|Y;Y`xA^`q28QQ6P=)3_Ro^15_QXUADgF`W(dBYy5Nvj$y_t*4F+J zj_oaInFN~oNmjbGIfREp8kb$9Qsy&^$|xp=lZE*)L-JeeDBbI6O|7O%{NutZ#@^=I z?gf8CUoq-#hSa`z&`eYqS++3I$U(&FnXTNgJ_(L*V`9$s5NyV>0(`HNWNDUgzK5iE z4PGNeLMl87dZRhF{s=K97uQ8s_u)|8%K-ray!aiK1@ta&;u;1bnbg45z; z`q5^oS1wPza+u&!(B{EwT$@GOwF#$c>%AaM70$o1%J5&u=?=F11q5xdZ`GMfF|D-!%A-k%#h%5K#K~JryNaCwtX3;`DFlBD zoGQWKj*IH6o!7EjWvj^&Vlu#X%sv-LT5_yu$-LVMyJ>6b-R^)4>*OQ@@G?)WRZ*lQ z$32rrH2>c!$iRvehQDJ3(`K3HlO^c??M^(MAJ1lBo(@a-CA@YQja0(b8N6CTkgjC9 z2Z-D2xxEB-DbQd{a3tKQpm#92t=oU?tsV1jkGFfD;LZ<@rm!UPxJpt+2udyz~ zbeh>~S9vS9m1bvyM7623Mg;8OFw32NK!q1WlT%TY%NZDF-=uc8-saY>v*&-I6;qRI z|AASV1h_k57<5I|@FJ&z*4evGSa)y)w)XmSDpM12^Cp&p9>%UMahEN`7*PUB^mNz0LBXD(wIw5Pj%ahweo)OOC!QS4SEB$w0|B_q7Tl^Q2G zCG;0uy(R_TsQ=Yz0r~WwEsB3ejm2K5H38#-JPz0`=t+9sC*qpWLQDJ=W~dgtL+p#Y z$CT)FKB}Yy!v0hf>|J|KVgh@vs$4akq-cS;q+?eB$<`S#)&W=^q|zED@&sofdySRr ziT#Wsq$H+hJs7q=umA4z6NBiP~8Mb*@rBh-F3OZ%<9QmHPR~MmNgo}> z2~1&flI~sn$tr>wxZ4_muO6iaqX@OXF(>b3(XD6<78p`MXvlzCTT?yURd(Pj24&jS z>TwpXzv3M>S|3@p1>S!&?gwbT9puc7L{8$@;yO@0?oZ`-3jw%~YY%JG5O^bB}W?q}EwXIyP;L>Mz^vkak zh=T?HGqI0P=F`ay(&pz=q?t326nyjB&nO80qk>2SL8MuW<{U`V31zeToq!$Q^%A)6(1<;V;t~!!@(BFJpxZ zo>2ngljr&npAvWBnRV2+4*KWro|9^YpBYME9$Y6LilG#lH`ps_UDU%K+a zR6yZRb8LUr>AkhNK9LFLHHq|}HY-7;I9DCcMTbQOO@_6M%tbXr`abw+Am2MI_ zbHF&X$Lj`kbbMt=4{P`i*fpM?UR?ipdVX=8fGVeKQ32^3l+Ns%PY=%Jbas)P-Ckd^ zI9-1!jxvtzy(qGEfXB)a7ef;!iEuL+OH-_V=0 zU^Cgic@~3}56m_cYKU^MGlrQg5^y(2_+fu#m{t`ZGlVvmadc@UO)Oj2Y@o_|E0>XS z0XNo}!EVE8TbwY=vghKZ<)dk#%S{ZrV#S8Mn@|0s_~KbB2Ir* z?shk@rn&djOjYi7V)awk*A7Vj9v?~w$xm-)4RhO_@x+{R*m%BT49D|dz7Yo|UDT$>tjQ0!rPyIv) zihak%yK&3E>l~NAPQ)HUw-FUXM=F2P@kFOW;@Brf_Zs9@rkDb72$eu0)TC?T0o#YP zSb{#S&1Rs<`(WuoZwF!`bb4SN)%1Q<1HSCP-0KepyxRjz@J23`@;yngXf@rp`tMe? zdW-%R`W-FOA-?V^+#|u6;cW_focnuQSEh-{LQ$=Y?XTGiE(6lNrEmUURVTYWWE<}ha zR8y4e3b&@M3iyW}bf}KTQO@M&II?86UkdvAa8l51wy50`!J~v2M55A)l9h62LH{S) zXixYCi*TRTdHJbCA18s;>8F2eGb?(M2n9NfCRj1<^L1B?9egL8R(0#Lo=!K)lgZqV zixe5QCVfhC^lF8%3lA>1S3;u8I?;HN{v0NHI`K<76K504qgA>zfvizhJ3^IPYBfQq ziKt;F3eimz`M-rH3L$W(ouUhT~4Y%vQXdJW1$#Pf;zs0RmVaCPhwe z>k;!^o$ZmtgPOA#98u*ncM1Abh%=9x3P=n*Iv0HRQY`!1kR^$Ez=y8%hrINMy9@=o zWP@CehPMocwj2#=857VlGL&UFjOAzmOHc61k+79~$jZZlRdRoUDh~-!84gc58ko`> zl=2zjD1)IW4-Y~a3P5>q=*d9X$wLE9js=^1UYJQf#N@MsOL_xK9v)KC7fv!NkR;A( zqKyKIJ7&|uoWq%|3djoQps?gxvN*?QQNFO?O7T#2A#6%797`cyV~D#~vOWQb@IVhr zlXu`i>N@S81Py-+dmjMBFNdBns&~-6g9w+6djBLW4qbwI4MnIe&_=APN41;E@G4mu z0*>|tMvM;cGNOaQ(X;DWUr>>92NnSTj(0QWo#j24kfuLQ7y0Uo71hQeSg!PvvRu|F zBOsC)_7#-x^K_9eI3Xk^lE{b_@ba})YYu@CX<)VEGLnDktU%@w$XJ25={9|+sDMi6 z1j==na{?lr75LH>tN^An0-&c)J4QgJGXe$7b$k0B*P$>%b^7gF`Ut@f>MUzPXG4X03Ke>X3M>0=*`-jSa~SVj4_E0_;P+~imSxsJXIxV;i^>Rh-1H_= zDf4?!$+2~<35*C_RJT>fbcUY72<7XvV0wW{=LCOBFidE;l4%MRx=7a*Ge^s0hCN?^ z89YCW7&Jz(+HxY*mSyhXD8PJ&O6LR?Fb|={A1Bn)IKc)+F()L_slX+)@Pe7vR5~ZH z>snSoq;mqWt?4)ckjSLuuZ%#9DrP1UxH&_JVt*If#ARCVi$sXvoQMQ`A&T}`g4 zN2W&@G%7extIv1r7y?Ri$B!e}Ixf10mMLXVVT9lcyy7MvO<+X2GG`_cnM~O~fr@BT zWe-%MrH&Mf|rsMP6!Io!2ysJ z>uIdu8ZX33+=78$4s6lk4AZbEHe(_ggOJCdbV0RK8pIp8!eA9#7v1KM;2+`49Am5_ zu=T21jNwV1Akgoyh1~~XBu5bBBK>@uS6{-#xkwYq6ok42#a#6*jHG#jK#b$G=<;S1N-zbX?mEy{j}%Jt1c5%l z(wUBQbj`y8b)H_`d3tiwl}lTnO-P(rn?FwQ*Of;~5Ll7CVLwi!a{}RYXl(U4PPdCXU#&A}d~Q1K=?T(#fE`SDHTDc% zeh1T>B(vh>vMX6ZL10DTs*3HTn!*bC*Vf>QdYtaFvgAKaQg|VLQd0y@WCMSH*A*wD zD7+BNXd8C>GtfA}yj!I!To3B(8z(I2iK24=u#YP~pQB@GS91VJPc)qe@N6Uk+lOPY zI>sfRZ$>00g2;;QH);N7$F^io;{`=J$vv9CX&pFV{{Cz!zcnP0=Jdc(*Q1o6RNggi^cYn zp2UcquC`zr%T`HG;>5s}waj*0-|9)6Sb^F~*mqZ4jetV1r*gt2c*~?K?hAVoBL;yJ z+Rs-Ibe;W3q$Y~$6aJL$+4)e&N}@*;uIkwx_CR9<-(d&_&*~&-zLkIJT?L`((*-A{ zD69}fcJSqfG;eOW#C^W&YHm~rVj_sFsIMV4g%j$)C#`_%Pp&BpA}`efYBU&kdfXJ=q24_{=f!+r0LLQ}#SCs>fq?vsQYZDle_i&hpkOO~vxWoYM0=W|jaD~7? zkq#bG89xnhTpSpT%+Iqc4ge_0?1~ktg#lasg=n7!P@1XHQ0w)4N8#n<> z=GgIMS+28!9WxhruvAo5IM6=fX1S=IvikE)jf0WT8R&_oGXR&cFUYy3R0LL}OnF7J zrg((9#v{{tidKK;aQbIuB!w3O4VLHlVh~wTINid1FJDScV+A3|alsyAdlDz+Rip;g zr$EA8d6w~FipC0pc7o4L5Lgi?`U94q73+zt=+}H%`BpPY^{!)=*h0JpswZ(`Rk`F( zB$~nt!P;DBdtOY@e1d39Rrw?((IX0Ww1r1dkUU~=!wP?P0ox)Z-IE?cMPx*;QrCpX zCU7Fz)8~Iy>?R#Zi6C)e@T$+6Z3Io>gq(XrGnr)vql`_;N=OPX)NVe5Net){K?4dl z=^GvZ1GiaRV_^710ee!nz!cXQ7*;zRW?4Z!c`(^oFK*~Y8LyZ zv!3E$$(;CqCPyz=Zo2MCoLJ6Zbjoy2pao?eJ}v`G2DjOnw~0aL0l-SJYF(Q zZR-(}Bwp;ITe5evASOw?*xd$P7ChN08ZRg{8Z9rWiJa(De&2qEyNB8%jr2s*8Gt*8 zJGn%o$sPB(CY2N3bnLlNrt<=LH|3GGVrf@>ID{Hxdr765nTd^k( zO<{yS;sn_7E5|_N1oL{$F42m>h(M6dHUldy_7>V8a-w-z=DUpB1ob3F?4Rian2d8> zOj9@^RKh$xl>EA8QcYo;pCN3uG;OyJM=psM0}ohRYv+gJv1A+SD zPq>qR%{>?c*(dJa_Ys;$aCzCmt*l2<5qZ&@wAk7`m(X~@+X|*_+xvkMc~S8+*yo$d zSJ#4`+*ICrp6{zCPtKCWi`{{1SHX)(5-;`Sh?OqsDK4nA=pv% z2-OsBa}Q~U2j$tT_;oajT7X=N~Y`&9p!!C ziczi~?CxkLQ+9CXvpuiOWWt`og*d5ml}ebwApRkvMN!Fw9k1GR!Yxx>=3{i>Z*B8` zEGwM|z~V=_MIvRb@X$MVl2vh{L3JRTsJ6DU!Ay49Prx~s ze6&oYtgs1P+S+g{od|3kACMuDu;vA6SCK{-!CaGaibTo^b}=QLF8Xv=;0!)w-8Hlm z314{j2dAK#;aBNI z033oESDR@QT#@%F^8qrEvTorypOy}dfj~vTLTr;$0hvtMafz!byG*9+5Ck883|KlU zpb3nKPr@SrLvFQS({I-4M4&1jQrgBknXrS^a#`&mMkMKWCW(ll8z&sux4Vv37t#_% zqk{5*1tLS9fZ9m^`TN-W_ZLEjC2Tpv`!}M;OX_yeJe9&3bJUNV3RNMnwQfgSL}LA`9&IG zv;#X2X)fc<25v+@LqIh$qYFwRC`1Tt7eJLk$}$j$$R1A-DecAs!4>%f1|R7YGLf*N zy>6M3<3*oN1oq$d1sDgDmL3SM2x!LF655dUN_Cm%se2`VXrxkRI3W}{ z!>-b)09x**<%&vZ0ujk7y2i*v!V1&sdP8?geL4|}+Cj$6dU_;?mGGWi)Kt&o(rBY^a@PEu7DLc6LV>%Jo zpi3xWm#G}{Zo_i#HVk`z^{^)kg-oQZOe4uem-PcY2C#-p1#k(cGlL)|nn*+;_CmQx zxjDilF=8bscH4@RDwDVFYkrbcciTTnBdb)x49~=0WpoSJr@I0la}gM|B;#Qq5Rn2L zydR0G$0fO9LJ3OSMkiDDl9`oMDq+rGvji&B(zW!YL@EkF2k5YW(8(q{S_+{~YWl5m zoRl02C-fFfa65a-cS)QWxDTavv=N8G3+*zvTdr+SCX<>-DkprFW_861Dhe;;mGO!E z4rAdmb(DSvF_A=0^b)MX^O~(oP2$9Ev;D)D)Ui6HL{3yHV&7#ITsN@!N=>Y0U?JMi z=Wu&h^5XbpWZ?#XCK#wGT#4^_K}j;j-~3t;3;NA*pKyeMp1Kc9ntF)J^NZ@LI7D}0F$_Tr-| ztnff(g|FtHSTRiJ1<*>17ZDUz2;vBOgd~L(g0Qy`61Q-+z8skq7c&YgDJi_r9(JR9 zUP_W!G1y{4+va@N?1`-C$6SQI<3)QSEBeWPN6{g$BLBo&Vi;Z}pPxZCAGw0?WO?zU zW2a@NQs$F?jG8eL5*0g5txqyjDYNtaW~v!B_SBrj@s~-Q-~D=gVlk)`EAT8jU;U`p z3@hlYO`$?)7ik~o9Rd}>uZ6&fR74Y~$R)8n$bm(1@U{;+Z?^FhuKV z49ms$*ohQzYIVRjU0Ru_ z6w379f1+%V#xTBS7+rlLh5bk2uyNj+NtN8Q(CBZ#c%E8OEPE#$Oo5UpU5J z8OC3KImW*+jDO=8e`6Sb;~4+WF#erkoG|S;;hJ&6wBqD7%g8~#<}q1N9`%T0Jz`nk zaI9}w)}J|6uGJ@8{%rPK?ri2<-fY%PhnsL6Zo+i93D@B!Ooy9r9d5#OxCz(cCQOH$ za2<~69JFgd_<`~%Uv6C9KHU-caO_TmACAd?MEK$E*kytgm#IV}?#5bx>~u%q-@Br7 zctw)Q*3}SM9BgTD9a>qzO$mt+1FtLlkmnE>5!@JpV!C96dJ-cBHd*#-+g3UifRH`+ z_Rh~Cup;X^wT{{@g%hf6-MgL62w)g@Snwa2YBOk@U1>>u9V}(WQKj8bKLu!V}`K(i;AgVmCgu! zfe!V>w)-X|My!H)lgme@GXmY$Y>Ud6H6t|8s30h1>zrv2mCgy+fdZ{$jvW+r@G^YK ziD(id24RgK7!i@outR`v%elu+=LF!b_a{z3q*DQE0N@a)$aPaNMG~o$8Qk!fDK!Ed zeebjr9~N^q~N|km`|}AO;d64fbsV1EXRlyPiUYKBkw^H3BLEpTL*>H3BjN zcW(`sGByHbe`X_+Id%+Z!tslAD)1hjf$Q)FnaT_#Dt4J}GAbcbIpzYaJF!bxIu43`TeyV{c|Af1A$(T3Y%`e_Km`X${HCxl8=p<%z=}Qmoep zF(wRn!+c5co;0&wAaqE)*xa%_b_i7D9lRcAUpzSkL*(k;4L0~;ktWCI(>seUQ%ok% zfqXGXn@TWPZf;GT8U8Yf^Sj?}tQyoQCX>8a`Vn4ax8AKalR0(>*yT)xgv5xwg%>2? z+RXLSe`LvwI81*KS#rxrY6>f4$2GCj83B1A#YiXuCvu;*+y}{YP5}L-!SZX*3H3Ba z@C*#VtNV%((-cl9|B`bOn9Q+vWqNPF)kH{)7)0BLiNs#FD2)}|E^@ZUHH8y`Ae`V! z25V@+2^v&hc*{$%T?qC95MaYnBoh)NCc+!of5D{)i4l9Bmb{Q6a3VP_S>QLywV6TW zL@QiGI5A1!M407)(ip+Z^h;NBeOD1!ks5+`HEF|2X&NV(ubxtFcqsxSas?|t*TyoP z6UaNR=0q~bUctMc1veLI3M&LJ4yUY)ATS~~Fwj2V*x^u2T5{vWLm7xg+Oa}K%2vQl zf8H+Py(6w8^dwFUdcSRf?owDGh})U5GJ?R0Kq#e48A^o}gh)!7%!uEDl_(6jHCOus zjS&Pd1$&3(5Ll6Im9i3;&IsJWlM(E2R|Hn1PM=a%L=ZR;{KOXg^f;-W#tGK!qAQXa z_D|^p*R&#;WiRabt`30_`DeOg_%YRFf3Dn3O+~gc&}m?oVl6s4$$VY<+T5ApFN^rz zBEGj?NoIUOST_(1cx_!ns08yxbL-;Oxpe(7hgbt>A7rxhX)Yr?Jega4+Dvtr>-<|) zy09wIW~GK)MxxD%yRV6g-)r+*6e?s}eHn>1>p2X8JhwSjn$4(knt+jWZS`78f3pQI zK_vm|W2ZEIs>4i;&&*Vtnc9sRi8iY{(ez0sn{^fMT*Gr$@a*;br^)<-!6SZ}Wb0`7 z$vzr7*Y@(t9-j>^n&W&2u}S6-SlN0!xGkF3ZzgHhOpbo}b+TS=C*WuP<~P%b10IHd z{pw9JJ)Hwf^6vODnV(OQ#mIPoe|dfMn}z{)76w=}46+ayxLk(+j((beGI#&_%}>90 z!Qku%{~i65++1J$6FGUzNw!>VGW^@5lc&7hgg^N7_~iUFfxq+V=vVX5-b!$wgEc{+ zBPzzs;IP`M52%~l_?oE?sF&OLTB#4NliOg8$Uy?@R=(Wd?RV}NF^B%iCG_evfetuL zc7ok3pz}ui-E=a)n=^c6C9^uK>_~Q36(TEvY%3;N{_CVTaYUUWi;4rBzG@$|IN| ztl*4Tm1k*xUUatBUn;i}Bd28GPwtg~-M`aWDR|fphb;>37f_sBaT&s^5=?oPjQG`j zetPWYY<~tgMrOAeyp&;AqsSR@s}(6(bgiffP^p23{KD2ZLFZS!I)f%Vt!v9Q@=Ro! zKxD{!Bq#pC(c{o>v4uPp0rH=+#qJ^Z%LD!9{VZI6CvhW7wW2k&z#p$c9ksL09@z_6 z@+jGo*RPJqBIsp(BQt>*8res9Cn0kO@Vu?<5Dzi|Kh-{7e<+)LU1qD9<)o}rp{Kudy}M)k~Iy@1dMvjZxHR16uD#a_Gr6> zUu8I_BpD`C$g9rsqr;z?Nq2+V?E)8-+5Ye=xzFv9I&a}oVzUt7htN2I=YB^=Eo-|v z-$O~a@Bo{l7|hpgR-Cu3c~-mzW4h!HL!82YgIN3IFXwl6H-B|y-qpKhKxTb=bNtu- zOQ9QIKs6QSw`R^Pqi3BZgpR7=_y4l^`|IUvYd8OtS|=O0nl9yt8DxI`<;_o)nQva+ z+ZTA^XD{&jm*0MRV^65rqBC3JMExE_RzGJe%jUSn#x%#h`rV8CEjv0|W~4fz#s^k%i-znX3v8Q}6OQO4yN9@V)Tkb&|^vsf;*WX456Nu zlkLj%B{;vF9#1Y$ELVbcWA4k}&8;1(z<^W3YZn9#2d_ECqobc0R!rrXt+PnaY#(e4 zW;`VE0)oN_*(WqD;GD<|=E>{7AMpZ$!U+BHniKftwU2qif0toC0xy5Ee1tbzSInmE z(X7`;WJ-R1W!cd@z9AfMkmFzf;|JJNH=9KTNsYjSeJJcHVI%jLXQqYJ^^mmencKWA zgt8KzpC}Tn1MI(WV-Dr}w@&?RU>#l}R{?4y_>GHZzC-}Q@@k1%uYZH@OV=>g)_AA- zkAF)aarT4rB%G`8AliRz7oxsbS+lJ3o!BEmg)W6~lR!{(h#(A!utiCi@F-6s&Y{Z! zPeS!8Q5SY$Ps9<`DDDE@-&?_?BYcelWtL~#?h?**Y^S9)hHZNBjEk0JjKb*k*hR+y!jqzG18G`c%R;__5a{9ZtJ%SLU~gE;X#B9Rgd>(vVr7^ebbFbHn1a!vsI})l zqu3BM7H3b`8lfT%Mo6AXUOkD{Y7{)LYh$Hpq(+|3{_}tO4Sqdn-IfnPCQW#$=Lg6v zrXIF>E{mP3aAL(0h%@DxJ!Ro4rq(TpJ@{llP<7R(&{b=Gc?YLt2>C5KmFjZ?L}*^! zw!2qSaspyyw#44l#PvY4;%1lAXBGiWK3=8>H`h0Bo>zojwR`g-MZh!n^NO%(U#KDG z;B%Edvon97=*tuWJ7inQ2n?zSI|yK%jV>CZLk@XQgnm4BLUbl-(m$V7BGEtp14<;i z>$6HEy6b;HiA3*xR*6LKeW?=htCQ$b$3~}&n4NJ$ChvQm)5FYF3bc0&lwMe0rC?OO z-L(NMchPZI@0l!WVhp?Fv3Zr~jE6;B<>hQkFF}7T4!ciy4i)TuK#Rdwm8fO5s&ITf zv)`Nbgq`}Coz;X9lbDUO#rIdUiJYUN@cu>m_JMVxhr)9@I^;gA=Ah?BV|~*IW_Y1ou5aN8*MHhSN^$+KcL0n0@Z(`;laRav~scN2~DtevG%ZSsUMyJ?b2?kxo{ zs@#7M*(jqtM6@f-Pg{t4i^wlBHiy^>O17si;C$yMLeOZ!SX5x0QH#{)9xy7DhBGfb z+6z}Q@>GdeR7a4U)bLEzcOJm-43nsW3vF58|rA^Q)9yL3zY zC@6)RjjelN3#Pa97;XT%4`jN1=>ncyPH$3=6Qv(-%p!vI)4Iqa2NFDwCf?SY$g{<( z75Kj{)emVDp>@p9>qdI+m&NVn`?D#y47AC8k)nNhq-XYbV{fE>XQKb*AiW4v&$)t_POX_~C6+)cXl6fX|TSNZhASbsNAuVvfhkc22pDdSP zLjo&*ufpde-3`9)^Te+5MLrJHjW``<+6Y7J%|u^9tDmM@qC)$=EBfy|WaH7R6K@Aq z-+!8(``|*(r55pp)zdG%9YLkDT~ag@nY7v0HnO;e!t>z4d8?M(?<>JG^ztM6X0wNl z9_giDrGFc_;dhai#(6h_;oFLiXpZf!7@<0U?iY;j?v>`}VJ|zp>zD{?Z}zO#57tpX zfwpo2ea;Cyavn9mN6qffTyChgzMq)QZxh=9SYf_JeORdRy%)09HU*B7Vw{zh@ym^s zN;fMr(ZlHyjg}{iw8;)VwY|hLCe|)kup}fb(`EJqmsk3VDN$LXg=8hHW8lX@+6K5; z2mOjCaQl-RcoS!9B%t>Q?O+W$cc|#W4Dg_Fj#8iC zyq`HA-0cqi+$ThN>F*VwFZsc5VgBM87)I!70QJ)vlLqeaI zy+r~Te?e|r^_37j_uu|8*hy=fPD8CGz$Yt%g65o8rtK+2`r1|a%)^fZ!2iI{2MF{_ zhP{E22Y6cP0CbMU=TI);ZuAdXydQeASY@?o7vbwaRQiv?v{tk@C;bjCaCTl@wLeCTxgZf1z7#0nCbAi%YEI0w2XAqJk)C*n7N< z>pf>{H05poWtph5kdEv{{+T zRHbJyi#}xBBb@h3z(`j8-P54rwz~uRNc^a{dM1BUYt?EPPqPhx|ME9QuQbR=S9eGI ze+6)wn7(GY?KK>X7L3#wQ@=B%0v+pp>I%f}Jb3AG`W0h4q=hU=V3dk`4!a7ACRTcS zdq(D$`reiPSGX^U#`><0MTf=bf2=G0uRz0IFYus|;v=r@p1leg`3USmiSqav?`xXT z`$gakNG1<-7Yuv?<^a!eNezx|_W=(ie+e_i3mMagQu)V8`|Bx)2_-f%5hySw!kb z{L?ddA@F%Im}?T;7;`WpeQdt{U7F&JPE^~R?xubRqrD&Tk0N_Zdeb!ZJ7R~|ST$q`uBmBpcjFx#fPe_+NZ(~qtUrlya9HE@2F{IB@9?2KeLZOW1E2Aau; zg%8x1H|V#uVus5}+02Jn8eaKZp$rvSyW%}5xbh9EFsjlYD}R5?Cb$1vT01LrY$j^-g0kw9=Ae@<#GTfF@f zikH!~9xMPL9t%9U&bD3;^&|e$jQ*~O*&k!bh;BCyry2cY4_YvPBwHyLt7jWNJaBAP z(F($lJ(~gPpNozmrV*(=nHU&yQ_GCZJWN0x*Pz@i+h;FT4z|C+l=32H4ynCf!+%_x zogDX57de^dt7T6M?GeSUfBjiIRU_sP-K7n*KnFX%JoV4L)8#gc`2vViGkgK)UD_=2 z7^v)>B0f}FRl)R-(s6o;lPshwCjZx-o?l#tAx<~y?HazeKHQy$<)`{_WPXyH&MuO( z+w03P2&$g~3Z!E&|GXRdj@#ufP!A{5yJ=YdM8{j;>k$sXrM8xHf8`D0*iLQM`$o@N^dlr}m2*SJ7}EU4o( zGbjw7wVX@&@l!YIojGN5w7gdb7mF~zAu7Z9?mkwU*Wg8$ett#D-juHNNK>8VMH_W{ zbJ;Vx9Ya!rb7*o7HZhzmriV{zUM1D&9s&$x;F(Q%WoGY{e+<%LH#e#<@&YGtx#NYQ0%KQ(e?vXJdX>5YX)E_W+y5x)sSatRR20+1bIZsB3X!o3$Pc#{paxi}BU}{n zhq@Rff(je7&OM~eN@T3eFh698e{YRQ$kKC!r%7>jY8VTsUt_yYtp&5P7Fa)uznUYk zLldFdngB-6640J=n->FOOh0>CF6;e{+vFP{22*Ige}M@aBE{9Pu~YhKGct`LMl3%A zr=uoO>48~DY-1TUkd`$#{D;>ft+$O2K~Mw2Dun1;T%BDlfuy}v3%9QtvFqaIc)m$h z#8<8EsS2q*8L2=qJUkr~X`?-J$k`rODmk=#D}Q?PwtHEvB6P#@vL+FCu)8{InKciw zC7a3Rf1lYfz}y%fGDV@O^38>DS`#Rnyvyk5ozpoO zH)ZAcbv0OUE%!P+L^HlVfRjF>(MQrOr133^>WPt0!4ra)*1>g z-Xv_G8%Zm?K6ASbkT`}g+(f{tIku`s!c0U&f8#N*k19Mdca=TwYtO7rJ@%RV7QdOC zsd(3m2bye7l&RuK`^eDA4Qmfnk8DNN{mHY>7+T)1c>sq5@5O?WhUY`!y+7PrVIbwe z79YGYGtkm?%_xw;8n~Q*3(gI9*o1U>*z}LZtY(Z8#c|EQ@epybMWAE4e?giR8Jh2P ze`u$XNQ=*!2EikC9h#H1#h4lAobr2C}xP+*B_DzYRMWX0KrEm`-g!YgJuo@(yrEa diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 4ee30b7..ffa25a9 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -0CZ88XU8AvHiGwlrTmlc5Lt-4dgmms3pJphCNzK5FKI \ No newline at end of file +SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc \ No newline at end of file diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs new file mode 100644 index 0000000..316dd15 --- /dev/null +++ b/tests/src/system/account_lock.rs @@ -0,0 +1,436 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Account lock with delegation acceptance tests, from +//! `inbuxa-drafts/specs/audit-hold-lock.md` (AL-1 to AL-12; tests 10 to 15 +//! of its list). Each check names the requirement or test number. + +use crate::{ + jmap::mail::submission::{ + MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server, + }, + utils::{ + account::Account, + dns::DnsCache, + server::{TestServer, TestServerBuilder}, + smtp::SmtpConnection, + }, +}; +use registry::{ + schema::{ + enums::MtaProtocol, + structs::{ + Expression, ExpressionMatch, Imap, MtaOutboundStrategy, MtaRoute, MtaRouteRelay, + MtaStageAuth, + }, + }, + types::list::List, +}; +use serde_json::{Value, json}; +use std::time::{Duration, Instant}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:submission", + "urn:inbuxa:jmap", +]; + +const OWNER_SECRET: &str = "owner-secret-4471"; +const DELEGATE_SECRET: &str = "delegate-secret-9902"; + +impl Account { + async fn call(&self, method: &str, arguments: Value) -> (String, Value) { + let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + (call[0].as_str().unwrap_or_default().to_string(), call[1].clone()) + } + + async fn lock_set(&self, arguments: Value) -> Value { + let mut arguments = arguments; + arguments["accountId"] = self.id_string().into(); + let (name, response) = self.call("inbuxa:AccountLock/set", arguments).await; + assert_eq!(name, "inbuxa:AccountLock/set", "{response}"); + response + } + + async fn session_status(&self) -> u16 { + self.http_get_raw(&format!("{}/jmap/session", self.base_url()), None) + .await + .status + } +} + +fn message(from: &str, subject: &str) -> String { + format!("From: {from}\r\nTo: owner@example.com\r\nSubject: {subject}\r\n\r\nHello.\r\n") +} + +pub async fn test(test: &mut TestServer) { + println!("Running account lock tests..."); + let admin = test.account("admin@example.com"); + let owner = admin + .create_user_account("owner@example.com", OWNER_SECRET, "Owner", &[], vec![]) + .await; + let delegate = admin + .create_user_account("delegate@example.com", DELEGATE_SECRET, "Delegate", &[], vec![]) + .await; + let owner_id = owner.id_string().to_string(); + + // Mail leaving the server goes to the mock + let (mut smtp_rx, _smtp_settings) = spawn_mock_smtp_server(); + test.server.ipv4_add( + "localhost", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(60), + ); + + // The owner set a vacation reply before leaving + owner + .jmap_client() + .await + .vacation_response_enable("Away", "I'm away.".into(), None::) + .await + .unwrap(); + // Control: before the lock, the vacation reply goes out, so its absence + // later means the lock stopped it + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "dave@remote.org", + &["owner@example.com"], + &message("dave@remote.org", "Before the lock"), + ) + .await; + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("", [""], "@Away"), + ) + .await; + + let right_password = owner.session_status().await; + assert_eq!(right_password, 200, "the owner can sign in before the lock"); + let wrong = Account::new("owner@example.com", "wrong-password", &[], "", owner.id()); + let wrong_password = wrong.session_status().await; + + // AU-12: no lock without a reason + let response = admin + .lock_set(json!({"create": {"l": {"accountId": owner_id, + "delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}})) + .await; + assert_eq!( + response["notCreated"]["l"]["type"], "invalidProperties", + "AU-12: {response}" + ); + + // AL-12: nobody locks themselves + let response = admin + .lock_set(json!({"create": {"l": {"accountId": admin.id_string(), "reason": "test"}}})) + .await; + assert_eq!(response["notCreated"]["l"]["type"], "forbidden", "AL-12: {response}"); + + // AL-8: send-as needs more than read + let response = admin + .lock_set(json!({"create": {"l": {"accountId": owner_id, "reason": "Left", + "delegates": [{"accountId": delegate.id_string(), "access": "read", "sendAs": true}]}}})) + .await; + assert_eq!( + response["notCreated"]["l"]["type"], "invalidProperties", + "AL-8: {response}" + ); + + // Lock, with a read-only delegate (AL-1) + let response = admin + .lock_set(json!({"create": {"l": {"accountId": owner_id, + "reason": "Left the company; mail to be reviewed", + "delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}})) + .await; + assert_eq!(response["created"]["l"]["id"], owner_id.as_str(), "AL-1: {response}"); + + // AL-2: the right password fails as a wrong one does + let right_password = owner.session_status().await; + assert_ne!(right_password, 200, "AL-2: a locked account signed in"); + assert_eq!( + right_password, wrong_password, + "AL-2: the right password is told apart from a wrong one" + ); + + // Test 11, AL-4: mail keeps arriving, and nothing is sent: no vacation + lmtp.ingest( + "bill@remote.org", + &["owner@example.com"], + &message("bill@remote.org", "Quarterly report"), + ) + .await; + expect_nothing(&mut smtp_rx).await; + + // AL-7: the delegate sees the account, read-only, marked as delegated + let session = delegate.jmap_session_object().await.0; + let entry = &session["accounts"][owner_id.as_str()]; + assert_eq!(entry["isPersonal"], false, "AL-7: {session}"); + assert_eq!(entry["isReadOnly"], true, "AL-6: {entry}"); + let delegation = &entry["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"]; + assert_eq!(delegation["locked"], true, "AL-7: {entry}"); + assert_eq!(delegation["access"], "read", "AL-7"); + assert_eq!(delegation["sendAs"], false, "AL-7"); + + // The delegate reads the mail that arrived + let (_, found) = delegate + .call( + "Email/query", + json!({"accountId": owner_id, "filter": {"text": "Quarterly"}}), + ) + .await; + let email_id = found["ids"][0] + .as_str() + .unwrap_or_else(|| panic!("AL-4: the mail didn't arrive: {found}")) + .to_string(); + + // Test 12, AL-6: read means nothing changes, not even $seen + let (_, response) = delegate + .call( + "Email/set", + json!({"accountId": owner_id, "update": {email_id.as_str(): {"keywords/$seen": true}}}), + ) + .await; + assert!( + response["notUpdated"][email_id.as_str()].is_object(), + "test 12: a read delegate changed a keyword: {response}" + ); + + // AU-12: changing delegates needs a reason + let response = admin + .lock_set(json!({"update": {owner_id.as_str(): {"delegates": [ + {"accountId": delegate.id_string(), "access": "organize"}]}}})) + .await; + assert_eq!( + response["notUpdated"][owner_id.as_str()]["type"], "invalidProperties", + "AU-12: {response}" + ); + let response = admin + .lock_set(json!({"reason": "Manager files the mail", + "update": {owner_id.as_str(): {"delegates": [ + {"accountId": delegate.id_string(), "access": "organize"}]}}})) + .await; + assert!( + response["updated"].get(owner_id.as_str()).is_some(), + "AL-5: {response}" + ); + + // Test 12, AL-6, AL-7: organize makes folders it can see, moves mail, + // never deletes it + let (_, mailboxes) = delegate + .call("Mailbox/get", json!({"accountId": owner_id, "ids": null})) + .await; + let inbox = mailboxes["list"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "inbox") + .unwrap_or_else(|| panic!("AL-7: no inbox seen: {mailboxes}"))["id"] + .as_str() + .unwrap() + .to_string(); + let (_, created) = delegate + .call( + "Mailbox/set", + json!({"accountId": owner_id, "create": {"f": {"name": "Reviewed", "parentId": inbox}}}), + ) + .await; + let folder = created["created"]["f"]["id"] + .as_str() + .unwrap_or_else(|| panic!("AL-6: organize couldn't make a folder: {created}")) + .to_string(); + let (_, mailboxes) = delegate + .call("Mailbox/get", json!({"accountId": owner_id, "ids": [folder]})) + .await; + assert_eq!( + mailboxes["list"].as_array().map(Vec::len), + Some(1), + "AL-7: the delegate can't see the folder it made: {mailboxes}" + ); + let (_, moved) = delegate + .call( + "Email/set", + json!({"accountId": owner_id, "update": {email_id.as_str(): { + "mailboxIds": {folder.as_str(): true}}}}), + ) + .await; + assert!( + moved["updated"].get(email_id.as_str()).is_some(), + "test 12: organize couldn't move mail: {moved}" + ); + let (_, destroyed) = delegate + .call( + "Email/set", + json!({"accountId": owner_id, "destroy": [email_id]}), + ) + .await; + assert_eq!( + destroyed["notDestroyed"][email_id.as_str()]["type"], "forbidden", + "test 12: organize deleted mail: {destroyed}" + ); + + // AL-8: no sending without send-as + let (name, _) = delegate + .call("Identity/get", json!({"accountId": owner_id, "ids": null})) + .await; + assert_eq!(name, "error", "AL-8: identities of a locked account without send-as"); + + // Test 11, AL-4: a Sieve reject is kept instead, and nobody is answered + admin + .jmap_client() + .await + .set_default_account_id(owner_id.clone()) + .sieve_script_create( + "rejector", + "require \"reject\";\r\nreject \"Not here.\";\r\n", + true, + ) + .await + .unwrap(); + lmtp.ingest( + "carol@remote.org", + &["owner@example.com"], + &message("carol@remote.org", "Invoice 77"), + ) + .await; + expect_nothing(&mut smtp_rx).await; + let (_, kept) = delegate + .call( + "Email/query", + json!({"accountId": owner_id, "filter": {"text": "Invoice"}}), + ) + .await; + assert_eq!( + kept["ids"].as_array().map(Vec::len), + Some(1), + "AL-4: the rejected message wasn't kept: {kept}" + ); + + // AL-10: unlocking needs a reason, then restores everything + let response = admin + .lock_set(json!({"destroy": [owner_id]})) + .await; + assert_eq!( + response["notDestroyed"][owner_id.as_str()]["type"], "invalidProperties", + "AU-12: {response}" + ); + let response = admin + .lock_set(json!({"reason": "Review done", "destroy": [owner_id]})) + .await; + assert_eq!(response["destroyed"][0], owner_id.as_str(), "AL-10: {response}"); + assert_eq!(owner.session_status().await, 200, "AL-10: the owner can sign in"); + let session = delegate.jmap_session_object().await.0; + assert!( + session["accounts"].get(owner_id.as_str()).is_none(), + "test 15: the delegate kept the account: {session}" + ); + + // AL-9, AU-12: every step is recorded, with its reason + let (_, query) = admin + .call( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:AccountLock"}}), + ) + .await; + let (_, records) = admin + .call( + "inbuxa:AuditEvent/get", + json!({"accountId": admin.id_string(), "ids": query["ids"]}), + ) + .await; + let reasons = records["list"] + .as_array() + .unwrap() + .iter() + .filter(|r| r["outcome"]["status"] == "success") + .filter_map(|r| r["reason"].as_str()) + .collect::>(); + for reason in [ + "Left the company; mail to be reviewed", + "Manager files the mail", + "Review done", + ] { + assert!(reasons.contains(&reason), "AU-12: {reason} missing from {reasons:?}"); + } + let (_, query) = admin + .call( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), + "filter": {"actorId": delegate.id_string(), "accountId": owner_id}}), + ) + .await; + assert!( + query["ids"].as_array().is_some_and(|ids| ids.len() >= 2), + "AL-9: the delegate's access and changes weren't recorded: {query}" + ); +} + +/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn account_lock_tests() { + let mut test = TestServerBuilder::new("account_lock_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + admin + .registry_create_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + route: Expression { + match_: List::from_iter([ + ExpressionMatch { + if_: "rcpt_domain == 'example.com'".into(), + then: "'local'".into(), + }, + ExpressionMatch { + if_: "rcpt_domain == 'remote.org'".into(), + then: "'mock-smtp'".into(), + }, + ]), + else_: "'mx'".to_string(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaRoute::Relay(MtaRouteRelay { + address: "127.0.0.1".into(), + port: 9999, + allow_invalid_certs: true, + implicit_tls: false, + name: "mock-smtp".into(), + protocol: MtaProtocol::Smtp, + ..Default::default() + })) + .await; + admin.reload_settings().await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index d1d6ea6..4e27ebd 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -11,6 +11,7 @@ pub mod authentication; pub mod ai; pub mod ai_calibration; pub mod ai_explain; +pub mod account_lock; // inbuxa: account lock with delegation pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once