Cross-company isolation audit (2026-09-10)

Sep 10, 2026 · worktree: isolation-audit-wt off origin/main (HEAD f34ba37b12) · read-only audit, no files changed

Summary

A Sept 9 Western Slope walk-through found one cross-company data leak: an unauthenticated request, or a transient session-resolution failure on an authenticated one, returns full dashboard metrics across every customer in the deployment. Subsequent audit of all reachable read/write routes outside the already-known metric-history leak (being fixed separately) found 13 additional findings: 7 critical, 4 high, 2 medium, 0 low-severity. The root cause appears in nearly all of them: getCurrentUserUnchecked() returns nullnull scope as "platform staff—unrestricted." The durable fix is structural: requester identity must reach the data layer and fail closed, with a guard test that blocks new fail-open code. Every route below is fixable in 1–2 lines; critical and high findings carry open PRs or PR-pending status.

Findings

#What a customer could see or doSeverityStatus
ASKAsk Clara (the in-app assistant) answered a zero-building company's admin with other customers' units, leases, renewals, transcripts and balancesCriticalOpen, held at handoff — PR #7667
1Work order read/write/approve/complete/reopen across any org's records on session-resolution failure; see also lines 696–698, complete/route.ts, progress/route.ts, request-vendor-quote/route.ts, story/route.tsHighOpen, held at handoff — PR #7663
2Property manager at Property A can approve/reject a tenant's renewal at Property B in the same org (no property-scope check unlike the patched sibling outreach/route.ts)CriticalOpen, held at handoff — PR #7657
3Unauthenticated request or resolution failure returns full cross-org dashboard financials/occupancy/tenant counts for every customerCriticalMerged, live — PR #7653 (SHA faaa29d5)
4Same root cause as #3; leasing metrics endpoint, lighter weight, same attack shapeCriticalFixing — PR pending
5Same root cause; cost-savings summary, explicitly intentional per code comment ("tolerates null user with unrestricted scope"), contradicts isolation hard floorCriticalFixing — PR pending
6Work orders list (polled every 5s by PM dashboard) returns every work order in deployment across every org on resolution failureCriticalFixing — PR pending
7Conversations, search (all tenants/prospects/work orders/vendors), activity log — every org's records returned on resolution failureHighFixing — PR pending
8Any authenticated user can persist report data under an arbitrary property they do not own via no org/property scoping on writeCriticalMerged, live — PR #7659
9Anonymous or resolution-failed request gets vendor detail including crew contact PII (names/phones/emails) across every org engaging that vendorHighFixing — PR pending
10Org-bounded user with unresolved org can rename/delete/toggle in-house status on any org's vendor, or list another org's crew contactsMedium-HighOpen, held at handoff — PR #7670
11Unauthenticated request returns every org's vacant units, pricing, property names via availability endpointHighFixing — PR pending
12Latent: getReportData(undefined) reads pooled GLOBAL partition with no org filter; no current caller invokes it unscoped but flagged to prevent future no-arg callersCritical (latent)Fixing — PR pending
13Default "All Properties" dashboard view calls getConversationsMeta, getRenewals(undefined), getProperties with no scoping; returns every org's conversations, renewals, propertiesCriticalFixing — PR pending
14No-propertyId portfolio insights fanout calls getAllPropertyIds() unfiltered; returns LLM-written property commentary from whichever org generated insights most recentlyCriticalFixing — PR pending

The rule going forward

Requester identity reaches the data layer and fails closed. Every route that accepts a session must resolve the user and check scope before any query or write. The pattern already exists in the codebase in three places:

New code must use one of these patterns. Guard tests in CI will block any new code that repeats the scope = user ? getUserPropertyScope(user) : null pattern when user can be null from a session failure (not a role-based check).

Evidence pointers

Dashboard audit (2026-09-10, follow-up)

Full read-only sweep of every card on the main dashboard, plus a live test on production with a brand-new, empty company. No customer data touched; the test company was created and deleted the same session.

We checked every card on the dashboard home screen — occupancy, monthly revenue, NOI, the leasing pipeline, the renewals pipeline, "this week," maintenance, turnovers, collections, and the three history charts (occupancy / rent income / NOI) — plus the sidebar and the notification icon in the header. The sidebar has no counters and the header icon isn't wired to any data, so there was nothing to check there.

What we found: every card traces back to one of the leaks already listed in the table above — nothing brand-new turned up. Two things are worth calling out plainly:

One more small thing surfaced during the live test: the Maintenance card on the dashboard home page showed "Created (30 days): 2" for the empty test company, even though the dedicated Maintenance page correctly showed zero. That's the same underlying bug as #3/#4 above (same endpoint), not a separate hole — it closes automatically once PR #7663 ships.

We also confirmed something outside the dashboard cards themselves, while signed in as the same test company: asking Ask Clara (the in-app assistant) a broad question also pulled in other customers' data. That's the same issue already tracked above with an open fix, PR #7667.

Where things stand

