Dashboard isolation handoff

A customer with no properties is shown another customer's dashboard numbers. Here is the path, why our September isolation work missed it, the two fix shapes, and how to prove the fix.

Sep 8, 2026 · author: session 007 for Fede · status: Handoff, fix not started, shape decision pending · for Gera and Sean

Related. The wider isolation picture lives on Multi-tenant isolation: architecture decision, which lists this as an open item. The onboarding walkthrough carries the full raw investigation under Epic: every dashboard number belongs to the company looking at it. This page is the handoff: what to build, and how to know it worked.

In plain terms

A brand-new customer with no properties yet opens their dashboard and sees real numbers instead of zeros. Those numbers belong to a different customer, Customer A, because a nightly job writes one shared summary row for the whole deployment and nobody's name is attached to it. The dashboard asks for that shared row whenever no property is selected, which is always true for a customer with no properties, and the code skips the ownership check on purpose because there is no company id on the row to check. It is wide: about sixty numbers on the dashboard plus the live gauges, covering work orders, rent, occupancy, past due, conversations and renewals. It is counts and charts only, so no tenant names, addresses, phone numbers or dollar-level records reached the screen, and the Turnovers, Collections and Leasing pages are all correctly scoped. Next: Fede picks the fix shape, we build it dark, test it on a test company, and Fede decides separately whether it goes on at a customer.

The path

