UnlockOS Developers
← Back to blog
🔐

Trust Boundaries: Key Issuance, RLS, and Role Lifecycles

Jul 20, 2026Jul 26, 2026
8 min
100 commits
Depth 8/10
securityaccess-controlstate-machinetypescriptpostgresql

Trust Boundaries: Key Issuance, RLS, and Role Lifecycles

Introduction

In a smart lock platform, a bug is not a cosmetic glitch — it is a door that opens when it should not, or refuses to open when a guest is standing outside in the rain at 23:00. This week's work on the UnlockOS SDK clustered around a single theme: every credential, every row, and every role must be derived from verified state, never from an optimistic assumption.

This article walks through the patterns behind that work: state-gated credential issuance, database-level access control, role lifecycle correctness, and a plan/apply/verify loop that refuses to lie about what it changed. The examples are generalized — they apply to any system where an API call has physical consequences.


1. Never Issue a Credential Without Verifying State

The most dangerous shortcut in access control is issuing a key because the request looked valid, instead of because the system state permits it.

Two separate fixes landed in this area: guest key issuance must be bound to an active check-in, and entry keys must not be issued before the check-in is confirmed (i.e., payment/authorization has actually settled). Both are the same underlying rule — a credential is a projection of a state machine, not a standalone resource.

The naive implementation looks like this:

// ANTI-PATTERN: issuance derived from request shape, not system state
async function issueGuestKey(reservationId: string) {
  const reservation = await db.reservations.findById(reservationId);
  if (!reservation) throw new NotFoundError();
  return lockApi.createPinCode({ lockId: reservation.lockId });
}

This grants access to anyone holding a reservation ID — including reservations that were cancelled, refunded, not yet paid, or already checked out.

The corrected version models check-in as an explicit finite state and makes issuance a guarded transition:

export type CheckInState =
  | 'pending'
  | 'awaiting_payment'
  | 'confirmed'
  | 'active'
  | 'completed'
  | 'cancelled';
const KEY_ISSUABLE_STATES: ReadonlySet<CheckInState> = new Set(['confirmed', 'active']);
export interface KeyIssuanceContext {
  checkIn: { id: string; state: CheckInState; startAt: Date; endAt: Date };
  now: Date;
}
export function assertKeyIssuable(ctx: KeyIssuanceContext): void {
  const { checkIn, now } = ctx;
  if (!KEY_ISSUABLE_STATES.has(checkIn.state)) {
    throw new AccessDeniedError('KEY_ISSUE_STATE_INVALID', {
      checkInId: checkIn.id,
      state: checkIn.state,
      allowed: [...KEY_ISSUABLE_STATES],
    });
  }
  if (now > checkIn.endAt) {
    throw new AccessDeniedError('KEY_ISSUE_WINDOW_EXPIRED', { checkInId: checkIn.id });
  }
}

Three properties make this trustworthy:

  1. Allow-list, not deny-list. New states added later default to not issuable. A deny-list (if (state === 'cancelled') throw) silently grants access to every state you forget to enumerate.
  2. Structured error codes. KEY_ISSUE_STATE_INVALID is machine-readable, loggable, and translatable — far better than a free-text message.
  3. Validity window is part of the guard. A credential inherits the time bounds of the state that justified it.

The key issuance call site then becomes a thin wrapper:

export async function issueEntryKey(checkInId: string, actor: Actor) {
  const checkIn = await repo.getCheckInForUpdate(checkInId);
  assertKeyIssuable({ checkIn, now: new Date() });
  const credential = await lockApi.createPinCode({
    lockId: checkIn.lockId,
    validFrom: checkIn.startAt,
    validUntil: checkIn.endAt,
  });
  await audit.record({
    event: 'entry_key.issued',
    actorId: actor.id,
    subjectId: checkInId,
    metadata: { credentialId: credential.id, state: checkIn.state },
  });
  return credential;
}

Note getCheckInForUpdate — the state read happens under a row lock inside the same transaction as issuance, so a concurrent cancellation cannot slip between the check and the effect (TOCTOU).


2. Database Views Must Run As the Caller

A batch of public views was changed from SECURITY DEFINER to SECURITY INVOKER. This is one of the highest-leverage security fixes available in a PostgreSQL/RLS architecture, and it is routinely misunderstood.

With SECURITY DEFINER, a view executes with the privileges of its owner. If the owner is a superuser or a role that bypasses Row Level Security, then every RLS policy on the underlying tables is silently bypassed for anyone who can select from the view. A tenant-scoped table becomes a global data export.

