diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 2b5b88b..55993ad 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -51,6 +51,16 @@ const LegacyProtocolsPage = lazyFeature( () => import('@/features/hardening/LegacyProtocolsPage'), (m) => m.LegacyProtocolsPage, ); +// inbuxa: Management › Compliance › Overview and Data Inventory +// (personal-data catalog spec, §8). +const ComplianceOverviewPage = lazyFeature( + () => import('@/features/compliance/ComplianceOverviewPage'), + (m) => m.ComplianceOverviewPage, +); +const DataInventoryPage = lazyFeature( + () => import('@/features/compliance/DataInventoryPage'), + (m) => m.DataInventoryPage, +); // inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec). const LocalAiPage = lazyFeature( () => import('@/features/ai/LocalAiPage'), @@ -151,6 +161,12 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LegalHolds') { return ; } + if (componentName === 'ComplianceOverview') { + return ; + } + if (componentName === 'DataInventory') { + return ; + } return (
Unknown component: {componentName} diff --git a/src/features/compliance/ComplianceOverviewPage.tsx b/src/features/compliance/ComplianceOverviewPage.tsx new file mode 100644 index 0000000..8e2dd24 --- /dev/null +++ b/src/features/compliance/ComplianceOverviewPage.tsx @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Overview (personal-data catalog spec, + * §7, §8). What the server holds, in four counts, and what has no limit; + * recent changes that weaken review (audit retention, tracers, webhooks, + * roles, released holds), each with who and when; and the inventory's + * history. Facts, never a verdict. + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useAccountStore } from '@/stores/accountStore'; +import { EMPTY_FILTER, queryEvents, type AuditEvent } from '@/features/audit/auditLog'; +import { + fetchInventory, + fetchSnapshots, + InventoryUnavailable, + itemName, + unboundedItems, + type Inventory, + type Snapshot, + type Summary, +} from './inventory'; + +/** Changes that weaken or widen review, shown with who made them (§7). */ +const REVIEW_KINDS = [ + 'inbuxa:AuditSettings', + 'inbuxa:LogSettings', + 'x:Tracer', + 'x:WebHook', + 'x:Role', + 'inbuxa:LegalHold', +]; +const REVIEW_DAYS = 30; + +type Load = + | { kind: 'loading' } + | { kind: 'ready'; inventory: Inventory; snapshots: Snapshot[] } + | { kind: 'error'; message: string }; + +function Tile({ value, label }: { value: number; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function changeText(t: (k: string, d: string, o?: Record) => string, now: Summary, before?: Summary) { + if (!before) return t('overview.first', 'First record'); + const parts: string[] = []; + const diff = (key: keyof Summary, label: string) => { + const d = now[key] - before[key]; + if (d !== 0) parts.push(`${label} ${d > 0 ? '+' : ''}${d}`); + }; + diff('collected', t('overview.dCollected', 'collected')); + diff('unbounded', t('overview.dUnbounded', 'no limit')); + diff('leavingHost', t('overview.dLeaving', 'leaving')); + diff('processors', t('overview.dHosts', 'hosts')); + return parts.join(', ') || t('overview.noCount', 'Same counts'); +} + +export function ComplianceOverviewPage() { + const { t } = useTranslation(); + const canAudit = useAccountStore((s) => s.hasPermission('sysAuditGet')); + const [load, setLoad] = useState({ kind: 'loading' }); + const [review, setReview] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + Promise.all([fetchInventory(controller.signal), fetchSnapshots(controller.signal)]) + .then(([inventory, snapshots]) => !controller.signal.aborted && setLoad({ kind: 'ready', inventory, snapshots })) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof InventoryUnavailable + ? t('inventory.unavailable', 'This server doesn’t report a data inventory.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + if (canAudit) { + const from = new Date(Date.now() - REVIEW_DAYS * 86_400_000); + const day = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`; + Promise.all( + REVIEW_KINDS.map((targetKind) => + queryEvents({ ...EMPTY_FILTER, from: day, targetKind }, 0, controller.signal).catch(() => ({ + events: [] as AuditEvent[], + total: 0, + })), + ), + ).then((pages) => { + if (controller.signal.aborted) return; + const events = pages + .flatMap((p) => p.events) + .filter((e) => e.outcome.status === 'success') + .sort((a, b) => b.at.localeCompare(a.at)) + .slice(0, 20); + setReview(events); + }); + } + return () => controller.abort(); + }, [t, canAudit]); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + const { inventory, snapshots } = load; + const unbounded = unboundedItems(inventory.items); + + return ( +
+ + +
+ + + + +
+

+ + {t('overview.toInventory', 'See the data inventory')} + +

+ +
+

{t('overview.noLimitTitle', 'Kept with no time limit')}

+ {unbounded.length === 0 ? ( +

+ {t('overview.noLimitNone', 'Everything this server holds has a limit or goes with what it belongs to.')} +

+ ) : ( +
    + {unbounded.map((item) => ( +
  • + {itemName(item.id)} + + {'setting' in item.retention && item.retention.setting + ? t('overview.limitWith', 'A limit can be set: {{setting}}', { setting: item.retention.setting }) + : t('overview.noSetting', 'No setting limits it')} + +
  • + ))} +
+ )} +
+ + {canAudit && ( +
+

+ {t('overview.reviewTitle', 'Changes that affect review, last {{days}} days', { days: REVIEW_DAYS })} +

+

+ {t( + 'overview.reviewBody', + 'Audit and log retention, tracers, webhooks, roles and legal holds, with who changed them. Every one is in the audit log.', + )} +

+ {review === null ? ( + + ) : review.length === 0 ? ( +

+ {t('overview.reviewNone', 'None in this period.')} +

+ ) : ( +
    + {review.map((event) => ( +
  • + + {event.target.name || event.target.kind}{' '} + + {event.action} + {event.target.name && event.target.name !== event.target.kind && ` · ${event.target.kind}`} + {event.reason && ` · ${event.reason}`} + + + + {event.actor.name} · {new Date(event.at).toLocaleString()} + +
  • + ))} +
+ )} +
+ )} + +
+

{t('overview.historyTitle', 'When the picture changed')}

+ {snapshots.length === 0 ? ( +

+ {t( + 'overview.historyNone', + 'Nothing recorded yet. A record is made when a setting the inventory reads changes, and checked daily.', + )} +

+ ) : ( +
    + {snapshots.map((snap, i) => ( +
  • + + {new Date(snap.takenAt).toLocaleString()}{' '} + + {snap.trigger.kind === 'settingChanged' + ? t('overview.byChange', 'after {{setting}} changed', { setting: snap.trigger.setting }) + : t('overview.byDaily', 'daily check')} + + + {changeText(t, snap.summary, snapshots[i + 1]?.summary)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/features/compliance/DataInventoryPage.tsx b/src/features/compliance/DataInventoryPage.tsx new file mode 100644 index 0000000..03b29b0 --- /dev/null +++ b/src/features/compliance/DataInventoryPage.tsx @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Data Inventory (personal-data catalog + * spec, §8). What this server holds, evaluated from its settings: each kind + * of personal data, whose it is, where it lives, how long it's kept and + * whether it leaves the server; then every host that receives some. Facts + * only; whether a host is a processor is the operator's to determine. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { cn } from '@/lib/utils'; +import { + EMPTY_INVENTORY_FILTER, + fetchInventory, + filterItems, + InventoryUnavailable, + itemName, + retentionText, + whereName, + type Inventory, + type InventoryFilter, +} from './inventory'; + +const CATEGORIES = ['identifier', 'contact', 'network', 'content', 'metadata', 'credential']; +const PLACES = ['data-store', 'blob-store', 'search-store', 'in-memory-store', 'memory', 'log-file', 'external']; +const ANY = '__any'; + +type Load = { kind: 'loading' } | { kind: 'ready'; inventory: Inventory } | { kind: 'error'; message: string }; + +export function DataInventoryPage() { + const { t } = useTranslation(); + const [load, setLoad] = useState({ kind: 'loading' }); + const [filter, setFilter] = useState(EMPTY_INVENTORY_FILTER); + + useEffect(() => { + const controller = new AbortController(); + fetchInventory(controller.signal) + .then((inventory) => !controller.signal.aborted && setLoad({ kind: 'ready', inventory })) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof InventoryUnavailable + ? t('inventory.unavailable', 'This server doesn’t report a data inventory.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + return () => controller.abort(); + }, [t]); + + const items = useMemo(() => (load.kind === 'ready' ? filterItems(load.inventory.items, filter) : []), [load, filter]); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + const { inventory } = load; + const names = new Map(inventory.items.map((i) => [i.id, itemName(i.id)])); + const pick = (value: string) => (value === ANY ? '' : value); + + return ( +
+ + +
+ + + + +
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
{t('inventory.colWhat', 'What')}{t('inventory.colHolds', 'Holds')}{t('inventory.colWhose', 'Whose')}{t('inventory.colWhere', 'Where')}{t('inventory.colKept', 'Kept')}
+
{names.get(item.id)}
+
{item.id}
+ {!item.collected && ( + + {t('inventory.notCollected', 'Not collected here')} + + )} +
{item.categories.join(', ')}{item.whose.join(', ')} + {item.where.map(whereName).join(', ')} + {item.leavesHost && ( +
+ {t('inventory.leaves', 'Leaves this server')} + {item.endpoints.length > 0 && `: ${item.endpoints.join(', ')}`} +
+ )} +
+
{retentionText(item.retention)}
+ {'setting' in item.retention && item.retention.setting && item.retention.kind !== 'setting' && ( +
{item.retention.setting}
+ )} +
+ {items.length === 0 && ( +

{t('inventory.none', 'Nothing matches.')}

+ )} +
+ +
+

{t('inventory.hostsTitle', 'Hosts that receive personal data')}

+

+ {t( + 'inventory.hostsBody', + 'Each is a candidate processor. Whether it is one, and what agreement it needs, is for you to determine.', + )} +

+ {inventory.processors.length === 0 ? ( +

+ {t('inventory.noHosts', 'Nothing this server collects is sent to another host.')} +

+ ) : ( +
    + {inventory.processors.map((p) => ( +
  • + + + {p.host} + + + {p.receives.join(', ')} · {p.sources.map((s) => names.get(s) ?? s).join(', ')} + +
  • + ))} +
+ )} +
+ +

+ {t('inventory.evaluated', 'Worked out {{when}}, from the catalog in {{version}}.', { + when: inventory.evaluatedAt ? new Date(inventory.evaluatedAt).toLocaleString() : '', + version: inventory.catalogVersion, + })} +

+
+ ); +} diff --git a/src/features/compliance/inventory.test.ts b/src/features/compliance/inventory.test.ts new file mode 100644 index 0000000..bb28e92 --- /dev/null +++ b/src/features/compliance/inventory.test.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { + EMPTY_INVENTORY_FILTER, + filterItems, + itemName, + retentionText, + unboundedItems, + type InventoryItem, +} from './inventory'; + +const item = (patch: Partial): InventoryItem => ({ + id: 'x:UserAccount', + kind: 'object', + categories: ['identifier'], + whose: ['holder'], + where: ['data-store'], + scope: 'tenant', + collected: true, + retention: { kind: 'object-life' }, + leavesHost: false, + controlledBy: [], + endpoints: [], + ...patch, +}); + +describe('data inventory', () => { + it('names catalog ids for people', () => { + expect(itemName('x:UserAccount')).toBe('User account'); + expect(itemName('inbuxa:AuditEvent')).toBe('Audit event'); + expect(itemName('log-file')).toBe('Log files'); + expect(itemName('x:ArfFeedbackReport')).toBe('ARF feedback report'); + expect(itemName('x:BlockedIp')).toBe('Blocked IP'); + expect(itemName('x:DmarcTroubleshoot')).toBe('DMARC troubleshoot'); + }); + + it('says retention as a fact', () => { + expect(retentionText({ kind: 'days', days: 30 })).toBe('Kept 30 days'); + expect(retentionText({ kind: 'days', days: 1 })).toBe('Kept 1 day'); + expect(retentionText({ kind: 'unbounded' })).toBe('Kept with no limit'); + expect(retentionText({ kind: 'setting', setting: 'x:Jmap.uploadTtl' })).toBe('Set by x:Jmap.uploadTtl'); + }); + + it('filters by kind, category, place, and what is collected', () => { + const items = [ + item({ id: 'a' }), + item({ id: 'b', kind: 'source', categories: ['network'], where: ['log-file'] }), + item({ id: 'c', collected: false }), + item({ id: 'd', where: ['data-store'], leavesHost: true, endpoints: ['db.example.net'] }), + ]; + const ids = (f: Partial) => + filterItems(items, { ...EMPTY_INVENTORY_FILTER, ...f }).map((i) => i.id); + expect(ids({})).toEqual(['a', 'b', 'd']); + expect(ids({ onlyCollected: false })).toEqual(['a', 'b', 'c', 'd']); + expect(ids({ kind: 'source' })).toEqual(['b']); + expect(ids({ category: 'network' })).toEqual(['b']); + expect(ids({ where: 'external' })).toEqual(['d']); + }); + + it('lists what is kept with no limit', () => { + const items = [ + item({ id: 'a', retention: { kind: 'unbounded' } }), + item({ id: 'b', retention: { kind: 'unbounded' }, collected: false }), + item({ id: 'c', retention: { kind: 'days', days: 3 } }), + ]; + expect(unboundedItems(items).map((i) => i.id)).toEqual(['a']); + }); +}); diff --git a/src/features/compliance/inventory.ts b/src/features/compliance/inventory.ts new file mode 100644 index 0000000..8fe58a2 --- /dev/null +++ b/src/features/compliance/inventory.ts @@ -0,0 +1,240 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:DataInventory` and `inbuxa:InventorySnapshot`, the + * personal-data catalog evaluated against this server, and its history + * (personal-data catalog spec, §6, §8). Facts only: what is held, where, + * for how long, and which hosts receive it. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; + +const CAPABILITY = 'urn:inbuxa:jmap'; + +export type Retention = + | { kind: 'days'; days: number; setting?: string } + | { kind: 'unbounded'; setting?: string } + | { kind: 'object-life' } + | { kind: 'receiver' } + | { kind: 'setting'; setting: string }; + +export interface InventoryItem { + id: string; + kind: 'object' | 'source'; + categories: string[]; + whose: string[]; + where: string[]; + scope: 'tenant' | 'server'; + collected: boolean; + retention: Retention; + leavesHost: boolean; + controlledBy: string[]; + endpoints: string[]; +} + +export interface Processor { + host: string; + receives: string[]; + sources: string[]; +} + +export interface Summary { + collected: number; + unbounded: number; + leavingHost: number; + processors: number; +} + +export interface Inventory { + evaluatedAt: string; + catalogVersion: string; + summary: Summary; + items: InventoryItem[]; + processors: Processor[]; +} + +export interface Snapshot { + id: string; + takenAt: string; + trigger: { kind: 'settingChanged'; setting: string } | { kind: 'daily' }; + summary: Summary; +} + +/** The server doesn't evaluate the catalog (an older server). */ +export class InventoryUnavailable extends Error {} + +function failure(name: string | undefined, result: unknown): Error { + const r = result as { type?: string; description?: string } | undefined; + if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new InventoryUnavailable(r.type); + return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`); +} + +const EMPTY_SUMMARY: Summary = { collected: 0, unbounded: 0, leavingHost: 0, processors: 0 }; + +export async function fetchInventory(signal?: AbortSignal): Promise { + const responses = await jmapRequest( + [['inbuxa:DataInventory/get', { accountId: getAccountId('x:Account'), ids: null }, '0']], + signal, + [CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== 'inbuxa:DataInventory/get') throw failure(name, result); + const raw = ((result as { list?: Record[] }).list ?? [])[0] ?? {}; + return { + evaluatedAt: String(raw.evaluatedAt ?? ''), + catalogVersion: String(raw.catalogVersion ?? ''), + summary: { ...EMPTY_SUMMARY, ...((raw.summary as Summary | undefined) ?? {}) }, + items: (raw.items as InventoryItem[] | undefined) ?? [], + processors: (raw.processors as Processor[] | undefined) ?? [], + }; +} + +/** Every snapshot kept, newest first, without their inventories. */ +export async function fetchSnapshots(signal?: AbortSignal): Promise { + const responses = await jmapRequest( + [ + [ + 'inbuxa:InventorySnapshot/get', + { accountId: getAccountId('x:Account'), ids: null, properties: ['id', 'takenAt', 'trigger', 'summary'] }, + '0', + ], + ], + signal, + [CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== 'inbuxa:InventorySnapshot/get') throw failure(name, result); + return ((result as { list?: Snapshot[] }).list ?? []).map((s) => ({ + ...s, + summary: { ...EMPTY_SUMMARY, ...(s.summary ?? {}) }, + })); +} + +const SOURCE_NAMES: Record = { + 'mail-and-groupware': 'Mail, calendars, contacts and files', + 'full-text-index': 'Search index', + 'trace-index': 'Delivery history search index', + 'log-file': 'Log files', + 'console-and-journal': 'Console and system journal output', + 'otel-tracer': 'OpenTelemetry export', + webhooks: 'Webhooks', + 'spam-trainer-state': 'Spam classifier training state', + 'spam-llm': 'AI spam classification', + 'explain-cache': 'Explain answers (in memory)', + 'spam-dnsbl': 'Blocklist lookups', + 'spam-pyzor': 'Pyzor lookups', + 'spam-url-redirects': 'Short-link lookups', + 'mta-milter-and-hooks': 'Milters and MTA hooks', + relay: 'Relay (smart host)', + 'push-subscriptions': 'Push subscriptions', + 'rate-limit-and-greylist': 'Rate limits and greylisting', + 'legacy-last-use': 'Legacy mail app last use', + 'masked-address-records': 'Masked address records', + 'outbound-reports': 'DMARC and TLS reports sent', +}; + +/** Words in object names that are written in capitals. */ +const ACRONYMS: Record = Object.fromEntries( + [ + 'AI', + 'API', + 'ARF', + 'ASN', + 'CSV', + 'DAV', + 'DKIM', + 'DMARC', + 'DNS', + 'DNSBL', + 'DSN', + 'EHLO', + 'HTTP', + 'IMAP', + 'IP', + 'JMAP', + 'LDAP', + 'LLM', + 'MTA', + 'MX', + 'OIDC', + 'OTP', + 'SPF', + 'SQL', + 'STS', + 'TLS', + 'TSIG', + 'TTL', + 'URL', + ].map((a) => [a.toLowerCase(), a]), +); + +/** A readable name for a catalog id. */ +export function itemName(id: string): string { + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; + const bare = id.replace(/^(x|inbuxa):/, ''); + // BlockedIp -> Blocked IP + const words = bare + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') + .split(' ') + .map((w, i) => ACRONYMS[w.toLowerCase()] ?? (i === 0 ? w : w.toLowerCase())); + return words.join(' '); +} + +const WHERE_NAMES: Record = { + 'data-store': 'data store', + 'blob-store': 'blob store', + 'search-store': 'search store', + 'in-memory-store': 'in-memory store', + memory: 'memory', + 'log-file': 'log files', + external: 'sent elsewhere', +}; + +export function whereName(where: string): string { + return WHERE_NAMES[where] ?? where; +} + +/** A retention as a fact, in words. */ +export function retentionText(retention: Retention): string { + switch (retention.kind) { + case 'days': + return retention.days === 1 ? 'Kept 1 day' : `Kept ${retention.days} days`; + case 'unbounded': + return 'Kept with no limit'; + case 'object-life': + return 'Kept while it exists'; + case 'receiver': + return 'Kept by whoever receives it'; + case 'setting': + return `Set by ${retention.setting}`; + } +} + +export interface InventoryFilter { + kind: '' | 'object' | 'source'; + category: string; + where: string; + onlyCollected: boolean; +} + +export const EMPTY_INVENTORY_FILTER: InventoryFilter = { kind: '', category: '', where: '', onlyCollected: true }; + +export function filterItems(items: InventoryItem[], filter: InventoryFilter): InventoryItem[] { + return items.filter( + (item) => + (!filter.kind || item.kind === filter.kind) && + (!filter.category || item.categories.includes(filter.category)) && + (!filter.where || item.where.includes(filter.where) || (filter.where === 'external' && item.leavesHost)) && + (!filter.onlyCollected || item.collected), + ); +} + +/** Collected items kept with no limit, for the Overview. */ +export function unboundedItems(items: InventoryItem[]): InventoryItem[] { + return items.filter((i) => i.collected && i.retention.kind === 'unbounded'); +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index f2d7d7f..a56f185 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -85,6 +85,10 @@ function checkSpecialLink( if (viewName === 'CustomComponent/LegalHolds') { return { visible: canGet ? canGet('sysLegalHold') : true, enterprise: false }; } + // INBUXA: the compliance overview and data inventory (personal-data catalog) + if (viewName === 'CustomComponent/ComplianceOverview' || viewName === 'CustomComponent/DataInventory') { + return { visible: canGet ? canGet('sysCompliance') : true, enterprise: false }; + } // inbuxa: locked accounts, for whoever may see them (AL-12). if (viewName === 'CustomComponent/AccountLocks') {