flowchart TD
  A["Nightly snapshot job
lambda/metric-snapshot/handler.ts:325-343"] -->|"fetchSnapshotInput(null) = whole table,
no org argument"| B["One shared summary row
PROP#PORTFOLIO / METRIC#date
~140 keys, no organizationId"] B --> C["Dashboard summary feed
/api/dashboard/metric-history"] B2["Live table reads
conversations, renewals, properties"] --> D["Live gauges feed
/api/dashboard/live"] C --> E{{"propertyId === PORTFOLIO ?"}} E -->|"yes: ownership check SKIPPED"| F["Any company with
no property selected"] E -->|"no: canViewProperty runs"| G["Scoped, correct"] D --> H{{"propertyId named ?"}} H -->|"no: whole deployment read"| F H -->|"yes: canViewProperty runs"| G F --> I["~60 dashboard numbers +
agent-ops gauges, polled every 10s"] style E fill:#B3261E,color:#fff,stroke:#B3261E style H fill:#B3261E,color:#fff,stroke:#B3261E style F fill:#B45309,color:#fff,stroke:#B45309

The two red diamonds are where the ownership check is skipped. Both are the "no property id was named" branch, which the September work never examined.

What leaks

FeedRouteWhat it carriesWho sees itEvidence
Daily summary series/api/dashboard/metric-history with no property selected~60 metric keys from METRIC_METRIC_KEYS; on stage today 22 open work orders, $299,100 monthly rent, 89.66% occupancy, $48,196.84 past due, 78 delinquent tenants, 328 Clara escalationsEvery login with no property selectedroute.ts:207-248; analytics.ts:711-719
Live gauges/api/dashboard/live with no property namedClara conversations active, calls in progress, escalations pending, renewal runs in flight, AppFolio last-sync across every propertySame, polled every 10 secondslive/route.ts:52-115, esp. :82-90, :101-111, :116

Tiles on the shared row: five consumer groups. (1) Maintenance Created (30d) and Resolved (30d) numbers, DashboardHome.tsx:542,546, the reported defect. (2) Occupancy, Rents and NOI sparklines, :1463,1483,1510; the org-scoped monthly series normally replaces these via stripMonthlyServedKeys, but a company with zero properties has zero monthly rows, so nothing is stripped and all three fall back to the unscoped daily series. (3) The Metric Summary Table trend column, :1632, one unscoped trend line per row. (4) Every expanded metric inspector and chart hero, :1106, useInspectorHistory.ts:83, use-property-override-series.ts:41, all defaulting to PORTFOLIO_METRIC_ID. (5) Agent-ops cards, agent-ops/data.ts:6,38-62, about 21 keys.

Structural. Any future route that reads a pre-rolled aggregate, or treats "no property id supplied" as "unrestricted", inherits this.

What is clean

SurfaceVerdictWhyCaveat
Maintenance → TurnoverscleanOne feed. loadScopedTurnovers intersects property scope with org scope before returning (load-turnovers-list.ts:55-91); all four tiles and the tab counts are pure functions of that scoped set. Empty scope means show nothing (scope.ts:63).The shared loader fails open on an unresolvable caller. What holds it shut is a check in api/turnovers/route.ts:25-29. Any new caller of that loader must repeat it.
CollectionscleanScopes org ∩ property before any ledger read, then narrows to the requested property inside that set (load-collections-list.ts:451-495). Fails closed on an unresolvable caller. Per-tenant screens take the property from the occupancy, never the request, and 404 rather than 403.Not covered by the sweep test at all; protected by its own route tests.
Leasing overviewclean numbersBoth cards and the funnel drill-downs are org-scoped (leasing/stats/route.ts:68-69, load-leasing-stats.ts:186, pipeline-members/route.ts:106). Empty company sees zeros.The register still files both leasing routes as unverified-legacy. Sweep test covers neither. The Renewals card renders for a company that never bought the module, display bug, correctly scoped zeros.
Work-orders list APInot involvedThe tile never touches it. The number comes from the summary feed.None

Structural note across all four: /api/dashboard/stats, /api/leasing/stats and the turnovers loader each write user ? getUserPropertyScope(user) : null, and null means unrestricted. Each is closed today by something outside itself. Collections is the only one that refuses on its own, and is the pattern the others should follow.

Why it was missed

  1. Pre-classified as done, not missed. src/__tests__/property-scope-surface-registry.drift.test.ts:67-68 lists both dashboard/metric-history/route.ts and dashboard/live/route.ts as guarded-in-route. That verdict came from an earlier IDOR sweep asking one question: can a caller name someone else's property id? For these routes the answer is no. Nobody asked what happens when no id is named.
  2. The Sep 7 to 8 stopgap fixed the other half. #7308 (merged Sep 7 23:23Z), plus #7318, #7365, changed which ids a caller may name after getUserPropertyScope() was found returning null for org_admin. Files touched: auth/scope.ts, auth/helpers.ts, api/activity-log, api/leasing/renewals, api/maintenance-manuals, api/settings, api/team, api/dashboard/stats, middleware.ts, nav. api/dashboard/metric-history/route.ts and the snapshot Lambda appear in none of them. Renewals and manuals did get their omitted-id branch fixed; metric-history's omitted-id branch resolves to PORTFOLIO, which is not a property partition, so no equivalent filter was added.
  3. QA was the wrong shape. A page-by-page screenshot walk on a fresh non-staff login, hunting per-row personal data on list and detail pages. An aggregate tile carrying a pre-rolled number is a different failure shape, and /api/dashboard/metric-history was never exercised directly. The RCA files the dashboard leak under /api/dashboard/stats and never names metric-history.
  4. A green test for the wrong property. src/__tests__/api-routes-property-visibility-sweep.test.ts:253 asserts the current behaviour as correct, "leaves the pre-rolled PORTFOLIO row alone", and :262-272 pin that an explicit ?propertyId=PORTFOLIO returns the same row.
  5. There is no database-layer guard to bypass. No requireOrg, assertOrgScope, withOrgScope or orgGuard exists in the repo. What exists is per-route, in-memory filtering after the read: auth/scope.ts, properties/visibility.ts, visible-property.ts. Org filtering in the repository layer is an optional argument, dynamo/property.ts:154-165, getProperties(organizationId?). The edge-binding POC, PR #7160 on fede/poc-org-at-edge, was closed unmerged Sep 6. getMetricHistory (analytics.ts:711) takes a raw property id, no user, no org, no check. And the stored row has no organizationId attribute, so a database-layer guard would have nothing to compare against until the writer changes.

Fix options

A. Scope the summary per company recommended

M

The nightly job writes one summary row per org instead of one per deployment. Both feeds read the caller's own row. The deliberate skip of the ownership check goes away, because there is now something to check against.

Touched: lambda/metric-snapshot/handler.ts:325-343 and metrics/snapshots/compute.ts:214-266 (write per org, pass a scope); api/dashboard/metric-history/route.ts:207-248 (resolve the caller's org row, remove the skip); api/dashboard/live/route.ts:52-115 (scope the three no-property reads); dynamo/analytics.ts:711 (org-keyed read); DashboardHome.tsx:1306-1316 and the inspector hooks if the metric id changes shape; the sweep test and the register. Keep the old row written until the reads are switched, then delete it.

Why this one: it is the root cause. Option B leaves a deployment-wide row one query away from any future reader, and it does nothing for a customer who has properties but is looking at the portfolio view, who is served the same shared row today.

B. Serve zeros to companies with no properties

S

Keep the shared row. In both routes, if the caller's property scope is empty, return an empty series. Smaller and faster, but it fixes only the empty-company case and leaves the unscoped aggregate in place.

Verification plan

  1. Regression fixture, build-failing. Two fixtures, a company with zero properties and a company with one property, asserted against both feeds. For the summary feed, assert every tile group: the maintenance numbers, the three sparkline series, the metric-summary trend column, the expanded inspector series, and the agent-ops keys. For the live feed, assert conversations active, calls in progress, escalations pending, renewal runs in flight and last-sync. Empty company sees zeros. One-property company sees only its own totals, and never the other fixture's.
  2. Register fix. Re-audit both dashboard routes in property-scope-surface-registry.drift.test.ts and record what guarded-in-route actually covers, so the verdict says whether the omitted-id branch was checked. While in there, refile the two leasing routes currently stuck at unverified-legacy.
  3. Close the sweep gap. api-routes-property-visibility-sweep.test.ts covers eight routes and none of collections, dashboard/stats, leasing/stats, leasing/pipeline-members, turnovers. Its metric-history case must be inverted: line 253 currently asserts the bug.
  4. Final check on production. Fede signs in as Customer B (Western Slope) and confirms the dashboard reads zeros, then confirms a company with properties still reads its own numbers.

Side item: the company name

The name staff type when creating a company is written to the organization record (ORG#<id> / PROFILE, field Organization.name), and the onboarding wizard reads exactly that via api/onboarding/prefill/route.ts:37, which is why the wizard shows the right name. Settings → Company reads and writes a different, per-user field, User.companyName, through GET/PATCH /api/auth/me (OrganizationSettings.tsx:140,149,226), and fires its required-field error at :220. Nobody has ever PATCHed that user, so the field is empty and Settings nags for a name the company already has. The file's own header comment (lines 14-17) admits it: company name is org identity but is stored per user because the organization row has no writer yet, and dynamo/organization.ts:2 confirms the organization repository is read-only. The fix is a writer on the organization record plus pointing Settings at it. Separate from the leak, same onboarding epic.

Open decisions for Fede

1. Fix shape. (A) per-company summary row, both feeds read the caller's (recommended, it is the root cause). (B) zeros for empty companies, shared row stays.

2. The shared row today serves customers who do have properties. A customer viewing the all-properties portfolio view is served the same deployment-wide row. (A) treat as part of this fix (recommended). (B) ship the empty-company case first and file this separately.

3. Nightly job disagreement. Per-property rows read workOrdersCreated = 0 on the two dates where the portfolio row reads 1. The two passes disagree about the same day's tickets. (A) fix inside this work, since we are touching the writer (recommended). (B) separate ticket.

4. Fail-open loaders. Three routes read "cannot identify the caller" as "show everything", each closed today by something else. (A) make them fail closed like Collections, in this work. (B) own item on the isolation architecture page (recommended, it is broader than the dashboard).

5. Renewals card for a company without the module. (A) make the dashboard grid consult enabledModules (recommended, small). (B) leave it, it reads zeros.

Evidence index

Repo ~/.claude/propflowai @ bfa841b5 (main), read-only. Live reads on propflow-stage, us-east-1, no writes.

PropFlow Docs