Multi-Property Tenant Isolation
Status: Verified and documented in Phase 1 hardening (2026-04, branch
feat/multi-property-isolation-tests). All five isolation tests pass; no bugs found.
KeyNova is a multi-tenant SaaS where each tenant (a hotel chain or independent property owner) has one or more properties. Two layers of scoping protect against cross-tenant data leakage and enforce property-level access:
- Tenant scope — every row in every table carries
tenantId. All queries are scoped by it. This is structural and never overridable. - Property scope — within a tenant, users may have access to one or more properties. The current "active property" is in JWT and is checked against ownership on every operation that touches property-scoped data.
This document covers how the two layers interact, how the API gateway enforces them, and how we test the boundary.
How the API Gateway Enforces Property Scope
The api-gateway proxy strips authentication headers from inbound requests and rebuilds them from the verified JWT. The relevant lines (proxy.service.ts:407-408):
// Override any client-supplied X-Property-Id with the JWT claim
headers['x-property-id'] = req.user.propertyId;
headers['x-tenant-id'] = req.user.tenantId;
This means a user cannot spoof a different propertyId by setting the header in their own request — the gateway will overwrite it with whatever's in their JWT. To switch properties, the user must hit the auth endpoint that issues a new JWT bound to the new propertyId.
Architectural finding from C3: This is good (a client can't escalate by header), but it also means downstream services should treat
x-property-idas authoritative for the request and not double-check against the body. Documenting here so future endpoints don't add redundant header-vs-body checks.
Receiver-Side Tenant Ownership Enforcement
When one service calls another via ServiceClient (see
cross-service-communication.md), the receiving service
gets x-tenant-id and x-property-id headers. Those headers come from the caller's ALS
context, which originated from the caller's JWT verification. By the time the receiver
sees them, they've passed through the gateway once.
The receiver still cannot trust them blindly, because:
- The caller is authenticated as a service-account, not as a user. The service-account JWT doesn't carry tenantId.
- The caller could (in theory) be compromised and forward arbitrary tenant IDs.
The defense is assertPropertyTenant(), which lives in every service that exposes
property-scoped endpoints:
@Injectable()
export class BookingEngineSupportService {
constructor(private readonly prisma: PrismaService) {}
async assertPropertyTenant(propertyId: string, tenantId: string): Promise<void> {
const property = await this.prisma.property.findFirst({
where: { id: propertyId, tenantId },
select: { id: true },
});
if (!property) {
throw new ForbiddenException(
`Property ${propertyId} does not belong to tenant ${tenantId}`,
);
}
}
}
This is a single SELECT, cached for 5 minutes per (propertyId, tenantId) pair via
BusinessCacheService. It runs as the first line of every controller method that
accepts a propertyId from path or query.
The feat/booking-engine-serviceclient branch added this enforcement to all 8 new
booking-engine-support endpoints in property-service. This is the critical security fix
from C4b.
Tenant Scoping at the Database Layer
Independent of the request-level checks above, every Prisma query includes tenantId in its
where clause. The pattern is enforced by the TenantScopedRepository base class in
@hmp/database:
async findMany(args: Prisma.PropertyFindManyArgs) {
const tenantId = AsyncContext.getStore()?.tenantId;
if (!tenantId) throw new Error('No tenant in context');
return this.prisma.property.findMany({
...args,
where: { ...args.where, tenantId },
});
}
If any code path forgets to scope manually, the base class adds it. If the ALS context is empty (typically a programming error in a background job), the call fails fast.
Composite unique constraints also include tenantId:
model Room {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid
propertyId String @db.Uuid
roomNumber String
@@unique([tenantId, propertyId, roomNumber])
}
This means two tenants can both have a "Room 101" without conflict.
Testing Approach
The C3 work added e2e/tests/19-multi-property-isolation.spec.ts in hmp-web-dashboard.
Five Playwright tests, all passing:
| # | Test | What It Verifies |
|---|---|---|
| 1 | Login to property A, switch to property B, verify reservations list refreshes | Property switch updates active scope |
| 2 | User with access to A only cannot fetch property B reservations via API | Tenant + role enforcement |
| 3 | Reservation created on property A is not visible on property B | Tenant scope on writes |
| 4 | HK task on property A floor 3 is not visible to property B users | Property scope on real-time channels |
| 5 | Folio close on property A doesn't trigger night audit on property B | Event topics are property-scoped |
No bugs were found by these tests. They're now in CI as a regression net.
Known Gaps
- Cross-property reads within a tenant (e.g., a chain manager viewing all 5 properties) are not yet covered by isolation tests. EP-018 has stories for chain rollups; tests will follow.
- Audit log scoping (auth-service
auditLogtable) does not yet enforce property scope on read — it scopes by tenantId only. Acceptable for now since auditLog is admin-only.
Related Docs
cross-service-communication.md— the ServiceClient pattern and why receiver-side checks matterinternal-service-auth.md— service-account tokens that flow through the same headershmp-web-dashboard/e2e/tests/19-multi-property-isolation.spec.ts— the test suitehmp-api-gateway/src/proxy/proxy.service.ts— header rewrite logic