Accepted (Fede, 2026-07-30)

ADR-0120 — Every request resolves an org envelope before it resolves property scope

org_admin means all properties in that org, never null-scope.

Deciders: Fede  ·  Date: 2026-07-30  ·  Related: ADR-0019 (organization model), ADR-0024, ADR-0027  ·  Evidence: PR #5059 sweep


Context

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.

The shape of the gap

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:

SurfaceGateWhat an org admin of org A reads from org B
GET /api/properties/[id]
→ loadPropertyDetail
isAccessDenied onlyproperty, units, tenants, leases, work orders, knowledge
.../units/[unitId]
→ loadUnitDetail
isAccessDenied onlyunit, tenant PII, leases, appliances, signals
GET + PATCH /api/work-orders/[displayId]scope null → store filter skippedthe work order — and PATCH writes it
GET /api/dashboard/stats, /api/leasing/prospectsproperty scope onlyorg-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.

Two adjacent gaps the same sweep found

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.

Why a regex cannot finish this

#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.


Decision

1 Every request resolves an org envelope before property scope

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.

2 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.

3 A route that makes no auth call is a build error

"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.

4 The 21 unverified-legacy routes migrate in four stages

Ordered by blast radius, each its own PR with fails-on-old-code tests in both directions:

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.


Consequences

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:

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.


Alternatives considered

AlternativeWhy it lost
Make getUserPropertyScope org-awareNeeds 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 filterVisiblePropertiesAlready 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 routeMiddleware 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 PRUnreviewable. 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 trustToday 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.

PropFlow Docs