Multi-tenant isolation: architecture decision

Fede wants a real hard floor: a request for the wrong company returns nothing even if application code forgets to filter — not per-route if-statements. This page collects the research and, later, the decision.

Status: Research — input to the decision · written 2026-09-07 · no recommendation yet, Fable writes the decision

Why this page exists. This is the hub page for the isolation-architecture initiative — the research below, the exhaustive product audit further down, and the decision itself (still pending) all live here rather than spread across separate docs. The new-customer onboarding walkthrough (Sep 7) is what first caught this live: a real, non-staff org_admin account with zero properties of its own saw another company's real tenants, work orders, and conversations on five pages. That's the incident that makes "hard floor" a real requirement rather than a nice-to-have. The portfolio-architecture "HOW" and portfolio-architecture options pages cover an adjacent but distinct question — which company or companies are modeled as running a given building — not the security boundary between two different customers' data that this page is about (see §5's note on X10-X17 below for exactly where they diverge). This page starts with the research on how other systems enforce that floor, then an exhaustive audit of where today's floor actually holds and where it doesn't; the decision itself — which option, in what order — comes later.

Sections on this page: Decision · Adversarial audit · Tenancy map · Research · Podcast episode (audio pending)

Decision (approved by Fede Sep 8, not scheduled): a wall in two steps

Status, Sep 8: Fede approved the two-step shape below ("yes") and ruled it is not being built right now. No build starts, no owner is assigned, until he schedules it. Owner when scheduled: Gera. Not this week (Fede, Sep 8). The Sep 7 stopgap (company admins see only their own company; fail-closed on empty scope) stays as the protection in the meantime.

Proposed — pending Fede's pick

What the audits settled

The decision: two steps, both required

Step one — make forgetting impossible (3–4 weeks). Every function that reads data has to be given "which company is asking" as a required input — there's no default, so code that skips it doesn't compile. A lint rule and an automated check block anyone from adding a new one that skips it. Reads get bounded at the moment the question is asked, not filtered afterward — the system turns "which company" into "which specific properties that company owns" and only ever asks the database about those. Reading the entire table at once is banned outright. Staff who legitimately need to see across every company use a separate, logged "all companies" mode — never a read that simply forgot to ask. The 662-path test we ran by hand becomes a permanent, automatic check that runs on every future change: log in as one company, confirm zero rows from any other company come back, for every single one of those paths. This is enforced by the code itself refusing to run, not by a human remembering to write an if-statement.

Step two — make the database itself refuse, even if the code has a bug (6–10 weeks, starts after step one). Every record gets the company id filled in, worked out from the property it belongs to. The company id becomes the lead part of how records are organized and looked up, on every table and index. Each request gets its own short-lived database key that AWS itself restricts to that one company's data — so even a bug in our code can't reach another company's rows, because the credential physically can't. Staff get their own separate, logged credential for cross-company work. The one gap step two doesn't need to solve on its own — reading a whole table at once — is already closed by step one's ban.

Rejected approaches: a separate database table per customer and a separate cloud account per customer both create real operational cost once we're serving 50 customers and staff still need to see across all of them. Rewriting the whole product onto a different type of database was rejected too — it would take a full rewrite to get the same protection step two gives us on the database we already have.

Policy until step one ships

This isn't a workaround, it's the rule while the real fix is being built: nobody outside PropFlow gets the company-admin role — including Western Slope's Jay. Nobody outside the company holds that role today, so the exposure stays theoretical while step one is built. It stops being theoretical the moment an outside person gets that role, so this policy holds until step one ships.

How Fede will know it's actually fixed

The automatic test suite runs against The Willows and the sandbox, using logins that have never touched those companies before. The report that comes back says what was fixed, shows the wall in place, and explains why the same leak can't happen again. Fede doesn't have to test it himself.

What Fede needs to pick

Stopgap ordered Sep 7 (draft in progress): a company-admin login now only sees their own company's properties; five routes that had no filter at all are being scoped to that. This is not the wall — it's a patch to hold the line while step one is actually built.

