A shared account refuses top-level folders, so an organize or full delegate couldn't add anything to a locked account with no folders. A delegate who may write now can, as the owner could; the reconcile after the create grants it the new folder. Read delegates still can't (AL-6, AL-7).
1089 lines
40 KiB
Rust
1089 lines
40 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
use super::AccessToken;
|
|
use crate::{
|
|
Server,
|
|
auth::{
|
|
AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, Permissions, RECOVERY_ADMIN_ID,
|
|
permissions::{BuildPermissions, PermissionsListBuilder},
|
|
},
|
|
network::limiter::{ConcurrencyLimiter, LimiterResult},
|
|
};
|
|
use ahash::AHasher;
|
|
use registry::{
|
|
schema::{
|
|
enums::Permission,
|
|
structs::{self, Account, Roles, UserRoles},
|
|
},
|
|
types::EnumImpl,
|
|
};
|
|
use std::{
|
|
hash::{Hash, Hasher},
|
|
net::IpAddr,
|
|
sync::Arc,
|
|
};
|
|
use store::{query::acl::AclQuery, rand, write::now};
|
|
use tinyvec::TinyVec;
|
|
use trc::{AddContext, StoreEvent};
|
|
use types::{acl::Acl, collection::Collection};
|
|
use utils::map::bitmap::{Bitmap, BitmapItem};
|
|
use xxhash_rust::xxh3;
|
|
|
|
impl Server {
|
|
async fn build_access_token(
|
|
&self,
|
|
account: Account,
|
|
account_id: u32,
|
|
revision: u64,
|
|
revision_account: u64,
|
|
) -> trc::Result<AccessTokenInner> {
|
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
|
// locked accounts are handed to it. The token is their cache: every
|
|
// change to a lock invalidates the tokens it touches.
|
|
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.is_some();
|
|
let now_secs = now();
|
|
let delegations: Box<[super::Delegation]> =
|
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.into_iter()
|
|
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
|
.map(|(locked_id, delegate)| super::Delegation {
|
|
account_id: locked_id,
|
|
access: delegate.access,
|
|
send_as: delegate.send_as,
|
|
until: delegate.until,
|
|
})
|
|
.collect();
|
|
match account {
|
|
Account::User(account) => {
|
|
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
|
|
let permissions = self
|
|
.effective_permissions(
|
|
&account.permissions,
|
|
match &account.roles {
|
|
UserRoles::User => {
|
|
self.core.network.security.default_role_ids_user.as_slice()
|
|
}
|
|
UserRoles::Admin => {
|
|
if tenant_id.is_none() {
|
|
self.core.network.security.default_role_ids_admin.as_slice()
|
|
} else {
|
|
self.core
|
|
.network
|
|
.security
|
|
.default_role_ids_tenant
|
|
.as_slice()
|
|
}
|
|
}
|
|
UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(),
|
|
},
|
|
tenant_id,
|
|
)
|
|
.await?;
|
|
|
|
let member_of = account
|
|
.member_group_ids
|
|
.iter()
|
|
.map(|m| m.id() as u32)
|
|
.collect::<TinyVec<[u32; 3]>>();
|
|
let mut access_to: Vec<AccessTo> = Vec::new();
|
|
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
|
for acl_item in self
|
|
.store()
|
|
.acl_query(AclQuery::HasAccess { grant_account_id })
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
if acl_item.to_account_id != account_id
|
|
&& !member_of.contains(&acl_item.to_account_id)
|
|
{
|
|
let acl = Bitmap::<Acl>::from(acl_item.permissions);
|
|
let collection = acl_item.to_collection;
|
|
if !collection.is_valid() {
|
|
return Err(trc::StoreEvent::DataCorruption
|
|
.ctx(trc::Key::Reason, "Corrupted collection found in ACL key.")
|
|
.details(format!("{acl_item:?}"))
|
|
.account_id(grant_account_id)
|
|
.caused_by(trc::location!()));
|
|
}
|
|
|
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
|
if acl.contains(Acl::Read) {
|
|
collections.insert(collection);
|
|
}
|
|
if acl.contains(Acl::ReadItems)
|
|
&& let Some(child_col) = collection.child_collection()
|
|
{
|
|
collections.insert(child_col);
|
|
}
|
|
|
|
if !collections.is_empty() {
|
|
if let Some(idx) = access_to
|
|
.iter()
|
|
.position(|a| a.account_id == acl_item.to_account_id)
|
|
{
|
|
access_to[idx].collections.union(&collections);
|
|
} else {
|
|
access_to.push(AccessTo {
|
|
account_id: acl_item.to_account_id,
|
|
collections,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// inbuxa: AL-7: a delegate reaches the whole locked account,
|
|
// mail, calendars, contacts and files, even a kind it holds
|
|
// none of yet, so an empty one reads as empty rather than
|
|
// refused. What it may see or change there is still each
|
|
// container's grant.
|
|
for delegation in delegations.iter() {
|
|
let whole: Bitmap<Collection> = Bitmap::from_iter([
|
|
Collection::Mailbox,
|
|
Collection::Email,
|
|
Collection::Calendar,
|
|
Collection::CalendarEvent,
|
|
Collection::AddressBook,
|
|
Collection::ContactCard,
|
|
Collection::FileNode,
|
|
]);
|
|
match access_to.iter_mut().find(|a| a.account_id == delegation.account_id) {
|
|
Some(entry) => entry.collections.union(&whole),
|
|
None => access_to.push(AccessTo {
|
|
account_id: delegation.account_id,
|
|
collections: whole,
|
|
}),
|
|
}
|
|
}
|
|
|
|
let now = now();
|
|
let mut credential_version = 0;
|
|
let mut credential_scopes = Vec::with_capacity(account.credentials.len());
|
|
|
|
credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX));
|
|
|
|
for credential in account.credentials {
|
|
match credential {
|
|
structs::Credential::Password(credential) => {
|
|
credential_version = xxh3::xxh3_64(credential.secret.as_bytes()).max(1);
|
|
|
|
if credential.expires_at.is_some() || !credential.allowed_ips.is_empty()
|
|
{
|
|
let credential_scope = &mut credential_scopes[0];
|
|
credential_scope.expires_at = credential
|
|
.expires_at
|
|
.map(|v| v.timestamp() as u64)
|
|
.unwrap_or(u64::MAX);
|
|
credential_scope.allowed_ips =
|
|
credential.allowed_ips.into_inner().into_boxed_slice();
|
|
}
|
|
}
|
|
structs::Credential::ApiKey(credential)
|
|
| structs::Credential::AppPassword(credential) => {
|
|
let credential_id = credential.credential_id.document_id();
|
|
let expires_at = credential
|
|
.expires_at
|
|
.map(|v| v.timestamp() as u64)
|
|
.unwrap_or(u64::MAX);
|
|
if expires_at > now {
|
|
let permissions = &credential_scopes[0].permissions;
|
|
let permissions = match credential.permissions {
|
|
structs::CredentialPermissions::Inherit => permissions.clone(),
|
|
structs::CredentialPermissions::Disable(list) => {
|
|
let mut permissions = permissions.clone();
|
|
permissions.clear_many(&Permissions::from_permission(
|
|
list.permissions.as_slice(),
|
|
));
|
|
permissions
|
|
}
|
|
structs::CredentialPermissions::Replace(list) => {
|
|
let mut replace_permissions = Permissions::from_permission(
|
|
list.permissions.as_slice(),
|
|
);
|
|
replace_permissions.intersection(permissions);
|
|
replace_permissions
|
|
}
|
|
};
|
|
credential_scopes.push(AccessScope {
|
|
credential_id,
|
|
permissions,
|
|
expires_at,
|
|
allowed_ips: credential
|
|
.allowed_ips
|
|
.into_inner()
|
|
.into_boxed_slice(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(AccessTokenInner {
|
|
concurrent_imap_requests: self
|
|
.core
|
|
.imap
|
|
.rate_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
concurrent_http_requests: self
|
|
.core
|
|
.jmap
|
|
.request_max_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
concurrent_uploads: self
|
|
.core
|
|
.jmap
|
|
.upload_max_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
obj_size: 0,
|
|
locked,
|
|
delegations: delegations.clone(),
|
|
revision,
|
|
revision_account,
|
|
credential_version,
|
|
account_id,
|
|
tenant_id,
|
|
member_of,
|
|
access_to: access_to.into_boxed_slice(),
|
|
scopes: []
|
|
.into_iter()
|
|
.chain(credential_scopes.into_iter().map(|mut scope| {
|
|
// inbuxa: AL-2: no credential of a locked
|
|
// account authenticates; receiving mail isn't
|
|
// signing in, so EmailReceive stays
|
|
if locked {
|
|
scope.permissions.clear(Permission::Authenticate as usize);
|
|
}
|
|
scope
|
|
}))
|
|
.collect::<Box<[AccessScope]>>(),
|
|
}
|
|
.update_size())
|
|
}
|
|
Account::Group(account) => {
|
|
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
|
|
let permissions = self
|
|
.effective_permissions(
|
|
&account.permissions,
|
|
account.roles.role_ids().unwrap_or(
|
|
self.core.network.security.default_role_ids_group.as_slice(),
|
|
),
|
|
tenant_id,
|
|
)
|
|
.await?;
|
|
|
|
Ok(AccessTokenInner {
|
|
concurrent_imap_requests: self
|
|
.core
|
|
.imap
|
|
.rate_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
concurrent_http_requests: self
|
|
.core
|
|
.jmap
|
|
.request_max_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
concurrent_uploads: self
|
|
.core
|
|
.jmap
|
|
.upload_max_concurrent
|
|
.map(ConcurrencyLimiter::new),
|
|
obj_size: 0,
|
|
locked,
|
|
delegations: delegations.clone(),
|
|
revision,
|
|
revision_account,
|
|
credential_version: 0,
|
|
account_id,
|
|
tenant_id,
|
|
member_of: Default::default(),
|
|
access_to: Default::default(),
|
|
scopes: Box::new([AccessScope::new(permissions.finalize(), u32::MAX)]),
|
|
}
|
|
.update_size())
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn access_token(&self, account_id: u32) -> trc::Result<Arc<AccessTokenInner>> {
|
|
match self
|
|
.inner
|
|
.cache
|
|
.access_tokens
|
|
.get_value_or_guard_async(&account_id)
|
|
.await
|
|
{
|
|
Ok(token) => {
|
|
trc::event!(
|
|
Store(StoreEvent::CacheHit),
|
|
Key = account_id,
|
|
Collection = "accessToken",
|
|
);
|
|
|
|
Ok(token)
|
|
}
|
|
Err(guard) => {
|
|
trc::event!(
|
|
Store(StoreEvent::CacheMiss),
|
|
Key = account_id,
|
|
Collection = "accessToken",
|
|
);
|
|
|
|
let token: Arc<AccessTokenInner> = if let Some(account) =
|
|
self.registry().object::<Account>(account_id.into()).await?
|
|
{
|
|
let revision = rand::random::<u64>();
|
|
let revision_account = hash_account(&account);
|
|
self.build_access_token(account, account_id, revision, revision_account)
|
|
.await?
|
|
.into()
|
|
} else if account_id == RECOVERY_ADMIN_ID {
|
|
AccessTokenInner::new_admin().into()
|
|
} else {
|
|
return Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details("Account not found")
|
|
.account_id(account_id)
|
|
.caused_by(trc::location!()));
|
|
};
|
|
|
|
let _ = guard.insert(token.clone());
|
|
Ok(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn access_token_from_account(
|
|
&self,
|
|
account_id: u32,
|
|
account: Account,
|
|
) -> trc::Result<Arc<AccessTokenInner>> {
|
|
let revision_account = hash_account(&account);
|
|
match self
|
|
.inner
|
|
.cache
|
|
.access_tokens
|
|
.get_value_or_guard_async(&account_id)
|
|
.await
|
|
{
|
|
Ok(token) => {
|
|
if token.revision_account == revision_account {
|
|
trc::event!(
|
|
Store(StoreEvent::CacheHit),
|
|
Key = account_id,
|
|
Collection = "accessToken",
|
|
);
|
|
|
|
Ok(token)
|
|
} else {
|
|
// Token is stale, rebuild it
|
|
trc::event!(
|
|
Store(StoreEvent::CacheStale),
|
|
Key = account_id,
|
|
Collection = "accessToken",
|
|
);
|
|
|
|
debug_assert!(
|
|
false,
|
|
"Token is stale, invalidation should have been triggered"
|
|
);
|
|
let revision = rand::random::<u64>();
|
|
let token: Arc<AccessTokenInner> = self
|
|
.build_access_token(account, account_id, revision, revision_account)
|
|
.await?
|
|
.into();
|
|
self.inner
|
|
.cache
|
|
.access_tokens
|
|
.update(account_id, token.clone());
|
|
Ok(token)
|
|
}
|
|
}
|
|
Err(guard) => {
|
|
trc::event!(
|
|
Store(StoreEvent::CacheMiss),
|
|
Key = account_id,
|
|
Collection = "accessToken",
|
|
);
|
|
|
|
let revision = rand::random::<u64>();
|
|
let token: Arc<AccessTokenInner> = self
|
|
.build_access_token(account, account_id, revision, revision_account)
|
|
.await?
|
|
.into();
|
|
let _ = guard.insert(token.clone());
|
|
Ok(token)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AccessToken {
|
|
pub fn new(inner: Arc<AccessTokenInner>, remote_ip: IpAddr) -> trc::Result<Self> {
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner,
|
|
}
|
|
.assert_is_valid(remote_ip)
|
|
}
|
|
|
|
pub fn new_maybe_invalid(inner: Arc<AccessTokenInner>) -> Self {
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner,
|
|
}
|
|
}
|
|
|
|
pub fn new_scoped(
|
|
inner: Arc<AccessTokenInner>,
|
|
credential_id: u32,
|
|
remote_ip: IpAddr,
|
|
) -> trc::Result<Self> {
|
|
inner
|
|
.scopes
|
|
.iter()
|
|
.position(|scope| scope.credential_id == credential_id)
|
|
.ok_or_else(|| {
|
|
trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.ctx(trc::Key::AccountId, inner.account_id)
|
|
.ctx(trc::Key::Id, credential_id)
|
|
.reason("Credential expired or removed.")
|
|
})
|
|
.map(|scope_idx| AccessToken {
|
|
scope_idx,
|
|
inner,
|
|
origin: None,
|
|
})
|
|
.and_then(|token| token.assert_is_valid(remote_ip))
|
|
}
|
|
|
|
pub fn renew(
|
|
inner: Arc<AccessTokenInner>,
|
|
credential_id: Option<u32>,
|
|
remote_ip: IpAddr,
|
|
) -> trc::Result<Self> {
|
|
if let Some(credential_id) = credential_id {
|
|
Self::new_scoped(inner, credential_id, remote_ip)
|
|
} else {
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner,
|
|
}
|
|
.assert_is_valid(remote_ip)
|
|
}
|
|
}
|
|
|
|
pub fn state(&self) -> u32 {
|
|
// Hash state
|
|
let mut s = AHasher::default();
|
|
self.inner.member_of.hash(&mut s);
|
|
self.inner.access_to.hash(&mut s);
|
|
s.finish() as u32
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn account_id(&self) -> u32 {
|
|
self.inner.account_id
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn tenant_id(&self) -> Option<u32> {
|
|
self.inner.tenant_id
|
|
}
|
|
|
|
pub fn secondary_ids(&self) -> impl Iterator<Item = &u32> {
|
|
self.inner
|
|
.member_of
|
|
.iter()
|
|
.chain(self.inner.access_to.iter().map(|a| &a.account_id))
|
|
}
|
|
|
|
pub fn member_ids(&self) -> impl Iterator<Item = u32> {
|
|
[self.inner.account_id]
|
|
.into_iter()
|
|
.chain(self.inner.member_of.iter().copied())
|
|
}
|
|
|
|
pub fn all_ids(&self) -> impl Iterator<Item = u32> {
|
|
[self.inner.account_id]
|
|
.into_iter()
|
|
.chain(self.inner.member_of.iter().copied())
|
|
.chain(self.inner.access_to.iter().map(|a| a.account_id))
|
|
}
|
|
|
|
pub fn all_ids_by_collection(&self, collection: Collection) -> impl Iterator<Item = u32> {
|
|
[self.inner.account_id]
|
|
.into_iter()
|
|
.chain(self.inner.member_of.iter().copied())
|
|
.chain(self.inner.access_to.iter().filter_map(move |a| {
|
|
if a.collections.contains(collection) {
|
|
Some(a.account_id)
|
|
} else {
|
|
None
|
|
}
|
|
}))
|
|
}
|
|
|
|
pub fn is_member(&self, account_id: u32) -> bool {
|
|
self.inner.account_id == account_id
|
|
|| self.inner.member_of.contains(&account_id)
|
|
|| self.has_permission(Permission::Impersonate)
|
|
}
|
|
|
|
/// inbuxa: AU-1.6: whether the account is reachable without
|
|
/// impersonation: its own, a group's it belongs to, or one shared with
|
|
/// it.
|
|
pub fn is_member_directly(&self, account_id: u32) -> bool {
|
|
self.inner.account_id == account_id
|
|
|| self.inner.member_of.contains(&account_id)
|
|
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
|
}
|
|
|
|
pub fn is_account_id(&self, account_id: u32) -> bool {
|
|
self.inner.account_id == account_id
|
|
}
|
|
|
|
pub fn personal_id(&self, account_id: u32, collection: Collection) -> u32 {
|
|
let child_collection = collection.child_collection();
|
|
if self.is_account_id(account_id)
|
|
|| self.inner.member_of.contains(&account_id)
|
|
|| self.inner.access_to.iter().any(|a| {
|
|
a.account_id == account_id
|
|
&& (a.collections.contains(collection)
|
|
|| child_collection.is_some_and(|child| a.collections.contains(child)))
|
|
})
|
|
{
|
|
self.inner.account_id
|
|
} else {
|
|
account_id
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn has_permission(&self, permission: Permission) -> bool {
|
|
self.inner
|
|
.scopes
|
|
.get(self.scope_idx)
|
|
.is_some_and(|scope| scope.permissions.get(permission as usize))
|
|
}
|
|
|
|
pub fn assert_is_valid(self, remote_ip: IpAddr) -> trc::Result<Self> {
|
|
if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
|
|
let has_expired = scope.expires_at <= now();
|
|
let is_valid_ip = scope.allowed_ips.is_empty()
|
|
|| scope
|
|
.allowed_ips
|
|
.iter()
|
|
.any(|ip_mask| ip_mask.matches(&remote_ip));
|
|
|
|
let mut access_token = self;
|
|
if has_expired {
|
|
if access_token.scope_idx > 0 {
|
|
return Err(trc::AuthEvent::CredentialExpired
|
|
.into_err()
|
|
.ctx(trc::Key::AccountId, access_token.inner.account_id)
|
|
.reason("Credential expired."));
|
|
} else {
|
|
trc::event!(
|
|
Auth(trc::AuthEvent::CredentialExpired),
|
|
AccountId = access_token.inner.account_id,
|
|
Reason = "Main credential expired, downgrading permissions.",
|
|
);
|
|
}
|
|
|
|
// Downgrade permissions to allow password change
|
|
let mut scopes = Vec::with_capacity(access_token.inner.scopes.len());
|
|
for (idx, scope) in access_token.inner.scopes.iter().enumerate() {
|
|
if idx == 0 {
|
|
let mut permissions = Permissions::new();
|
|
|
|
for permission in [
|
|
Permission::Authenticate,
|
|
Permission::AuthenticateWithAlias,
|
|
Permission::SysAccountPasswordGet,
|
|
Permission::SysAccountPasswordUpdate,
|
|
Permission::EmailReceive,
|
|
] {
|
|
if scope.permissions.get(permission as usize) {
|
|
permissions.set(permission as usize);
|
|
}
|
|
}
|
|
|
|
scopes.push(AccessScope {
|
|
permissions,
|
|
credential_id: scope.credential_id,
|
|
expires_at: u64::MAX,
|
|
allowed_ips: scope.allowed_ips.clone(),
|
|
});
|
|
} else {
|
|
scopes.push(scope.clone());
|
|
}
|
|
}
|
|
let old_inner = &access_token.inner;
|
|
let inner = AccessTokenInner {
|
|
scopes: scopes.into_boxed_slice(),
|
|
account_id: old_inner.account_id,
|
|
tenant_id: old_inner.tenant_id,
|
|
member_of: old_inner.member_of.clone(),
|
|
access_to: old_inner.access_to.clone(),
|
|
concurrent_http_requests: old_inner.concurrent_http_requests.clone(),
|
|
concurrent_imap_requests: old_inner.concurrent_imap_requests.clone(),
|
|
concurrent_uploads: old_inner.concurrent_uploads.clone(),
|
|
revision_account: old_inner.revision_account,
|
|
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 {
|
|
scope_idx: access_token.scope_idx,
|
|
origin: access_token.origin.clone(),
|
|
inner: Arc::new(inner),
|
|
};
|
|
}
|
|
|
|
if is_valid_ip {
|
|
Ok(access_token)
|
|
} else {
|
|
Err(trc::SecurityEvent::IpUnauthorized
|
|
.into_err()
|
|
.ctx(trc::Key::AccountId, access_token.inner.account_id)
|
|
.reason("IP address not allowed."))
|
|
}
|
|
} else {
|
|
Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.ctx(trc::Key::AccountId, self.inner.account_id)
|
|
.reason("Credential not valid."))
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn credential_id(&self) -> Option<u32> {
|
|
self.inner
|
|
.scopes
|
|
.get(self.scope_idx)
|
|
.map(|scope| scope.credential_id)
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn revision(&self) -> u64 {
|
|
self.inner.revision
|
|
}
|
|
|
|
pub fn assert_has_permissions(self, permissions: &[Permission]) -> trc::Result<Self> {
|
|
for permission in permissions {
|
|
if !self.has_permission(*permission) {
|
|
return Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details(permission.as_str())
|
|
.account_id(self.account_id()));
|
|
}
|
|
}
|
|
|
|
Ok(self)
|
|
}
|
|
|
|
pub fn assert_has_permission(self, permission: Permission) -> trc::Result<Self> {
|
|
if self.has_permission(permission) {
|
|
Ok(self)
|
|
} else {
|
|
Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details(permission.as_str())
|
|
.account_id(self.account_id()))
|
|
}
|
|
}
|
|
|
|
pub fn enforce_permission(&self, permission: Permission) -> trc::Result<()> {
|
|
if self.has_permission(permission) {
|
|
Ok(())
|
|
} else {
|
|
Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details(permission.as_str())
|
|
.account_id(self.account_id()))
|
|
}
|
|
}
|
|
|
|
pub fn permissions(&self) -> Vec<Permission> {
|
|
if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
|
|
scope.permissions.build_permissions_list()
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn access_scope(&self) -> Option<&AccessScope> {
|
|
self.inner.scopes.get(self.scope_idx)
|
|
}
|
|
|
|
pub(crate) fn permissions_bits(&self) -> &Permissions {
|
|
&self
|
|
.inner
|
|
.scopes
|
|
.get(self.scope_idx)
|
|
.unwrap_or(&self.inner.scopes[0])
|
|
.permissions
|
|
}
|
|
|
|
pub fn account_permissions(&self) -> &Permissions {
|
|
&self.inner.scopes[0].permissions
|
|
}
|
|
|
|
pub fn is_shared(&self, account_id: u32) -> bool {
|
|
!self.is_member(account_id)
|
|
&& self
|
|
.inner
|
|
.access_to
|
|
.iter()
|
|
.any(|a| a.account_id == account_id)
|
|
}
|
|
|
|
pub fn shared_accounts(&self, collection: Collection) -> impl Iterator<Item = &u32> {
|
|
self.inner
|
|
.member_of
|
|
.iter()
|
|
.chain(self.inner.access_to.iter().filter_map(move |a| {
|
|
if a.collections.contains(collection) {
|
|
Some(&a.account_id)
|
|
} else {
|
|
None
|
|
}
|
|
}))
|
|
}
|
|
|
|
pub fn has_access(&self, to_account_id: u32, to_collection: impl Into<Collection>) -> bool {
|
|
let to_collection = to_collection.into();
|
|
self.is_member(to_account_id)
|
|
|| self
|
|
.inner
|
|
.access_to
|
|
.iter()
|
|
.any(|a| a.account_id == to_account_id && a.collections.contains(to_collection))
|
|
}
|
|
|
|
pub fn has_account_access(&self, to_account_id: u32) -> bool {
|
|
self.is_member(to_account_id)
|
|
|| self
|
|
.inner
|
|
.access_to
|
|
.iter()
|
|
.any(|a| a.account_id == to_account_id)
|
|
}
|
|
|
|
pub fn is_http_request_allowed(&self) -> LimiterResult {
|
|
self.inner
|
|
.concurrent_http_requests
|
|
.as_ref()
|
|
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
|
|
}
|
|
|
|
pub fn concurrent_http_requests(&self) -> u64 {
|
|
self.inner
|
|
.concurrent_http_requests
|
|
.as_ref()
|
|
.map(|limiter| limiter.max_concurrent())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
pub fn is_imap_request_allowed(&self) -> LimiterResult {
|
|
self.inner
|
|
.concurrent_imap_requests
|
|
.as_ref()
|
|
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
|
|
}
|
|
|
|
pub fn is_upload_allowed(&self) -> LimiterResult {
|
|
self.inner
|
|
.concurrent_uploads
|
|
.as_ref()
|
|
.map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
|
|
}
|
|
|
|
pub fn concurrent_uploads(&self) -> u64 {
|
|
self.inner
|
|
.concurrent_uploads
|
|
.as_ref()
|
|
.map(|limiter| limiter.max_concurrent())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
pub fn account_tenant_ids(&self) -> AccountTenantIds {
|
|
AccountTenantIds {
|
|
account_id: self.account_id(),
|
|
tenant_id: self.tenant_id(),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
|
/// the locked account as its owner could, top-level folders included.
|
|
pub fn delegate_may_write(&self, account_id: u32) -> bool {
|
|
self.delegation(account_id)
|
|
.is_some_and(|d| d.access != inbuxa_features::lock::Access::Read)
|
|
}
|
|
|
|
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
|
|
let now = now();
|
|
self.inner
|
|
.delegations
|
|
.iter()
|
|
.find(|d| d.account_id == account_id && d.until.is_none_or(|until| until > now))
|
|
}
|
|
|
|
/// inbuxa: AL-5: every current delegation this account holds.
|
|
pub fn delegations(&self) -> impl Iterator<Item = &super::Delegation> {
|
|
let now = now();
|
|
self.inner
|
|
.delegations
|
|
.iter()
|
|
.filter(move |d| d.until.is_none_or(|until| until > now))
|
|
}
|
|
|
|
/// inbuxa: how this session signed in (AU-5).
|
|
pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> {
|
|
self.origin.as_deref()
|
|
}
|
|
|
|
/// inbuxa: records how this session signed in (AU-5).
|
|
pub fn with_origin(mut self, origin: inbuxa_features::audit::Via) -> Self {
|
|
self.origin = Some(Arc::new(origin));
|
|
self
|
|
}
|
|
|
|
pub fn origin_arc(&self) -> Option<Arc<inbuxa_features::audit::Via>> {
|
|
self.origin.clone()
|
|
}
|
|
|
|
/// inbuxa: restores how a cached session signed in (AU-5).
|
|
pub fn with_origin_arc(mut self, origin: Option<Arc<inbuxa_features::audit::Via>>) -> Self {
|
|
self.origin = origin;
|
|
self
|
|
}
|
|
|
|
pub fn new_admin() -> AccessToken {
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner: Arc::new(AccessTokenInner::new_admin()),
|
|
}
|
|
}
|
|
|
|
pub fn from_permissions(
|
|
account_id: u32,
|
|
set_permissions: impl IntoIterator<Item = Permission>,
|
|
) -> AccessToken {
|
|
let mut permissions = Permissions::new();
|
|
for permission in set_permissions {
|
|
permissions.set(permission as usize);
|
|
}
|
|
AccessToken {
|
|
scope_idx: 0,
|
|
origin: None,
|
|
inner: Arc::new(AccessTokenInner {
|
|
account_id,
|
|
tenant_id: Default::default(),
|
|
member_of: Default::default(),
|
|
access_to: Default::default(),
|
|
scopes: Box::new([AccessScope::new(permissions, u32::MAX)]),
|
|
concurrent_http_requests: Default::default(),
|
|
concurrent_imap_requests: Default::default(),
|
|
concurrent_uploads: Default::default(),
|
|
revision: Default::default(),
|
|
revision_account: Default::default(),
|
|
credential_version: Default::default(),
|
|
obj_size: Default::default(),
|
|
locked: false,
|
|
delegations: Default::default(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn from_id_maybe_invalid(account_id: u32) -> Self {
|
|
AccessToken::new_maybe_invalid(Arc::new(AccessTokenInner::from_id(account_id)))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
self.scopes
|
|
.first()
|
|
.is_some_and(|scope| scope.permissions.get(permission as usize))
|
|
}
|
|
|
|
pub fn from_id(account_id: u32) -> Self {
|
|
Self {
|
|
account_id,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn with_tenant_id(mut self, tenant_id: Option<u32>) -> Self {
|
|
self.tenant_id = tenant_id;
|
|
self
|
|
}
|
|
|
|
pub fn update_size(mut self) -> Self {
|
|
self.obj_size = (std::mem::size_of::<AccessToken>()
|
|
+ (self.member_of.len() * std::mem::size_of::<u32>())
|
|
+ (self.access_to.len() * (std::mem::size_of::<u32>() + std::mem::size_of::<u64>()))
|
|
+ (self.scopes.len() * std::mem::size_of::<AccessScope>()))
|
|
as u64;
|
|
self
|
|
}
|
|
|
|
pub fn new_admin() -> Self {
|
|
AccessTokenInner {
|
|
account_id: RECOVERY_ADMIN_ID,
|
|
tenant_id: Default::default(),
|
|
member_of: Default::default(),
|
|
access_to: Default::default(),
|
|
scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]),
|
|
concurrent_http_requests: Default::default(),
|
|
concurrent_imap_requests: Default::default(),
|
|
concurrent_uploads: Default::default(),
|
|
revision: Default::default(),
|
|
revision_account: Default::default(),
|
|
credential_version: Default::default(),
|
|
obj_size: Default::default(),
|
|
locked: false,
|
|
delegations: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn revision(&self) -> u64 {
|
|
self.revision
|
|
}
|
|
|
|
pub fn revision_account(&self) -> u64 {
|
|
self.revision_account
|
|
}
|
|
|
|
pub fn credential_version(&self) -> u64 {
|
|
self.credential_version
|
|
}
|
|
}
|
|
|
|
impl AccessScope {
|
|
pub fn new(permissions: Permissions, credential_id: u32) -> Self {
|
|
Self {
|
|
permissions,
|
|
credential_id,
|
|
expires_at: u64::MAX,
|
|
allowed_ips: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hash_account(account: &Account) -> u64 {
|
|
let mut s = AHasher::default();
|
|
|
|
match account {
|
|
Account::User(account) => {
|
|
account.member_tenant_id.hash(&mut s);
|
|
match &account.roles {
|
|
UserRoles::User => {
|
|
0u8.hash(&mut s);
|
|
}
|
|
UserRoles::Admin => {
|
|
1u8.hash(&mut s);
|
|
}
|
|
UserRoles::Custom(custom_roles) => {
|
|
2u8.hash(&mut s);
|
|
custom_roles.role_ids.as_slice().hash(&mut s);
|
|
}
|
|
}
|
|
hash_permissions(&mut s, &account.permissions);
|
|
for credential in account
|
|
.credentials
|
|
.iter()
|
|
.filter_map(|credential| credential.as_secondary_credential())
|
|
{
|
|
credential.credential_id.hash(&mut s);
|
|
credential.expires_at.hash(&mut s);
|
|
hash_credential_permissions(&mut s, &credential.permissions);
|
|
}
|
|
for group_id in account.member_group_ids.iter() {
|
|
group_id.hash(&mut s);
|
|
}
|
|
}
|
|
Account::Group(account) => {
|
|
account.member_tenant_id.hash(&mut s);
|
|
match &account.roles {
|
|
Roles::Default => {}
|
|
Roles::Custom(custom_roles) => {
|
|
custom_roles.role_ids.as_slice().hash(&mut s);
|
|
}
|
|
}
|
|
hash_permissions(&mut s, &account.permissions);
|
|
}
|
|
}
|
|
|
|
s.finish()
|
|
}
|
|
|
|
fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) {
|
|
match permissions {
|
|
structs::Permissions::Inherit => {
|
|
0u8.hash(hasher);
|
|
}
|
|
structs::Permissions::Merge(permissions) => {
|
|
2u8.hash(hasher);
|
|
permissions.enabled_permissions.as_slice().hash(hasher);
|
|
permissions.disabled_permissions.as_slice().hash(hasher);
|
|
}
|
|
structs::Permissions::Replace(permissions) => {
|
|
3u8.hash(hasher);
|
|
permissions.enabled_permissions.as_slice().hash(hasher);
|
|
permissions.disabled_permissions.as_slice().hash(hasher);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hash_credential_permissions(hasher: &mut AHasher, permissions: &structs::CredentialPermissions) {
|
|
match permissions {
|
|
structs::CredentialPermissions::Inherit => {
|
|
0u8.hash(hasher);
|
|
}
|
|
structs::CredentialPermissions::Disable(permissions) => {
|
|
2u8.hash(hasher);
|
|
permissions.permissions.as_slice().hash(hasher);
|
|
}
|
|
structs::CredentialPermissions::Replace(permissions) => {
|
|
3u8.hash(hasher);
|
|
permissions.permissions.as_slice().hash(hasher);
|
|
}
|
|
}
|
|
}
|