Step 12: Configure Row-Level Security
Phase: implement
Context
You have project_id, service_key, and tables_created. Now declare which tables are reachable via the REST API and what access rules apply.
What to do
Tables are dark by default
A table you just created with CREATE TABLE is unreachable from /rest/v1/* until you declare it in the expose manifest. This eliminates the "agent created a table, forgot to set RLS, data leaked" footgun. You must explicitly list every table you want to expose.
Available RLS policies
| Policy | Who can read | Who can write | Use when |
|---|---|---|---|
user_owns_rows |
Only the row owner (matched by owner_column) |
Only the row owner | Personal data (my tasks, my profile, my scores). Requires owner_column. |
public_read_authenticated_write |
Everyone (anon) | Any authenticated user (any row) | Shared content with sign-in (comments, public bios). NOT row-scoped writes. |
public_read_write_UNRESTRICTED |
Everyone (anon) | Everyone (anon) | Open collaboration without auth (voting, anonymous shared lists). Requires i_understand_this_is_unrestricted: true. |
custom |
Escape hatch — provide your own custom_sql with CREATE POLICY statements. |
Anything the built-ins can't express (e.g. anon read + owner-only writes). | |
One policy per table. The manifest forbids stacking multiple policies on the same table. If you need "anon reads + auth user writes", use public_read_authenticated_write. If you need anything more nuanced, use policy: "custom" with hand-rolled SQL.
Preferred: declare in unified deploy
Put the manifest under database.expose in your p.apply() call (Step 15). The gateway validates it against your migration SQL and applies it atomically with the rest of the release — schema, policies, exposure, and PostgREST reload all land together.
const p = await r.project(env.PROJECT_ID);
await p.apply({
database: {
expose: {
version: "1",
tables: [
{ name: "todos", expose: true, policy: "user_owns_rows", owner_column: "user_id" },
{ name: "categories", expose: true, policy: "public_read_authenticated_write" },
{ name: "audit", expose: false }
]
}
}
});
Imperative escape hatch — apply manifest standalone
For ad-hoc changes outside a deploy, POST the same manifest directly:
POST https://api.run402.com/projects/v1/admin/{project_id}/expose
Content-Type: application/json
Authorization: Bearer {service_key}
{
"version": "1",
"tables": [
{ "name": "todos", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id" },
{ "name": "categories", "expose": true, "policy": "public_read_authenticated_write" }
]
}
The MCP equivalents are validate_manifest (non-mutating check) and apply_expose (write). get_expose returns the live state.
The manifest is convergent: applying the same manifest twice is a no-op. Items removed between applies have their policies, grants, triggers, and views dropped. Always include everything you want exposed in each call — anything missing gets dark again.
How user_owns_rows works
The policy matches rows where auth.uid() = owner_column. The owner_column must be a uuid column on the table.
Set force_owner_on_insert: true to create an idempotent trigger that fills the owner column with auth.uid() when the client omits it:
{ "name": "todos", "expose": true, "policy": "user_owns_rows",
"owner_column": "user_id", "force_owner_on_insert": true }
The trigger fires BEFORE INSERT and only fills omitted / explicit-null owner values. Authenticated clients passing a non-null owner are still subject to the WITH CHECK (owner_column = auth.uid()) rule, so they can't impersonate.
Views and RPCs
Views are dark too — list them in views[] with expose: true:
{
"views": [
{ "name": "leaderboard", "base": "items", "select": ["user_id", "score"], "expose": true }
]
}
Views run with security_invoker=true — they inherit the base table's RLS.
Postgres functions / RPCs need an explicit grant — they're not reachable via PostgREST otherwise:
{
"rpcs": [
{ "name": "compute_streak", "signature": "(user_id uuid)", "grant_to": ["authenticated"] }
]
}
Decision guide
- App has auth? Use
user_owns_rowsfor personal data,public_read_authenticated_writefor shared data anyone signed-in can edit. - App has no auth? Use
public_read_write_UNRESTRICTED(with thei_understand_this_is_unrestrictedack) for everything. Anyone with the link can read and write. - Anon read + owner-only writes? Use
policy: "custom"withcustom_sql— the built-ins can't express this in a single template.
Important notes
- The
service_keybypasses RLS. Use only for admin setup; never in frontend code. - The
anon_keyrespects RLS — this is what goes in the frontend. - If the manifest references a table that doesn't exist in the schema, the deploy is rejected with a structured
errorsarray. - Use one expose manifest for the reachable tables, views, and RPCs your app needs.
What to tell the user
"I've set up the access rules for your app. [Everyone can see everything / Each person can only see their own stuff / Some things are shared, some are private]."
Expected output
rls_configured— Map of table → policy applied:{ "todos": "user_owns_rows", "categories": "public_read_authenticated_write" }