AUTH-2

propertyProcedure middleware

Origin

Spec-source alignment locked in Phase 0 (2026-04-07) during the authority-hierarchy lockdown. Restated on 2026-04-17 after the 2.13.5 security audit (finding F-003) and the auth audit (finding F-001) triggered a full rewrite of the middleware contract. The earlier two-tier FORBIDDEN "Property not in active org" + NOT_FOUND "Property not found" pattern was eliminated. The string "Property not in active org" does not exist in the middleware source anymore.

Rule Text

propertyProcedure is the base tRPC procedure for every endpoint scoped to a specific property. It inherits authenticatedProcedure (per AUTH-1), validates that the input contains propertyId as a UUIDv7, and then executes one canonical access-check query:

SELECT 1 FROM properties
  JOIN propertyUsers ON propertyUsers.propertyId = properties.id
 WHERE properties.id = input.propertyId
   AND propertyUsers.userId = ctx.session.userId
   AND propertyUsers.organizationId = ctx.session.activeOrganizationId
   AND properties.deletedAt IS NULL
   AND propertyUsers.deletedAt IS NULL

Properties have NO organizationId column. They are global records. Org-scoped access is exclusively through the propertyUsers junction.

If the join returns zero rows, the middleware throws NOT_FOUND "Property not found". That single error code covers all four failure cases: property does not exist, property is soft-deleted, user has no propertyUsers row under the active organization, or the propertyUsers row is soft-deleted. The middleware MUST NOT throw FORBIDDEN "Property not in active org" or FORBIDDEN "Access denied". Those strings do not appear in the source. Per AUTH-5.

On success, the middleware populates ctx.propertyId, ctx.propertyUser (the full propertyUsers row), and ctx.propertyPerms (the resolved permission-flag object derived from the row).

Testable Assertion

// Any cross-org access attempt returns NOT_FOUND, indistinguishable from "doesn't exist":
expect(error.code).toBe("NOT_FOUND");
expect(error.message).toBe("Property not found");

// The middleware populates ctx fields for the procedure body on success:
expect(ctx.propertyId).toBe(input.propertyId);
expect(ctx.propertyUser.relationship).toBeDefined();
expect(ctx.propertyPerms).toBeDefined();

Enforcement

  • Runtime — The middleware body runs before every property-scoped procedure. Non-matching requests are rejected before the procedure executes.
  • Gate-time — A static-analysis rule blocks the appearance of FORBIDDEN "Property not in active org" or FORBIDDEN "Access denied" in any procedure file. The legacy strings cannot reappear without editing the gate configuration.

Violation Closed

Cross-organization entity enumeration via error-code probing. Under the previous two-tier model, an attacker could distinguish FORBIDDEN (entity exists in another org) from NOT_FOUND (entity doesn’t exist) by reading the error code, enabling a clean enumeration of which property IDs existed elsewhere in the system. Collapsing all four failure cases into one canonical NOT_FOUND removes the side channel entirely. A probing client observes identical output regardless of which failure mode they hit.