Card / surfaceReads other companies' data today?Fix
Occupancy, revenue, NOI, leasing pipeline, renewals pipeline, "this week," maintenance card, turnovers, collectionsYes — confirmed livePR #7663 (open, ready)
History charts (occupancy / rent income / NOI)Yes — confirmed live tonight, real production numbersPR #7653 (open, ready)
Live activity feed ("this week" call/text counts, renewals in progress)Yes — confirmed live tonightNot yet started — no pull request open
Clara's portfolio commentary panelYes, per the earlier code audit (not separately re-tested live tonight)Not yet started — no pull request open
Ask Clara (chat assistant)Yes — confirmed live tonightPR #7667 (open, ready)
Sidebar counters, header notification iconN/A — don't show any data today

Net new work opened tonight: none — every leak found already had a name and a number in the table above. Nothing new to file. The two rows above with no pull request open (the live activity feed and Clara's commentary panel) are the two gaps still needing someone assigned.

Handoff — structural isolation work (2026-09-10)

This session hands the lane off and steps back to onboarding UX. Full source: ~/agents/006/HANDOFF-isolation-structural-2026-09-10.md

In plain terms: tonight's three pull requests each closed one specific hole a customer could have walked through. They don't close the pattern that keeps producing new holes. The next session's job is two things, done in order: first, finish closing every hole already found and prove — by trying it for real, not by inference — that nobody can see another company's data anywhere in the product; second, change the underlying code so a request simply cannot get an answer unless the system knows exactly who is asking and what they're allowed to see, so this bug shape stops being possible to write by accident.

1. Situation

During Fede's Western Slope walk-through on 2026-09-10, a brand-new company with zero properties saw other customers' data on its dashboard — work-order counts, occupancy history, and later, in a follow-up audit, insights commentary written about a different customer's buildings. The immediate cause and the wider pattern are the same bug shape: several read and write paths — dashboard rollups, the “All Properties” insights panel, and (per the Sept 7 incident and Fede's hard-floor ruling) potentially Ask Clara's own tool calls — return or act on data across company lines whenever the system fails to cleanly resolve who is asking. A same-day audit of the whole src/app/api surface found 14 instances of this pattern, most still open. This is the second time this shape of bug has been found in five days (the first, Sept 7, was patched route-by-route and missed the aggregate-rollup and chat-tool cases entirely) — which is why Fede's Sept 7 ruling stands: no more per-route patching as the fix of record, an architectural floor as the real fix, verified adversarially, not by Fede.

2. Findings ledger

Source: ~/agents/006/terms-acceptance/isolation-audit-2026-09-10.md (the full audit — every handler and call chain read, not grep-only). The ledger with current status is the Findings table above on this same page; see the Status-at-handoff block just above for what's merged, what's open and held, and what has no PR yet.