How real multi-tenant systems enforce the wall (research, Sep 7)

Research — input to the decision. No recommendation here; Fable writes the decision.

The problem, in three sentences. Today one AWS credential and one DynamoDB table serve all customers, so the only thing standing between company A and company B's data is whether every route remembers to filter by company — and the onboarding walkthrough already caught five pages that forgot (dashboard, leasing, maintenance, tenants, conversations all bled another company's real data to a zero-property login). A "hard floor" means the wrong-company request comes back empty even when the code forgets, which requires either the database engine itself to refuse the row, or a code layer positioned so no route can physically reach the data store without first supplying a tenant. Neither is currently true for us: there is no per-request credential scoping and no data-access layer that a route is forced to go through.

Comparison, compact

OptionWhat enforces itResidual holesEffort (2 eng + agents)Runtime costWho uses it at 50–500 tenants
A. App-layer tenant context + CI + adversarial testsCode discipline (AsyncLocalStorage-backed context, DAL that refuses to run without it, lint/CI banning raw table access)Any code path that bypasses the DAL (raw SDK call, a new Lambda, a script) is unprotected; no DB-level backstop2–4 weeks to build the DAL + CI rule + a fuzz suite that logs in as tenant A and checks zero tenant-B rows on every endpoint~0 (no extra AWS calls)Very common — this is the default pattern for most Node/Rails/Django SaaS at this scale before they invest in DB-enforced isolation
B. Per-request STS credentials + LeadingKeys on existing tableDynamoDB/IAM itself, via dynamodb:LeadingKeys tied to a session tag minted per requestDoes not cover Scan; does not cover aggregate/cross-tenant staff queries; one static credential still exists for anything that legitimately spans tenants (staff, Temporal workers, batch jobs)6–10 weeks — needs a full re-key (every PK/SK and every GSI PK must lead with tenant id), a token-vending Lambda, and STS call on every request path (API, Lambda ingress, Temporal workers)STS AssumeRole adds ~100–300ms cold, ~sub-10ms once cached per session; session policy plaintext capped at 2,048 characters and 50 session tags (128-char keys, 256-char values) per AWS's own quota pageCommon in AWS-native SaaS reference architectures (AWS SaaS Factory's own worked examples); less common as a bolt-on to an existing un-rekeyed table
C. Table per tenantAWS account/IAM resource boundary — each tenant literally has its own tableNone from the DB's own boundary for isolation, but GSI/queries that need cross-tenant staff views must fan out or use a separate rollup; per-table cost and connection/quota overhead grows with tenant count3–5 weeks migration (schema is already single-table so mostly automation + backfill scripting), ongoing per-tenant provisioning workDynamoDB on-demand billing means no extra $ per table, but 50 tables means 50x the operational surface (backups, metrics, alarms, IAM roles) unless heavily automatedUsed by SaaS vendors serving a small number of large/regulated tenants (silo model per AWS's own naming) — less common past a few dozen tenants without heavy automation
D. Account per tenantAWS account boundary itself (hardest wall — separate root, billing, IAM)Cross-tenant staff views and shared services (Temporal, ElevenLabs webhooks) now need cross-account roles; onboarding a customer means provisioning a whole AWS accountMonths, not weeks — new CI/CD, new cross-account networking, new staff tooling; realistically a re-platformMeaningfully higher — per-account minimums, cross-account data transfer, duplicated infra per tenantReserved for enterprise/regulated software (health, government, defense) or a handful of very large accounts — essentially never used for a 50-tenant SMB SaaS book like ours
E. Move to Postgres + row-level securityThe database engine itself, via CREATE POLICY evaluated against a per-transaction session variable (e.g. current_setting('app.tenant_id'))Any DB role with BYPASSRLS or superuser skips the policy entirely; a pooled connection that uses plain SET instead of SET LOCAL can leak one tenant's context into the next pooled request; RLS itself doesn't stop a bug in how the app decides which tenant id to setThis is not a tweak — it's a rewrite of the data layer: modeling ~500 units/3 customers worth of single-table DynamoDB access patterns as relational tables, migrating Temporal workflow state, and rebuilding every GSI-shaped query as SQL. Order of months for a 2-engineer team even with heavy agent helpManaged Postgres (RDS/Aurora/Neon/Crunchy Bridge) at our data size is inexpensive; RLS itself has modest per-query planner overhead, not a meaningful cost line at 500 unitsExtremely common — this is the default recommended pattern from Supabase, Crunchy Data, and most Postgres-native multi-tenant SaaS advice at exactly our scale (dozens to low hundreds of tenants)

