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:
- 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. - Structured error codes.
KEY_ISSUE_STATE_INVALIDis machine-readable, loggable, and translatable — far better than a free-text message. - 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:
- They compare only the fields the writer intended to change, so unexpected server-side mutations are invisible.
- They use loose equality, so
0vsnull,"100"vs100, orundefinedvs missing all compare as equal. - 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 INVOKERviews 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.