Compliance Overview and Data inventory pages #37

Merged
jcoffey-dev merged 2 commits from feature/compliance-pages into main 2026-09-28 18:11:49 +00:00
6 changed files with 803 additions and 0 deletions

No files matched your search

+16
View File
@@ -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>
);
}
+73
View File
@@ -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']);
});
});
+240
View File
@@ -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');
}
+4
View File
@@ -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') {