What AWS specifically says about DynamoDB

AWS's own SaaS tenant-isolation guidance names three models: silo (a table per tenant, full isolation, higher operational cost), pool (one shared table, isolation enforced at the row/item level through the partition key), and a bridge model that mixes the two by layer — e.g. a pooled web tier in front of siloed storage.1 For the pool model, AWS's pattern is to make the tenant id the leading segment of the partition key on the base table and every GSI, then use the dynamodb:LeadingKeys IAM condition key — paired with attribute-based access control (ABAC) and STS session tags (aws:PrincipalTag/TenantID) — so a single IAM role can serve unlimited tenants, each one only ever seeing rows whose partition key matches the tenant id burned into their session's temporary credentials.2,3 Credentials are minted per request (or per user session) by a small "token vending machine" — typically a Lambda that calls sts:AssumeRole with a session tag and hands back short-lived, tenant-scoped credentials; AWS's own worked example is for S3 but the same TVM shape is described for DynamoDB.4 Two hard limits from AWS's own docs: an AssumeRole session policy's plaintext is capped at 2,048 characters, and you can pass at most 50 session tags (128-char keys, 256-char values) — comfortably enough for a tenant-id tag at our scale.5 AWS's own documentation is explicit about what this does not cover: LeadingKeys is excluded from Scan entirely — "the list of actions does not include permissions for Scan because Scan returns all items regardless of the leading keys" — so any Scan-based code path (a common shortcut in single-table designs) is invisible to this control, and it says nothing about aggregate queries or the legitimate cross-tenant views platform staff need.6

Staff access, without weakening the floor

The pattern real multi-tenant products use is to never give staff a permanent "see everything" credential; instead staff access is minted the same way tenant access is, just with a different, explicitly-tenant-scoped or explicitly-broadened role — a separate admin API that takes a tenant id as an argument, impersonation sessions that are logged and time-boxed, or "break-glass" accounts that are normally locked and require an approval step (commonly a two-person rule) to activate, with every activation and action audited.7 Applied to our case: platform_admin should look like a staff member assuming a role that is itself scoped per-request to one named company (chosen explicitly, logged), not a blanket bypass baked into normal auth resolution — which is closer to what today's @propflowai.co-domain override actually does (any staff sign-in silently gets full cross-company visibility with no explicit per-action tenant choice or audit trail).

Application-layer hard floors, when the DB can't fully enforce

Teams that can't (yet) push isolation into the database itself use a mandatory request-scoped tenant context — in Node/TypeScript, AsyncLocalStorage carrying a TenantCtx that every data-access method requires by type, so a repository call literally cannot compile or run without a tenant in scope — combined with lint/CI rules that ban any raw table client call outside that one data-access layer, and adversarial tests that log in as tenant A and assert zero tenant-B rows across every endpoint on every deploy.8 This is a discipline-enforced wall, not a database-enforced one: it stops the class of bug this doc's onboarding walkthrough found (a route that forgot to filter), but nothing stops a new script or Lambda from reaching the table directly outside the DAL. Separately, authorization engines like Oso, Cerbos, OpenFGA, and AuthZed/SpiceDB (the two Zanzibar-style relationship-based ones) answer a different question — "is this specific user allowed to do this specific action on this specific object" — and are not, by themselves, tenant-data-isolation tools; OpenFGA's own multi-tenancy, for instance, is enforced by application-level store IDs, not cryptographic or DB-level separation.9 They complement a tenant-isolation wall (fine-grained permissions within a tenant) but don't replace one.

