Skip to main content

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:

  1. Tenant scope — every row in every table carries tenantId. All queries are scoped by it. This is structural and never overridable.
  2. 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-id as 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:

#TestWhat It Verifies
1Login to property A, switch to property B, verify reservations list refreshesProperty switch updates active scope
2User with access to A only cannot fetch property B reservations via APITenant + role enforcement
3Reservation created on property A is not visible on property BTenant scope on writes
4HK task on property A floor 3 is not visible to property B usersProperty scope on real-time channels
5Folio close on property A doesn't trigger night audit on property BEvent 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 auditLog table) does not yet enforce property scope on read — it scopes by tenantId only. Acceptable for now since auditLog is admin-only.

  • cross-service-communication.md — the ServiceClient pattern and why receiver-side checks matter
  • internal-service-auth.md — service-account tokens that flow through the same headers
  • hmp-web-dashboard/e2e/tests/19-multi-property-isolation.spec.ts — the test suite
  • hmp-api-gateway/src/proxy/proxy.service.ts — header rewrite logic