Personal-data catalog spec, §6 (Phase 3c). inbuxa:DataInventory/get evaluates the catalog against the server's live settings and says what this server holds: for each source and each object that can hold personal data, its categories and whose data it is, whether it is collected here at all, what bounds its retention (the live value of the setting that does, or unbounded), whether it leaves the host and to which endpoints, and a summary. Every host that receives something is listed once as a candidate processor with what it receives. Inside a tenant it answers with the tenant's slice and none of the server's processors. Read-only, with sysComplianceGet. inbuxa:InventorySnapshot/get is the history: a dated copy of the evaluated inventory, recorded when it changes -- after a registry write to an object the inventory reads, after inbuxa's log, audit or AI settings change, and on the daily clean-up -- and kept as long as the audit log's records. ids: null lists every snapshot, newest first; the full inventory only when asked for. The catalog is embedded and parsed at start (new dependency: toml, MIT/Apache); the evaluation is a pure function of it and the live facts, so each configuration is tested without a server. Loopback endpoints stay on the host; any other configured endpoint leaves it. Tested: unit tests for the evaluation (a new install's defaults, an external blob store, a hosted AI endpoint, telemetry off, a tenant's slice, hosts from URLs, loopback), snapshots, and the fact gathering's store and duration rules; the compliance system test, extended (the officer reads the inventory, a plain user is refused, a tenant's officer sees its slice and no processors, a webhook to another host becomes a processor and a snapshot names x:WebHook, a retention change reads through); the system, audit, legal hold and account lock suites; fork checks. The system suite failed once of three runs with an email import's blob not found, in antispam.rs; the same happened once in purge.rs on the previous branch. Nothing here touches uploads; noted for a separate look.
1174 lines
51 KiB
Rust
1174 lines
51 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 crate::registry::{
|
|
EnterpriseRegistry,
|
|
mapping::{
|
|
ObjectResponse, RegistrySetResponse,
|
|
account::account_set,
|
|
action::action_set,
|
|
bootstrap::bootstrap_set,
|
|
dkim::validate_dkim_signature,
|
|
domain::{validate_dns_server, validate_domain},
|
|
map_bootstrap_error,
|
|
principal::{
|
|
AccountUpdate, schedule_account_destruction, validate_account, validate_role,
|
|
validate_tenant_quota,
|
|
},
|
|
public_key::validate_public_key,
|
|
queued_message::queued_message_set,
|
|
report::report_set,
|
|
sieve::validate_sieve_script,
|
|
spam_sample::spam_sample_set,
|
|
task::task_set,
|
|
tls::{validate_acme_provider, validate_certificate},
|
|
},
|
|
};
|
|
use common::{
|
|
Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder,
|
|
expr::if_block::BootstrapExprExt, ipc::CacheInvalidation,
|
|
manager::application::WebApplicationManager,
|
|
};
|
|
use directory::core::secret::{hash_secret, is_password_hash};
|
|
use http_proto::HttpSessionData;
|
|
use jmap_proto::{
|
|
error::set::{SetError, SetErrorType},
|
|
method::set::{SetRequest, SetResponse, SettingsReload},
|
|
object::registry::Registry,
|
|
references::resolve::ResolveCreatedReference,
|
|
request::{IntoValid, MaybeInvalid},
|
|
};
|
|
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
|
use registry::{
|
|
jmap::{JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryValue},
|
|
schema::{
|
|
enums::{Permission, TenantStorageQuota},
|
|
prelude::{
|
|
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
|
|
Property,
|
|
},
|
|
structs::{
|
|
Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript,
|
|
SieveUserScript, Task,
|
|
},
|
|
},
|
|
types::id::ObjectId,
|
|
};
|
|
use std::borrow::Cow;
|
|
use store::{
|
|
registry::{
|
|
bootstrap::Bootstrap,
|
|
write::{RegistryWrite, RegistryWriteResult},
|
|
},
|
|
write::BatchBuilder,
|
|
};
|
|
use trc::AddContext;
|
|
use types::id::Id;
|
|
use utils::map::vec_map::VecMap;
|
|
|
|
pub trait RegistrySet: Sync + Send {
|
|
fn registry_set(
|
|
&self,
|
|
object_type: ObjectType,
|
|
request: SetRequest<'_, Registry>,
|
|
access_token: &AccessToken,
|
|
session: &HttpSessionData,
|
|
) -> impl Future<Output = trc::Result<SetResponse<Registry>>> + Send;
|
|
}
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum Modification {
|
|
Create {
|
|
client_id: String,
|
|
object: Option<Object>,
|
|
},
|
|
Update {
|
|
id: Id,
|
|
object: Object,
|
|
},
|
|
}
|
|
|
|
impl RegistrySet for Server {
|
|
async fn registry_set(
|
|
&self,
|
|
object_type: ObjectType,
|
|
mut request: SetRequest<'_, Registry>,
|
|
access_token: &AccessToken,
|
|
session: &HttpSessionData,
|
|
) -> trc::Result<SetResponse<Registry>> {
|
|
// Initial assertions
|
|
if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) {
|
|
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
|
|
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
|
|
"can be modified until the bootstrap process is complete.",
|
|
)));
|
|
}
|
|
self.assert_enterprise_object(object_type)?;
|
|
|
|
let object_flags = object_type.flags();
|
|
let is_singleton = (object_flags & OBJ_SINGLETON) != 0;
|
|
let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0;
|
|
let is_tenant_filtered =
|
|
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
|
|
let can_set_tenant = access_token.tenant_id().is_none();
|
|
let can_set_account = access_token.has_permission(Permission::Impersonate);
|
|
let is_account_filtered = has_account_id && !can_set_account;
|
|
|
|
// Build response
|
|
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
|
|
|
// Initial create validation for singletons
|
|
let create = request.unwrap_create();
|
|
|
|
// Initial destroy validation for singletons
|
|
let mut destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
|
|
if is_singleton && !destroy.is_empty() {
|
|
response.not_destroyed.extend(
|
|
destroy
|
|
.drain(..)
|
|
.map(|id| (MaybeInvalid::Value(id), SetError::singleton())),
|
|
);
|
|
}
|
|
|
|
// Update validation for willDestroy
|
|
let update = request
|
|
.unwrap_update()
|
|
.into_valid()
|
|
.filter_map(|(id, value)| {
|
|
if is_singleton {
|
|
if id.is_singleton() {
|
|
Some((id, value))
|
|
} else {
|
|
response.not_updated.append(id, SetError::not_found());
|
|
None
|
|
}
|
|
} else if !destroy.contains(&id) {
|
|
Some((id, value))
|
|
} else {
|
|
response.not_updated.append(id, SetError::will_destroy());
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut set = RegistrySetResponse {
|
|
access_token,
|
|
server: self,
|
|
remote_ip: session.remote_ip,
|
|
account_id: request.account_id.document_id(),
|
|
object_type,
|
|
response,
|
|
is_tenant_filtered,
|
|
is_account_filtered,
|
|
create,
|
|
update,
|
|
destroy,
|
|
};
|
|
let result = match object_type {
|
|
ObjectType::AddressBook
|
|
| ObjectType::Asn
|
|
| ObjectType::Authentication
|
|
| ObjectType::BlobStore
|
|
| ObjectType::Cache
|
|
| ObjectType::Calendar
|
|
| ObjectType::CalendarAlarm
|
|
| ObjectType::CalendarScheduling
|
|
| ObjectType::Coordinator
|
|
| ObjectType::DataRetention
|
|
| ObjectType::DataStore
|
|
| ObjectType::DkimReportSettings
|
|
| ObjectType::DmarcReportSettings
|
|
| ObjectType::DnsResolver
|
|
| ObjectType::Email
|
|
| ObjectType::Enterprise
|
|
| ObjectType::FileStorage
|
|
| ObjectType::Http
|
|
| ObjectType::HttpForm
|
|
| ObjectType::Imap
|
|
| ObjectType::InMemoryStore
|
|
| ObjectType::Jmap
|
|
| ObjectType::SystemSettings
|
|
| ObjectType::Metrics
|
|
| ObjectType::MetricsStore
|
|
| ObjectType::MtaConnectionStrategy
|
|
| ObjectType::MtaExtensions
|
|
| ObjectType::MtaInboundSession
|
|
| ObjectType::MtaOutboundStrategy
|
|
| ObjectType::MtaOutboundThrottle
|
|
| ObjectType::MtaStageAuth
|
|
| ObjectType::MtaStageConnect
|
|
| ObjectType::MtaStageData
|
|
| ObjectType::MtaStageEhlo
|
|
| ObjectType::MtaStageMail
|
|
| ObjectType::MtaStageRcpt
|
|
| ObjectType::MtaSts
|
|
| ObjectType::OidcProvider
|
|
| ObjectType::ReportSettings
|
|
| ObjectType::Search
|
|
| ObjectType::SearchStore
|
|
| ObjectType::Security
|
|
| ObjectType::SenderAuth
|
|
| ObjectType::Sharing
|
|
| ObjectType::SieveSystemInterpreter
|
|
| ObjectType::SieveUserInterpreter
|
|
| ObjectType::SpamClassifier
|
|
| ObjectType::SpamDnsblSettings
|
|
| ObjectType::SpamLlm
|
|
| ObjectType::SpamPyzor
|
|
| ObjectType::SpamSettings
|
|
| ObjectType::SpfReportSettings
|
|
| ObjectType::TaskManager
|
|
| ObjectType::TlsReportSettings
|
|
| ObjectType::TracingStore
|
|
| ObjectType::WebDav
|
|
| ObjectType::DsnReportSettings
|
|
| ObjectType::AcmeProvider
|
|
| ObjectType::AiModel
|
|
| ObjectType::Alert
|
|
| ObjectType::AllowedIp
|
|
| ObjectType::Application
|
|
| ObjectType::BlockedIp
|
|
| ObjectType::Certificate
|
|
| ObjectType::Directory
|
|
| ObjectType::DnsServer
|
|
| ObjectType::EventTracingLevel
|
|
| ObjectType::HttpLookup
|
|
| ObjectType::MemoryLookupKey
|
|
| ObjectType::MemoryLookupKeyValue
|
|
| ObjectType::MtaVirtualQueue
|
|
| ObjectType::MtaQueueQuota
|
|
| ObjectType::MtaRoute
|
|
| ObjectType::MtaDeliverySchedule
|
|
| ObjectType::MtaInboundThrottle
|
|
| ObjectType::MtaTlsStrategy
|
|
| ObjectType::MtaMilter
|
|
| ObjectType::MtaHook
|
|
| ObjectType::NetworkListener
|
|
| ObjectType::ClusterRole
|
|
| ObjectType::SieveSystemScript
|
|
| ObjectType::SieveUserScript
|
|
| ObjectType::SpamDnsblServer
|
|
| ObjectType::SpamFileExtension
|
|
| ObjectType::SpamRule
|
|
| ObjectType::SpamTag
|
|
| ObjectType::StoreLookup
|
|
| ObjectType::Tracer
|
|
| ObjectType::WebHook
|
|
| ObjectType::PublicKey
|
|
| ObjectType::DkimSignature
|
|
| ObjectType::MaskedEmail
|
|
| ObjectType::Account
|
|
| ObjectType::MailingList
|
|
| ObjectType::OAuthClient
|
|
| ObjectType::Role
|
|
| ObjectType::Tenant
|
|
| ObjectType::Domain => {
|
|
// Bundle modifications together
|
|
let mut modifications = Vec::with_capacity(set.create.len() + set.update.len());
|
|
for (id, value) in set.create.drain() {
|
|
if is_singleton
|
|
&& let Some(object) = self
|
|
.registry()
|
|
.get(ObjectId::new(object_type, Id::singleton()))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
modifications.push((
|
|
Modification::Create {
|
|
client_id: id,
|
|
object: Some(object),
|
|
},
|
|
value,
|
|
Object::from(set.object_type),
|
|
));
|
|
} else {
|
|
modifications.push((
|
|
Modification::Create {
|
|
client_id: id,
|
|
object: None,
|
|
},
|
|
value,
|
|
Object::from(set.object_type),
|
|
));
|
|
}
|
|
}
|
|
for (id, value) in set.update.drain(..) {
|
|
if let Some(object) = self
|
|
.registry()
|
|
.get(ObjectId::new(object_type, id))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
if (is_tenant_filtered
|
|
&& access_token.tenant_id().map(Id::from)
|
|
!= object.inner.member_tenant_id())
|
|
|| (is_account_filtered
|
|
&& object.inner.account_id() != Some(Id::from(set.account_id)))
|
|
{
|
|
set.response.not_updated.append(id, SetError::not_found());
|
|
continue;
|
|
}
|
|
|
|
modifications.push((
|
|
Modification::Update {
|
|
id,
|
|
object: object.clone(),
|
|
},
|
|
value,
|
|
object,
|
|
));
|
|
} else if is_singleton {
|
|
modifications.push((
|
|
Modification::Update {
|
|
id,
|
|
object: Object::from(set.object_type),
|
|
},
|
|
value,
|
|
Object::from(set.object_type),
|
|
));
|
|
} else {
|
|
set.response.not_updated.append(id, SetError::not_found());
|
|
}
|
|
}
|
|
|
|
// Process modifications
|
|
let mut cache_invalidator = CacheInvalidationBuilder::default();
|
|
'outer: for (modification, mut value, mut new_object) in modifications {
|
|
// Initial validations
|
|
let is_create = matches!(modification, Modification::Create { .. });
|
|
let mut unpatched_properties = VecMap::new();
|
|
|
|
if let Err(err) = set.response.resolve_self_references(&mut value, 0, true) {
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
};
|
|
|
|
if is_create
|
|
|| value
|
|
.as_object()
|
|
.unwrap()
|
|
.get(&Key::Property(Property::Type))
|
|
.and_then(|v| v.as_str())
|
|
.is_some_and(|t| new_object.object_variant().is_some_and(|v| v != t))
|
|
{
|
|
// Patch object
|
|
match new_object.patch(
|
|
JsonPointerPatch::new(&JsonPointer::new(vec![]))
|
|
.with_create(true)
|
|
.with_can_set_tenant(can_set_tenant)
|
|
.with_can_set_account(can_set_account),
|
|
value,
|
|
) {
|
|
Ok(MaybeUnpatched::Patched) => {}
|
|
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
|
unpatched_properties.append(property, value);
|
|
}
|
|
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
|
unpatched_properties = properties;
|
|
}
|
|
Err(err) => {
|
|
set.failed(modification, err.into());
|
|
continue 'outer;
|
|
}
|
|
}
|
|
|
|
// Add tenantId for tenant filtered objects
|
|
if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id()
|
|
{
|
|
new_object.inner.set_member_tenant_id(tenant_id.into());
|
|
}
|
|
|
|
// inbuxa: MT-7: a principal takes its domain's tenant
|
|
if can_set_tenant {
|
|
inbuxa_features::tenancy::writes::default_tenant(
|
|
self.registry(),
|
|
&mut new_object,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Add accountId
|
|
if has_account_id {
|
|
new_object.inner.set_account_id(set.account_id.into());
|
|
}
|
|
} else {
|
|
for (key, value) in value.into_expanded_object() {
|
|
let ptr = match key {
|
|
Key::Property(Property::Type) => {
|
|
continue;
|
|
}
|
|
Key::Property(prop) => {
|
|
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(
|
|
prop,
|
|
))])
|
|
}
|
|
Key::Borrowed(other) => JsonPointer::parse(other),
|
|
Key::Owned(other) => JsonPointer::parse(&other),
|
|
};
|
|
|
|
// Patch object
|
|
match new_object.patch(
|
|
JsonPointerPatch::new(&ptr)
|
|
.with_create(false)
|
|
.with_can_set_tenant(can_set_tenant)
|
|
.with_can_set_account(can_set_account),
|
|
value,
|
|
) {
|
|
Ok(MaybeUnpatched::Patched) => {}
|
|
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
|
unpatched_properties.append(property, value);
|
|
}
|
|
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
|
if unpatched_properties.is_empty() {
|
|
unpatched_properties = properties;
|
|
} else {
|
|
unpatched_properties.extend(properties);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
set.failed(modification, err.into());
|
|
continue 'outer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate objects
|
|
let mut tasks = Vec::new();
|
|
let result = match &mut new_object.inner {
|
|
ObjectInner::Account(account) => {
|
|
validate_account(self, access_token, account, modification.as_account())
|
|
.await?
|
|
}
|
|
ObjectInner::Role(role) => {
|
|
validate_role(self, access_token, role, modification.as_role()).await?
|
|
}
|
|
ObjectInner::PublicKey(key) => {
|
|
validate_public_key(&set, key, modification.as_public_key()).await?
|
|
}
|
|
ObjectInner::DkimSignature(key) => {
|
|
validate_dkim_signature(&set, key, modification.as_dkim_signature())
|
|
.await?
|
|
}
|
|
ObjectInner::Domain(domain) => {
|
|
validate_domain(&set, domain, modification.as_domain(), &mut tasks)
|
|
.await?
|
|
}
|
|
ObjectInner::DnsServer(dns) => {
|
|
validate_dns_server(&set, dns, modification.as_dns_server()).await?
|
|
}
|
|
ObjectInner::MailingList(_) if is_create => {
|
|
validate_tenant_quota(
|
|
self,
|
|
access_token,
|
|
TenantStorageQuota::MaxMailingLists,
|
|
)
|
|
.await?
|
|
}
|
|
ObjectInner::OAuthClient(client) => {
|
|
if let Some(secret) = client.secret.as_mut()
|
|
&& !secret.is_empty()
|
|
&& !(matches!(secret.as_bytes().first(), Some(&b'$' | &b'{'))
|
|
&& is_password_hash(secret))
|
|
{
|
|
*secret = hash_secret(
|
|
set.server.core.network.security.password_hash_algorithm,
|
|
std::mem::take(secret).into_bytes(),
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
}
|
|
if is_create {
|
|
validate_tenant_quota(
|
|
self,
|
|
access_token,
|
|
TenantStorageQuota::MaxOauthClients,
|
|
)
|
|
.await?
|
|
} else {
|
|
Ok(ObjectResponse::default())
|
|
}
|
|
}
|
|
ObjectInner::Directory(_) if is_create => {
|
|
validate_tenant_quota(
|
|
self,
|
|
access_token,
|
|
TenantStorageQuota::MaxDirectories,
|
|
)
|
|
.await?
|
|
}
|
|
// inbuxa: legacy-protocols LP-4
|
|
ObjectInner::NetworkListener(listener) => {
|
|
crate::inbuxa::protocol_policy::validate_listener(&set, listener)
|
|
.await?
|
|
}
|
|
// inbuxa: ME-12 to ME-17
|
|
ObjectInner::MaskedEmail(mask) => {
|
|
let old = match &modification {
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::MaskedEmail(old) => Some(old),
|
|
_ => None,
|
|
},
|
|
Modification::Create { .. } => None,
|
|
};
|
|
crate::inbuxa::masked_email::validate(
|
|
&set,
|
|
mask,
|
|
old,
|
|
unpatched_properties,
|
|
)
|
|
.await?
|
|
}
|
|
ObjectInner::AcmeProvider(provider) if is_create => {
|
|
validate_acme_provider(&set, provider, unpatched_properties).await?
|
|
}
|
|
ObjectInner::Certificate(cert) => {
|
|
validate_certificate(cert, modification.as_certificate()).await?
|
|
}
|
|
ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => {
|
|
validate_sieve_script(
|
|
set.server,
|
|
contents,
|
|
modification.as_sieve_script(),
|
|
false,
|
|
)
|
|
.await?
|
|
}
|
|
ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
|
|
validate_sieve_script(
|
|
set.server,
|
|
contents,
|
|
modification.as_sieve_script(),
|
|
true,
|
|
)
|
|
.await?
|
|
}
|
|
_ => Ok(ObjectResponse::default()),
|
|
};
|
|
|
|
let mut response = match result {
|
|
Ok(response) => response,
|
|
Err(err) => {
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
}
|
|
};
|
|
|
|
// inbuxa: MT-3, MT-8, MT-17
|
|
let (stored_id, stored) = match &modification {
|
|
Modification::Update { id, object } => (Some(*id), Some(object)),
|
|
Modification::Create { .. } => (None, None),
|
|
};
|
|
let after_save = match inbuxa_features::tenancy::writes::check(
|
|
self.registry(),
|
|
stored_id,
|
|
stored,
|
|
&new_object,
|
|
)
|
|
.await?
|
|
{
|
|
Ok(after_save) => after_save,
|
|
Err(err) => {
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
}
|
|
};
|
|
|
|
// inbuxa: BT-3, BT-15, BT-22: logos and templates follow their rules
|
|
let before = match &modification {
|
|
Modification::Update { object, .. }
|
|
| Modification::Create {
|
|
object: Some(object),
|
|
..
|
|
} => Some(object),
|
|
Modification::Create { object: None, .. } => None,
|
|
};
|
|
if let Err(err) =
|
|
inbuxa_features::branding::writes::check(before, &new_object)
|
|
{
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
}
|
|
|
|
// inbuxa: AI-12, AI-18: the classifier and its models follow their rules
|
|
match inbuxa_features::ai::writes::check(self.registry(), &new_object).await? {
|
|
Ok(()) => {}
|
|
Err(err) => {
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
}
|
|
}
|
|
|
|
// inbuxa: UD-16: a kept account's addresses stay its own
|
|
if let Some(err) =
|
|
crate::inbuxa::deleted_account::reserved(self, stored, &new_object).await?
|
|
{
|
|
set.failed(modification, err);
|
|
continue 'outer;
|
|
}
|
|
|
|
// Validate expressions
|
|
// inbuxa: MON-25: an alert condition may name metrics with underscores
|
|
let alert_condition = match &new_object.inner {
|
|
ObjectInner::Alert(alert) => Some(
|
|
common::telemetry::alerts::rewrite_condition(&alert.condition),
|
|
),
|
|
_ => None,
|
|
};
|
|
let expressions = match (&new_object.inner, &alert_condition) {
|
|
(ObjectInner::Alert(alert), Some(condition)) => {
|
|
Some(vec![registry::schema::prelude::ExpressionContext {
|
|
expr: condition,
|
|
..alert.ctx_condition()
|
|
}])
|
|
}
|
|
_ => new_object.inner.expression_ctxs(),
|
|
};
|
|
if let Some(expressions) = expressions {
|
|
let mut bp = Bootstrap::new_uninitialized(self.registry().clone());
|
|
|
|
for expression in expressions {
|
|
bp.compile_expr(ObjectId::new(object_type, 0u64.into()), &expression);
|
|
if !bp.errors.is_empty() {
|
|
set.failed(
|
|
modification,
|
|
map_bootstrap_error(bp.errors)
|
|
.with_object_id_opt(None)
|
|
.with_property(expression.property),
|
|
);
|
|
continue 'outer;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save object
|
|
let result = match &modification {
|
|
Modification::Create { client_id, object } => {
|
|
if let Some(object) = object {
|
|
if object.inner != new_object.inner {
|
|
self.registry()
|
|
.write(RegistryWrite::update(
|
|
Id::singleton(),
|
|
&new_object,
|
|
object,
|
|
))
|
|
.await?
|
|
} else {
|
|
set.response.created(client_id.to_string(), Id::singleton());
|
|
continue;
|
|
}
|
|
} else {
|
|
self.registry()
|
|
.write(RegistryWrite::Insert {
|
|
object: &new_object,
|
|
id: response.id,
|
|
})
|
|
.await?
|
|
}
|
|
}
|
|
Modification::Update { id, object } => {
|
|
if object.inner != new_object.inner {
|
|
if !(is_singleton && object.revision == 0) {
|
|
self.registry()
|
|
.write(RegistryWrite::update(*id, &new_object, object))
|
|
.await?
|
|
} else {
|
|
self.registry()
|
|
.write(RegistryWrite::insert(&new_object))
|
|
.await?
|
|
}
|
|
} else {
|
|
set.response.updated.append(*id, None);
|
|
continue;
|
|
}
|
|
}
|
|
};
|
|
|
|
let object_id = match (modification, result) {
|
|
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
|
|
cache_invalidator.process_update(id, &object, &new_object);
|
|
// inbuxa: AI-2: content leaving the network is flagged
|
|
if let ObjectInner::AiModel(model) = &new_object.inner {
|
|
self.ai_warn_if_remote(model).await;
|
|
}
|
|
// inbuxa: MT-8: what moves with a domain follows it
|
|
for (id, old, new) in inbuxa_features::tenancy::writes::after_save(
|
|
&self.core.storage.data,
|
|
self.registry(),
|
|
after_save,
|
|
)
|
|
.await?
|
|
{
|
|
cache_invalidator.process_update(id, &old, &new);
|
|
}
|
|
// inbuxa: ME-1, ME-2
|
|
if let (ObjectInner::MaskedEmail(old), ObjectInner::MaskedEmail(new)) =
|
|
(&object.inner, &new_object.inner)
|
|
{
|
|
crate::inbuxa::masked_email::updated(self, id, old, new).await?;
|
|
}
|
|
if let (
|
|
ObjectInner::Application(previous),
|
|
ObjectInner::Application(updated),
|
|
) = (&object.inner, &new_object.inner)
|
|
&& previous.resource_url != updated.resource_url
|
|
&& let Err(err) =
|
|
WebApplicationManager::delete_bundle(self, id).await
|
|
{
|
|
trc::error!(
|
|
err.details("Failed to delete cached application bundle")
|
|
);
|
|
}
|
|
set.response.updated.append(
|
|
id,
|
|
if !response.object.is_empty() {
|
|
Some(JmapValue::Object(response.object))
|
|
} else {
|
|
None
|
|
},
|
|
);
|
|
Some(id)
|
|
}
|
|
(
|
|
Modification::Create { client_id, .. },
|
|
RegistryWriteResult::Success(id),
|
|
) => {
|
|
cache_invalidator.process_create(&new_object);
|
|
// inbuxa: AI-2: content leaving the network is flagged
|
|
if let ObjectInner::AiModel(model) = &new_object.inner {
|
|
self.ai_warn_if_remote(model).await;
|
|
}
|
|
// inbuxa: ME-7a
|
|
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
|
|
crate::inbuxa::masked_email::created(self, id, mask).await?;
|
|
}
|
|
// inbuxa: personal-data catalog: a new tenant gets its
|
|
// Compliance Officer role
|
|
if matches!(new_object.inner, ObjectInner::Tenant(_)) {
|
|
common::manager::compliance_roles::tenant_created(
|
|
self.registry(),
|
|
&self.core.storage.data,
|
|
id,
|
|
)
|
|
.await?;
|
|
}
|
|
response.object.insert(Property::Id, RegistryValue::Id(id));
|
|
set.response
|
|
.created
|
|
.insert(client_id, JmapValue::Object(response.object));
|
|
Some(id)
|
|
}
|
|
(Modification::Update { id, .. }, err) => {
|
|
set.response.not_updated.append(id, map_write_error(err));
|
|
None
|
|
}
|
|
(Modification::Create { client_id, .. }, err) => {
|
|
set.response
|
|
.not_created
|
|
.append(client_id, map_write_error(err));
|
|
None
|
|
}
|
|
};
|
|
|
|
// Dispatch tasks
|
|
if !tasks.is_empty()
|
|
&& let Some(object_id) = object_id
|
|
{
|
|
let mut batch = BatchBuilder::new();
|
|
for mut task in tasks.drain(..) {
|
|
match &mut task {
|
|
Task::AcmeRenewal(task) => task.domain_id = object_id,
|
|
Task::DkimManagement(task) => task.domain_id = object_id,
|
|
Task::DnsManagement(task) => task.domain_id = object_id,
|
|
_ => unreachable!(),
|
|
}
|
|
batch.schedule_task(task);
|
|
}
|
|
set.server.store().write(batch.build_all()).await?;
|
|
set.server.notify_task_queue();
|
|
}
|
|
}
|
|
|
|
// Process destroy
|
|
for id in set.destroy.drain(..) {
|
|
let object_id = ObjectId::new(object_type, id);
|
|
if let Some(object) = self
|
|
.registry()
|
|
.get(object_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.filter(|object| {
|
|
!((is_tenant_filtered
|
|
&& access_token.tenant_id().map(Id::from)
|
|
!= object.inner.member_tenant_id())
|
|
|| (is_account_filtered
|
|
&& object.inner.account_id() != Some(Id::from(set.account_id))))
|
|
})
|
|
{
|
|
// inbuxa: personal-data catalog: a tenant's compliance
|
|
// role, while nobody holds it, goes first
|
|
let role_released = matches!(object.inner, ObjectInner::Tenant(_))
|
|
&& common::manager::compliance_roles::tenant_deleting(
|
|
self.registry(),
|
|
&self.core.storage.data,
|
|
id,
|
|
)
|
|
.await?;
|
|
match self
|
|
.registry()
|
|
.write(RegistryWrite::Delete {
|
|
object_id,
|
|
object: Some(&object),
|
|
allowed_orphan_types: if object_type == ObjectType::Account {
|
|
&[ObjectType::PublicKey, ObjectType::MaskedEmail]
|
|
} else {
|
|
&[]
|
|
},
|
|
})
|
|
.await?
|
|
{
|
|
RegistryWriteResult::Success(_) => {
|
|
// inbuxa: UD-15, UD-17a: kept for its period, shares suspended
|
|
if let ObjectInner::Account(account) = &object.inner
|
|
&& let Some(others) =
|
|
crate::inbuxa::deleted_account::keep(self, id, account)
|
|
.await?
|
|
{
|
|
for other in others {
|
|
cache_invalidator
|
|
.invalidate(CacheInvalidation::AccessToken(other));
|
|
}
|
|
} else if let ObjectInner::Account(account) = &object.inner {
|
|
for sharee_id in self
|
|
.store()
|
|
.acl_revoke_all(id.document_id())
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
cache_invalidator
|
|
.invalidate(CacheInvalidation::AccessToken(sharee_id));
|
|
}
|
|
|
|
schedule_account_destruction(set.server, id, account).await?;
|
|
}
|
|
|
|
if matches!(object.inner, ObjectInner::Application(_))
|
|
&& let Err(err) =
|
|
WebApplicationManager::delete_bundle(self, id).await
|
|
{
|
|
trc::error!(
|
|
err.details("Failed to delete cached application bundle")
|
|
);
|
|
}
|
|
|
|
// inbuxa: ME-3
|
|
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
|
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
|
}
|
|
// inbuxa: legacy-protocols, a tenant's switch goes with it
|
|
if matches!(object.inner, ObjectInner::Tenant(_)) {
|
|
inbuxa_features::security::tenant_protocol_policy::remove(
|
|
&self.core.storage.data,
|
|
id.document_id(),
|
|
)
|
|
.await?;
|
|
}
|
|
cache_invalidator.process_delete(id, &object);
|
|
set.response.destroyed.push(id);
|
|
}
|
|
err => {
|
|
// inbuxa: refused for another reason: the role comes back
|
|
if role_released {
|
|
common::manager::compliance_roles::tenant_kept(
|
|
self.registry(),
|
|
&self.core.storage.data,
|
|
id,
|
|
)
|
|
.await?;
|
|
}
|
|
set.response.not_destroyed.append(id, map_write_error(err));
|
|
}
|
|
}
|
|
} else {
|
|
set.response.not_destroyed.append(id, SetError::not_found());
|
|
}
|
|
}
|
|
|
|
// Finalize cache invalidation
|
|
self.invalidate_caches(cache_invalidator).await?;
|
|
|
|
// inbuxa: personal-data catalog: what the server holds may
|
|
// have changed, so the inventory's history is brought up to date
|
|
let response = set.into_response();
|
|
if !response.created.is_empty()
|
|
|| !response.updated.is_empty()
|
|
|| !response.destroyed.is_empty()
|
|
{
|
|
self.inventory_snapshot_after(&format!(
|
|
"x:{}",
|
|
registry::types::EnumImpl::as_str(&object_type)
|
|
))
|
|
.await;
|
|
}
|
|
Ok(response)
|
|
}
|
|
ObjectType::ArfExternalReport
|
|
| ObjectType::DmarcExternalReport
|
|
| ObjectType::TlsExternalReport
|
|
| ObjectType::DmarcInternalReport
|
|
| ObjectType::TlsInternalReport => report_set(set).await.map(|set| set.into_response()),
|
|
|
|
ObjectType::SpamTrainingSample => {
|
|
spam_sample_set(set).await.map(|set| set.into_response())
|
|
}
|
|
|
|
ObjectType::AccountSettings
|
|
| ObjectType::ApiKey
|
|
| ObjectType::AccountPassword
|
|
| ObjectType::AppPassword => Box::pin(account_set(set))
|
|
.await
|
|
.map(|set| set.into_response()),
|
|
|
|
ObjectType::QueuedMessage => {
|
|
queued_message_set(set).await.map(|set| set.into_response())
|
|
}
|
|
|
|
ObjectType::Task => task_set(set).await.map(|set| set.into_response()),
|
|
|
|
// inbuxa: undelete (UD-8, UD-12)
|
|
ObjectType::ArchivedItem => crate::inbuxa::undelete::set(set)
|
|
.await
|
|
.map(|set| set.into_response()),
|
|
|
|
ObjectType::Action => Box::pin(action_set(set))
|
|
.await
|
|
.map(|set| set.into_response()),
|
|
|
|
ObjectType::Bootstrap => Box::pin(bootstrap_set(set))
|
|
.await
|
|
.map(|set| set.into_response()),
|
|
|
|
// inbuxa: MON-32: a trace can be destroyed, never created or changed
|
|
ObjectType::Trace => crate::inbuxa::telemetry::trace_set(set)
|
|
.await
|
|
.map(|set| set.into_response()),
|
|
ObjectType::Log | ObjectType::Metric | ObjectType::ClusterNode => {
|
|
set.fail_all_create("Telemetry objects cannot be created");
|
|
set.fail_all_update("Telemetry objects cannot be modified");
|
|
set.fail_all_destroy("Telemetry objects cannot be deleted");
|
|
Ok(set.into_response())
|
|
}
|
|
#[allow(unreachable_patterns)] // inbuxa: ArchivedItem was the last one
|
|
_ => {
|
|
set.fail_all_create("Enterprise objects cannot be created");
|
|
set.fail_all_update("Enterprise objects cannot be modified");
|
|
set.fail_all_destroy("Enterprise objects cannot be deleted");
|
|
Ok(set.into_response())
|
|
}
|
|
};
|
|
|
|
// inbuxa: a write to an object the running settings are built from
|
|
// applies at once, here and on every node (DIR-17 did this for
|
|
// directories and the server default; now it covers every such object)
|
|
let mut result = result;
|
|
if let Ok(response) = &mut result
|
|
&& (!response.created.is_empty()
|
|
|| !response.updated.is_empty()
|
|
|| !response.destroyed.is_empty())
|
|
&& let Some(reload) = self.reload_after_write(object_type).await
|
|
{
|
|
response.settings_reload = Some(match reload {
|
|
Ok(()) => SettingsReload {
|
|
applied: true,
|
|
description: None,
|
|
},
|
|
Err(reason) => SettingsReload {
|
|
applied: false,
|
|
description: Some(format!(
|
|
"Saved, but the running settings were not reloaded. {reason}"
|
|
)),
|
|
},
|
|
});
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
impl RegistrySetResponse<'_> {
|
|
fn failed(&mut self, modification: Modification, error: SetError<Property>) {
|
|
match modification {
|
|
Modification::Create { client_id, .. } => {
|
|
self.response.not_created.append(client_id, error)
|
|
}
|
|
Modification::Update { id, .. } => self.response.not_updated.append(id, error),
|
|
}
|
|
}
|
|
|
|
pub fn fail_all(&mut self, error: SetError<Property>) {
|
|
for (client_id, _) in self.create.drain() {
|
|
self.response.not_created.append(client_id, error.clone());
|
|
}
|
|
for (id, _) in self.update.drain(..) {
|
|
self.response.not_updated.append(id, error.clone());
|
|
}
|
|
for id in self.destroy.drain(..) {
|
|
self.response.not_destroyed.append(id, error.clone());
|
|
}
|
|
}
|
|
|
|
pub fn fail_all_create(&mut self, error: impl Into<Cow<'static, str>>) {
|
|
let error = error.into();
|
|
for (client_id, _) in self.create.drain() {
|
|
self.response.not_created.append(
|
|
client_id,
|
|
SetError::forbidden().with_description(error.clone()),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn fail_all_update(&mut self, error: impl Into<Cow<'static, str>>) {
|
|
let error = error.into();
|
|
for (id, _) in self.update.drain(..) {
|
|
self.response
|
|
.not_updated
|
|
.append(id, SetError::forbidden().with_description(error.clone()));
|
|
}
|
|
}
|
|
|
|
pub fn fail_all_destroy(&mut self, error: impl Into<Cow<'static, str>>) {
|
|
let error = error.into();
|
|
for id in self.destroy.drain(..) {
|
|
self.response
|
|
.not_destroyed
|
|
.append(id, SetError::forbidden().with_description(error.clone()));
|
|
}
|
|
}
|
|
|
|
fn into_response(self) -> SetResponse<Registry> {
|
|
self.response
|
|
}
|
|
}
|
|
|
|
impl Modification {
|
|
fn as_account(&self) -> AccountUpdate<'_> {
|
|
match self {
|
|
Modification::Create { client_id, .. } => AccountUpdate::Create(client_id),
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::Account(account) => AccountUpdate::Update(account),
|
|
_ => unreachable!(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_role(&self) -> Option<&Role> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::Role(role) => Some(role),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_public_key(&self) -> Option<&PublicKey> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::PublicKey(key) => Some(key),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_dkim_signature(&self) -> Option<&DkimSignature> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::DkimSignature(key) => Some(key),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_domain(&self) -> Option<&Domain> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::Domain(domain) => Some(domain),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_dns_server(&self) -> Option<&DnsServer> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::DnsServer(dns) => Some(dns),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_certificate(&self) -> Option<&Certificate> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::Certificate(cert) => Some(cert),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn as_sieve_script(&self) -> Option<&str> {
|
|
match self {
|
|
Modification::Create { .. } => None,
|
|
Modification::Update { object, .. } => match &object.inner {
|
|
ObjectInner::SieveUserScript(SieveUserScript { contents, .. })
|
|
| ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
|
|
Some(contents.as_str())
|
|
}
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError<Property> {
|
|
match err {
|
|
RegistryWriteResult::CannotDeleteLinked {
|
|
object_id,
|
|
linked_objects,
|
|
} => SetError::new(SetErrorType::ObjectIsLinked)
|
|
.with_object_id(object_id)
|
|
.with_linked_objects(linked_objects),
|
|
RegistryWriteResult::InvalidSingletonId => SetError::invalid_properties()
|
|
.with_property(Property::Id)
|
|
.with_description("Invalid singleton id"),
|
|
RegistryWriteResult::CannotDeleteSingleton => {
|
|
SetError::forbidden().with_description("Singleton objects cannot be deleted")
|
|
}
|
|
RegistryWriteResult::InvalidForeignKey { object_id } => {
|
|
SetError::new(SetErrorType::InvalidForeignKey).with_object_id(object_id)
|
|
}
|
|
RegistryWriteResult::PrimaryKeyConflict {
|
|
property,
|
|
existing_id,
|
|
} => SetError::new(SetErrorType::PrimaryKeyViolation)
|
|
.with_property(property)
|
|
.with_object_id(existing_id),
|
|
RegistryWriteResult::ValidationError { errors } => {
|
|
SetError::new(SetErrorType::ValidationFailed).with_validation_errors(errors)
|
|
}
|
|
RegistryWriteResult::NotSupported => SetError::forbidden()
|
|
.with_description("The requested action is not supported by the registry store"),
|
|
RegistryWriteResult::NotFound { .. } => SetError::not_found(),
|
|
RegistryWriteResult::Success(_) => unreachable!(),
|
|
}
|
|
}
|