Relational alternative, for comparison only

Postgres row-level security is the closest relational analog to DynamoDB's LeadingKeys approach: a policy attached to a table (CREATE POLICY ... USING (org_id = current_setting('app.tenant_id')::uuid)) filters every query at the engine level once the connection sets that session variable — Crunchy Data documents exactly this pattern, and Supabase's own guide layers the same idea on top of its JWT-derived auth.uid().10 The production footgun both note: the value must be set with SET LOCAL inside a transaction, never plain SET, or a connection pooler (PgBouncer/Supavisor in transaction mode) can leak one tenant's context into the next pooled request — and any role with BYPASSRLS or superuser skips the policy outright. Schema-per-tenant and database-per-tenant are the relational versions of the silo model, with the same operational-overhead-grows-with-tenant-count tradeoff as DynamoDB table-per-tenant. None of this is a recommendation to move off DynamoDB — it's what the cost line would look like if we did: re-modeling the existing single-table access patterns as relational tables, migrating Temporal workflow state, and rebuilding every GSI-shaped query as SQL, which is a rewrite measured in months, not weeks, for a 2-engineer team even with heavy agent help.

Sources

  1. AWS, "SaaS Tenant Isolation Strategies" whitepaper — silo, pool, and bridge models: docs.aws.amazon.com/whitepapers/.../the-bridge-model.html and .../pool-isolation.html (AWS SaaS Factory).
  2. AWS Security Blog, "How to implement SaaS tenant isolation with ABAC and AWS IAM," published 2021-06-09, updated 2023-04-25: aws.amazon.com/blogs/security/...
  3. AWS DynamoDB Developer Guide, "Using IAM policy conditions for fine-grained access control" — dynamodb:LeadingKeys definition and example policies: docs.aws.amazon.com/amazondynamodb/.../specifying-conditions.html
  4. AWS Prescriptive Guidance, "Implement SaaS tenant isolation for Amazon S3 by using an AWS Lambda token vending machine": docs.aws.amazon.com/prescriptive-guidance/... — pattern description could not be independently re-verified beyond the search summary; treat the specific latency/TTL figures in this doc's table as general AWS STS knowledge, not a direct quote of that page.
  5. AWS IAM User Guide, "IAM and AWS STS quotas" — session policy 2,048-character plaintext limit, 50 session tags, 128/256-char key/value limits: docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html
  6. AWS DynamoDB Developer Guide, same page as (3) — "the list of actions does not include permissions for Scan because Scan returns all items regardless of the leading keys" (Example 2).
  7. General break-glass/impersonation pattern, cross-referenced across cloud-identity vendor writeups (Britive, CyberArk, Microsoft Entra docs) — no single canonical AWS SaaS-specific doc found; treat as an industry-common pattern, not a single-sourced AWS claim.
  8. AsyncLocalStorage-backed mandatory tenant context and DAL-enforced isolation — pattern described across multiple 2025–2026 engineering writeups (DEV Community, Security Boulevard); no single canonical vendor source, so treat as a documented common pattern rather than one authoritative citation.
  9. OpenFGA/AuthZed/Cerbos/Oso comparison, including "OpenFGA's multi-tenancy is application-level, enforced by store IDs rather than cryptographic isolation" — cross-referenced across startwithidentity.com and pkgpulse.com 2026 comparison writeups.
  10. Crunchy Data Blog, "Row Level Security for Tenants in Postgres," published 2024-04-03: crunchydata.com/blog/row-level-security-for-tenants-in-postgres

How tenancy is built today, and what a hard floor needs

Codebase + production audit, 2026-09-07 · read-only, worktree ~/.claude/propflowai-lookup at commit a9ba8eadd8 (origin/main) · production counts via DYNAMODB_TABLE_NAME reads against the live table. Facts below are grep/read/scan results with file:line citations; anything not directly read is labeled "inferred" or "sampled."

1. Does a row know which company owns it?

