Files
inbuxa-admin/src/features/hardening/protocolPolicy.ts
T
jcoffey-dev 11d85cd759 Each tenant's page carries its legacy protocols switch (LP-9 to LP-18)
A card above the tenant form: whether legacy mail protocols are on or off
for the organization, a one-click "Turn legacy protocols back on", and
"Turn off legacy protocols…", which opens -- before anything can change --
the impact panel with the tenant's own people (LP-15), the statement at
tenant scope and the typed confirmation (LP-16, LP-17). The statement reads
"everyone in {organization}" and names no ports and no firewall: a
tenant's switch closes nothing, other tenants share the ports (LP-13).

It reads and turns inbuxa:TenantProtocolPolicy, with the domain
permissions the server checks for it. It shows on the read-only tenant page
too: a tenant administrator reads its tenant without changing it (MT-12)
and may still turn the switch. Turning it back on while the server has
legacy protocols off is refused by the server, and the refusal is shown.

The banner (LP-18) falls back to the tenant's switch where the server's
can't be read -- inside a tenant -- and reads "off for your
organization".

The impact panel, statement and confirmation move to parts.tsx, shared by
Hardening and the tenant card; the statement takes its scope.

Checked by hand against a local server with a tenant, two of its users and
the admin signed in over IMAP: the tenant card's panel lists the tenant's
two users and not the admin; the statement reads "everyone in Example Co"
with no ports or firewall; turning it off makes the tenant's user get "NO
[ALERT] Your organization allows only INBUXA webmail and JMAP apps" over
IMAP; one click turns it back on; Hardening's panel lists all three. Not
checked by hand: the banner as a tenant administrator sees it.
2026-09-21 13:39:56 -07:00

