Phase 2 lesson 3 walkthrough: entitlements
| As of | |
|---|---|
| Version | 1.1.0 |
| Kind | guidance |
| Length | 2,069 words, about 9 min |
| Tags | buildwalkthroughphase-2feature-systemdatabasedrizzlerlspostgresqltesting |
This guide reproduces lesson 3 of Phase 2 step by step: tasks 2.8 and 2.9, which record which features each user has and let the guard read them. It assumes lesson 2 is merged (lesson 2 walkthrough) and that the latest migration is 0005.
The result shipped as sidekick#15. The design is recorded in contracts (version 2.4.0).
Every step explains what you are doing and why. Follow the reasoning, not just the commands.
Step 1 — Design the Table
What and why: An entitlement says "user X has feature Y". A present row means enabled; disabling deletes the row. Design the columns from three questions: what identifies an entitlement, can a user have the same feature twice, and what happens when the user is deleted?
- Branch from an up-to-date
main:
git switch main
git pull origin main
git switch -c feature/P2L3-entitlements- Settle the columns:
| Column | Type | Why |
|---|---|---|
user_id | uuid | References profiles.id, which is the auth user's id. Named user_id because the value is userId everywhere else. |
feature_key | text | A FeatureManifest.key. Not a Postgres enum: an enum needs a migration for every new feature, and features live in code. |
created_at | timestamptz | Answers "when did this user get this feature?" during support. Not an audit log. |
- No duplicates: a composite primary key on
(user_id, feature_key). It forbids the same feature twice and gives the indexgetEnabledFeaturesuses, because its leading column isuser_id. A surrogateidwould only pay off if something referenced a single row, and nothing does. - User deleted:
ON DELETE CASCADEtoprofiles. Deleting fromauth.userscascades toprofiles, then here. Referencingprofileskeeps our tables pointing at our tables (auth.usersbelongs to Supabase, which is why0004needed aREFERENCESgrant), and no entitlement can exist without a profile. An entitlement is account state, not a transaction record, so nothing requires keeping it once the user erases their account. - No
deleted_at: soft delete is for syncable entities that clients create offline. Entitlements aren't one, and a soft-deleted row would make re-enabling an un-delete.
- Create
packages/core/src/db/schema/user-feature-entitlements.ts:
import { pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { profiles } from './profiles';
export const userFeatureEntitlements = pgTable(
'user_feature_entitlements',
{
userId: uuid('user_id')
.notNull()
.references(() => profiles.id, { onDelete: 'cascade' }),
featureKey: text('feature_key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(self) => {
return [
primaryKey({
columns: [self.userId, self.featureKey],
}),
];
},
);The string passed to uuid() is the SQL column name; the object key is only the TypeScript name. A column-level .primaryKey() makes that one column the primary key, so two of them would declare two primary keys, which Postgres rejects. The composite key goes in pgTable's third argument, which returns an array of constraints (returning the bare primaryKey(...) gives a confusing overload error).
- Export it from
packages/core/src/db/schema/index.ts. Drizzle Kit reads only that file, so an unexported table produces no migration:
export * from './user-feature-entitlements';Step 2 — Generate the Migration
What and why: Drizzle Kit compares the schema with the last snapshot in
migrations/meta/and writes only the difference. It doesn't connect to the database, but the config still needs.env.localto load.
- Generate it with a readable name:
pnpm --filter @sidekick/core db:generate --name user_feature_entitlements-
Read
0006_user_feature_entitlements.sql. The composite key is a named constraint insideCREATE TABLE, and the foreign key comes after it as a separateALTER TABLE, because Drizzle always adds foreign keys last so tables can be created in any order.--> statement-breakpointis where Drizzle splits the file into separate statements. -
Notice what's missing: access control.
0001'sALTER DEFAULT PRIVILEGESgrantsapp_runtimeSELECT,INSERT,UPDATEandDELETEthe moment the table exists, with no RLS on it.
The journal and snapshot. _journal.json lists migrations in order. drizzle-kit migrate records each one it applies in drizzle.__drizzle_migrations and on the next run applies only newer entries. An edited migration therefore never re-runs where it was applied, while a fresh database gets the edited version, so the two drift. That's why applied migrations are never edited. 0006_snapshot.json is the whole schema after this migration, the baseline for the next generate.
Step 3 — Lock It Down
What and why: Who may write this table? Not
app_runtime: every user request runs as that role, so one careless route would let a user grant themselves a feature. Only the privilegedpostgresrole (DATABASE_DIRECT_URL) writes rows, and it needs nothing granted: it owns the table, and it hasBYPASSRLS, which beats evenFORCE. Nobody updates rows: granting is an insert and revoking is a delete.
- Create an empty custom migration:
pnpm --filter @sidekick/core db:generate --custom --name user_feature_entitlements_rls- Write
0007_user_feature_entitlements_rls.sql:
-- Ensure RLS is on (idempotent — safe to run even if already enabled)
ALTER TABLE public.user_feature_entitlements ENABLE ROW LEVEL SECURITY;
-- FORCE means the policy applies even to the table owner role.
ALTER TABLE public.user_feature_entitlements FORCE ROW LEVEL SECURITY;
-- Revoke mutations by app_runtime role on this table
-- By default, all tables on `public` schema get CRUD permissions
-- Blocking mutations here since users must never grant themselves features
REVOKE UPDATE, INSERT, DELETE ON public.user_feature_entitlements FROM app_runtime;
-- Drop the policy if already exists before creating to allow migration re-runs
DROP POLICY IF EXISTS "users_read_own_entitlements" ON public.user_feature_entitlements;
-- A user can only read their own entitlements.
CREATE POLICY "users_read_own_entitlements"
ON public.user_feature_entitlements
FOR SELECT
USING (user_id::text = current_setting('app.current_user_id', true));Don't copy 0002's FOR ALL … WITH CHECK policy. FOR ALL with WITH CHECK lets a user insert rows carrying their own user_id, which, combined with the default INSERT grant, is exactly a user granting themselves a feature. FOR SELECT takes only USING; WITH CHECK has nothing to check on a read, and Postgres rejects it.
REVOKE mirrors the GRANT in 0001: same three parts, with GRANT … TO swapped for REVOKE … FROM.
Step 4 — Read It
What and why: The guard (lesson 4) opens
withRLS(caller.userId, …)and needs the caller's feature keys inside that transaction.
Why it takes tx, not userId:
withRLSsetsapp.current_user_idwithset_config(…, true), which lasts only until the transaction ends, so a pooled connection never carries one user's id into the next request.- The user is already inside
tx, and the policy filters on it, so the query needs nowhere. AuserIdparameter would be redundant, or could disagree with the transaction and quietly return nothing. - Without
tx, the function would need its own connection: one with no user sees zero rows, and a secondwithRLScosts a second pooled connection per request. - It returns keys, not manifests, so
packages/corenever imports the registry.
- Create
packages/core/src/features/entitlements.ts:
import 'server-only';
import type { RlsTx } from '../db';
import { userFeatureEntitlements } from '../db/schema';
const selectFeatureKeys = async (tx: RlsTx) =>
tx.select({ featureKey: userFeatureEntitlements.featureKey }).from(userFeatureEntitlements);
const toFeatureKeyArray = (entitlements: Awaited<ReturnType<typeof selectFeatureKeys>>) =>
entitlements.map(({ featureKey }) => featureKey);
/**
* Retrieves the enabled feature keys of the user whose id withRLS set on tx.
* RLS filters the rows, so the query needs no user filter of its own.
* @param tx Transaction opened by withRLS for the caller
* @returns Keys of enabled features
*/
export async function getEnabledFeatures(tx: RlsTx): Promise<string[]> {
return toFeatureKeyArray(await selectFeatureKeys(tx));
}The database call and the pure transform are separate functions, and getEnabledFeatures composes them. Selecting only featureKey lets Drizzle infer the row type. There's no ORDER BY, so callers must not rely on order.
Step 5 — Prove It
What and why: Unit tests can't prove RLS or grants; only a real Postgres can.
tests/with-test-db.shbuilds a throwaway PostgreSQL 17 from the Supabase stand-ins and every migration, runs the command and deletes the cluster. Install the tools once withbrew install postgresql@17.
- The seed needs the privileged role, and the script didn't export one.
DATABASE_URLisapp_runtimeandTEST_AUTH_DATABASE_URLissupabase_auth_admin; neither can write this table. Add totests/with-test-db.sh:
# postgres: the privileged migration/seed role, the only one that writes entitlements
export TEST_ADMIN_DATABASE_URL="$(url postgres)"- Create
packages/core/tests/integration/entitlements.test.ts, not undersrc/:pnpm testruns everything undersrc/without a database. The file:
- opens
authDb,adminDbandruntimeDbwithpostgres(url, { max: 1 }); - inserts three users into
auth.users(the0005trigger creates their profiles, which the foreign key needs), then seeds entitlements throughadminDbwith postgres.js's multi-row helper,adminDb(rows, 'user_id', 'feature_key'). Building(${a}, ${b})strings inside the tagged template sends them as one text parameter, not SQL; - has six tests:
| Test | How |
|---|---|
returns only userA's / userB's feature keys (it.each) | withRLS(userId, getEnabledFeatures), sorted before comparing |
| returns zero rows without a user context | runtimeDb\SELECT 1 FROM user_feature_entitlements`` |
| cannot insert, even for the caller's own user | withRLS insert → rejects with "permission denied for table …" |
| cannot delete, even the caller's own rows | withRLS delete → rejects with "permission denied for table …" |
| cascades through profiles to their entitlements | delete from auth.users via authDb, then count via adminDb (bypasses RLS) |
Match the error message, not just the rejection. Both the REVOKE and RLS fail with SQLSTATE 42501, but "permission denied" comes only from the REVOKE. Drizzle wraps the driver error, so the message sits on cause:
const permissionDenied = {
cause: expect.objectContaining({
message: 'permission denied for table user_feature_entitlements',
}),
};
await expect(insert).rejects.toMatchObject(permissionDenied);- Run it:
pnpm --filter @sidekick/core test:integrationExpect 9 passed: 6 here and 3 in rls.test.ts.
- Break each wall, one at a time. Guess which test fails before running, and put each one back before the next. Editing a migration is safe here because the script rebuilds the database every run.
| Break | What fails |
|---|---|
Policy USING (true) | Both own-rows reads (each user sees everything) and the context-free query, which only returned zero rows because NULL never matched. |
REVOKE commented out | Insert now fails with "new row violates row-level security policy" (RLS denies by default). Delete doesn't fail at all: RLS filters it to zero rows. |
ON DELETE restrict in 0006 | The cascade test fails on the DELETE FROM auth.users line with a foreign key violation. The whole statement rolls back, user and profile included. |
The second row is the reason both walls exist: without the REVOKE, a forbidden delete silently does nothing.
Step 6 — Apply to Dev
What and why: Put the table on the dev Supabase project, which is a real database, so commit first.
- From the repo root:
pnpm db:migrate-
Check it in the Supabase dashboard: the table shows RLS enabled and no rows, and
users_read_own_entitlementsis listed forSELECT. -
In the SQL Editor, list who holds which privileges:
select grantee, privilege_type
from information_schema.role_table_grants
where table_name = 'user_feature_entitlements'
order by grantee, privilege_type;app_runtime holds only SELECT, and postgres and service_role hold everything. anon and authenticated don't appear, if you followed the post notes in Phase 1.1 and Pre-2. Sidekick's dev project showed both with full privileges here, TRUNCATE included, which is how the gap was found (see the post note below).
Step 7 — Verify Like CI and Ship
- Run everything uncached:
pnpm prettier:check
pnpm turbo lint typecheck build test --force
pnpm --filter @sidekick/core test:integrationIf web#build fails with Cannot find module …/next/dist/bin/next, node_modules is stale after a dependency update. Run pnpm install and try again.
- Commit, push and open the PR:
git add packages/core
git commit -m "feat(core): add user_feature_entitlements table and getEnabledFeatures"
git push -u origin feature/P2L3-entitlements
gh pr create --fillWait for the checks job (it runs test:integration on PostgreSQL 17), then squash-merge.
What you have now
user_feature_entitlementswith a composite primary key, a cascading foreign key toprofiles, forced RLS with a read-only own-rows policy, and no write privileges forapp_runtime.getEnabledFeatures(tx)inpackages/core/src/features/entitlements.ts, internal tocoreuntil the guard uses it.- Six integration tests, each proven to fail when the wall it guards is removed.
Next: lesson 4, withApiGuard.
Post note — Found at the End of This Lesson (added 2026-10-23)
Three follow-ups came out of this lesson. If you're following along, they're already folded into earlier walkthroughs as post notes, so you do them before wrapping up those phases:
- Data API roles. Step 6's grants query showed
anonandauthenticatedwith every privilege onprofilesanduser_feature_entitlements. Fixed in sidekick#17: see the post notes in Phase 1.1 (the migration and turning off the Data API) and Pre-2 (the stub roles,TEST_ADMIN_DATABASE_URLand the proof test). If you did those, step 5'swith-test-db.shchange is already in place. undefined, notnull.resolveApiCallernow returnsundefinedfor no caller (sidekick#16); see the lesson 2 walkthrough.- pnpm's dead
"pnpm"field. Removed in sidekick#14; see the post note in Phase 0 checkpoint B.