Only 5 of the ~20 entity types checked put organizationId anywhere on the row at all, and none put it in the table's primary partition key — the primary key is built from propertyId almost everywhere (PK=PROP#{propertyId}, e.g. src/lib/data/dynamo/property.ts:5-10). Two entities (Occupancy, Prospect/Inquiry) do query a secondary index (GSI3) whose leading key IS the organization id — the one place in the codebase where "wrong company" is structurally excluded from a query's result set, not filtered out afterward. Everything else derives "which company" only by a second lookup: read the row's propertyId, then read that Property, then read its (optional, nullable) organizationId.

EntityorganizationId on the row?In a DynamoDB key?Prod rows / org coverageNotes
PropertyYes, optional attribute (types.ts:2871)No — PK=PROP#{id}getProperties() reads every property, then filters in app code (property.ts:154-164); a null organizationId is treated as "belongs to no org," not an error
PersonYes, required (types.ts:14898)No primary key; claim sub-rows key on it (claimPK(organizationId,...), persons.ts:241)9,076 rows, 100% coverageCross-org guard is a conditional write, not key isolation (persons.ts:487-507)
TenantOccupancyYesYes — GSI3 leading key occupancyEntityGSI3PK(organizationId) (occupancies.ts:76,259)266 rows, 100% coverageReal DB-level query bound — but only via findOccupancyById(id, organizationId); the caller still supplies the org id, unverified against the caller's identity
ProspectInquiryYesYes — GSI3 leading key inquiryEntityGSI3PK(organizationId) (inquiries.ts:321,886)629 rows, 100% coverageSame pattern and same caveat as Occupancy
Household / HouseholdMemberYesPartial — GSI3 keyed by org (households.ts:102); primary PK is householdPK(id)2,607 / 90+ rows, 100% coverage
VendorMembershipYesNo837 rows, 100% coverage
PurchaseOrderYes, required (purchase-orders.ts:87)Partial — GSI2 PONUM#{organizationId}#{poNumber}; primary PK still PROP#{propertyId}
ConversationNo field at all (types.ts:8907)No — PK=PROP#{propertyId}3,499 rows, 0% (attribute doesn't exist)Org reachable only via Property lookup
WorkOrderNo field (types.ts:6054)No2,959 rows, 0%
TurnoverNo field (types.ts:7460)No — PK=PROP#{propertyId} (turnover.ts:276)63 rows, 0%organizationId appears only as a local variable resolved at write time from property.organizationId — never persisted
RenewalSagaNo field (types.ts:11174)No — keyed by personId/propertyId only141 rows, 0%
TourNo fieldNo211 rows, 0%
Unit / LeaseNo fieldNo — PK=PROP#{propertyId}409 / 473 rows, 0%
EscalationMatterNo field — has propertyId commented "HARD SCOPE" (types.ts:16719,16726)No — PK=CONV#{conversationId}109 rows, 0%Org only derivable transitively: matter → property → org
KnowledgeSection / SettingsNo field anywhere in the fileNo — property-scoped or fully global (PK_CONFIG)Some Settings rows are platform-wide, not even property-scoped
UserComment says "carries no organizationId post Phase 6d-pt2" (dynamo/user.ts)No17 rows, 11 missing (6 carry it anyway — likely pre-cutover rows)Org membership resolved separately (memberships.ts) at request time, not from this field
VendorCompanyNo field — removed per ADR-0033 (comment, property.ts:922)Only on the contact-point sub-row's GSI sort key832 rows, 100% missing (by design)Vendor is deliberately cross-org (one vendor can serve multiple companies); org lives on the membership row, not the vendor

