audit: Tier 2/3/4 batch — reports math, portal copy, authz escalation guard

Tier 2.2: revenue PDF totalCompleted now filters on outcome='won' —
setInterestOutcome forces stage='completed' for every outcome (incl.
lost + cancelled), so the stage-only filter was including those toward
"TOTAL COMPLETED REVENUE".

Tier 2.3: fetchPipelineData stageCounts adds the missing .groupBy() —
without it Postgres rejects the SELECT (per-stage breakdown was broken
or coercing to ELSE-stage row).

Tier 2.4: hot-deals widget rank ladder fixed two stage-name typos —
'in_comms' → 'in_communication', 'deposit_10' → 'deposit_10pct'. Both
stages were collapsing to the ELSE 0 branch server-side AND rendering
raw enum to the user in hot-deals-card.tsx.

Tier 3.2: portal /portal/interests no longer renders raw enum to
clients. New PORTAL_SIGNING_LABELS table maps every EOI/contract
status to plain English (e.g. "waiting_for_signatures" → "Waiting for
signatures").

Tier 4.1 (CRITICAL): permission-overrides PUT now requires caller-
superset on every `true` write. Admins with only `admin.manage_users`
could previously grant other users leaves they don't hold themselves
(permanently_delete_clients, system_backup). Super-admins bypass.

Tier 4.4: search graph-expansion re-gates every merged bucket by the
destination's view permission. A user with berths.view but no
interests.view searching "A12" no longer sees interest rows surfaced
via expansion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 17:13:04 +02:00
parent 16ef609e1b
commit 50f48a8b6a
8 changed files with 116 additions and 29 deletions

View File

@@ -181,6 +181,16 @@ export const PUT = withAuth(
// here we drop unknown resources/actions so a malicious client
// can't seed garbage keys that a future resolver might accidentally
// honour.
// CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only
// `admin.manage_users` previously could grant another user any
// permission leaf — including ones they don't hold themselves
// (e.g. `permanently_delete_clients`, `system_backup`). Require
// every `true` write to be a leaf the caller already has.
// Super-admins bypass (they hold all leaves by definition).
const callerPerms = ctx.permissions as Record<
string,
Record<string, boolean>
> | null;
const sanitized: Record<string, Record<string, boolean>> = {};
for (const [resource, actions] of Object.entries(overrides)) {
const allowed = ALLOWED_RESOURCE_ACTIONS[resource];
@@ -193,6 +203,14 @@ export const PUT = withAuth(
`permission override for ${resource}.${action} must be true or false`,
);
}
if (value === true && !ctx.isSuperAdmin) {
const callerHas = callerPerms?.[resource]?.[action] === true;
if (!callerHas) {
throw new ForbiddenError(
`You don't hold ${resource}.${action} yourself, so you can't grant it.`,
);
}
}
cleaned[action] = value;
}
if (Object.keys(cleaned).length > 0) sanitized[resource] = cleaned;

View File

@@ -10,22 +10,22 @@ type AlertStatus = 'open' | 'dismissed' | 'resolved';
// page uses.
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
const rows = await listAlertsForPort(ctx.portId, {
includeDismissed: status !== 'open',
includeResolved: status !== 'open',
});
const rows = await listAlertsForPort(ctx.portId, {
includeDismissed: status !== 'open',
includeResolved: status !== 'open',
});
// Filter to the requested status bucket so callers don't see overlap.
const filtered = rows.filter((a) => {
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
if (status === 'resolved') return Boolean(a.resolvedAt);
return true;
});
// Filter to the requested status bucket so callers don't see overlap.
const filtered = rows.filter((a) => {
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
if (status === 'resolved') return Boolean(a.resolvedAt);
return true;
});
return NextResponse.json({ data: filtered });
return NextResponse.json({ data: filtered });
}),
);