-- BEFORE: view owner's privileges apply; caller RLS is bypassed
CREATE VIEW public.reservation_summary
WITH (security_invoker = false) AS
SELECT r.id, r.facility_id, r.guest_name, r.total_fee
FROM public.reservations r;
-- AFTER: the view executes with the caller's privileges, so RLS is enforced
CREATE OR REPLACE VIEW public.reservation_summary
WITH (security_invoker = true) AS
SELECT r.id, r.facility_id, r.guest_name, r.total_fee
FROM public.reservations r;

The underlying policy then actually does its job:

ALTER TABLE public.reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY reservations_tenant_isolation ON public.reservations
FOR SELECT USING (
  facility_id IN (
    SELECT m.facility_id FROM public.memberships m
    WHERE m.user_id = auth.uid() AND m.revoked_at IS NULL
  )
);

Operational lesson: audit this continuously, not once. A simple guard query can run in CI against a migrated database:

SELECT c.relname AS view_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'v'
  AND n.nspname = 'public'
  AND COALESCE((SELECT option_value FROM pg_options_to_table(c.reloptions)
                WHERE option_name = 'security_invoker'), 'false') <> 'true';

If the result set is non-empty, fail the build. Security regressions that are only caught by review will eventually not be caught.


3. Role Lifecycles: Grant, Accept, Revoke

Two related fixes shipped: invite acceptance must preserve an existing owner role rather than downgrading it, and member removal must revoke roles, not merely delete a membership row. A third fix synchronized invite status on accept so the invite cannot be redeemed twice.

These are all symptoms of the same anti-pattern: treating authorization as a set of loosely coupled rows that happen to be updated together.

// ANTI-PATTERN: partial writes leave the system in a half-authorized state
async function acceptInvite(inviteId: string, userId: string) {
  const invite = await db.invites.findById(inviteId);
  await db.memberships.upsert({ userId, orgId: invite.orgId, role: invite.role });
  // invite left 'pending' -> replayable; existing owner silently downgraded
}

A correct implementation is atomic, idempotent, and monotonic with respect to privilege:

const ROLE_RANK = { viewer: 0, member: 1, manager: 2, owner: 3 } as const;
export type Role = keyof typeof ROLE_RANK;
export function effectiveRole(existing: Role | null, incoming: Role): Role {
  if (!existing) return incoming;
  return ROLE_RANK[existing] >= ROLE_RANK[incoming] ? existing : incoming;
}
export async function acceptInvite(inviteId: string, userId: string) {
  return db.transaction(async (tx) => {
    const invite = await tx.invites.lockById(inviteId);
    if (invite.status !== 'pending') {
      throw new ConflictError('INVITE_ALREADY_RESOLVED', { status: invite.status });
    }
    if (invite.expiresAt < new Date()) {
      throw new ConflictError('INVITE_EXPIRED');
    }
    const current = await tx.memberships.find({ userId, orgId: invite.orgId });
    const role = effectiveRole(current?.role ?? null, invite.role);
    await tx.memberships.upsert({ userId, orgId: invite.orgId, role, revokedAt: null });
    await tx.invites.update(inviteId, { status: 'accepted', acceptedBy: userId });
    await tx.audit.record({ event: 'membership.granted', subjectId: userId, metadata: { role } });
  });
}

Removal is the mirror image — and the important part is that it revokes derived authority too:

export async function removeMember(orgId: string, userId: string, actor: Actor) {
  return db.transaction(async (tx) => {
    await tx.memberships.revoke({ orgId, userId, revokedAt: new Date() });
    await tx.roleAssignments.revokeAllFor({ orgId, userId });
    await tx.invites.cancelPendingFor({ orgId, email: null, userId });
    await tx.sessions.invalidateForOrg({ orgId, userId });
    await tx.audit.record({ event: 'membership.revoked', actorId: actor.id, subjectId: userId });
  });
}

Rule of thumb: if removing a user requires deleting rows in more than one table, that removal belongs in a single transaction with an audit record — otherwise a crash mid-way leaves a ghost with residual permissions.


4. Plan → Apply → Verify: Catching Drift Instead of Trusting It

A multi-phase effort introduced a deterministic provisioning planner, conflict detection, an apply orchestrator, and finally round-trip verification. Notably, a follow-up self-review commit fixed the verifier because it was missing real drift — the most valuable kind of bug to find, because a verifier that always passes is worse than no verifier at all (it manufactures false confidence).

The general pattern is the Terraform-style loop, and it is applicable to any system that mutates external state (lock controllers, payment providers, notification channels):

