UnlockOS Developers
← Back to blog
🔐

Tenant Isolation and Safe State Transitions in a Lock SDK

Jul 6, 2026Jul 12, 2026
9 min
86 commits
Depth 8/10
securitymulti-tenancystate-machinetypescriptreliability

Tenant Isolation and Safe State Transitions in a Lock SDK

Introduction

A smart-lock platform is not a CRUD app with doors attached. Every record — a reservation, a staff account, a check-in — eventually resolves into a physical event: a door opens, or it does not. That makes two classes of bugs unacceptable:

  1. Authorization bugs, where one tenant can read or mutate another tenant's data.
  2. State bugs, where a reservation ends up in a status that the physical world cannot honor (a "checked-out" booking that still issues credentials, a "paid" booking that was never paid).

This article walks through the patterns we apply in the UnlockOS SDK to eliminate both classes: strict server-derived tenancy, an explicit reservation state machine with guarded transitions, server-side payment reconciliation, idempotent check-in actions, and conflict-safe external calendar sync. All examples are generalized — they are patterns you can lift into any multi-tenant, physical-access system.


1. Never Trust a Client-Supplied Tenant Identifier (IDOR)

The most common multi-tenant vulnerability is also the most boring: an endpoint accepts organizationId from the request body and uses it to scope a query. Any authenticated user of tenant A can then enumerate or mutate tenant B's resources — a textbook IDOR (Insecure Direct Object Reference).

The vulnerable shape usually looks innocuous:

// ❌ ANTI-PATTERN: tenant scope comes from the request payload
export async function updateFrontdeskStaff(req: Request) {
  const { organizationId, staffId, role } = req.body;
  return db.frontdeskStaff.update({
    where: { id: staffId, organizationId },
    data: { role },
  });
}

Even with a valid session, the caller controls the scope. The fix is a rule that must be enforced everywhere, without exception:

Tenant scope is derived from the authenticated principal on the server. It is never read from the request.

import { z } from 'zod';
// Schema intentionally omits organizationId; `.strict()` rejects it outright.
const UpdateStaffInput = z
  .object({
    staffId: z.string().uuid(),
    role: z.enum(['frontdesk', 'manager', 'viewer']),
  })
  .strict();
export async function updateFrontdeskStaff(req: AuthenticatedRequest) {
  const input = UpdateStaffInput.parse(req.body);
  const organizationId = req.principal.organizationId; // server-derived, signed session
  const updated = await db.frontdeskStaff.updateMany({
    where: { id: input.staffId, organizationId },
    data: { role: input.role },
  });
  if (updated.count === 0) {
    // Do not distinguish "not found" from "other tenant": avoid an existence oracle.
    throw new NotFoundError('STAFF_NOT_FOUND');
  }
  return updated;
}

Two details matter more than they look:

  • .strict() instead of silent stripping. If a client sends organizationId, the request fails loudly. That turns a would-be exploit into a 400 and a log line, and it surfaces internal callers that were relying on the unsafe shape.
  • Uniform 404 for cross-tenant access. Returning 403 Forbidden for records that exist in another tenant leaks existence. Return the same error you would return for a nonexistent ID.

Defense in depth: enforce the boundary in the database

Application code is written by humans on deadlines. Push the invariant one layer down so that a forgotten WHERE clause cannot become a breach:

ALTER TABLE frontdesk_staff ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON frontdesk_staff
  USING (organization_id = current_setting('app.organization_id')::uuid)
  WITH CHECK (organization_id = current_setting('app.organization_id')::uuid);

The request middleware sets app.organization_id once per transaction from the session. WITH CHECK is the part people forget: without it, RLS blocks reads across tenants but still allows an INSERT or UPDATE that moves a row into another tenant.

Make it a regression test, not a code review habit

