diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e3541c9..917908b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,6 +40,12 @@ jobs: # nothing. CI never sees the difference; a release does. - if: always() run: python3 tools/fork/context-check.py + # The personal-data catalog must classify every object and field the + # schema has, and name nothing that is gone. + - if: always() + run: python3 tools/fork/privacy-check.py + - if: always() + run: python3 -m unittest discover -s tools/fork/tests build: # Either runner (host1 or host2): the build needs no docker socket. diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml new file mode 100644 index 0000000..f647a70 --- /dev/null +++ b/resources/privacy/catalog.toml @@ -0,0 +1,2090 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-only +# +# The personal-data catalog (docs/spec/features/personal-data-catalog.md). +# +# Every place the server can store or send personal data, as possibilities: +# what a particular server holds depends on its settings, and the server +# evaluates this file against them. Facts only; nothing here judges. +# +# Three kinds of entry: +# [object."x:Name"] a registry object from resources/schema/schema.json.gz +# [object."inbuxa:Name"] one of inbuxa's own JMAP objects (crates/jmap-proto) +# [source."name"] anything that is no object: files, exporters, lookups +# +# An object's `default = "none"` covers every property not listed under its +# `.properties`. A property the schema types as an address, an IP or a secret +# is always listed. tools/fork/privacy-check.py enforces both, and that every +# object and setting named here exists. + +[vocabulary] +categories = ["identifier", "contact", "network", "content", "metadata", "credential"] +whose = ["holder", "correspondent", "administrator"] +where = ["data-store", "blob-store", "search-store", "in-memory-store", "memory", "log-file", "external"] +scope = ["tenant", "server"] +retention = ["unbounded", "object-life", "receiver"] + +# --------------------------------------------------------------------------- +# inbuxa's own objects. `file` names the jmap-proto source whose property +# names the check reads. + +[object."inbuxa:AuditEvent"] +file = "inbuxa_audit.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "inbuxa:AuditSettings.keepForDays" } +[object."inbuxa:AuditEvent".properties] +actor = ["identifier"] +via = ["identifier"] +remoteIp = ["network"] +target = ["identifier"] +changes = ["content"] +details = ["content"] +reason = ["content"] +at = ["metadata"] + +[object."inbuxa:AuditExport"] +file = "inbuxa_audit.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["blob-store"] +scope = "tenant" +retention = { setting = "x:Jmap.uploadTtl" } +[object."inbuxa:AuditExport".properties] +blobId = ["identifier", "network", "content"] +filter = ["identifier"] + +[object."inbuxa:AuditSettings"] +file = "inbuxa_audit.rs" +default = "none" + +[object."inbuxa:AuditVerification"] +file = "inbuxa_audit.rs" +default = "none" + +[object."inbuxa:AccountLock"] +file = "inbuxa_account_lock.rs" +default = "none" +whose = ["holder", "administrator"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."inbuxa:AccountLock".properties] +accountId = ["identifier"] +name = ["identifier"] +reason = ["content"] +lockedAt = ["metadata"] +lockedBy = ["identifier"] +delegates = ["identifier"] + +[object."inbuxa:LegalHold"] +file = "inbuxa_legal_hold.rs" +default = "none" +whose = ["administrator", "holder"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:LegalHold".properties] +name = ["content"] +reference = ["content"] +description = ["content"] +scope = ["identifier"] +reason = ["content"] +placedBy = ["identifier"] +releasedBy = ["identifier"] +releaseReason = ["content"] + +[object."inbuxa:HoldExport"] +file = "inbuxa_hold_export.rs" +default = "none" +whose = ["holder", "correspondent", "administrator"] +where = ["data-store", "blob-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:HoldExport".properties] +accountIds = ["identifier"] +reason = ["content"] +createdBy = ["identifier"] +blobId = ["content", "identifier", "contact", "metadata"] + +[object."inbuxa:DeletedAccount"] +file = "inbuxa_deleted_account.rs" +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedAccountsFor" } +[object."inbuxa:DeletedAccount".properties] +name = ["identifier"] +addresses = ["identifier"] +password = ["credential"] +deletedAt = ["metadata"] +keptUntil = ["metadata"] + +[object."inbuxa:Explanation"] +file = "inbuxa_explanation.rs" +default = "none" +whose = ["correspondent", "holder"] +where = ["memory", "external"] +scope = "server" +retention = "receiver" +[object."inbuxa:Explanation".properties] +subject = ["identifier", "network", "metadata"] +text = ["identifier", "network", "metadata"] +preparedFor = ["identifier"] + +[object."inbuxa:ProtocolPolicy"] +file = "inbuxa_protocol_policy.rs" +default = "none" +whose = ["administrator", "holder"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:ProtocolPolicy".properties] +changedBy = ["identifier"] +recentLegacyUse = ["identifier", "metadata"] + +[object."inbuxa:TenantProtocolPolicy"] +file = "inbuxa_tenant_protocol_policy.rs" +default = "none" +whose = ["administrator", "holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."inbuxa:TenantProtocolPolicy".properties] +changedBy = ["identifier"] +recentLegacyUse = ["identifier", "metadata"] + +[object."inbuxa:AiLimits"] +file = "inbuxa_ai_limits.rs" +default = "none" + +# --------------------------------------------------------------------------- +# Sources that are no object. Settings are `.`: a schema +# field object, or one of inbuxa's own. + +[source."mail-and-groupware"] +categories = ["content", "identifier", "contact", "metadata"] +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = "unbounded" +captures = ["x:DataRetention.expungeTrashAfter", "x:DataRetention.expungeSubmissionsAfter"] +leaves_host = false +written_by = ["crates/email/src/message/ingest.rs", "crates/groupware/src/calendar/storage.rs", "crates/groupware/src/contact/storage.rs", "crates/groupware/src/file/storage.rs"] + +[source."full-text-index"] +categories = ["content", "identifier", "contact", "metadata"] +whose = ["holder", "correspondent"] +where = ["search-store"] +scope = "tenant" +retention = "object-life" +enabled_by = ["x:Search.indexEmail", "x:Search.indexCalendar", "x:Search.indexContacts"] +captures = ["x:Search.indexEmailFields", "x:Search.indexCalendarFields", "x:Search.indexContactFields"] +leaves_host = false +written_by = ["crates/services/src/task_manager/index.rs"] + +[source."trace-index"] +categories = ["network", "identifier", "metadata"] +whose = ["correspondent", "holder"] +where = ["search-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +enabled_by = ["x:Search.indexTelemetry"] +captures = ["x:Search.indexTracingFields"] +leaves_host = false +written_by = ["crates/services/src/task_manager/index.rs"] + +[source."log-file"] +categories = ["network", "identifier", "metadata"] +whose = ["correspondent", "holder", "administrator"] +where = ["log-file"] +scope = "server" +retention = "unbounded" +enabled_by = ["x:TracerLog.enable"] +captures = ["x:TracerLog.level", "x:TracerLog.events", "x:EventTracingLevel.level"] +leaves_host = false +written_by = ["crates/common/src/telemetry/tracers/log.rs"] + +[source."console-and-journal"] +categories = ["network", "identifier", "metadata"] +whose = ["correspondent", "holder", "administrator"] +where = ["log-file"] +scope = "server" +retention = "receiver" +enabled_by = ["x:TracerStdout.enable"] +captures = ["x:TracerStdout.level"] +leaves_host = false +written_by = ["crates/common/src/telemetry/tracers/stdout.rs", "crates/common/src/telemetry/tracers/journald.rs"] + +[source."otel-tracer"] +categories = ["network", "identifier", "metadata"] +whose = ["correspondent", "holder", "administrator"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:TracerOtelHttp.enable", "x:TracerOtelGrpc.enable"] +captures = ["x:TracerOtelHttp.level", "x:TracerOtelGrpc.level"] +leaves_host = true +written_by = ["crates/common/src/telemetry/tracers/otel.rs"] + +[source."webhooks"] +categories = ["network", "identifier", "metadata", "content"] +whose = ["correspondent", "holder", "administrator"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:WebHook.enable"] +captures = ["x:WebHook.level", "x:WebHook.events", "x:WebHook.eventsPolicy"] +leaves_host = true +written_by = ["crates/common/src/telemetry/webhooks/mod.rs"] + +[source."spam-trainer-state"] +categories = ["metadata"] +whose = ["correspondent", "holder"] +where = ["blob-store"] +scope = "server" +retention = "unbounded" +enabled_by = ["x:SpamClassifier.model"] +leaves_host = false +written_by = ["crates/spam-filter/src/modules/classifier.rs"] + +[source."spam-llm"] +categories = ["content"] +whose = ["correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:SpamLlmProperties.modelId"] +captures = ["inbuxa:AiLimits.maxContentBytes"] +leaves_host = true +written_by = ["crates/spam-filter/src/analysis/llm.rs", "crates/common/src/enterprise/llm.rs"] + +[source."explain-cache"] +categories = ["identifier", "network", "metadata"] +whose = ["correspondent", "holder"] +where = ["memory"] +scope = "server" +retention = "unbounded" +enabled_by = ["inbuxa:AiLimits.explainEnabled"] +leaves_host = false +written_by = ["crates/features/src/ai/explain/memory.rs"] + +[source."spam-dnsbl"] +categories = ["network", "identifier", "metadata"] +whose = ["correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:SpamDnsblServerIp.enable", "x:SpamDnsblServerDomain.enable", "x:SpamDnsblServerEmail.enable", "x:SpamDnsblServerUrl.enable"] +leaves_host = true +written_by = ["crates/spam-filter/src/modules/dnsbl.rs"] + +[source."spam-pyzor"] +categories = ["content"] +whose = ["correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:SpamPyzor.enable"] +leaves_host = true +written_by = ["crates/spam-filter/src/modules/pyzor.rs"] + +[source."spam-url-redirects"] +categories = ["network"] +whose = ["correspondent", "holder"] +where = ["external"] +scope = "server" +retention = "receiver" +leaves_host = true +written_by = ["crates/spam-filter/src/analysis/url.rs"] + +[source."mta-milter-and-hooks"] +categories = ["network", "identifier", "content"] +whose = ["holder", "correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +enabled_by = ["x:MtaMilter.enable", "x:MtaHook.enable"] +leaves_host = true +written_by = ["crates/smtp/src/inbound/hooks/mod.rs"] + +[source."relay"] +categories = ["content", "identifier", "metadata"] +whose = ["holder", "correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +leaves_host = true +written_by = ["crates/smtp/src/outbound/delivery.rs"] + +[source."push-subscriptions"] +categories = ["network", "credential", "content"] +whose = ["holder"] +where = ["data-store", "external"] +scope = "tenant" +retention = "object-life" +captures = ["x:Jmap.maxSubscriptions"] +leaves_host = true +written_by = ["crates/jmap/src/push/set.rs", "crates/services/src/state_manager/http.rs"] + +[source."rate-limit-and-greylist"] +categories = ["network", "identifier"] +whose = ["holder", "correspondent"] +where = ["in-memory-store"] +scope = "server" +retention = "object-life" +enabled_by = ["x:SpamSettings.greylistFor"] +leaves_host = false +written_by = ["crates/common/src/auth/rate_limit.rs", "crates/smtp/src/inbound/rcpt.rs"] + +[source."legacy-last-use"] +categories = ["metadata"] +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +leaves_host = false +written_by = ["crates/features/src/security/legacy_use.rs"] + +[source."masked-address-records"] +categories = ["identifier", "metadata"] +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +captures = ["x:Email.maxMaskedAddresses"] +leaves_host = false +written_by = ["crates/features/src/masked_email/data.rs"] + +[source."outbound-reports"] +categories = ["network", "identifier", "content"] +whose = ["correspondent"] +where = ["external"] +scope = "server" +retention = "receiver" +captures = ["x:DmarcReportSettings.aggregateSendFrequency", "x:DmarcReportSettings.failureSendFrequency", "x:TlsReportSettings.sendFrequency"] +leaves_host = true +written_by = ["crates/smtp/src/reporting/dmarc.rs", "crates/smtp/src/reporting/tls.rs"] + +# --------------------------------------------------------------------------- +# Registry objects, one per schema field object. + +[object."AddressBook"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."AddressBook".properties] +description = ["content"] +name = ["content"] +shareWith = ["identifier"] + +[object."AddressBookRights"] +default = "none" + +[object."Calendar"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."Calendar".properties] +description = ["content"] +name = ["content"] +shareWith = ["identifier"] +timeZone = ["metadata"] + +[object."CalendarRights"] +default = "none" + +[object."Mailbox"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."Mailbox".properties] +name = ["content"] +shareWith = ["identifier"] + +[object."MailboxRights"] +default = "none" + +[object."Principal"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."Principal".properties] +description = ["content"] +email = ["identifier"] +name = ["identifier"] +timeZone = ["metadata"] + +[object."SieveScript"] +default = "none" +whose = ["holder"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = "object-life" +[object."SieveScript".properties] +blobId = ["content"] +name = ["content"] + +[object."VacationResponse"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."VacationResponse".properties] +fromDate = ["metadata"] +htmlBody = ["content"] +subject = ["content"] +textBody = ["content"] +toDate = ["metadata"] + +[object."x:AccountPassword"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:AccountPassword".properties] +currentSecret = ["credential"] +otpAuth = ["credential"] +secret = ["credential"] + +[object."x:AccountSettings"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:AccountSettings".properties] +description = ["content"] +locale = ["metadata"] +timeZone = ["metadata"] + +[object."x:AcmeProvider"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:AcmeProvider".properties] +accountKey = ["credential"] +contact = ["contact"] +eabHmacKey = ["credential"] +eabKeyId = ["credential"] + +[object."x:AddressBook"] +default = "none" + +[object."x:AiModel"] +default = "none" +[object."x:AiModel".properties] +httpAuth = ["credential"] + +[object."x:Alert"] +default = "none" + +[object."x:AlertEmailProperties"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:AlertEmailProperties".properties] +fromAddress = ["contact"] +fromName = ["contact"] +to = ["contact"] + +[object."x:AlertEventProperties"] +default = "none" + +[object."x:AllowedIp"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:AllowedIp.expiresAt" } +[object."x:AllowedIp".properties] +address = ["network"] +createdAt = ["metadata"] +expiresAt = ["metadata"] +reason = ["content"] + +[object."x:ApiKey"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:ApiKey".properties] +allowedIps = ["network"] +createdAt = ["metadata"] +description = ["content"] +expiresAt = ["metadata"] +secret = ["credential"] + +[object."x:AppPassword"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:AppPassword".properties] +allowedIps = ["network"] +createdAt = ["metadata"] +description = ["content"] +expiresAt = ["metadata"] +secret = ["credential"] + +[object."x:Application"] +default = "none" + +[object."x:ArchivedCalendarEvent"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedItemsFor" } +[object."x:ArchivedCalendarEvent".properties] +archivedAt = ["metadata"] +archivedUntil = ["metadata"] +blobId = ["content"] +createdAt = ["metadata"] +startTime = ["metadata"] +title = ["content"] + +[object."x:ArchivedContactCard"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedItemsFor" } +[object."x:ArchivedContactCard".properties] +archivedAt = ["metadata"] +archivedUntil = ["metadata"] +blobId = ["contact"] +createdAt = ["metadata"] +name = ["contact"] + +[object."x:ArchivedEmail"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedItemsFor" } +[object."x:ArchivedEmail".properties] +archivedAt = ["metadata"] +archivedUntil = ["metadata"] +blobId = ["content"] +from = ["identifier"] +receivedAt = ["metadata"] +size = ["metadata"] +subject = ["content"] + +[object."x:ArchivedFileNode"] +default = "none" +whose = ["holder"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedItemsFor" } +[object."x:ArchivedFileNode".properties] +archivedAt = ["metadata"] +archivedUntil = ["metadata"] +blobId = ["content"] +createdAt = ["metadata"] +name = ["content"] + +[object."x:ArchivedSieveScript"] +default = "none" +whose = ["holder"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.archiveDeletedItemsFor" } +[object."x:ArchivedSieveScript".properties] +archivedAt = ["metadata"] +archivedUntil = ["metadata"] +blobId = ["content"] +content = ["content"] +createdAt = ["metadata"] +name = ["content"] + +[object."x:ArfExternalReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:ArfExternalReport".properties] +from = ["identifier"] +receivedAt = ["metadata"] +subject = ["content"] +to = ["identifier"] + +[object."x:ArfFeedbackReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:ArfFeedbackReport".properties] +arrivalDate = ["metadata"] +dkimIdentity = ["identifier"] +headers = ["content"] +message = ["content"] +originalEnvelopeId = ["metadata"] +originalMailFrom = ["identifier"] +originalRcptTo = ["identifier"] +reportedDomains = ["identifier"] +reportedUris = ["content"] +sourceIp = ["network"] +sourcePort = ["network"] +userAgent = ["network"] + +[object."x:AsnDns"] +default = "none" + +[object."x:AsnResource"] +default = "none" +[object."x:AsnResource".properties] +httpAuth = ["credential"] + +[object."x:Authentication"] +default = "none" + +[object."x:AzureStore"] +default = "none" +[object."x:AzureStore".properties] +accessKey = ["credential"] +sasToken = ["credential"] + +[object."x:BlockedIp"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."x:BlockedIp".properties] +address = ["network"] +createdAt = ["metadata"] +expiresAt = ["metadata"] +reason = ["content"] + +[object."x:Bootstrap"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:Bootstrap".properties] +defaultDomain = ["identifier"] +secret = ["credential"] +username = ["identifier"] + +[object."x:Cache"] +default = "none" + +[object."x:Calendar"] +default = "none" + +[object."x:CalendarAlarm"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:CalendarAlarm".properties] +fromEmail = ["contact"] +fromName = ["contact"] + +[object."x:CalendarScheduling"] +default = "none" + +[object."x:Certificate"] +default = "none" +[object."x:Certificate".properties] +privateKey = ["credential"] + +[object."x:CertificateManagementProperties"] +default = "none" + +[object."x:ClusterListenerGroupProperties"] +default = "none" + +[object."x:ClusterNode"] +default = "none" + +[object."x:ClusterRole"] +default = "none" + +[object."x:ClusterTaskGroupProperties"] +default = "none" + +[object."x:CredentialPermissionsList"] +default = "none" + +[object."x:CronDaily"] +default = "none" + +[object."x:CronHourly"] +default = "none" + +[object."x:CronWeekly"] +default = "none" + +[object."x:CustomRoles"] +default = "none" + +[object."x:DataRetention"] +default = "none" + +[object."x:DeliveryError"] +default = "none" + +[object."x:Dkim1Signature"] +default = "none" +[object."x:Dkim1Signature".properties] +privateKey = ["credential"] + +[object."x:Dkim2Signature"] +default = "none" +[object."x:Dkim2Signature".properties] +privateKey = ["credential"] + +[object."x:DkimManagementProperties"] +default = "none" + +[object."x:DkimReportSettings"] +default = "none" + +[object."x:DmarcDkimResult"] +default = "none" + +[object."x:DmarcExtension"] +default = "none" + +[object."x:DmarcExternalReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:DmarcExternalReport".properties] +from = ["identifier"] +receivedAt = ["metadata"] +subject = ["content"] +to = ["identifier"] + +[object."x:DmarcInternalReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "server" +retention = "receiver" +[object."x:DmarcInternalReport".properties] +domain = ["identifier"] +rua = ["identifier"] + +[object."x:DmarcPolicyOverrideReason"] +default = "none" + +[object."x:DmarcReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:DmarcReport".properties] +email = ["contact"] +extraContactInfo = ["contact"] +orgName = ["identifier"] + +[object."x:DmarcReportRecord"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:DmarcReportRecord".properties] +count = ["metadata"] +envelopeFrom = ["identifier"] +envelopeTo = ["identifier"] +headerFrom = ["identifier"] +sourceIp = ["network"] + +[object."x:DmarcReportSettings"] +default = "none" + +[object."x:DmarcSpfResult"] +default = "none" + +[object."x:DmarcTroubleshoot"] +default = "none" +whose = ["correspondent"] +where = ["memory"] +scope = "server" +retention = "unbounded" +[object."x:DmarcTroubleshoot".properties] +ehloDomain = ["network"] +ipRevPtr = ["network"] +mailFrom = ["identifier"] +message = ["content"] +remoteIp = ["network"] +to = ["identifier"] + +[object."x:DmarcTroubleshootDetails"] +default = "none" + +[object."x:DnsCustomResolver"] +default = "none" +[object."x:DnsCustomResolver".properties] +address = ["network"] + +[object."x:DnsManagementProperties"] +default = "none" + +[object."x:DnsResolverCommon"] +default = "none" + +[object."x:DnsResolverCustom"] +default = "none" + +[object."x:DnsResolverTls"] +default = "none" + +[object."x:DnsServerAlidns"] +default = "none" +[object."x:DnsServerAlidns".properties] +secretKey = ["credential"] +securityToken = ["credential"] + +[object."x:DnsServerAutodns"] +default = "none" +[object."x:DnsServerAutodns".properties] +password = ["credential"] + +[object."x:DnsServerAzureDns"] +default = "none" +[object."x:DnsServerAzureDns".properties] +clientSecret = ["credential"] + +[object."x:DnsServerBaiduCloud"] +default = "none" +[object."x:DnsServerBaiduCloud".properties] +secretKey = ["credential"] + +[object."x:DnsServerBluecatV2"] +default = "none" +[object."x:DnsServerBluecatV2".properties] +password = ["credential"] + +[object."x:DnsServerClouDns"] +default = "none" +[object."x:DnsServerClouDns".properties] +password = ["credential"] + +[object."x:DnsServerCloud"] +default = "none" +[object."x:DnsServerCloud".properties] +secret = ["credential"] + +[object."x:DnsServerCloudflare"] +default = "none" +[object."x:DnsServerCloudflare".properties] +secret = ["credential"] + +[object."x:DnsServerConstellix"] +default = "none" +[object."x:DnsServerConstellix".properties] +secretKey = ["credential"] + +[object."x:DnsServerCpanel"] +default = "none" +[object."x:DnsServerCpanel".properties] +token = ["credential"] + +[object."x:DnsServerDnsMadeEasy"] +default = "none" +[object."x:DnsServerDnsMadeEasy".properties] +secret = ["credential"] + +[object."x:DnsServerDnsimple"] +default = "none" +[object."x:DnsServerDnsimple".properties] +authToken = ["credential"] +secret = ["credential"] + +[object."x:DnsServerDomeneshop"] +default = "none" +[object."x:DnsServerDomeneshop".properties] +secret = ["credential"] + +[object."x:DnsServerEasyDns"] +default = "none" +[object."x:DnsServerEasyDns".properties] +key = ["credential"] + +[object."x:DnsServerEdgeDns"] +default = "none" +[object."x:DnsServerEdgeDns".properties] +accessToken = ["credential"] +clientSecret = ["credential"] + +[object."x:DnsServerExoscale"] +default = "none" +[object."x:DnsServerExoscale".properties] +secret = ["credential"] + +[object."x:DnsServerGlesys"] +default = "none" +[object."x:DnsServerGlesys".properties] +apiKey = ["credential"] + +[object."x:DnsServerGodaddy"] +default = "none" +[object."x:DnsServerGodaddy".properties] +secret = ["credential"] + +[object."x:DnsServerGoogleCloudDns"] +default = "none" +[object."x:DnsServerGoogleCloudDns".properties] +serviceAccountJson = ["credential"] + +[object."x:DnsServerHuaweiCloud"] +default = "none" +[object."x:DnsServerHuaweiCloud".properties] +secretKey = ["credential"] + +[object."x:DnsServerHurricane"] +default = "none" + +[object."x:DnsServerIbmCloud"] +default = "none" +[object."x:DnsServerIbmCloud".properties] +apiKey = ["credential"] + +[object."x:DnsServerInfoblox"] +default = "none" +[object."x:DnsServerInfoblox".properties] +password = ["credential"] + +[object."x:DnsServerInwx"] +default = "none" +[object."x:DnsServerInwx".properties] +password = ["credential"] +sharedSecret = ["credential"] + +[object."x:DnsServerJoker"] +default = "none" + +[object."x:DnsServerLightsail"] +default = "none" +[object."x:DnsServerLightsail".properties] +secretAccessKey = ["credential"] +sessionToken = ["credential"] + +[object."x:DnsServerLuaDns"] +default = "none" +[object."x:DnsServerLuaDns".properties] +authToken = ["credential"] + +[object."x:DnsServerMythicBeasts"] +default = "none" +[object."x:DnsServerMythicBeasts".properties] +password = ["credential"] + +[object."x:DnsServerNameDotCom"] +default = "none" +[object."x:DnsServerNameDotCom".properties] +authToken = ["credential"] + +[object."x:DnsServerNamecheap"] +default = "none" +[object."x:DnsServerNamecheap".properties] +apiKey = ["credential"] + +[object."x:DnsServerNetcup"] +default = "none" +[object."x:DnsServerNetcup".properties] +password = ["credential"] + +[object."x:DnsServerNifcloud"] +default = "none" +[object."x:DnsServerNifcloud".properties] +secretKey = ["credential"] + +[object."x:DnsServerOracleCloud"] +default = "none" +[object."x:DnsServerOracleCloud".properties] +privateKeyPassword = ["credential"] +privateKeyPem = ["credential"] + +[object."x:DnsServerOvh"] +default = "none" +[object."x:DnsServerOvh".properties] +applicationSecret = ["credential"] +consumerKey = ["credential"] + +[object."x:DnsServerPlesk"] +default = "none" +[object."x:DnsServerPlesk".properties] +apiKey = ["credential"] + +[object."x:DnsServerPorkbun"] +default = "none" +[object."x:DnsServerPorkbun".properties] +secret = ["credential"] +secretApiKey = ["credential"] + +[object."x:DnsServerRoute53"] +default = "none" +[object."x:DnsServerRoute53".properties] +secretAccessKey = ["credential"] +sessionToken = ["credential"] + +[object."x:DnsServerSpaceship"] +default = "none" +[object."x:DnsServerSpaceship".properties] +secret = ["credential"] + +[object."x:DnsServerTencentCloud"] +default = "none" +[object."x:DnsServerTencentCloud".properties] +secretKey = ["credential"] +sessionToken = ["credential"] + +[object."x:DnsServerTransip"] +default = "none" +[object."x:DnsServerTransip".properties] +privateKeyPem = ["credential"] + +[object."x:DnsServerTsig"] +default = "none" +[object."x:DnsServerTsig".properties] +host = ["network"] +key = ["credential"] + +[object."x:DnsServerUltraDns"] +default = "none" +[object."x:DnsServerUltraDns".properties] +password = ["credential"] + +[object."x:DnsServerVercel"] +default = "none" +[object."x:DnsServerVercel".properties] +authToken = ["credential"] + +[object."x:DnsServerVolcengine"] +default = "none" +[object."x:DnsServerVolcengine".properties] +secretKey = ["credential"] + +[object."x:DnsServerWebSupport"] +default = "none" +[object."x:DnsServerWebSupport".properties] +secret = ["credential"] + +[object."x:DnsServerYandexCloud"] +default = "none" +[object."x:DnsServerYandexCloud".properties] +apiKey = ["credential"] + +[object."x:Domain"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:Domain".properties] +catchAllAddress = ["identifier"] +createdAt = ["metadata"] +description = ["content"] + +[object."x:DsnReportSettings"] +default = "none" + +[object."x:ElasticSearchStore"] +default = "none" +[object."x:ElasticSearchStore".properties] +httpAuth = ["credential"] + +[object."x:Email"] +default = "none" + +[object."x:EmailAlias"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:EmailAlias".properties] +description = ["content"] +name = ["identifier"] + +[object."x:EmailFolder"] +default = "none" + +[object."x:EncryptionSettings"] +default = "none" + +[object."x:Enterprise"] +default = "none" +[object."x:Enterprise".properties] +apiKey = ["credential"] +licenseKey = ["credential"] + +[object."x:EventTracingLevel"] +default = "none" + +[object."x:Expression"] +default = "none" + +[object."x:ExpressionMatch"] +default = "none" + +[object."x:FileStorage"] +default = "none" + +[object."x:FileSystemStore"] +default = "none" + +[object."x:FoundationDbStore"] +default = "none" + +[object."x:FtrlParameters"] +default = "none" + +[object."x:GroupAccount"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:GroupAccount".properties] +aliases = ["identifier"] +createdAt = ["metadata"] +description = ["content"] +emailAddress = ["identifier"] +externalId = ["identifier"] +locale = ["metadata"] +name = ["identifier"] +timeZone = ["metadata"] +usedDiskQuota = ["metadata"] + +[object."x:Http"] +default = "none" + +[object."x:HttpAuthBasic"] +default = "none" +[object."x:HttpAuthBasic".properties] +secret = ["credential"] + +[object."x:HttpAuthBearer"] +default = "none" +[object."x:HttpAuthBearer".properties] +bearerToken = ["credential"] + +[object."x:HttpForm"] +default = "none" +whose = ["administrator", "correspondent"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:HttpForm".properties] +defaultFromAddress = ["contact"] +defaultName = ["contact"] +deliverTo = ["contact"] + +[object."x:HttpLookup"] +default = "none" + +[object."x:HttpLookupCsv"] +default = "none" + +[object."x:HurricaneCredential"] +default = "none" +[object."x:HurricaneCredential".properties] +secret = ["credential"] + +[object."x:Imap"] +default = "none" + +[object."x:Jmap"] +default = "none" +[object."x:Jmap".properties] +webPushKey = ["credential"] + +[object."x:JokerAuthApiKey"] +default = "none" +[object."x:JokerAuthApiKey".properties] +apiKey = ["credential"] + +[object."x:JokerAuthUsernamePassword"] +default = "none" +[object."x:JokerAuthUsernamePassword".properties] +password = ["credential"] + +[object."x:KafkaCoordinator"] +default = "none" + +[object."x:LdapDirectory"] +default = "none" +[object."x:LdapDirectory".properties] +bindSecret = ["credential"] + +[object."x:Log"] +default = "none" +whose = ["correspondent", "holder", "administrator"] +where = ["log-file"] +scope = "server" +retention = "unbounded" +[object."x:Log".properties] +details = ["network", "identifier", "metadata"] +timestamp = ["metadata"] + +[object."x:MailExchanger"] +default = "none" + +[object."x:MailingList"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:MailingList".properties] +aliases = ["identifier"] +description = ["content"] +emailAddress = ["identifier"] +name = ["identifier"] +recipients = ["identifier"] + +[object."x:MaskedEmail"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:MaskedEmail".properties] +createdAt = ["metadata"] +createdBy = ["identifier"] +description = ["content"] +email = ["identifier"] +emailDomain = ["identifier"] +emailPrefix = ["identifier"] +expiresAt = ["metadata"] +forDomain = ["content"] +url = ["content"] + +[object."x:MeilisearchStore"] +default = "none" +[object."x:MeilisearchStore".properties] +httpAuth = ["credential"] + +[object."x:MemoryLookupKey"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:MemoryLookupKey".properties] +key = ["identifier"] + +[object."x:MemoryLookupKeyValue"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "server" +retention = "object-life" +[object."x:MemoryLookupKeyValue".properties] +key = ["identifier"] +value = ["identifier"] + +[object."x:MetricCount"] +default = "none" + +[object."x:MetricSum"] +default = "none" + +[object."x:Metrics"] +default = "none" + +[object."x:MetricsOtelGrpc"] +default = "none" + +[object."x:MetricsOtelHttp"] +default = "none" +[object."x:MetricsOtelHttp".properties] +httpAuth = ["credential"] + +[object."x:MetricsPrometheusProperties"] +default = "none" +[object."x:MetricsPrometheusProperties".properties] +authSecret = ["credential"] + +[object."x:MtaConnectionIpHost"] +default = "none" +[object."x:MtaConnectionIpHost".properties] +sourceIp = ["network"] + +[object."x:MtaConnectionStrategy"] +default = "none" + +[object."x:MtaDeliveryExpirationAttempts"] +default = "none" + +[object."x:MtaDeliveryExpirationTtl"] +default = "none" + +[object."x:MtaDeliverySchedule"] +default = "none" + +[object."x:MtaDeliveryScheduleInterval"] +default = "none" + +[object."x:MtaDeliveryScheduleIntervals"] +default = "none" + +[object."x:MtaExtensions"] +default = "none" + +[object."x:MtaHook"] +default = "none" +[object."x:MtaHook".properties] +httpAuth = ["credential"] + +[object."x:MtaInboundSession"] +default = "none" + +[object."x:MtaInboundThrottle"] +default = "none" + +[object."x:MtaMilter"] +default = "none" + +[object."x:MtaOutboundStrategy"] +default = "none" + +[object."x:MtaOutboundThrottle"] +default = "none" + +[object."x:MtaQueueQuota"] +default = "none" + +[object."x:MtaRouteCommon"] +default = "none" + +[object."x:MtaRouteMx"] +default = "none" + +[object."x:MtaRouteRelay"] +default = "none" +[object."x:MtaRouteRelay".properties] +authSecret = ["credential"] + +[object."x:MtaStageAuth"] +default = "none" + +[object."x:MtaStageConnect"] +default = "none" + +[object."x:MtaStageData"] +default = "none" + +[object."x:MtaStageEhlo"] +default = "none" + +[object."x:MtaStageMail"] +default = "none" + +[object."x:MtaStageRcpt"] +default = "none" + +[object."x:MtaSts"] +default = "none" + +[object."x:MtaTlsStrategy"] +default = "none" + +[object."x:MtaVirtualQueue"] +default = "none" + +[object."x:MySqlSettings"] +default = "none" +[object."x:MySqlSettings".properties] +authSecret = ["credential"] + +[object."x:MySqlStore"] +default = "none" +[object."x:MySqlStore".properties] +authSecret = ["credential"] + +[object."x:NatsCoordinator"] +default = "none" +[object."x:NatsCoordinator".properties] +authSecret = ["credential"] +credentials = ["credential"] + +[object."x:NetworkListener"] +default = "none" +[object."x:NetworkListener".properties] +overrideProxyTrustedNetworks = ["network"] + +[object."x:OAuthClient"] +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:OAuthClient.expiresAt" } +[object."x:OAuthClient".properties] +contacts = ["contact"] +createdAt = ["metadata"] +description = ["content"] +expiresAt = ["metadata"] +redirectUris = ["identifier"] +secret = ["credential"] + +[object."x:OidcDirectory"] +default = "none" + +[object."x:OidcProvider"] +default = "none" +[object."x:OidcProvider".properties] +encryptionKey = ["credential"] +signatureKey = ["credential"] + +[object."x:OtpAuth"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:OtpAuth".properties] +otpCode = ["credential"] +otpUrl = ["credential"] + +[object."x:PasswordCredential"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:PasswordCredential".properties] +allowedIps = ["network"] +expiresAt = ["metadata"] +otpAuth = ["credential"] +secret = ["credential"] + +[object."x:PermissionsList"] +default = "none" + +[object."x:PostgreSqlSettings"] +default = "none" +[object."x:PostgreSqlSettings".properties] +authSecret = ["credential"] + +[object."x:PostgreSqlStore"] +default = "none" +[object."x:PostgreSqlStore".properties] +authSecret = ["credential"] + +[object."x:PublicKey"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:PublicKey".properties] +createdAt = ["metadata"] +description = ["content"] +emailAddresses = ["identifier"] +expiresAt = ["metadata"] +key = ["credential"] + +[object."x:PublicStringValue"] +default = "none" + +[object."x:PublicTextValue"] +default = "none" + +[object."x:QueueExpiryAttempts"] +default = "none" + +[object."x:QueueExpiryTtl"] +default = "none" + +[object."x:QueuedMessage"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store", "blob-store"] +scope = "server" +retention = { setting = "x:MtaDeliveryExpirationTtl.expire" } +[object."x:QueuedMessage".properties] +blobId = ["content"] +createdAt = ["metadata"] +envId = ["metadata"] +receivedFromIp = ["network"] +recipients = ["identifier"] +returnPath = ["identifier"] +size = ["metadata"] + +[object."x:QueuedRecipient"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:MtaDeliveryExpirationTtl.expire" } +[object."x:QueuedRecipient".properties] +orcpt = ["identifier"] +status = ["metadata"] + +[object."x:Rate"] +default = "none" + +[object."x:RedisClusterStore"] +default = "none" +[object."x:RedisClusterStore".properties] +authSecret = ["credential"] + +[object."x:RedisSentinelStore"] +default = "none" +[object."x:RedisSentinelStore".properties] +authSecret = ["credential"] +sentinelSecret = ["credential"] + +[object."x:RedisStore"] +default = "none" + +[object."x:ReportSettings"] +default = "none" + +[object."x:RocksDbStore"] +default = "none" + +[object."x:Role"] +default = "none" + +[object."x:S3Store"] +default = "none" +[object."x:S3Store".properties] +secretKey = ["credential"] +securityToken = ["credential"] +sessionToken = ["credential"] + +[object."x:S3StoreCustomRegion"] +default = "none" + +[object."x:Search"] +default = "none" + +[object."x:SecondaryCredential"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:SecondaryCredential".properties] +allowedIps = ["network"] +createdAt = ["metadata"] +description = ["content"] +expiresAt = ["metadata"] +secret = ["credential"] + +[object."x:SecretKeyEnvironmentVariable"] +default = "none" + +[object."x:SecretKeyFile"] +default = "none" + +[object."x:SecretKeyValue"] +default = "none" +[object."x:SecretKeyValue".properties] +secret = ["credential"] + +[object."x:SecretTextValue"] +default = "none" +[object."x:SecretTextValue".properties] +secret = ["credential"] + +[object."x:Security"] +default = "none" + +[object."x:SenderAuth"] +default = "none" + +[object."x:ServerResponse"] +default = "none" + +[object."x:Service"] +default = "none" + +[object."x:ShardedBlobStore"] +default = "none" + +[object."x:ShardedInMemoryStore"] +default = "none" + +[object."x:Sharing"] +default = "none" + +[object."x:SieveSystemInterpreter"] +default = "none" + +[object."x:SieveSystemScript"] +default = "none" + +[object."x:SieveUserInterpreter"] +default = "none" + +[object."x:SieveUserScript"] +default = "none" +whose = ["holder"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = "object-life" +[object."x:SieveUserScript".properties] +contents = ["content"] +description = ["content"] +name = ["content"] + +[object."x:SpamClassifier"] +default = "none" + +[object."x:SpamClassifierFtrlCcfh"] +default = "none" + +[object."x:SpamClassifierFtrlFh"] +default = "none" + +[object."x:SpamClassify"] +default = "none" +whose = ["correspondent"] +where = ["memory"] +scope = "server" +retention = "unbounded" +[object."x:SpamClassify".properties] +authenticatedAs = ["identifier"] +ehloDomain = ["network"] +envFrom = ["identifier"] +envRcptTo = ["identifier"] +message = ["content"] +remoteIp = ["network"] + +[object."x:SpamClassifyTag"] +default = "none" + +[object."x:SpamDnsblServerAny"] +default = "none" + +[object."x:SpamDnsblServerBody"] +default = "none" + +[object."x:SpamDnsblServerDomain"] +default = "none" + +[object."x:SpamDnsblServerEmail"] +default = "none" + +[object."x:SpamDnsblServerHeader"] +default = "none" + +[object."x:SpamDnsblServerIp"] +default = "none" + +[object."x:SpamDnsblServerUrl"] +default = "none" + +[object."x:SpamDnsblSettings"] +default = "none" + +[object."x:SpamFileExtension"] +default = "none" + +[object."x:SpamLlmProperties"] +default = "none" + +[object."x:SpamPyzor"] +default = "none" + +[object."x:SpamRuleAny"] +default = "none" + +[object."x:SpamRuleBody"] +default = "none" + +[object."x:SpamRuleDomain"] +default = "none" + +[object."x:SpamRuleEmail"] +default = "none" + +[object."x:SpamRuleHeader"] +default = "none" + +[object."x:SpamRuleIp"] +default = "none" + +[object."x:SpamRuleUrl"] +default = "none" + +[object."x:SpamSettings"] +default = "none" + +[object."x:SpamTagAction"] +default = "none" + +[object."x:SpamTagScore"] +default = "none" + +[object."x:SpamTrainingSample"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = { setting = "x:SpamClassifier.holdSamplesFor" } +[object."x:SpamTrainingSample".properties] +blobId = ["content"] +expiresAt = ["metadata"] +from = ["identifier"] +subject = ["content"] + +[object."x:SpfReportSettings"] +default = "none" + +[object."x:SqlDirectory"] +default = "none" + +[object."x:SqliteStore"] +default = "none" + +[object."x:StoreLookup"] +default = "none" + +[object."x:SubAddressingCustom"] +default = "none" + +[object."x:SystemSettings"] +default = "none" +[object."x:SystemSettings".properties] +proxyTrustedNetworks = ["network"] + +[object."x:TaskAccountMaintenance"] +default = "none" + +[object."x:TaskCalendarAlarmEmail"] +default = "none" + +[object."x:TaskCalendarAlarmNotification"] +default = "none" + +[object."x:TaskCalendarItipContents"] +default = "none" +whose = ["holder", "correspondent"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."x:TaskCalendarItipContents".properties] +from = ["identifier"] +iCalendarData = ["content"] +summary = ["content"] +to = ["identifier"] + +[object."x:TaskCalendarItipMessage"] +default = "none" + +[object."x:TaskDestroyAccount"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."x:TaskDestroyAccount".properties] +accountName = ["identifier"] + +[object."x:TaskDmarcReport"] +default = "none" + +[object."x:TaskDnsManagement"] +default = "none" + +[object."x:TaskDomainManagement"] +default = "none" + +[object."x:TaskIndexDocument"] +default = "none" + +[object."x:TaskIndexTrace"] +default = "none" + +[object."x:TaskManager"] +default = "none" + +[object."x:TaskMergeThreads"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."x:TaskMergeThreads".properties] +messageIds = ["metadata"] +threadName = ["content"] + +[object."x:TaskRestoreArchivedItem"] +default = "none" + +[object."x:TaskRetryStrategyBackoff"] +default = "none" + +[object."x:TaskRetryStrategyFixed"] +default = "none" + +[object."x:TaskSpamFilterMaintenance"] +default = "none" + +[object."x:TaskStatusFailed"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."x:TaskStatusFailed".properties] +failureReason = ["content"] + +[object."x:TaskStatusPending"] +default = "none" + +[object."x:TaskStatusRetry"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."x:TaskStatusRetry".properties] +failureReason = ["content"] + +[object."x:TaskStoreMaintenance"] +default = "none" + +[object."x:TaskTenantMaintenance"] +default = "none" + +[object."x:TaskTlsReport"] +default = "none" + +[object."x:Tenant"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:Tenant".properties] +createdAt = ["metadata"] +name = ["identifier"] +usedDiskQuota = ["metadata"] + +[object."x:TlsExternalReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:TlsExternalReport".properties] +from = ["identifier"] +receivedAt = ["metadata"] +subject = ["content"] +to = ["identifier"] + +[object."x:TlsFailureDetails"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:TlsFailureDetails".properties] +receivingIp = ["network"] +receivingMxHelo = ["network"] +receivingMxHostname = ["network"] +sendingMtaIp = ["network"] + +[object."x:TlsInternalReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "server" +retention = "receiver" +[object."x:TlsInternalReport".properties] +domain = ["identifier"] +httpRua = ["identifier"] +mailRua = ["identifier"] + +[object."x:TlsReport"] +default = "none" +whose = ["correspondent"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:DataRetention.holdMtaReportsFor" } +[object."x:TlsReport".properties] +contactInfo = ["contact"] +organizationName = ["identifier"] + +[object."x:TlsReportPolicy"] +default = "none" + +[object."x:TlsReportSettings"] +default = "none" + +[object."x:Trace"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store", "search-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +[object."x:Trace".properties] +events = ["network", "identifier", "metadata"] +from = ["identifier"] +size = ["metadata"] +timestamp = ["metadata"] +to = ["identifier"] + +[object."x:TraceEvent"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +[object."x:TraceEvent".properties] +keyValues = ["network", "identifier", "metadata"] +timestamp = ["metadata"] + +[object."x:TraceKeyValue"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +[object."x:TraceKeyValue".properties] +value = ["network", "identifier", "metadata"] + +[object."x:TraceValueBoolean"] +default = "none" + +[object."x:TraceValueDuration"] +default = "none" + +[object."x:TraceValueEvent"] +default = "none" + +[object."x:TraceValueFloat"] +default = "none" + +[object."x:TraceValueInteger"] +default = "none" + +[object."x:TraceValueIpAddr"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +[object."x:TraceValueIpAddr".properties] +value = ["network"] + +[object."x:TraceValueList"] +default = "none" + +[object."x:TraceValueString"] +default = "none" +whose = ["correspondent", "holder"] +where = ["data-store"] +scope = "server" +retention = { setting = "x:DataRetention.holdTracesFor" } +[object."x:TraceValueString".properties] +value = ["identifier", "metadata"] + +[object."x:TraceValueUTCDateTime"] +default = "none" + +[object."x:TraceValueUnsignedInt"] +default = "none" + +[object."x:TracerCommon"] +default = "none" + +[object."x:TracerLog"] +default = "none" +whose = ["correspondent", "holder", "administrator"] +where = ["log-file"] +scope = "server" +retention = "unbounded" +[object."x:TracerLog".properties] +path = ["metadata"] + +[object."x:TracerOtelGrpc"] +default = "none" +whose = ["correspondent", "holder", "administrator"] +where = ["external"] +scope = "server" +retention = "receiver" +[object."x:TracerOtelGrpc".properties] +endpoint = ["network"] +httpAuth = ["credential"] +httpHeaders = ["credential"] + +[object."x:TracerOtelHttp"] +default = "none" +whose = ["correspondent", "holder", "administrator"] +where = ["external"] +scope = "server" +retention = "receiver" +[object."x:TracerOtelHttp".properties] +endpoint = ["network"] +httpAuth = ["credential"] +httpHeaders = ["credential"] + +[object."x:TracerStdout"] +default = "none" + +[object."x:UserAccount"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."x:UserAccount".properties] +aliases = ["identifier"] +createdAt = ["metadata"] +credentials = ["credential", "network"] +description = ["content"] +emailAddress = ["identifier"] +externalId = ["identifier"] +locale = ["metadata"] +memberGroupIds = ["identifier"] +name = ["identifier"] +timeZone = ["metadata"] +usedDiskQuota = ["metadata"] + +[object."x:WebDav"] +default = "none" + +[object."x:WebHook"] +default = "none" +whose = ["correspondent", "holder", "administrator"] +where = ["external"] +scope = "server" +retention = "receiver" +[object."x:WebHook".properties] +httpAuth = ["credential"] +httpHeaders = ["credential"] +signatureKey = ["credential"] +url = ["network"] + +[object."x:ZenohCoordinator"] +default = "none" diff --git a/tools/fork/README.md b/tools/fork/README.md index e20d37d..bbb6362 100644 --- a/tools/fork/README.md +++ b/tools/fork/README.md @@ -68,6 +68,25 @@ tools/fork/notice-check.py --fix # add it where it's missing Run `--fix` after resolving an upstream merge: a conflict resolved by taking upstream's side can drop a notice the file had. +## privacy-check.py + +Fails when the personal-data catalog (`resources/privacy/catalog.toml`) and +the code disagree: an object in the schema or one of inbuxa's own JMAP +objects with no entry, a property the schema types as an address, an IP or +a secret left to its object's default, or an entry naming an object, +property, setting or code path that no longer exists. CI runs it beside the +name check, with its tests (`python3 -m unittest discover -s tools/fork/tests`). +See `docs/spec/features/personal-data-catalog.md`. + +```bash +tools/fork/privacy-check.py # exit 1 on any finding +tools/fork/privacy-check.py --unlisted # starting entries for what's missing +``` + +After an upstream import, the strip report lists what is new and unclassified +under "Unclassified in the privacy catalog". `--unlisted` types each from the +schema alone; read the field's description before trusting it. + ## record-compat.py Records what the `*_compat` tests compare against, from the Enterprise diff --git a/tools/fork/privacy-check.py b/tools/fork/privacy-check.py new file mode 100755 index 0000000..312d3a6 --- /dev/null +++ b/tools/fork/privacy-check.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-only +""" +Fail when the personal-data catalog and the code disagree. + + tools/fork/privacy-check.py # check; exit 1 on any finding + tools/fork/privacy-check.py --unlisted # print catalog entries for what's missing + +The catalog (`resources/privacy/catalog.toml`, spec +`docs/spec/features/personal-data-catalog.md`) says, for every object and +source, what personal data it can hold. An upstream import can bring objects +and fields nobody has classified, and a refactor can leave the catalog naming +things that are gone; either way the catalog stops being true without anyone +noticing, so this runs in CI on every push and pull request. It fails when: + + 1. an object in the schema's `fields`, or one of inbuxa's own JMAP objects, + has no catalog entry; + 2. a property the schema types as an email address, an IP address or + network, or a secret is covered only by its object's `default` -- it + must be listed, so a new personal field can't hide behind a default; + 3. an entry names an object, property, setting or code path that doesn't + exist (stale); + 4. an entry uses a word outside the catalog's own vocabulary. + +When it fails on a new object or field, classify it: `--unlisted` prints a +starting entry for each, typed from the schema alone. Read the property's +description before trusting it. +""" +import argparse +import gzip +import json +import os +import re +import sys +import tomllib + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SCHEMA = 'resources/schema/schema.json.gz' +CATALOG = 'resources/privacy/catalog.toml' +OBJECTS_DIR = 'crates/jmap-proto/src/object' +METHODS = 'crates/jmap-proto/src/request/method.rs' + +SENSITIVE_FORMATS = { + 'emailAddress': 'identifier', + 'ipAddress': 'network', + 'ipNetwork': 'network', + 'secret': 'credential', + 'secretText': 'credential', +} +INBUXA_OBJECT = re.compile(r'"(inbuxa:[A-Z][A-Za-z]*)"') +PROPERTY_NAME = re.compile(r'=> "([a-z][A-Za-z0-9]*)"') + + +def sensitive(type_): + """The category a property's schema type alone implies, or None.""" + fmt = type_.get('format') or (type_.get('class') or {}).get('format') + if fmt in SENSITIVE_FORMATS: + return SENSITIVE_FORMATS[fmt] + name = type_.get('objectName') or (type_.get('class') or {}).get('objectName') or '' + if name.startswith(('x:SecretKey', 'x:SecretText')) or name == 'x:HttpAuth': + return 'credential' + return None + + +def load_schema(root): + with gzip.open(os.path.join(root, SCHEMA)) as f: + return json.load(f)['fields'] + + +def load_inbuxa_objects(root): + """inbuxa's own objects, from the method names jmap-proto parses.""" + with open(os.path.join(root, METHODS), encoding='utf-8') as f: + return set(INBUXA_OBJECT.findall(f.read())) + + +def inbuxa_properties(root, file_name): + """The property names a jmap-proto object file maps, or None if it's gone.""" + path = os.path.join(root, OBJECTS_DIR, file_name) + if not os.path.isfile(path): + return None + with open(path, encoding='utf-8') as f: + return set(PROPERTY_NAME.findall(f.read())) + + +def findings(root, catalog): + """Everything wrong with `catalog` against the tree at `root`.""" + fields = load_schema(root) + inbuxa = load_inbuxa_objects(root) + vocab = catalog.get('vocabulary', {}) + objects = catalog.get('object', {}) + sources = catalog.get('source', {}) + out = [] + inbuxa_props = {} + + def vocabulary(where, key, values): + allowed = set(vocab.get(key, [])) + for value in values if isinstance(values, list) else [values]: + if value not in allowed: + out.append(f'{where}: "{value}" is not in vocabulary.{key}') + + def setting_exists(where, setting): + obj, _, prop = setting.partition('.') + if obj in fields: + if prop not in fields[obj].get('properties', {}): + out.append(f'{where}: setting {setting} names no property of {obj}') + elif obj in inbuxa: + props = inbuxa_props.get(obj) + if props is not None and prop not in props: + out.append(f'{where}: setting {setting} names no property of {obj}') + else: + out.append(f'{where}: setting {setting} names no object') + + def common(where, entry): + for key in ('whose', 'where', 'scope'): + if key in entry: + vocabulary(where, key, entry[key]) + retention = entry.get('retention') + if isinstance(retention, dict): + setting_exists(where, retention.get('setting', '')) + elif retention is not None: + vocabulary(where, 'retention', retention) + for key in ('enabled_by', 'captures'): + for setting in entry.get(key, []): + setting_exists(where, setting) + + # inbuxa objects' properties first, so settings can name them + for name, entry in objects.items(): + if name.startswith('inbuxa:'): + inbuxa_props[name] = inbuxa_properties(root, entry.get('file', '')) + + # 1: nothing unlisted + for name in sorted(fields): + if name not in objects: + out.append(f'{name}: schema object has no catalog entry') + for name in sorted(inbuxa): + if name not in objects: + out.append(f'{name}: inbuxa object has no catalog entry') + + for name, entry in sorted(objects.items()): + props = entry.get('properties', {}) + if name.startswith('inbuxa:'): + known = inbuxa_props.get(name) + if name not in inbuxa: + out.append(f'{name}: no such inbuxa object (stale)') + if known is None: + out.append(f'{name}: file "{entry.get("file", "")}" not found in {OBJECTS_DIR}') + known = set() + elif name in fields: + known = set(fields[name].get('properties', {})) + else: + out.append(f'{name}: no such schema object (stale)') + continue + if entry.get('default') != 'none': + out.append(f'{name}: default must be "none"') + for prop, categories in props.items(): + if prop not in known: + out.append(f'{name}.{prop}: no such property (stale)') + vocabulary(f'{name}.{prop}', 'categories', categories) + common(name, entry) + # 2: typed-sensitive properties are listed + if name in fields: + for prop, spec in fields[name].get('properties', {}).items(): + if prop not in props and sensitive(spec['type']): + out.append(f'{name}.{prop}: typed as {sensitive(spec["type"])} but not listed') + + for name, entry in sorted(sources.items()): + where = f'source "{name}"' + vocabulary(where, 'categories', entry.get('categories', [])) + common(where, entry) + if 'leaves_host' not in entry: + out.append(f'{where}: leaves_host is missing') + for path in entry.get('written_by', []): + if not os.path.exists(os.path.join(root, path)): + out.append(f'{where}: written_by {path} doesn\'t exist (stale)') + return out + + +def unlisted(root, catalog): + """Starting catalog entries for unlisted objects and properties.""" + fields = load_schema(root) + objects = catalog.get('object', {}) + lines = [] + for name in sorted(fields): + entry = objects.get(name) + listed = (entry or {}).get('properties', {}) + missing = { + p: sensitive(spec['type']) + for p, spec in fields[name].get('properties', {}).items() + if p not in listed and sensitive(spec['type']) + } + if entry is None or missing: + lines.append(f'[object.{json.dumps(name)}]' if entry is None else f'# add to {name}:') + if entry is None: + lines.append('default = "none"') + if missing: + if entry is None: + lines.append(f'[object.{json.dumps(name)}.properties]') + for prop, category in sorted(missing.items()): + lines.append(f'{prop} = ["{category}"]') + lines.append('') + return lines + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n')[1]) + parser.add_argument('--unlisted', action='store_true', help='print entries for what is missing') + parser.add_argument('--root', default=ROOT, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + with open(os.path.join(args.root, CATALOG), 'rb') as f: + catalog = tomllib.load(f) + if args.unlisted: + print('\n'.join(unlisted(args.root, catalog))) + return 0 + found = findings(args.root, catalog) + if found: + print(f'privacy check: {len(found)} finding(s) in {CATALOG}:') + for line in found: + print(f' {line}') + print('Classify new objects and fields (--unlisted helps); remove what no longer exists.') + return 1 + print(f'privacy check: clean ({len(catalog.get("object", {}))} objects, ' + f'{len(catalog.get("source", {}))} sources).') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/fork/strip.py b/tools/fork/strip.py index 575f9cc..dad168a 100755 --- a/tools/fork/strip.py +++ b/tools/fork/strip.py @@ -373,6 +373,39 @@ def schema_flags(tree): return {'objects': objects, 'fields': fields} +def privacy_flags(tree): + """Objects and properties new in this import that the personal-data + catalog doesn't classify (docs/spec/features/personal-data-catalog.md). + Informational, like the Enterprise flags: privacy-check.py is what fails + CI once the import is merged.""" + import importlib.util + import tomllib + here = Path(__file__).resolve().parent + catalog_path = here.parents[1] / 'resources' / 'privacy' / 'catalog.toml' + current_path = here.parents[1] / 'resources' / 'schema' / 'schema.json.gz' + path = tree / 'resources' / 'schema' / 'schema.json.gz' + if not (path.is_file() and catalog_path.is_file() and current_path.is_file()): + return None + spec = importlib.util.spec_from_file_location('privacy_check', here / 'privacy-check.py') + check = importlib.util.module_from_spec(spec) + spec.loader.exec_module(check) + catalog = tomllib.loads(catalog_path.read_text(encoding='utf-8')).get('object', {}) + current = json.loads(gzip.decompress(current_path.read_bytes())).get('fields', {}) + upstream = json.loads(gzip.decompress(path.read_bytes())).get('fields', {}) + objects = sorted(name for name in upstream if name not in catalog) + fields = [] + for name, spec_ in upstream.items(): + if name not in catalog: + continue + listed = catalog[name].get('properties', {}) + known = current.get(name, {}).get('properties', {}) + for prop, p in spec_.get('properties', {}).items(): + if prop not in known and prop not in listed: + typed = check.sensitive(p.get('type', {})) + fields.append(f'{name}.{prop}' + (f' ({typed})' if typed else '')) + return {'objects': objects, 'fields': sorted(fields)} + + def remaining_hooks(tree): gates, checks = {}, {} for path in tree.rglob('*.rs'): @@ -456,6 +489,8 @@ def write_report(out_dir, report): ] if r['schema']: md.append(f'- Upstream schema flags {len(r["schema"]["objects"])} objects and {len(r["schema"]["fields"])} fields as Enterprise') + if r.get('privacy'): + md.append(f'- Unclassified in the privacy catalog: {len(r["privacy"]["objects"])} objects and {len(r["privacy"]["fields"])} new fields') md += ['', '## Removed files', ''] + [f'- `{f}`' for f in r['removed_files']] md += ['', '## Removed snippets', ''] + [f'- `{f}`: {n}' for f, n in r['removed_snippets'].items()] md += ['', '## Dangling module declarations removed', ''] + [f'- `{d["file"]}:{d["line"]}`: `mod {d["module"]}` ({" / ".join(d["lines"])})' for d in r['dangling_mods']] @@ -463,6 +498,13 @@ def write_report(out_dir, report): if r['schema']: md += ['', '## Flagged Enterprise in upstream\'s schema', '', '**Objects:** ' + ', '.join(f'`{o}`' for o in r['schema']['objects']), '', '**Fields:** ' + ', '.join(f'`{f}`' for f in r['schema']['fields'])] + if r.get('privacy'): + md += ['', '## Unclassified in the privacy catalog', '', + 'New in this import and not in `resources/privacy/catalog.toml`. `tools/fork/privacy-check.py` ' + 'fails CI on these once merged; `--unlisted` prints a starting entry. A type in brackets is what ' + 'the schema alone says the field holds.', '', + '**Objects:** ' + (', '.join(f'`{o}`' for o in r['privacy']['objects']) or 'none'), + '', '**Fields:** ' + (', '.join(f'`{f}`' for f in r['privacy']['fields']) or 'none')] md += ['', '## Third-party code', '', 'Comments in the stripped tree that name another copyright holder, another license, or a source the ' 'code came from. Files marked **new** aren\'t in THIRD-PARTY.md yet.', ''] @@ -537,7 +579,7 @@ def main(): 'removed_files': removed_files, 'removed_snippets': removed_snippets, 'cargo_edits': edits, 'dangling_mods': dangling, 'problems': problems, 'feature_gates': gates, 'edition_checks': checks, - 'schema': schema_flags(tree), 'third_party': others, 'third_party_unlisted': new_others, + 'schema': schema_flags(tree), 'privacy': privacy_flags(tree), 'third_party': others, 'third_party_unlisted': new_others, 'renames': renames, 'build': build, 'ossify_log': log, } write_report(args.out, report) diff --git a/tools/fork/tests/test_privacy_check.py b/tools/fork/tests/test_privacy_check.py new file mode 100644 index 0000000..94a2aa3 --- /dev/null +++ b/tools/fork/tests/test_privacy_check.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-only +"""Tests for tools/fork/privacy-check.py: python3 -m unittest discover tools/fork/tests""" +import gzip +import importlib.util +import json +import os +import tempfile +import tomllib +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location('privacy_check', os.path.join(HERE, '..', 'privacy-check.py')) +check = importlib.util.module_from_spec(spec) +spec.loader.exec_module(check) + +VOCAB = """ +[vocabulary] +categories = ["identifier", "contact", "network", "content", "metadata", "credential"] +whose = ["holder", "correspondent", "administrator"] +where = ["data-store", "blob-store", "search-store", "in-memory-store", "memory", "log-file", "external"] +scope = ["tenant", "server"] +retention = ["unbounded", "object-life", "receiver"] +""" + +GOOD = VOCAB + """ +[object."x:Widget"] +default = "none" +whose = ["holder"] +where = ["data-store"] +scope = "tenant" +retention = { setting = "x:Widget.keepFor" } +[object."x:Widget".properties] +owner = ["identifier"] + +[object."inbuxa:Gadget"] +file = "inbuxa_gadget.rs" +default = "none" +[object."inbuxa:Gadget".properties] +remoteIp = ["network"] + +[source."widget-log"] +categories = ["network"] +where = ["log-file"] +scope = "server" +retention = "unbounded" +enabled_by = ["x:Widget.enable", "inbuxa:Gadget.remoteIp"] +leaves_host = false +written_by = ["crates/widget.rs"] +""" + + +def make_tree(fields): + root = tempfile.mkdtemp() + os.makedirs(os.path.join(root, 'resources/schema')) + with gzip.open(os.path.join(root, check.SCHEMA), 'wt') as f: + json.dump({'fields': fields}, f) + os.makedirs(os.path.join(root, check.OBJECTS_DIR)) + with open(os.path.join(root, check.OBJECTS_DIR, 'inbuxa_gadget.rs'), 'w') as f: + f.write('Property::RemoteIp => "remoteIp",\nProperty::Id => "id",\n') + os.makedirs(os.path.join(root, 'crates/jmap-proto/src/request'), exist_ok=True) + with open(os.path.join(root, check.METHODS), 'w') as f: + f.write('"inbuxa:Gadget" => MethodObject::Gadget,\n') + with open(os.path.join(root, 'crates/widget.rs'), 'w') as f: + f.write('') + return root + + +def prop(type_, fmt=None): + t = {'type': type_} + if fmt: + t['format'] = fmt + return {'type': t} + + +FIELDS = { + 'x:Widget': {'properties': { + 'owner': prop('string', 'emailAddress'), + 'enable': prop('boolean'), + 'keepFor': prop('number', 'duration'), + 'label': prop('string', 'string'), + }}, +} + + +class PrivacyCheck(unittest.TestCase): + def run_check(self, catalog, fields=FIELDS): + return check.findings(make_tree(fields), tomllib.loads(catalog)) + + def test_the_repository_passes(self): + with open(os.path.join(check.ROOT, check.CATALOG), 'rb') as f: + catalog = tomllib.load(f) + self.assertEqual(check.findings(check.ROOT, catalog), []) + + def test_a_consistent_catalog_passes(self): + self.assertEqual(self.run_check(GOOD), []) + + def test_an_unclassified_object_fails(self): + fields = dict(FIELDS, **{'x:Sprocket': {'properties': {'size': prop('number', 'size')}}}) + found = self.run_check(GOOD, fields) + self.assertEqual(found, ['x:Sprocket: schema object has no catalog entry']) + + def test_an_address_hidden_behind_the_default_fails(self): + fields = {'x:Widget': {'properties': dict(FIELDS['x:Widget']['properties'], + contactEmail=prop('string', 'emailAddress'))}} + found = self.run_check(GOOD, fields) + self.assertEqual(found, ['x:Widget.contactEmail: typed as identifier but not listed']) + + def test_a_secret_in_a_set_or_object_counts(self): + fields = {'x:Widget': {'properties': dict( + FIELDS['x:Widget']['properties'], + ips={'type': {'type': 'set', 'class': {'type': 'string', 'format': 'ipNetwork'}}}, + key={'type': {'type': 'object', 'objectName': 'x:SecretKey'}}, + )}} + found = self.run_check(GOOD, fields) + self.assertIn('x:Widget.ips: typed as network but not listed', found) + self.assertIn('x:Widget.key: typed as credential but not listed', found) + + def test_a_stale_property_fails(self): + found = self.run_check(GOOD.replace('owner = ["identifier"]', 'owner = ["identifier"]\ngone = ["content"]')) + self.assertEqual(found, ['x:Widget.gone: no such property (stale)']) + + def test_a_stale_object_fails(self): + found = self.run_check(GOOD + '\n[object."x:Removed"]\ndefault = "none"\n') + self.assertEqual(found, ['x:Removed: no such schema object (stale)']) + + def test_a_stale_setting_fails(self): + found = self.run_check(GOOD.replace('x:Widget.keepFor', 'x:Widget.keepForever')) + self.assertEqual(found, ['x:Widget: setting x:Widget.keepForever names no property of x:Widget']) + + def test_a_stale_code_path_fails(self): + found = self.run_check(GOOD.replace('crates/widget.rs', 'crates/gone.rs')) + self.assertEqual(found, ['source "widget-log": written_by crates/gone.rs doesn\'t exist (stale)']) + + def test_an_unlisted_inbuxa_object_fails(self): + catalog = VOCAB + """ +[object."x:Widget"] +default = "none" +[object."x:Widget".properties] +owner = ["identifier"] +""" + found = self.run_check(catalog) + self.assertEqual(found, ['inbuxa:Gadget: inbuxa object has no catalog entry']) + + def test_words_outside_the_vocabulary_fail(self): + found = self.run_check(GOOD.replace('owner = ["identifier"]', 'owner = ["personal"]')) + self.assertEqual(found, ['x:Widget.owner: "personal" is not in vocabulary.categories']) + + def test_unlisted_prints_a_starting_entry(self): + fields = dict(FIELDS, **{'x:Sprocket': {'properties': {'mail': prop('string', 'emailAddress')}}}) + lines = check.unlisted(make_tree(fields), tomllib.loads(GOOD)) + self.assertIn('[object."x:Sprocket"]', lines) + self.assertIn('mail = ["identifier"]', lines) + + +class StripReport(unittest.TestCase): + def test_an_import_reports_what_is_new_and_unclassified(self): + import pathlib + strip_spec = importlib.util.spec_from_file_location('strip', os.path.join(HERE, '..', 'strip.py')) + strip = importlib.util.module_from_spec(strip_spec) + strip_spec.loader.exec_module(strip) + with gzip.open(os.path.join(check.ROOT, check.SCHEMA)) as f: + upstream = json.load(f) + upstream['fields']['x:NewThing'] = {'properties': {}} + upstream['fields']['x:UserAccount']['properties']['backupEmail'] = prop('string', 'emailAddress') + tree = pathlib.Path(tempfile.mkdtemp()) + (tree / 'resources/schema').mkdir(parents=True) + with gzip.open(tree / 'resources/schema/schema.json.gz', 'wt') as f: + json.dump(upstream, f) + self.assertEqual(strip.privacy_flags(tree), { + 'objects': ['x:NewThing'], + 'fields': ['x:UserAccount.backupEmail (identifier)'], + }) + + +if __name__ == '__main__': + unittest.main()