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:
@@ -1908,11 +1908,38 @@ export async function search(
|
||||
// Merge direct matches with expansion rows; direct rows always win
|
||||
// ties and sort first. Each bucket caps at `limit * 2` so reps still
|
||||
// see the full direct-match set plus a healthy expansion tail.
|
||||
const mergedClients = mergeWithExpansion(clients, expanded.clients, limit * 2);
|
||||
const mergedInterests = mergeWithExpansion(interests, expanded.interests, limit * 2);
|
||||
const mergedYachts = mergeWithExpansion(yachts, expanded.yachts, limit * 2);
|
||||
const mergedCompanies = mergeWithExpansion(companies, expanded.companies, limit * 2);
|
||||
const mergedBerths = mergeWithExpansion(berths, expanded.berths, limit * 2);
|
||||
//
|
||||
// SECURITY (search-auditor H1): expandGraph runs unconditionally,
|
||||
// but its results MUST be re-gated by the destination bucket's view
|
||||
// permission. A user with berths.view but no interests.view searching
|
||||
// "A12" was previously getting interest rows (client name + stage)
|
||||
// surfaced via expansion. Gate each merge call so the expansion
|
||||
// contributes empty rows for any bucket the caller can't see.
|
||||
const mergedClients = mergeWithExpansion(
|
||||
clients,
|
||||
can(opts, 'clients.view') ? expanded.clients : [],
|
||||
limit * 2,
|
||||
);
|
||||
const mergedInterests = mergeWithExpansion(
|
||||
interests,
|
||||
can(opts, 'interests.view') ? expanded.interests : [],
|
||||
limit * 2,
|
||||
);
|
||||
const mergedYachts = mergeWithExpansion(
|
||||
yachts,
|
||||
can(opts, 'yachts.view') ? expanded.yachts : [],
|
||||
limit * 2,
|
||||
);
|
||||
const mergedCompanies = mergeWithExpansion(
|
||||
companies,
|
||||
can(opts, 'companies.view') ? expanded.companies : [],
|
||||
limit * 2,
|
||||
);
|
||||
const mergedBerths = mergeWithExpansion(
|
||||
berths,
|
||||
can(opts, 'berths.view') ? expanded.berths : [],
|
||||
limit * 2,
|
||||
);
|
||||
|
||||
const result: SearchResults = {
|
||||
clients: apply(mergedClients),
|
||||
|
||||
Reference in New Issue
Block a user