describe('cross-tenant access', () => {
  it('rejects an organization_id supplied by the client', async () => {
    const res = await client
      .as(userInTenantA)
      .patch('/frontdesk/staff')
      .send({ staffId: staffInTenantB.id, role: 'manager', organizationId: tenantB.id });
    expect(res.status).toBe(400);
  });
  it('returns 404 (not 403) for a resource owned by another tenant', async () => {
    const res = await client
      .as(userInTenantA)
      .patch('/frontdesk/staff')
      .send({ staffId: staffInTenantB.id, role: 'manager' });
    expect(res.status).toBe(404);
    expect(await db.frontdeskStaff.findUnique({ where: { id: staffInTenantB.id } }))
      .toMatchObject({ role: 'frontdesk' });
  });
});

A useful practice: write one such test per mutating endpoint and generate the list from the route table, so a new route without an isolation test fails CI.


2. Reservation Lifecycle as an Explicit State Machine

Reservation status is the single field that decides whether a credential is issued, whether a door code is valid, and whether money can still be captured. Representing it as a free-form string updated from a dozen call sites is how systems end up issuing access to completed stays.

Model it explicitly:

export type ReservationStatus =
  | 'pending'
  | 'confirmed'
  | 'checked_in'
  | 'checked_out'
  | 'canceled';
type Actor = { role: 'guest' | 'staff' | 'platform_admin'; id: string };
type Transition = {
  to: ReservationStatus;
  allow: (actor: Actor) => boolean;
  requiresReason?: boolean;
};
const TRANSITIONS: Record<ReservationStatus, Transition[]> = {
  pending: [
    { to: 'confirmed', allow: () => true },
    { to: 'canceled', allow: () => true },
  ],
  confirmed: [
    { to: 'checked_in', allow: () => true },
    { to: 'canceled', allow: (a) => a.role !== 'guest' || withinCancellationWindow() },
  ],
  checked_in: [{ to: 'checked_out', allow: () => true }],
  checked_out: [], // terminal
  canceled: [
    // Reopening a canceled booking is privileged and always audited.
    { to: 'confirmed', allow: (a) => a.role === 'platform_admin', requiresReason: true },
  ],
};
export function assertTransition(
  from: ReservationStatus,
  to: ReservationStatus,
  actor: Actor,
  reason?: string,
): void {
  const t = TRANSITIONS[from].find((x) => x.to === to);
  if (!t) throw new InvalidTransitionError(`${from} -> ${to} is not allowed`);
  if (!t.allow(actor)) throw new ForbiddenError('TRANSITION_NOT_PERMITTED');
  if (t.requiresReason && !reason?.trim()) throw new ValidationError('REASON_REQUIRED');
}

Three properties fall out of this design for free:

Terminal states are terminal. checked_out has an empty transition list, so "edit a completed booking" is impossible at the domain layer — not merely hidden in the UI. The UI then derives its affordances from the same table rather than re-implementing the rule:

export const canEdit = (s: ReservationStatus) => TRANSITIONS[s].length > 0;
export const canCheckIn = (s: ReservationStatus) =>
  TRANSITIONS[s].some((t) => t.to === 'checked_in');

When a button's visibility and the server's authorization check read from one table, they cannot drift.

Privileged recovery is explicit and audited. Real operations need an escape hatch — a booking canceled by mistake must be recoverable. The wrong answer is a direct UPDATE in a back office. The right answer is a transition that exists in the machine, is restricted to a specific role, requires a reason, and emits an immutable audit record:

export async function reopenCanceledReservation(
  reservationId: string,
  actor: Actor,
  reason: string,
) {
  return db.$transaction(async (tx) => {
    const r = await tx.reservation.findUniqueOrThrow({ where: { id: reservationId } });
    assertTransition(r.status, 'confirmed', actor, reason);
    const next = await tx.reservation.update({
      where: { id: reservationId, status: r.status }, // optimistic guard on observed state
      data: { status: 'confirmed' },
    });
    await tx.auditLog.create({
      data: {
        entity: 'reservation',
        entityId: reservationId,
        action: 'status.reopen',
        actorId: actor.id,
        actorRole: actor.role,
        from: r.status,
        to: 'confirmed',
        reason,
        occurredAt: new Date(),
      },
    });
    return next;
  });
}