export interface ResourcePlan<T> {
  kind: string;
  id: string;
  desired: T;
  action: 'create' | 'update' | 'noop';
}
export interface DriftReport {
  id: string;
  field: string;
  expected: unknown;
  actual: unknown;
}
export async function applyAndVerify<T extends object>(
  plans: ReadonlyArray<ResourcePlan<T>>,
  driver: { apply(p: ResourcePlan<T>): Promise<void>; read(id: string): Promise<T | null> },
): Promise<DriftReport[]> {
  for (const plan of plans) {
    if (plan.action !== 'noop') await driver.apply(plan);
  }
  const drift: DriftReport[] = [];
  for (const plan of plans) {
    const actual = await driver.read(plan.id);
    if (actual === null) {
      drift.push({ id: plan.id, field: '*', expected: plan.desired, actual: null });
      continue;
    }
    drift.push(...diffStrict(plan.id, plan.desired, actual));
  }
  return drift;
}

The subtle part is diffStrict. Verifiers typically miss drift for three reasons:

  1. They compare only the fields the writer intended to change, so unexpected server-side mutations are invisible.
  2. They use loose equality, so 0 vs null, "100" vs 100, or undefined vs missing all compare as equal.
  3. They skip resources whose planned action was noop, which is exactly where drift accumulates.
function diffStrict<T extends object>(id: string, expected: T, actual: T): DriftReport[] {
  const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
  const out: DriftReport[] = [];
  for (const key of keys) {
    const e = (expected as Record<string, unknown>)[key];
    const a = (actual as Record<string, unknown>)[key];
    if (!Object.is(normalize(e), normalize(a))) {
      out.push({ id, field: key, expected: e, actual: a });
    }
  }
  return out;
}
function normalize(v: unknown): unknown {
  if (v === undefined) return null;
  if (typeof v === 'object' && v !== null) return JSON.stringify(v);
  return v;
}

A related fix in the same series removed silent demo prices and closed an approval-gate bypass. The principle: a system that silently substitutes a fallback value when real data is missing is indistinguishable, at runtime, from a system that is working. Fail loudly:

function resolvePrice(source: PriceSource): number {
  if (source.kind === 'catalog') return source.amount;
  throw new ConfigurationError('PRICE_SOURCE_UNRESOLVED', { kind: source.kind });
}

5. Validation at the Boundary

A cluster of smaller fixes — blocking reservations with past start times, rejecting negative values in plan forms, validating email format before dispatching invitations — all express the same discipline: validate at the trust boundary, and validate on the server even when the UI already did.

Client-side validation is a UX affordance. Server-side validation is the security control. A schema-first approach gives you both from one definition:

import { z } from 'zod';
export const CreateReservationInput = z.object({
  facilityId: z.string().uuid(),
  startAt: z.coerce.date(),
  endAt: z.coerce.date(),
  guestEmail: z.string().email().optional(),
  guestPhone: z.string().min(1).optional(),
}).superRefine((v, ctx) => {
  if (v.endAt <= v.startAt) {
    ctx.addIssue({ code: 'custom', path: ['endAt'], message: 'END_BEFORE_START' });
  }
  if (v.startAt.getTime() < Date.now() - 60_000) {
    ctx.addIssue({ code: 'custom', path: ['startAt'], message: 'START_IN_PAST' });
  }
});
export type CreateReservationInput = z.infer<typeof CreateReservationInput>;

The z.infer line is what makes this a type safety improvement rather than just a runtime check: the compile-time type is derived from the runtime validator, so the two can never diverge.

A financial variant of the same discipline: a pricing fix eliminated rounding drift for fractional durations by rounding totals once instead of rounding each intermediate component, and another fix stopped a double tax deduction in minimum-charge calculation. Monetary math should be performed in integer minor units and rounded exactly once, at the boundary where a human or a payment provider sees the number.

// Round once at the end, in minor units.
function computeTotalMinorUnits(hours: number, hourlyRateMinor: number, taxRate: number): number {
  const subtotal = hours * hourlyRateMinor;
  const withTax = subtotal * (1 + taxRate);
  return Math.round(withTax);
}

6. Error State Hygiene and Secret Handling

Two more items deserve mention because they represent classes of defect rather than one-off bugs.

Stale error state leaking across screens. A payment error was persisting after the user navigated away from the payment step and reappearing on the confirmation step. In state-machine terms, the error belonged to a state, not to the session — so it must be cleared on exit from that state:

const checkoutMachine = {
  states: {
    payment: {
      exit: 'clearPaymentError',
      on: { BACK: 'confirm', SUCCESS: 'complete', FAILURE: { actions: 'setPaymentError' } },
    },
    confirm: { on: { PAY: 'payment' } },
  },
};

Modeling UI flows as explicit states with entry/exit actions eliminates a whole family of "ghost state" bugs that are otherwise only found by manual QA.

