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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user