Note where: { id, status: r.status }. Including the previously observed status in the WHERE clause turns read-modify-write into a compare-and-swap: if a concurrent request already advanced the reservation, the update affects zero rows and the transaction fails instead of silently overwriting.

Audit entries are written in the same transaction as the state change. An audit log that can be committed separately from the fact it describes is not an audit log. Either both land or neither does.


3. Payment State Must Be Confirmed Server-Side

Redirect-based payment providers hand control back to your app via a browser URL. That URL is attacker-controllable, may be replayed, and — more mundanely — may never arrive at all because the guest closed the tab in a tunnel. Treating the redirect as proof of payment is both a security hole and a reliability hole.

The correct model: the redirect is a hint to reconcile, never evidence.

type PaymentState = 'unpaid' | 'pending' | 'paid' | 'failed' | 'refunded';
export async function reconcilePayment(reservationId: string): Promise<PaymentState> {
  const reservation = await db.reservation.findUniqueOrThrow({
    where: { id: reservationId },
    select: { paymentRef: true, paymentState: true, organizationId: true },
  });
  if (reservation.paymentState === 'paid') return 'paid'; // idempotent fast path
  // Source of truth is the provider, queried server-to-server with our own credentials.
  const remote = await paymentProvider.getPayment(reservation.paymentRef);
  const next = mapProviderStatus(remote.status);
  const updated = await db.reservation.updateMany({
    where: { id: reservationId, paymentState: reservation.paymentState },
    data: { paymentState: next, paymentSyncedAt: new Date() },
  });
  if (updated.count === 0) return reconcilePayment(reservationId); // lost race: re-read
  return next;
}

And the redirect handler carries no authority at all:

// The client says "I came back from the payment page". We verify everything ourselves.
export async function handlePaymentReturn(req: AuthenticatedRequest) {
  const { reservationId } = z.object({ reservationId: z.string().uuid() }).strict().parse(req.query);
  await assertOwnedByPrincipal(reservationId, req.principal); // tenancy + ownership check
  const state = await reconcilePayment(reservationId);
  return { state, entryInfoVisible: state === 'paid' };
}

Complementary safeguards worth building in from day one:

  • Webhook + polling, not either alone. Webhooks are fast but lossy; a periodic reconciliation sweep over paymentState = 'pending' older than N minutes catches everything the webhook dropped.
  • Never reveal entry information before reconciliation resolves. Access credentials are gated on the server-confirmed state, not on a ?status=success query parameter.
  • Gate bypasses are explicit and role-scoped. Operators sometimes must complete a check-in with an outstanding balance. Model that as a named, audited permission (payment.gate.bypass) — not as a conditional that happens to be reachable from an internal screen.

4. Idempotent, Non-Reentrant Check-In Actions

Check-in and check-out are the moments where software becomes hardware. A double-tapped button must not produce two credentials, two audit trails, or two charges.

On the client, disable the action for the entire duration of the request and render the in-flight state honestly:

function useGuardedAction<T>(fn: () => Promise<T>) {
  const [pending, setPending] = useState(false);
  const inFlight = useRef(false);
  const run = useCallback(async () => {
    if (inFlight.current) return; // guards double-submit before React re-renders
    inFlight.current = true;
    setPending(true);
    try {
      return await fn();
    } finally {
      inFlight.current = false;
      setPending(false);
    }
  }, [fn]);
  return { run, pending };
}

The useRef latch matters: setPending(true) is asynchronous, so two clicks within the same tick both pass a state-based check. A ref flips synchronously.

But client guards are a UX nicety, not a correctness mechanism — a retried request from a flaky network bypasses them entirely. The server must be idempotent:

export async function checkIn(reservationId: string, actor: Actor, idempotencyKey: string) {
  const existing = await db.idempotencyRecord.findUnique({ where: { key: idempotencyKey } });
  if (existing) return existing.response as CheckInResult;
  return db.$transaction(async (tx) => {
    const r = await tx.reservation.findUniqueOrThrow({ where: { id: reservationId } });
    assertTransition(r.status, 'checked_in', actor);
    const result = await issueCredentialAndAdvance(tx, r, actor);
    await tx.idempotencyRecord.create({ data: { key: idempotencyKey, response: result } });
    return result;
  });
}