Admin surface (123 routes under /api/admin/**): verified safe — every route self-gates on platform_admin or a bearer secret. Not a finding.

Ask Clara / tool-call surface: not yet fully in the ledger. Neither this audit nor the Sept 7 one probed everything a chat turn to Ask Clara can retrieve or act on across companies — see §4. Treat this as an open, unscoped finding, not a clean bill of health, until the V1 probe (§6) covers it.

3. What tonight's PRs do and do NOT solve

Do: PR #7653 (merged), #7657 (open) and #7659 (merged) each close one named finding with a route-level scope check, mirroring an existing correct sibling route, plus a regression test for that one route. All three are the right immediate fix for the specific finding they target — ship them, don't hold them for the structural work.

Do NOT solve:

4. Why the Sept 7 audit missed this

Source: the Sept 7 adversarial isolation audit (docs.propflowai.co/a/multi-tenant-isolation-architecture-2026-09) and the Sept 8 dashboard-isolation handoff, relayed for this handoff.

Scope gap. Eight sub-agents statically enumerated 662 data-reachable paths (430 API routes, 88 pages, 2 server actions, 36 domain loaders, 106 Clara tool handlers) — a good map. But the live adversarial probe, the part that proves anything, covered only about 25 plain GET routes as a zero-property outside org; 12 leaked and were fixed in PR #7308. Two categories were never probed at all:

Method gap. The null-session-falls-back-to-platform-staff pattern was already known on Sept 7. It got fixed on the named routes the probe happened to hit, not as a banned pattern, and not on the aggregate feed or the tool layer. So the same root cause produced a second, larger incident three days later.

The one change this handoff asks for: probe by surface class, not by route list — HTTP route, pre-rolled aggregate, Clara tool call, and export/voice/text tool calls — each as “two real orgs, one asks about the other,” from a zero-property login, including real chat turns to Ask Clara. A list of routes checked is not evidence a surface class is clean.

5. V2 — the structural target (data-layer fix), aligned with Gera's model

Source: Gera's portfolio/org model — model, wall, people, change, resolver sections — and the Sept 6 portfolio architecture study. No hard decisions have been made on the portfolio model yet (X10–X17 open); this section names the target and defers to Gera's resolver design rather than inventing a competing one.

Target shape, matching Gera's model's own vocabulary (org = the wall; a resolver decides visibility once; attachments — lines, calendars, mailboxes — hang off org or property, never floating):

  1. Requester identity resolved once per request, at the edge — org id plus the caller's visible property set — never re-derived per route. A failure to resolve is a 401/403, never a null that downstream code interprets as “unrestricted.”
  2. The data layer requires that identity and refuses without it. Query builders / repository functions for tenant, conversation, work-order, report, and metrics rows take a scoped-context object as a mandatory parameter, not an optional one a caller can pass null for. No new call site can compile (or pass a lint/CI rule) without it.
  3. GLOBAL/PORTFOLIO rollups are either staff-only or computed per-org. No shared, un-scoped aggregate row is readable by a customer-facing route. PR #7653 already moved metric-history this way (per-org sum instead of one shared GLOBAL row) — that's the reference pattern for #13 and #14.
  4. The assistant's tools take a scoped context object, never raw ids. Every Clara tool handler that touches tenant/property/conversation data receives the resolved scope as part of its call, and the tool itself cannot ask for a different org's row even if the model hallucinates one.

Options:

Recommendation: ship A now (it's the guard that makes V1's fixes permanent and is small enough to land this week), design B once Gera's resolver/attachment model has a decision on X10–X17 (so the typed scope object matches his final shape rather than a shape that gets re-migrated), and treat C as the follow-up architecture study Fede asked for on Sept 7 — cost and trade-offs, not a build, until B is proven. Rollout order for A: dark by construction (it's a guard, not a behavior change, per Article IV.4), smallest PR first (the CI drift check with the banned pattern, since it needs no behavior change to land and immediately stops new instances), then one PR per surface class, each ≤~300 lines per the small-PR rule.

6. Verification method — required before any “done”

No route, aggregate, or tool call is called fixed on code review alone. Before declaring V1 done, run a live two-company adversarial probe per surface class, using throwaway test companies (never a propflowai.co staff login, never Willows/sandbox alone — Fede's Sept 7 ruling was explicit that this must be autonomous, not manual QA from him):

  1. API routes — for every finding above, hit the route as company B and confirm it returns nothing about company A, including the session-failure window (simulate the resolution failure, don't just test the happy path).
  2. Dashboard cards — load every dashboard view with no property selected (the default state that caused the original leak) as a zero-property org and as a normal org; confirm nothing outside that org's own numbers appears.
  3. Ask Clara — real chat turns, not tool-handler unit tests: as company B's user, ask Clara about company A's occupancy, tenants, renewals, and vendors by name and by indirection (“how's the portfolio doing”).
  4. Search — search for company A's tenant/property names as company B.
  5. Exports — any CSV/PDF/report export, run as company B, checked for company A rows.
  6. Voice/text tool calls — the same tool handlers Clara uses on a live call/text, exercised end to end (per the “voice tests = full prod path” rule — never a direct tool-handler dial), not simulated.

Plus the drift guard from §5 Option A, proven to actually fail on the reintroduced pattern (red-before/green-after, per Constitution III.8) before it's trusted as protection.

Only after this probe comes back clean on every surface class does V1 count as done. V2 gets its own version of this same probe before it replaces V1's guards.

7. Hard rules for the incoming session

8. Exact first three actions

  1. Check the state of PRs #7653, #7657, #7659 (gh pr view <n>) — merge or finish whichever are still open, then re-read the audit ledger in §2 to confirm which findings are now actually closed vs. still unassigned.
  2. Open the CI drift-guard PR from §5 Option A (the banned user ? scope : null pattern) — it's the smallest PR, needs no behavior change, and immediately stops new instances of the bug while the rest of the ledger gets worked.
  3. Pick the next unassigned Critical finding by blast radius (recommend #13 or #14 — the two “default view” aggregate leaks, since they're the same shape as the original incident and the most likely to still be live right now), fix it, and start the two-company live probe described in §6 on that one surface as the template for the rest.

9. Suggested opening prompt for the new session

Read ~/agents/006/HANDOFF-isolation-structural-2026-09-10.md — this is your
brief in full. You own the cross-company isolation lane (LANES.md #6) in two
phases: V1, stop the leaks (finish the open findings in the ledger, close the
gaps the Sept 7 audit missed — aggregate rollups and Ask Clara's own tool
calls, prove every surface class clean with a live two-company adversarial
probe using throwaway test companies, ship a CI drift guard that blocks the
fail-open pattern from coming back); then V2, the structural data-layer fix
(requester identity required by the data layer itself, sized against Gera's
portfolio/org model at docs.propflowai.co/a/portfolio-architecture-how) —
V2 only starts once V1 is proven, not assumed.

Read the Constitution and LANES.md first. Small PRs (~300 lines, one concern,
dark), no holds on your own judgment, never a staff login to QA a customer
surface, never a security-exploit framing — this is multi-tenant correctness.
Start with the three actions in §8 of the handoff. Report progress to
#updates-fede in plain English; anything you're blocked on Fede for goes to
#agent-trinity, never a DM.
PropFlow Docs