321 lines
13 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch
* (legacy-protocols spec). This module is the wire and the rules; the screen
* and the banner draw from it.
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { JmapSetError } from '@/types/jmap';
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
const OBJECT = 'inbuxa:ProtocolPolicy';
/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */
export const CONFIRM_PHRASE = 'turn off legacy mail';
/** A listener the switch closed, or would close, by name and port (LP-16). */
export interface PolicyListener {
name: string;
protocol: string;
ports: number[];
}
export interface ProtocolPolicy {
legacyProtocols: 'enabled' | 'disabled';
closeSubmission: boolean;
/** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */
savedListeners: PolicyListener[];
/** Milliseconds since the epoch. */
changedAt: number | null;
changedBy: string | null;
/** Registry protocols the switch may never close (LP-21), as the server says. */
lockedProtocols: string[];
/** What turning the switch off would close, whichever way it is set now (LP-16). */
wouldClose: PolicyListener[];
/**
* Who signed in over a legacy protocol in the last 30 days (LP-15), or null
* from a server too old to say -- which is not the same as nobody.
*/
recentLegacyUse: RecentUse[] | null;
}
/** One account's last sign-in over one legacy protocol, as the server reports it. */
export interface RecentUse {
accountId: string;
name: string;
protocol: string;
/** Milliseconds since the epoch. */
lastUsedAt: number;
}
/** One account on the impact panel: every protocol it used, and when it last did. */
export interface ImpactEntry {
name: string;
protocols: string[];
lastUsedAt: number;
}
/**
* The server sends each listener as an object keyed by the policy's own
* property names: `id` is the listener's name, `legacyProtocols` its protocol
* and `wouldClose` its ports. Read them back into something that says so.
*/
function parseListener(raw: unknown): PolicyListener | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (typeof r.id !== 'string') return null;
return {
name: r.id,
protocol: typeof r.legacyProtocols === 'string' ? r.legacyProtocols : '',
ports: Array.isArray(r.wouldClose) ? r.wouldClose.filter((p): p is number => typeof p === 'number') : [],
};
}
function parseListeners(raw: unknown): PolicyListener[] {
return Array.isArray(raw) ? raw.map(parseListener).filter((l): l is PolicyListener => l !== null) : [];
}
export function parsePolicy(raw: Record<string, unknown>): ProtocolPolicy {
return {
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
closeSubmission: raw.closeSubmission === true,
savedListeners: parseListeners(raw.savedListeners),
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
changedBy: typeof raw.changedBy === 'string' ? raw.changedBy : null,
lockedProtocols: Array.isArray(raw.lockedProtocols)
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
: [],
wouldClose: parseListeners(raw.wouldClose),
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
};
}
function parseRecent(raw: unknown[]): RecentUse[] {
return raw.flatMap((entry) => {
if (!entry || typeof entry !== 'object') return [];
const r = entry as Record<string, unknown>;
if (typeof r.name !== 'string' || typeof r.protocol !== 'string' || typeof r.lastUsedAt !== 'number') return [];
return [
{
accountId: typeof r.accountId === 'string' ? r.accountId : '',
name: r.name,
protocol: r.protocol,
lastUsedAt: r.lastUsedAt,
},
];
});
}
const PROTOCOL_LABELS: Record<string, string> = {
imap: 'IMAP',
pop3: 'POP3',
manageSieve: 'ManageSieve',
submission: 'SMTP submission',
};
/**
* The impact panel's lines (LP-15): one per account, naming every protocol it
* used and when it last used any, most recent first.
*/
export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
const byAccount = new Map<string, ImpactEntry>();
for (const use of recent) {
const key = use.accountId || use.name;
const entry = byAccount.get(key) ?? { name: use.name, protocols: [], lastUsedAt: 0 };
const label = PROTOCOL_LABELS[use.protocol] ?? use.protocol;
if (!entry.protocols.includes(label)) entry.protocols.push(label);
entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt);
byAccount.set(key, entry);
}
const order = Object.values(PROTOCOL_LABELS);
return [...byAccount.values()]
.map((e) => ({ ...e, protocols: e.protocols.sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
}
/** "2 days ago", "3 hours ago", "just now", in the reader's language. */
export function ago(at: number, now: number, locale?: string): string {
const seconds = Math.round((at - now) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const steps: [Intl.RelativeTimeFormatUnit, number][] = [
['day', 86400],
['hour', 3600],
['minute', 60],
];
for (const [unit, size] of steps) {
if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
}
return rtf.format(0, 'minute');
}
/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */
export class PolicyUnavailable extends Error {}
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
const accountId = getAccountId('x:NetworkListener');
// No `properties`: the server answers with all of them, recentLegacyUse
// included where it has it.
const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
const [name, result] = responses[0] ?? [];
if (name !== `${OBJECT}/get`) {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
if (!list[0]) throw new PolicyUnavailable('notFound');
return parsePolicy(list[0]);
}
export interface SetOutcome {
/** What the server stored differently from what was asked (LP-21), or nothing. */
overruled: Record<string, unknown> | null;
}
export async function updateProtocolPolicy(
update: Partial<Pick<ProtocolPolicy, 'legacyProtocols' | 'closeSubmission'>>,
): Promise<SetOutcome> {
const accountId = getAccountId('x:NetworkListener');
const responses = await jmapRequest(
[[`${OBJECT}/set`, { accountId, update: { singleton: update } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${OBJECT}/set`) {
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
}
const r = result as {
updated?: Record<string, Record<string, unknown> | null> | null;
notUpdated?: Record<string, JmapSetError> | null;
};
const failed = r.notUpdated?.singleton;
if (failed) throw new Error(failed.description ?? failed.type);
const stored = r.updated?.singleton;
return { overruled: stored && Object.keys(stored).length > 0 ? stored : null };
}
/** How a protocol stands under the switch, for the selector (LP-21). */
export type RowState = 'closes' | 'refused' | 'locked';
export interface ProtocolRow {
key: string;
label: string;
state: RowState;
/** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */
ports: number[];
}
function portsOf(listeners: PolicyListener[], protocol: string): number[] {
const ports = listeners.filter((l) => l.protocol === protocol).flatMap((l) => l.ports);
return [...new Set(ports)].sort((a, b) => a - b);
}
/**
* Every mail protocol the server speaks, in one place, with what the switch
* does to each. The locked set comes from the server, so unlocking later is
* a server change and no admin release (LP-21).
*
* `listeners` is what the switch closes: `wouldClose` while it's on, or
* `savedListeners` once it's off.
*/
export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] {
const locked = new Set(policy.lockedProtocols.map((p) => p.toLowerCase()));
const legacy: ProtocolRow[] = [
{ key: 'imap', label: 'IMAP', state: 'closes', ports: portsOf(listeners, 'imap') },
{ key: 'pop3', label: 'POP3', state: 'closes', ports: portsOf(listeners, 'pop3') },
{ key: 'manageSieve', label: 'ManageSieve', state: 'closes', ports: portsOf(listeners, 'manageSieve') },
];
const smtpLocked = locked.has('smtp');
return [
...legacy,
{
key: 'submission',
label: 'SMTP submission',
// Locked submission keeps its ports; sign-in over them is refused instead.
state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused',
ports: smtpLocked ? [] : portsOf(listeners, 'smtp'),
},
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] },
];
}
/** Whether the typed confirmation matches (LP-17). Exact: no trimming, no case folding. */
export function phraseMatches(typed: string): boolean {
return typed === CONFIRM_PHRASE;
}
export function describeListener(l: PolicyListener): string {
return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name;
}
// ---- A tenant's switch: inbuxa:TenantProtocolPolicy (LP-9 to LP-14) ----
const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy';
export interface TenantPolicy {
/** The tenant's id, which is also the policy's. */
id: string;
legacyProtocols: 'enabled' | 'disabled';
/** Milliseconds since the epoch. */
changedAt: number | null;
/** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */
recentLegacyUse: RecentUse[] | null;
}
export function parseTenantPolicy(raw: Record<string, unknown>): TenantPolicy {
return {
id: typeof raw.id === 'string' ? raw.id : '',
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
};
}
/**
* A tenant's switch. With no id, the caller's own tenant's -- which is how a
* tenant administrator reads it; a server administrator names the tenant.
*/
export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortSignal): Promise<TenantPolicy> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/get`, { accountId, ids: tenantId ? [tenantId] : null }, '0']],
signal,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/get`) {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
// A tenant admin's /get with no ids holds exactly its own tenant's.
if (!list[0] || (!tenantId && list.length !== 1)) throw new PolicyUnavailable('notFound');
return parseTenantPolicy(list[0]);
}
/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */
export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise<void> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/set`) {
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
}
const failed = (result as { notUpdated?: Record<string, JmapSetError> | null }).notUpdated?.[tenantId];
if (failed) throw new Error(failed.description ?? failed.type);
}