Finally, after a successful transition, refresh state from the server rather than optimistically patching local state. Deriving the status badge from the authoritative response (and invalidating the relevant query keys) means the screen can never show checked_in for a reservation the backend rejected.


5. Conflict-Safe Synchronization with External Calendars

When reservations are mirrored from iCal or Google Calendar, a naive sync overwrites every field on every poll. That is fine until a guest checks in — and the next sync resets the status to confirmed, re-arming a credential that should have been consumed.

The fix is to declare field ownership explicitly:

// Statuses that can only be produced by our own domain events are never
// clobbered by an upstream calendar, which has no concept of check-in.
const LOCALLY_OWNED_STATUSES = new Set<ReservationStatus>(['checked_in', 'checked_out', 'canceled']);
export function mergeFromExternalCalendar(
  local: Reservation,
  remote: ExternalEvent,
): Partial<Reservation> {
  const patch: Partial<Reservation> = {
    startAt: remote.startAt,
    endAt: remote.endAt,
    guestName: remote.summary,
    externalUpdatedAt: remote.updatedAt,
  };
  if (!LOCALLY_OWNED_STATUSES.has(local.status)) {
    patch.status = mapExternalStatus(remote.status);
  }
  return patch;
}

Generalized rule: for every field in a synced entity, name the system of record. Times and titles come from the calendar; lifecycle status, payment state, and issued credentials come from us. Anything ambiguous becomes a bug at 3 a.m.


6. Time Correctness Is a Reliability Feature

Access windows are time-bounded, which makes timezone handling a security-adjacent concern: an off-by-one-day boundary is an unlock that happens when it should not.

Two rules cover most of the failure modes.

Store UTC, render in the facility's timezone — never the browser's. A front-desk operator in one region managing a property in another must see the property's local time, or they will grant the wrong window.

import { formatInTimeZone } from 'date-fns-tz';
export function renderStayWindow(r: Reservation, facility: { timeZone: string }) {
  return {
    checkIn: formatInTimeZone(r.startAt, facility.timeZone, 'yyyy-MM-dd HH:mm'),
    checkOut: formatInTimeZone(r.endAt, facility.timeZone, 'yyyy-MM-dd HH:mm'),
  };
}

The same applies to calendar grids: seed "today" from the local date in the target timezone, otherwise a user at 00:30 lands on the previous week.

Clamp derived durations instead of trusting arithmetic. Real data contains reservations whose window starts after the actual checkout (early departures, manual corrections). Subtracting blindly yields negative usage time, which then flows into billing:

export function usageMinutes(params: {
  reservedStart: Date;
  actualCheckIn: Date | null;
  actualCheckOut: Date;
}): number {
  const start = params.actualCheckIn ?? params.reservedStart;
  const effectiveStart = start > params.actualCheckOut ? params.actualCheckOut : start;
  const minutes = Math.round(
    (params.actualCheckOut.getTime() - effectiveStart.getTime()) / 60_000,
  );
  return Math.max(0, minutes);
}

Any formula that can produce a physically impossible value should be clamped and logged — the clamp protects the customer, the log tells you the upstream data is wrong.


7. Pipeline Reliability: Bound Every Job

A CI job with no timeout is an unbounded resource commitment. A hung integration test or a wedged runner holds a lock on the release pipeline for hours, which in practice means security fixes cannot ship. Availability of the deployment path is part of the security posture.

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm test --runInBand
        timeout-minutes: 15

Set the job timeout to roughly 2× the observed p95 duration. Too tight and you create flaky failures; absent and you create indefinite stalls. The same reasoning applies at runtime: every outbound HTTP call to a lock gateway or payment provider needs an explicit timeout and a bounded retry policy, because a request without a deadline is a request that can hang forever.


8. Test the Invariants, Not the Screens

UI tests decay quickly. Invariant tests do not. The highest-value suites for a system like this are:

describe('reservation state machine', () => {
  const ALL: ReservationStatus[] = ['pending', 'confirmed', 'checked_in', 'checked_out', 'canceled'];
  it('treats checked_out as terminal for every actor', () => {
    for (const to of ALL) {
      for (const role of ['guest', 'staff', 'platform_admin'] as const) {
        expect(() => assertTransition('checked_out', to, { role, id: 'x' }))
          .toThrow(InvalidTransitionError);
      }
    }
  });
  it('permits reopening a cancellation only for platform admins, with a reason', () => {
    expect(() => assertTransition('canceled', 'confirmed', { role: 'staff', id: 's' }, 'typo'))
      .toThrow(ForbiddenError);
    expect(() => assertTransition('canceled', 'confirmed', { role: 'platform_admin', id: 'a' }))
      .toThrow(ValidationError);
    expect(() => assertTransition('canceled', 'confirmed', { role: 'platform_admin', id: 'a' }, 'typo'))
      .not.toThrow();
  });
});

And for integrations, assert the contract, including the fields that drive routing and correlation:

it('propagates sourceApp so notification links resolve to the originating app', async () => {
  const spy = vi.spyOn(http, 'post');
  await submitCheckIn({ reservationId, sourceApp: 'booking' });
  expect(spy).toHaveBeenCalledWith(
    '/reservation-checkin',
    expect.objectContaining({ sourceApp: 'booking' }),
  );
});

These tests are cheap, deterministic, and they fail exactly when someone weakens an invariant — which is the only property that matters in a regression suite.


Summary

Trust in a physical-access system is the accumulation of small, boring guarantees:

  • Tenancy is server-derived, schema-enforced, and database-enforced. Client-supplied organizationId is rejected, not sanitized; cross-tenant misses return 404 to avoid existence oracles; RLS with WITH CHECK backstops the application layer.
  • Lifecycle is an explicit transition table. Terminal states are terminal, privileged recovery paths are named and audited, and the UI derives affordances from the same table the server enforces.
  • Payment and other external state are reconciled server-to-server. Redirect parameters are hints; provider APIs and webhooks are evidence; credentials are gated on confirmed state.
  • Mutations are idempotent and non-reentrant. Compare-and-swap updates, idempotency keys, and synchronous client latches make double-submits harmless.
  • Synchronization declares field ownership. External sources own schedule data; the domain owns lifecycle and payment state.
  • Time is stored in UTC, rendered in facility-local time, and derived values are clamped.
  • Every job and every outbound call has a deadline.

None of these individually is clever. Together, they are the difference between a system whose behavior you can reason about and one you merely hope is correct.

Key Insights

1
Security

Derive tenant scope from the session, never the payload

Cross-tenant IDOR is eliminated by rejecting client-supplied organization identifiers with a strict schema, returning uniform 404s to avoid existence oracles, and backstopping the application layer with Postgres RLS that includes WITH CHECK to block tenant-moving writes.

2
State Machine

An explicit transition table makes terminal states truly terminal

Modeling reservation status as a Record of allowed transitions with per-actor guards lets the UI derive affordances from the same source the server enforces, so hidden buttons and rejected requests can never drift apart.

3
Reliability

Treat payment redirects as hints, not evidence

Browser redirect parameters are attacker-controllable and lossy. Reconciling state server-to-server with the provider, combined with idempotent compare-and-swap updates and a polling sweep for pending payments, keeps access credentials gated on confirmed state only.

4
Concurrency

Idempotency keys plus CAS updates defeat double-submits

A synchronous useRef latch prevents client double-taps, but correctness comes from server-side idempotency records and UPDATE statements that include the previously observed status in the WHERE clause, turning read-modify-write into compare-and-swap.

5
Data Integrity

Declare field ownership before synchronizing external sources

External calendars own schedule fields; the domain owns lifecycle and payment state. Excluding locally-owned statuses from sync patches prevents a re-sync from resetting a checked-in reservation and re-arming a consumed credential.

6
Testing

Assert invariants, not screens

Exhaustive transition-table tests and contract assertions on request payloads fail precisely when an invariant is weakened, while remaining deterministic and cheap compared with UI-level regression suites.