The obvious objection

Ask a landlord admin to fetch a property that belongs to a different landlord, and PropertyOS answers with a 404. Not a 403. Not "you don't have permission." As far as that request is concerned, the property doesn't exist.

That looks like a bug — shouldn't "forbidden" say so? It's deliberate. A 403 confirms the record exists and you're merely not allowed to see it. That's a small leak by itself, but it's the kind of leak that compounds: probe enough IDs and a 403-vs-404 pattern becomes a map of which UUIDs are real. A 404 gives an attacker nothing to distinguish "wrong tenant" from "never existed." The cost is a slightly less helpful error message for a landlord admin who just fat-fingered their own property's ID. That's a trade worth making.

What actually changed

Every earlier module in this codebase dealt with a landlord admin's own account — one row, looked up by the actor's own ID. Properties is the first module where a landlord admin queries a whole collection of records they don't individually own by identity, only by tenancy. That's the first place multi-tenant isolation could actually go wrong, so it's the first place it needed a real, explicit rule rather than an implicit assumption.

The rule lives in one place — properties.service.js — not scattered across every handler:

async function resolveLandlordIdForActor(actor) {
  if (actor.type === "hqlead") return null; // unrestricted by design

  const identity = await landlordadminIdentityRepo.findById(actor.adminId);
  if (!identity) throw new AppError("Landlord admin not found", 404);
  return identity.landlord_id; // never read from the request
}

Every list, get, update, and archive call routes through this before touching the database. A landlord admin's landlord_id is resolved from their own identity record, never accepted from anything the client sent — not the request body, not a query string filter. I verified this isn't just correct in theory: with two real accounts (one per landlord), a direct attempt to override the filter — GET /properties?landlord_id=<the-other-landlord> — is silently ignored server-side. The response still only contains your own properties.

HQLead gets the opposite treatment on purpose. resolveLandlordIdForActor returns null for an HQLead actor — no restriction — because cross- tenant visibility is the entire point of that role. The same service function encodes both "always scoped" and "never scoped" as one decision point instead of two parallel code paths that could drift apart.

A second isolation gap, found by asking a different question

Landlord admin sessions are stateless JWTs with a 15-minute lifetime. That raises a question the ABAC design above doesn't answer: if HQLead blacklists a landlord right now, does that landlord's admin lose access immediately, or only once their current token expires?

Before this pass, the answer was "only at expiry" — properties routes checked the JWT and the session, but nothing re-verified that the landlord behind it was still in good standing. I fixed this by adding a middleware that re-resolves the landlord's status on every request:

const identity = await landlordadminIdentityRepo.findById(req.user.id);
await enforceLandlordAdminLifecycle({ identity, landlordRepo });

Tested live: log in as a landlord admin, note the token works, have HQLead blacklist that landlord, retry the exact same still-valid token — 403, immediately. No waiting for expiry, no forced logout required.

That correctness has a real cost: two extra database round-trips on every single properties request, purely for a check that almost always passes. At today's scale that's invisible. At real scale it's a candidate for caching the landlord's status for a few seconds rather than re-querying it on every request — a trade-off to make deliberately later, not by accident now.

What doesn't scale yet

  • Search is ILIKE '%term%'. Fine for a landlord with a few dozen properties. A leading wildcard can't use a standard index, so at real scale this becomes a sequential scan. Fixing it means a pg_trgm trigram index, not a code change — a schema decision deferred until there's real data to justify it.
  • Pagination is LIMIT/OFFSET. Same story: fine until the offset gets large, at which point Postgres has to walk and discard every row before it. Cursor-based pagination is the eventual fix; not worth the complexity for the handful of properties that exist today.
  • The HQLead "needs attention" flag is computed in the browser. It cross-references two separate API responses (all properties, all landlord admins) client-side rather than being a precomputed field. That means every HQLead page load fetches all landlord admin accounts just to paint a handful of badges. Fine at two tenants. Not fine at two thousand.

What's built but genuinely unused

property_settings — currency, timezone, billing cycle — exists as a migration and a foreign key, with zero endpoints reading or writing it yet. It's there because the schema needed to anticipate multi-currency billing before Invoices exists, not because anything consumes it today. Honest framing: it's forward-compatible schema, not a shipped feature.

The gap the "managers" question actually points at

A landlord admin delegating property management to staff — the scenario that prompted the MFA question this module started from — exposes the real missing piece: there's no audit trail. If a future staff account edits or archives a property, nothing records who did it or when. The ABAC boundary answers which tenant can touch a record; it says nothing about which user within that tenant did. That's not a bug in what shipped — Users/staff accounts don't exist yet — but it's the concrete reason "who changed this" needs an answer before delegation becomes real, not after.

What it cost

Properties currently ships with no rate limiting at all — every other module in this codebase (HQLead auth, landlord admin auth) has explicit per-route limiters; properties doesn't yet. That's not a deliberate trade-off, it's an honest gap: an authenticated client could hammer the create endpoint today with nothing to slow it down. Worth naming plainly rather than discovering it later.

Live link coming soon