Compliance Overview and Data inventory pages #37
No files matched your search
@@ -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 <LegalHoldsPage />;
|
||||
}
|
||||
if (componentName === 'ComplianceOverview') {
|
||||
return <ComplianceOverviewPage />;
|
||||
}
|
||||
if (componentName === 'DataInventory') {
|
||||
return <DataInventoryPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown component: {componentName}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="text-3xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function changeText(t: (k: string, d: string, o?: Record<string, unknown>) => 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<Load>({ kind: 'loading' });
|
||||
const [review, setReview] = useState<AuditEvent[] | null>(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 <LoadingFallback />;
|
||||
if (load.kind === 'error') {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{load.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { inventory, snapshots } = load;
|
||||
const unbounded = unboundedItems(inventory.items);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<PageHeader
|
||||
icon="shield-check"
|
||||
title={t('overview.title', 'Compliance overview')}
|
||||
subtitle={t(
|
||||
'overview.subtitle',
|
||||
'What this server holds about people, what has no time limit, who changed what affects review, and when the picture last changed.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile
|
||||
value={inventory.summary.collected}
|
||||
label={t('overview.collected', 'Kinds of personal data this server can hold')}
|
||||
/>
|
||||
<Tile value={inventory.summary.unbounded} label={t('overview.unbounded', 'Kept with no time limit')} />
|
||||
<Tile value={inventory.summary.leavingHost} label={t('overview.leaving', 'Sent off this server')} />
|
||||
<Tile value={inventory.summary.processors} label={t('overview.hosts', 'Hosts receiving personal data')} />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<Link className="underline underline-offset-4" to="/Management/CustomComponent/DataInventory">
|
||||
{t('overview.toInventory', 'See the data inventory')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-base font-semibold">{t('overview.noLimitTitle', 'Kept with no time limit')}</h2>
|
||||
{unbounded.length === 0 ? (
|
||||
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('overview.noLimitNone', 'Everything this server holds has a limit or goes with what it belongs to.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{unbounded.map((item) => (
|
||||
<li key={item.id} className="flex flex-wrap justify-between gap-2 px-4 py-2">
|
||||
<span className="font-medium">{itemName(item.id)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{'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')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canAudit && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-base font-semibold">
|
||||
{t('overview.reviewTitle', 'Changes that affect review, last {{days}} days', { days: REVIEW_DAYS })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'overview.reviewBody',
|
||||
'Audit and log retention, tracers, webhooks, roles and legal holds, with who changed them. Every one is in the audit log.',
|
||||
)}
|
||||
</p>
|
||||
{review === null ? (
|
||||
<LoadingFallback />
|
||||
) : review.length === 0 ? (
|
||||
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('overview.reviewNone', 'None in this period.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{review.map((event) => (
|
||||
<li key={event.id} className="flex flex-wrap justify-between gap-2 px-4 py-2">
|
||||
<span>
|
||||
<span className="font-medium">{event.target.name || event.target.kind}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{event.action}
|
||||
{event.target.name && event.target.name !== event.target.kind && ` · ${event.target.kind}`}
|
||||
{event.reason && ` · ${event.reason}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{event.actor.name} · {new Date(event.at).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-base font-semibold">{t('overview.historyTitle', 'When the picture changed')}</h2>
|
||||
{snapshots.length === 0 ? (
|
||||
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||
{t(
|
||||
'overview.historyNone',
|
||||
'Nothing recorded yet. A record is made when a setting the inventory reads changes, and checked daily.',
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{snapshots.map((snap, i) => (
|
||||
<li key={snap.id} className="flex flex-wrap justify-between gap-2 px-4 py-2">
|
||||
<span>
|
||||
<span className="font-medium">{new Date(snap.takenAt).toLocaleString()}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{snap.trigger.kind === 'settingChanged'
|
||||
? t('overview.byChange', 'after {{setting}} changed', { setting: snap.trigger.setting })
|
||||
: t('overview.byDaily', 'daily check')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{changeText(t, snap.summary, snapshots[i + 1]?.summary)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Load>({ kind: 'loading' });
|
||||
const [filter, setFilter] = useState<InventoryFilter>(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 <LoadingFallback />;
|
||||
if (load.kind === 'error') {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{load.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { inventory } = load;
|
||||
const names = new Map(inventory.items.map((i) => [i.id, itemName(i.id)]));
|
||||
const pick = (value: string) => (value === ANY ? '' : value);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
<PageHeader
|
||||
icon="database"
|
||||
title={t('inventory.title', 'Data inventory')}
|
||||
subtitle={t(
|
||||
'inventory.subtitle',
|
||||
'What this server holds about people, worked out from its settings as they are now: where each kind of data lives, how long it’s kept, and where it goes.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Select
|
||||
value={filter.kind || ANY}
|
||||
onValueChange={(v) => setFilter({ ...filter, kind: pick(v) as InventoryFilter['kind'] })}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t('inventory.anyKind', 'Every source')}</SelectItem>
|
||||
<SelectItem value="object">{t('inventory.objects', 'Stored records')}</SelectItem>
|
||||
<SelectItem value="source">{t('inventory.sources', 'Files, exports and lookups')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filter.category || ANY} onValueChange={(v) => setFilter({ ...filter, category: pick(v) })}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t('inventory.anyCategory', 'Any kind of data')}</SelectItem>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filter.where || ANY} onValueChange={(v) => setFilter({ ...filter, where: pick(v) })}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t('inventory.anyPlace', 'Anywhere')}</SelectItem>
|
||||
{PLACES.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{whereName(p)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={!filter.onlyCollected}
|
||||
onCheckedChange={(v) => setFilter({ ...filter, onlyCollected: v !== true })}
|
||||
/>
|
||||
{t('inventory.showOff', 'Also show what this server doesn’t collect')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('inventory.colWhat', 'What')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('inventory.colHolds', 'Holds')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('inventory.colWhose', 'Whose')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('inventory.colWhere', 'Where')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('inventory.colKept', 'Kept')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className={cn('border-t align-top', !item.collected && 'text-muted-foreground')}>
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium">{names.get(item.id)}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{item.id}</div>
|
||||
{!item.collected && (
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{t('inventory.notCollected', 'Not collected here')}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">{item.categories.join(', ')}</td>
|
||||
<td className="px-4 py-2">{item.whose.join(', ')}</td>
|
||||
<td className="px-4 py-2">
|
||||
{item.where.map(whereName).join(', ')}
|
||||
{item.leavesHost && (
|
||||
<div className="mt-1 text-xs">
|
||||
{t('inventory.leaves', 'Leaves this server')}
|
||||
{item.endpoints.length > 0 && `: ${item.endpoints.join(', ')}`}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div>{retentionText(item.retention)}</div>
|
||||
{'setting' in item.retention && item.retention.setting && item.retention.kind !== 'setting' && (
|
||||
<div className="font-mono text-xs text-muted-foreground">{item.retention.setting}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && (
|
||||
<p className="p-6 text-center text-sm text-muted-foreground">{t('inventory.none', 'Nothing matches.')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-base font-semibold">{t('inventory.hostsTitle', 'Hosts that receive personal data')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'inventory.hostsBody',
|
||||
'Each is a candidate processor. Whether it is one, and what agreement it needs, is for you to determine.',
|
||||
)}
|
||||
</p>
|
||||
{inventory.processors.length === 0 ? (
|
||||
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('inventory.noHosts', 'Nothing this server collects is sent to another host.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border text-sm">
|
||||
{inventory.processors.map((p) => (
|
||||
<li key={p.host} className="flex flex-wrap items-start justify-between gap-2 px-4 py-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{p.host}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{p.receives.join(', ')} · {p.sources.map((s) => names.get(s) ?? s).join(', ')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('inventory.evaluated', 'Worked out {{when}}, from the catalog in {{version}}.', {
|
||||
when: inventory.evaluatedAt ? new Date(inventory.evaluatedAt).toLocaleString() : '',
|
||||
version: inventory.catalogVersion,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>): 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<typeof EMPTY_INVENTORY_FILTER>) =>
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<Inventory> {
|
||||
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<string, unknown>[] }).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<Snapshot[]> {
|
||||
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<string, string> = {
|
||||
'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<string, string> = 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<string, string> = {
|
||||
'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');
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user