Row counts for Conversation/WorkOrder/Turnover/RenewalSaga/Tour/Unit/Lease/EscalationMatter are full prod counts (a background sub-audit's direct table reads); Person/Household/Occupancy/Inquiry/VendorMembership likewise. A separate 75,740-row segmented sample across the rest of the table (operational/audit entities — AutomationRun 52,839 rows, TOOL_LOG 7,769, Message 2,131, DispatchDecision 722, SmsStatus 577, EmailIngestion 894, etc.) found 0% organizationId coverage on every one of those operational tables — the gap isn't confined to core domain rows, it runs through the logging/audit trail too. A separate sampled scan found 7 distinct organizationId values live in prod today: org_jpco, org_western_slope, org_riverbend, plus org_sandbox, org-test, org_unrouted, org_propflow_staff (sandbox/test/staff scaffolding) — i.e. real paying-customer orgs number in the low single digits today.

2. Where the requester's company is known, and where it's lost

In one sentence: organizationId becomes known exactly once, at the route-handler auth call — and is dropped one layer down, at the repository, which will run for any id handed to it regardless of who's asking.

3. Property-manager containment, dissected the same way

The org gap above is one failure mode. The older, narrower boundary — a property manager sees only their assignedPropertyIds — is implemented by the exact same shape: an entirely post-fetch, in-application filter, with no database participation at all.

4. How many doors are there, and how many are guarded

SurfaceCountHow counted
API route files430find src/app/api -iname route.ts
...of which cron-triggered38path contains cron
Server-side data loaders (load-*.ts)80find src -iname "load-*.ts"
Files under a Clara/agent "tools" path123find src -path "*tools*" -iname "*.ts", tests excluded
Files anywhere in src/app+src/lib that reference at least one scope helper162grep -rl "getUserOrgScope\|scopeByOrg\|filterVisibleProperties\|getUserPropertyScope\|withOrgAndPropertyScope"

430 routes + 80 loaders + 123 tool-handler files is a surface well over 600 files that can originate a data read (with some overlap, since loaders are called from routes); only 162 of them touch a scope helper of any kind — and, per §3, "touches a scope helper" routinely means the property-only helper, not the composed org+property one. A second, independently-run count landed close but not identical (432 routes, 37 loaders, 114 tool files, 132 scope-helper references) — the loader gap (80 vs 37) likely comes from a narrower glob; both counts agree on the order of magnitude: roughly 500-600 candidate read surfaces, with somewhere around a quarter to a third referencing any scope helper at all, correct or not. This is a lower bound on the org-blind surface, not an exhaustive count — it would take exercising every route as a real non-staff user to know the true number, the same method the referenced onboarding walkthrough used to catch the 5 live examples above.

5. What already exists toward a floor

6. Feasibility, from these facts (not a recommendation)

OptionWhat blocks it todayMigration sizeEffect on shared-staff (platform_admin) view
A. Mandatory tenant context in the data layer + CI265 of 292 repository read functions (91%) take no tenant argument and would each need a signature change, plus every one of their ~600+ call sites across routes/loaders/tools updated to pass a context object instead of a raw id. The existing AsyncLocalStorage RequestContext is unused by the data layer today — it's a real starting point, not a green field, but it carries no tenant field yet.Code change, not data change — ~292 function signatures + hundreds of call sites; the 3 org-envelope-gap-held and 22 unverified-legacy routes already flagged are the known worst cases to start from.Straightforward — platform_admin becomes an explicit "no tenant filter" context value passed the same way, auditable and loggable, rather than a role check embedded in each helper.
B. Per-request STS + dynamodb:LeadingKeysThe primary partition key on nearly every entity leads with propertyId, not organizationId (§1) — Property, Unit, Tenant, Conversation, WorkOrder, Turnover, RenewalSaga, Tour, Lease, EscalationMatter, KnowledgeSection, Settings all fail this today. Only Occupancy and Inquiry already have an org-leading GSI (not the primary key — LeadingKeys can be scoped to a GSI, so this is a partial head start, not full compliance). LeadingKeys also excludes Scan entirely (per AWS's own docs, cited above) — and this table is scanned today (this audit's own sampling used Scan, as does at least the dashboard's "all properties" fallback path).A full re-key of every PK/SK/GSI to lead with organizationId, across ~1.04M rows spanning the entity list in §1 — the two entities that already have an org-leading GSI (Occupancy, Inquiry — under 1,000 rows combined) show the pattern is at least provably workable at small scale here.Needs an explicit token-vending path for staff (a Lambda minting a session tagged for a chosen org, or an un-scoped break-glass role) since the ambient @propflowai.co-domain override used today has no equivalent under LeadingKeys.
C. Table per companyNothing structural blocks it — the schema is already single-table, so per-tenant splitting is mostly automation + backfill scripting, not a redesign. Only 7 distinct organizationId values exist in prod today (§1), several of them sandbox/test/staff, so the near-term tenant count is small.Small at today's scale (~1.04M rows across a handful of real orgs) — but 91% of read functions still assume "no tenant needed" and would need the tenant id threaded through to pick the right table, so this doesn't avoid the Option A code change, it adds a table-selection step on top of it.Cross-org staff views (dashboards, the shared vendor directory) must fan out across every tenant's table or maintain a separate rollup — a new piece of infrastructure that doesn't exist today.
D. Account per companySame starting gaps as C, plus: shared services used across all customers today — one Temporal worker fleet, one ElevenLabs voice integration, one SendGrid/Twilio account — would need cross-account roles or per-account duplication.Same data-migration size as C at today's ~7-org scale, but the infrastructure lift (new CI/CD per account, cross-account networking, per-account credential provisioning) is much larger and ongoing per new customer.Every "see all my properties across companies" staff workflow (the current default for platform_admin) becomes a cross-account operation requiring assumed roles per account.

These are the mechanical starting conditions, not a ranking. Fable writes the decision separately.

Adversarial isolation audit (Sep 7)

Read-only static pass over ~/.claude/propflowai-lookup (8 parallel sub-agents, every file opened and read, no sampling) plus a live probe run as a real, non-staff org_admin — a brand-new test login on the throwaway org org_riverbend, which owns zero properties, so anything it can see came from another company. This section answers "does every customer-reachable screen actually enforce the wall," as a complement to the code-shape findings above (§1-§6), which explain why it doesn't.

What the test login actually saw, live

Signed in production as clara+isoaudit0907@inbound.propflowai.co, confirmed via GET /api/auth/me as role org_admin on org_riverbend (an org with no properties, no AppFolio connection, no phone numbers — anything it can see is not its own). Every call below is a plain GET; nothing was created, changed, or sent.

Path calledLive resultEvidence
GET /api/dashboard/statsLeakReturned a real portfolio's numbers to a zero-property org — 232 units, 89.6% occupied, $299,100 monthly rent, 22 open work orders
GET /api/tenantsLeak172 real Camellia Apartments tenant rows — full name, phone, unit, property
GET /api/leasing/prospectsLeak394 prospect rows spanning three different companies' data in one response (org_sandbox 340, org_jpco/Camellia 49, org_western_slope 5)
GET /api/leasing/renewalsLeak100 real renewal rows — tenant name, unit, current/market/proposed rent, decision outcome, all Camellia
GET /api/leasing/availabilityLeak41 real unit rows for a different company's property (Yale 25 Station), with bed/bath/sqft/asking rent
GET /api/maintenance-manualsLeakReal equipment manuals tied to another company's property id
GET /api/activity-logLeakReal tenant tool-call log lines naming a real tenant id and a real conversation id, not this org's
GET /api/conversationsLeak20 real conversations, incl. a real caller's name and a live voice call summary, none belonging to org_riverbend
GET /api/cost-savings/summaryLeakAnother company's real financials — trailing-twelve-month revenue ($1.6M), operating expense, and NOI, sourced from that company's AppFolio account
GET /api/settingsLeakAnother company's real org settings — company name, contact email, contact phone, billing address, AppFolio application link
GET /api/properties/{known id}Leak — id-taking, no ownership checkFull detail for a real property — name, street address, 120 units, 579 lifetime work orders, 15 knowledge entries — fetched by a plain numeric id with no ownership check, even though...
GET /api/properties (the list)Correctly bounded...the list version of the same resource correctly returned [] — the leak is specifically in the by-id path, not the whole resource
GET /api/teamLeak — confirmed after the static pass flagged it18 real user rows — name, email, role, status — spanning two other companies, with zero filtering; the route's own code branches to return every user in the system for org_admin and platform_admin alike
GET /api/vendors, /api/work-orders, /api/turnovers, /api/turnovers/upcoming, /api/leasing/tours, /api/properties/optionsCorrectly boundedAll returned an empty list for this zero-property org — a live control group proving these particular reads do apply a real boundary
GET /api/admin/users, GET /api/admin/waitlistCorrectly rejected403 "Admin access required" — confirms the platform_admin-only gate holds against an org_admin caller
GET /api/search, GET /api/collections, GET /api/dashboard/live (no property chosen)Empty this time, not provably boundedReturned no rows for this query/org combination — the static pass separately classifies /api/search and /api/dashboard/live (no propertyId) as property-scope-only/unbounded by code shape, so an empty live result here reflects this particular query, not a verified fix

Static enumeration, by surface

Read every file in each list below — not a sample. "GAP" means the only check applied is "is this building assigned to you," which returns "yes, all of them" for an org_admin by design (scope.ts:26-29), so in practice it is not a boundary for that role at all.

SurfaceTotalCompany-boundedProperty-only (GAP)UnboundedNot reachable / n-aId-taking, no ownership check
API routes (src/app/api/**/route.ts)430~105~66~13~258 (platform_admin-only, cron shared-secret, or webhook-signature-gated)~26
Workspace pages (src/app/(workspace)/**/page.tsx)88251223 (mostly /admin/dev/* — see below)24 (redirects/static)6
Server actions ('use server')2 real0002 (investor-deck password gate only, no tenant data)0
Domain loaders (load-*.ts)3615827 (platform-wide config, non-tenant)
Clara tool handlers (src/lib/tools/handlers/**)106Not individually tabulated — PR #7164 already deep-audits voice/SMS/email channel isolation. This pass separately confirmed ToolCtx carries no organizationId field, and flagged one adjacent, already-known gap below (not part of #7164's scope).~most (thin AppFolio passthrough wrappers take a bare id, forward to L4 with no ownership check)

Grand total, distinct data-reachable paths: 662. A page's middleware route maps every /admin/* path — including the ~20 pages under /admin/dev/* meant for staff debugging (agent traces, email-ingestion logs, a doc-traffic visitor-PII log, raw metrics) — to one coarse "admin" permission that org_admin is explicitly granted at the same level as platform_admin (permissions-source.ts:104-112, commented "operator-side full access"). Only 2 of those ~20 pages re-check for platform_admin specifically inside the page itself; the other ~18 are reachable, unintentionally, by any customer's own company admin.

Worst individually-confirmed paths

Shape of the problem

This is a systemic pattern, not a handful of missed routes. The clearest evidence is in the data-access layer itself (§2 above): of 292 repository read functions, only 27 (9%) take a company id as a parameter at all — and even most of those treat it as optional ("omitted = the whole platform" is a documented, intentional behavior on getProperties()). The other 91% — including the functions behind property, conversation, and work-order lookups — will return a row for any id handed to them, for anyone. The one place this is done right, Person and Tenant identity lookups, proves the pattern is fixable in this codebase: getPerson() takes an organization id as a required parameter and throws if the row belongs to someone else. That is the exception, not the house style. Every other entity relies on each of 430 routes individually remembering to re-check company membership after fetching the row — and the same route family often splits on this: /api/turnovers/[id]/scope checks the company, /api/turnovers/[id]/cancel on the identical turnover doesn't; the POST that creates a user checks the company, the PATCH that edits one doesn't. That inconsistency inside single files is the fingerprint of "each author has to remember," not a boundary the platform enforces — which is exactly the "hard floor" gap the architecture options above (§6) are weighing how to close.

This section is written as a company-isolation correctness audit of PropFlow's own product, in the spirit of ADR-0019's stated goal — not as an attack writeup. No customer data was altered, exported, or shared outside this session; the test account is a throwaway internal login with no real mailbox, and every finding above is described by row count and company name only, never by the underlying person's name, phone, or email. Test-login and rollback detail: ~/agents/006/onboarding-walkthrough-rollback-2026-09-07.md.

PropFlow Docs