org_admin means all properties in that org, never null-scope.
ADR-0019 decided the shape of multi-tenancy in one sentence:
Org-scoping is the outer envelope; existing property-scoping stays alongside as the inner privacy boundary within an org.
The inner boundary was built. The outer envelope was not — not as a thing every request passes through. It exists as a set of helpers that a route may call, and most routes don't.
The sweep in #5059 — 54 query-channel routes, 31 body-channel, 12 path-segment, each traced into its call stack rather than judged by its imports — fixed ten surfaces that had no visibility check at all, then stopped at a wall it explicitly refused to fix inside a security patch. This ADR is that wall.
Three helpers carry almost all authorization in the codebase, and all three apply property scope only:
// src/lib/platform/auth/scope.ts:25
export function getUserPropertyScope(user: AuthenticatedUser): Set<string> | null {
if (user.role === 'platform_admin' || user.role === 'org_admin') {
return null; // null = all properties
}
return new Set(user.assignedPropertyIds || []);
}
null means "no filter" to every consumer: scopeByProperty returns the input unchanged, isInScope returns true for any id, and isAccessDenied — the fail-closed gate that replaced ~21 hand-rolled route checks — returns false for every property in the deployment.
For platform_admin that is correct and intended. For org_admin it is a cross-org hole. The helper's own docblock asserts the bound is restored via org-scope.ts, and ORG_BYPASS_ROLES there correctly excludes org_admin — but nothing composes them. The docblock describes an intention the type system does not enforce.
Confirmed cross-org by reading the code, not inferred:
| Surface | Gate | What an org admin of org A reads from org B |
|---|---|---|
GET /api/properties/[id] | isAccessDenied only | property, units, tenants, leases, work orders, knowledge |
.../units/[unitId] | isAccessDenied only | unit, tenant PII, leases, appliances, signals |
GET + PATCH /api/work-orders/[displayId] | scope null → store filter skipped | the work order — and PATCH writes it |
GET /api/dashboard/stats, /api/leasing/prospects | property scope only | org-envelope-gap-held |
loadWorkOrderDetail is the sharpest illustration, because the comment above it names the exact threat the code then fails to cover:
// SECURITY (ADR-0024 §URL): scope the lookup by the user's assigned properties
// so a cross-org displayId collision can't surface the wrong WO. scope===null
// is admin → pass undefined to skip the filter.
const scope = user ? getUserPropertyScope(user) : null;
const workOrder = await resolveWorkOrderRef(displayId, scope ? [...scope] : undefined);
The author knew cross-org displayId collision was the risk. scope === null for an org admin turns the mitigation off.
Routes with no auth call at all. POST /api/turnovers contains no requireUser, no getCurrentUserUnchecked, no requireAdminAuth, no scope helper — nothing. Middleware's session gate is the only thing in front of it, and it short-circuits below production. Any authenticated user can mint a turnover against any property in any org. It resolves the tenant against the property's org, with a comment saying so explicitly ("NOT the session org"). The same file's GET is dual-mode session-or-admin-bearer with a long SECURITY comment: the read side was hardened, the write side on the next screen was not, and nothing detected the difference.
Reads with no filter at all. Omitting ?propertyId= on /api/leasing/renewals, /api/maintenance-manuals and /api/dashboard/live returned deployment-wide data before #5059 guarded the filtered path — strictly worse than a missing org envelope, and named separately so it isn't read as covered by it.
#5059 shipped a registry tripwire, and it is deliberately not the mechanical rule "a route reading propertyId must import filterVisibleProperties". /api/costs disproves that rule: its scoping correctly lives in its loader, so the rule would flag a fixed route and be silenced by an unused import in a broken one. What the registry asserts instead is the one thing a regex can honestly check — every route reading a caller-supplied ?propertyId= appears in a registry with a human verdict.
Its stated boundary is the query channel. propertyId also arrives in request bodies and path segments, and those are not machine-detectable. POST /api/simulate/sms is the proof: requireUser only, propertyId read from the body, driving the live Clara inbound router — real model calls, real conversation writes, against a property the caller may have no relationship to. The registry provably cannot see it.
Authorization is a composition of two boundaries in a fixed order — org first, property second — and never property alone. The canonical expression already exists in filterVisibleProperties: test-property gate → org scope → property scope. That order becomes the rule for every surface: list or single-id, read or write.
org_admin means all properties IN THAT ORG. Never null-scope.null may continue to mean "unbounded" for platform_admin only. For an org admin the effective scope is the intersection:
intersectScopes(propertyScope, orgPropertyIds)
// propertyScope === null → orgPropertyIds ← the line that re-bounds an org admin
Two shapes, both already in the tree, to be generalized rather than invented: list reads follow loadScopedTurnovers (property scope ∩ the org's property ids, computed from a Property[] the route already loaded — four lines, zero extra reads); single-id reads and all writes follow canViewProperty or the explicit resolvePropertyOrg intersection.
Failure modes are fixed: a read fails closed as empty; a write or detail read as 404, never 403, with a body byte-identical to a genuinely-missing id — so no endpoint becomes an oracle for which property ids are real in another organization. A property with no organizationId is out of scope for every org-bounded caller.
"Is this the right check?" is unanswerable by regex. "Is there any check?" is answerable by reading the module. So the registry gains a second, independent assertion: every route exporting a mutating handler (POST/PATCH/PUT/DELETE) must reference a recognized authentication entry point, or appear in an explicit reasoned exemption list. Same discipline as the existing registry — a new route fails CI until a human classifies it, and the classification is a reviewable line in the diff that cannot be satisfied by copying an import.
unverified-legacy routes migrate in four stagesOrdered by blast radius, each its own PR with fails-on-old-code tests in both directions:
dashboard/stats keys its cache on scope, so its cache contract changes with it.propertyId also arrives inside the OAuth state blob on their callback siblings.unverified-legacy is closed to new entries as of this ADR. The plan is complete when the verdict has no members and the value is deleted.
Easier: a single answer to "may this caller touch this property?" that reviewers can check by eye. New routes get the envelope by default rather than by remembering.
Harder:
getProperties, no org GSI reader. Every check either filters a Property[] the caller already loaded or resolves one property. A genuinely org-indexed read is a follow-up this ADR deliberately does not decide.null org scope is ambiguous — it means both "platform staff, intended cross-org reach" and "org-bounded caller whose org didn't resolve". Every site must disambiguate with a positive staff check. Falling through on null reintroduces the hole in a new place.PORTFOLIO rollups stay deployment-wide. No caller-supplied id reaches them, so they are not this bug — but re-scoping them would change every dashboard's numbers. Out of scope, named so it isn't mistaken for covered.Follow-ups implied, not decided: the state-blob propertyId in the four integrations/*/callback routes; PATCH /api/vendors/memberships/[id], where the row is org-scoped but the submitted propertyIds array is not checked against the caller's scope; and whether /api/simulate/sms should be gated out of production entirely rather than scoped — a product call, since it drives the live router.
| Alternative | Why it lost |
|---|---|
Make getUserPropertyScope org-aware | Needs an async property read inside a synchronous hot-path function, and silently changes null's meaning for every existing caller — including platform-staff paths that legitimately depend on it. It also hides the composition; the two boundaries answer different questions and should stay visibly separate. |
A lint rule requiring filterVisibleProperties | Already tried and rejected in #5059, with /api/costs as the disproof: scoping legitimately lives in loaders, so the rule flags correct code and is silenced by an unused import in incorrect code. |
| Middleware that resolves the envelope for every route | Middleware sees the URL and session but not which of a request's many ids is the property, nor whether the handler will read one property or fifty. It would be a no-op or a false sense of coverage. |
| Fix all 21 legacy routes in one PR | Unreviewable. Each needs two-directional tests and an individually-verified verdict — a 21-route diff gets rubber-stamped, which is how unverified-legacy accumulated in the first place. |
Leave org_admin unbounded, rely on operational trust | Today every org admin is a PropFlow-side operator, so the hole has no known exploitation path. But that is a property of the current customer list, not of the system — and it inverts the moment of discovery: the first customer-side org admin makes it a live cross-tenant exposure with no code change required. |
Source of record: docs/adr/0120-org-scope-envelope.md in PropFlow-Technologies/propflowai. Evidence: the #5059 IDOR sweep and its registry tripwire, read against main @ 49ddd4c8.