Dead cryptographic paths are a liability. Several commits removed an unused encryption path for integration credentials. Dead security code is dangerous in two directions: reviewers assume it is protecting something (it is not), and it can be accidentally re-enabled with stale key material. Either a secret is handled by a documented, tested path, or the path is deleted and the storage model is stated explicitly — with the actual protection moved to where it is enforced (column-level RLS, KMS, or a dedicated secret store). Ambiguity is the vulnerability.

Finally, the CI workflows were bumped off end-of-life Node 18 to Node 20. Running builds and publishes on a runtime that no longer receives security patches is a supply-chain risk, not a chore.


7. Testing Strategy That Matches the Risk

Behavioral tests were added at the RPC and handler level for invite acceptance — not just unit tests of a pure helper. That layering matters for authorization logic:

Layer What it proves Example
Unit Pure decision logic is correct effectiveRole('owner', 'member') === 'owner'
Handler/RPC Transaction boundaries, locking, error mapping Accepting a redeemed invite returns INVITE_ALREADY_RESOLVED
Integration (DB) RLS policies actually deny cross-tenant reads Select as tenant B returns 0 rows from tenant A
E2E The user-visible flow cannot bypass the guard Cancelled reservation cannot fetch an entry key
describe('acceptInvite', () => {
  it('does not downgrade an existing owner', async () => {
    await seedMembership({ userId: 'u1', orgId: 'o1', role: 'owner' });
    const invite = await seedInvite({ orgId: 'o1', role: 'member' });
    await acceptInvite(invite.id, 'u1');
    expect(await getRole('u1', 'o1')).toBe('owner');
  });
  it('rejects a second redemption of the same invite', async () => {
    const invite = await seedInvite({ orgId: 'o1', role: 'member' });
    await acceptInvite(invite.id, 'u1');
    await expect(acceptInvite(invite.id, 'u2')).rejects.toMatchObject({
      code: 'INVITE_ALREADY_RESOLVED',
    });
  });
});

The RLS test is the one teams most often skip, and it is the one that would have caught the SECURITY DEFINER views:

BEGIN;
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub":"tenant-b-user"}';
SELECT count(*) = 0 AS isolated FROM public.reservation_summary WHERE facility_id = 'tenant-a-facility';
ROLLBACK;

Summary

The through-line across this week's changes is that trust must be derived, verified, and revocable:

  • Derived — a key exists only because a check-in is in an issuable state, inside a locked transaction.
  • Verified — the database enforces tenant isolation with SECURITY INVOKER views plus RLS, and the provisioning pipeline re-reads what it wrote to detect drift with strict comparison.
  • Revocable — removing a member atomically revokes memberships, role assignments, pending invites, and sessions, with an audit record for every transition.

And one meta-lesson from the verifier fix: self-review your safety nets. A guard that never fires, a verifier that never reports drift, and a validator that silently coerces bad input all look identical to a healthy system on a dashboard. Write a test that proves the guard can fail.

Key Insights

1
Security

Credential issuance must be a guarded state transition

Entry keys are issued only when the check-in is in an allow-listed state (confirmed/active) and within its validity window, read under a row lock in the same transaction as issuance to prevent TOCTOU races.

2
Security

SECURITY INVOKER views are required for RLS to apply

Views defined with SECURITY DEFINER execute as their owner and silently bypass Row Level Security on underlying tables. Switching to security_invoker = true restores caller-scoped enforcement; a CI query should fail the build on any remaining definer view.

3
Authorization

Role lifecycle changes must be atomic and monotonic

Invite acceptance preserves the higher of the existing and incoming role, marks the invite resolved to prevent replay, and member removal revokes memberships, role assignments, pending invites, and sessions in a single transaction with audit records.

4
Reliability

Plan, apply, then verify with strict diffing

A provisioning pipeline that re-reads applied state and compares every field with strict normalization catches drift that loose or field-limited comparison misses. A verifier that never reports drift is worse than none because it creates false confidence.

5
Reliability

Fail loudly instead of substituting fallbacks

Removing silent demo-price fallbacks and closing an approval-gate bypass reflect the rule that a system silently substituting default data is indistinguishable at runtime from a working system.

6
Type Safety

Derive compile-time types from runtime validators

Schema-first validation with inferred types keeps the runtime contract and the TypeScript type in sync, and server-side validation remains the security control even when the UI already validates.

7
State Management

Error state belongs to a state, not the session

Payment errors leaking onto later steps are ghost-state bugs; modeling flows as explicit states with exit actions that clear transient errors eliminates the whole class.

8
Testing

Test authorization at every layer, including RLS

Unit tests for decision logic, handler tests for transaction boundaries and error codes, database-level tests proving cross-tenant reads return zero rows, and E2E tests proving the guard cannot be bypassed.