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 do | Severity | Status |
|---|---|---|---|
| ASK | Ask Clara (the in-app assistant) answered a zero-building company's admin with other customers' units, leases, renewals, transcripts and balances | Critical | Open, held at handoff — PR #7667 |
| 1 | Work 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.ts | High | Open, held at handoff — PR #7663 |
| 2 | Property 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) | Critical | Open, held at handoff — PR #7657 |
| 3 | Unauthenticated request or resolution failure returns full cross-org dashboard financials/occupancy/tenant counts for every customer | Critical | Merged, live — PR #7653 (SHA faaa29d5) |
| 4 | Same root cause as #3; leasing metrics endpoint, lighter weight, same attack shape | Critical | Fixing — PR pending |
| 5 | Same root cause; cost-savings summary, explicitly intentional per code comment ("tolerates null user with unrestricted scope"), contradicts isolation hard floor | Critical | Fixing — PR pending |
| 6 | Work orders list (polled every 5s by PM dashboard) returns every work order in deployment across every org on resolution failure | Critical | Fixing — PR pending |
| 7 | Conversations, search (all tenants/prospects/work orders/vendors), activity log — every org's records returned on resolution failure | High | Fixing — PR pending |
| 8 | Any authenticated user can persist report data under an arbitrary property they do not own via no org/property scoping on write | Critical | Merged, live — PR #7659 |
| 9 | Anonymous or resolution-failed request gets vendor detail including crew contact PII (names/phones/emails) across every org engaging that vendor | High | Fixing — PR pending |
| 10 | Org-bounded user with unresolved org can rename/delete/toggle in-house status on any org's vendor, or list another org's crew contacts | Medium-High | Open, held at handoff — PR #7670 |
| 11 | Unauthenticated request returns every org's vacant units, pricing, property names via availability endpoint | High | Fixing — PR pending |
| 12 | Latent: getReportData(undefined) reads pooled GLOBAL partition with no org filter; no current caller invokes it unscoped but flagged to prevent future no-arg callers | Critical (latent) | Fixing — PR pending |
| 13 | Default "All Properties" dashboard view calls getConversationsMeta, getRenewals(undefined), getProperties with no scoping; returns every org's conversations, renewals, properties | Critical | Fixing — PR pending |
| 14 | No-propertyId portfolio insights fanout calls getAllPropertyIds() unfiltered; returns LLM-written property commentary from whichever org generated insights most recently | Critical | Fixing — 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:
isAccessDenied()insrc/lib/platform/auth/scope.ts: "anulluser is always denied."load-collections-list.ts:459: explicitFAIL CLOSEDcomment, returns empty list on unresolved user.turnovers/upcoming/route.ts:30:if (!user) { requireAdminAuth(...) }guards before the nullable scope line.
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
- Full audit markdown:
~/agents/006/terms-acceptance/isolation-audit-2026-09-10.md - Scope helpers (root cause):
src/lib/platform/auth/scope.ts(isAccessDenied,scopeByProperty,scopeProperties,getOrgScopedPropertyIds) - Session resolution:
getCurrentUserUnchecked()insrc/lib/platform/auth/helpers.ts - Reference patterns (correct implementations):
src/lib/domain/leasing/collections/load-collections-list.ts:459,src/app/api/turnovers/upcoming/route.ts:30,src/lib/platform/auth/scope.ts - Admin API surface audit (verified safe, 123 routes): Every `/api/admin/*` route gates itself; see full audit for details
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:
- The five biggest cards — occupancy, revenue, NOI, the leasing pipeline, and the renewals pipeline — all read from the same two endpoints as finding #3 and #4 above. Those now have a fix ready and waiting to ship: PR #7663 (open, tests passing), which also closes #1, #5 (cost-savings), #6, #7, and #11 in one pull request.
- We proved the leak live, in production, tonight. Using a brand-new company with zero buildings, we could pull up the history charts and see real numbers that were never supposed to leave someone else's account: total rent across the whole platform ($299,100), how much is overdue ($37,047, or roughly 1 in 5 dollars owed), how full every building is on average (88%), how many prospects and tours are active right now (579 and 96), and how many work orders are open (22). We could see the same kind of leak on the "this week" data feed too — live call and text counts across every customer. Both leaks are the ones already tracked as #3 and #13 in the table above; #3 has a fix ready (PR #7663) and #13 does not yet.
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 / surface | Reads other companies' data today? | Fix |
|---|---|---|
| Occupancy, revenue, NOI, leasing pipeline, renewals pipeline, "this week," maintenance card, turnovers, collections | Yes — confirmed live | PR #7663 (open, ready) |
| History charts (occupancy / rent income / NOI) | Yes — confirmed live tonight, real production numbers | PR #7653 (open, ready) |
| Live activity feed ("this week" call/text counts, renewals in progress) | Yes — confirmed live tonight | Not yet started — no pull request open |
| Clara's portfolio commentary panel | Yes, 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 tonight | PR #7667 (open, ready) |
| Sidebar counters, header notification icon | N/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:
- The class. Ten of fourteen findings were still open with no PR as of this handoff. The pattern (
scope = user ? getUserPropertyScope(user) : null) is not banned anywhere — a fifteenth instance can be written tomorrow and nothing stops it. - The root cause.
getCurrentUserUnchecked()still returnsnullon a transient DynamoDB blip or cookie edge case, indistinguishable from “no session” or “platform staff” to every scoping helper. Nothing changed here tonight. - Drift protection. No CI guard exists yet that fails a PR for reintroducing this exact pattern. Constitution Article VIII.2 requires one.
- The chat-tool surface. None of tonight's merged PRs touch anything Ask Clara's tool handlers can read or do across companies.
- Aggregate/pre-rolled data as a category. Tonight's fixes are all per-route record checks. The two “All Properties” default-view findings (#13, #14) are aggregate/rollup shaped, the same shape the Sept 7 audit missed entirely (see §4) — fixing these two specific routes does not fix the category; the next pre-rolled aggregate someone builds will have the same hole unless the data layer itself refuses to answer without a scope.
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:
- Anything reached only through a chat turn — Ask Clara's own tool calls were enumerated (106 handlers) but never exercised adversarially. Nobody asked Clara, logged in as company B, “how's company A's occupancy.”
- Pre-rolled aggregate rows — a PORTFOLIO or GLOBAL rollup row is not “a record with an owner id” that a per-route ownership check can catch; it's a number that was already computed across everyone before the request arrived. The Sept 8 handoff itself flagged this in writing (“an aggregate tile carrying a pre-rolled number is a different failure shape”) — and then it wasn't acted on.
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):
- 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
nullthat downstream code interprets as “unrestricted.” - 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
nullfor. No new call site can compile (or pass a lint/CI rule) without it. - 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.
- 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:
- A — Fail-closed guard at the helper layer (smallest). Replace
getCurrentUserUncheckedplus the nullable-scope idiom everywhere with a singlerequireScopedUser()that throws/401s on any resolution failure, and a CI drift guard that greps/ASTs for the banneduser ? scope : nullshape and fails the build. Rollout: one PR per surface class (route helpers, then aggregate loaders, then tool handlers), each dark, each roughly 200–300 lines. Rough size: 4–6 small PRs, days not weeks. Does not change the data model. - B — Typed scoped-repository layer. Wrap the DynamoDB access functions themselves so a scope object is a required first argument at the type level (TypeScript won't compile a call without it), retiring the raw loaders. Larger: touches every domain loader (36+), needs a migration path so nothing silently regresses mid-conversion. Rough size: 3–4 weeks, sequenced loader by loader behind the Option A guard so nothing regresses while mid-migration.
- C — DB-level enforcement (Fede's Sept 7 ask, “ideally enforced below code”). Partition keys / IAM policies / row-level security so a query physically cannot cross the org boundary regardless of application code. Matches the hard-floor ruling most literally but is the most expensive: likely a per-org DynamoDB partitioning or table-per-tenant redesign, which interacts directly with Gera's org/wall model and needs his resolver design finished first. Rough size: a dedicated architecture study before any build estimate is credible.
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):
- 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).
- 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.
- 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”).
- Search — search for company A's tenant/property names as company B.
- Exports — any CSV/PDF/report export, run as company B, checked for company A rows.
- 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
- Constitution and LANES.md govern; this handoff does not override them. Read both fresh — this is lane #6 (“Multi-tenant isolation”) on the active lanes list.
- This is architecture/correctness, never framed as a security exploit (Fede's explicit framing rule) — write it up as multi-tenant correctness.
- No quick fixes as the fix of record. Route-level patches (tonight's PRs) are correct and should ship, but the deliverable of this lane is the structural guard (§5), not a growing patch list.
- Small PRs. Cut every V1/V2 change into a chain of ≤~300-line PRs, each dark and mergeable alone. If a PR is past three review rounds without merging, stop and split it (Fede, 2026-09-10, PR #7526 origin).
- No holds. Green, dark PRs merge under the standing grant. Never label
hold-for-reviewon your own judgment. - Never use a propflowai.co staff login to QA a customer surface — use a never-used non-staff login, per the Sept 7 ruling.
- No headless-browser QA of the manual-click kind — verify with automated adversarial scripts hitting real production, not “try it and look.”
- Never DM Fede. Blocked-on-Fede asks go to #agent-trinity. Outcomes the team should know go to #updates-fede, plain English.
- Go-live tracker. Any work touching Western Slope's go-live gets a row on the Western Slope go-live tracker; this lane's own tracker row is
iso-cross-company-read-paths— add PRs as they open and prod evidence once each is exercised in production. Status is derived, never typed by hand. - Turning anything on at a customer is Fede's call. Nothing here changes what a customer receives except closing a leak (which is a fix, not a feature activation) — but any new switch, if the design needs one, ships off by default.
8. Exact first three actions
- 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. - Open the CI drift-guard PR from §5 Option A (the banned
user ? scope : nullpattern) — 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. - 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.