Skip to content

Commit 93b130f

Browse files
MajorTalclaude
andcommitted
feat(rls)!: rename templates to match gateway security hardening
Aligns MCP tool schemas, CLI help, and agent docs with the gateway's 2026-04-21 rename (run402 PR #35). The rename is security messaging: the old names downplayed what the templates actually do. Breaking change — MCP Zod enum rejects the old names, matching server behavior. - public_read → public_read_authenticated_write - public_read_write → public_read_write_UNRESTRICTED (requires i_understand_this_is_unrestricted: true in body) - user_owns_rows unchanged (server-internal: now type-aware + auto-indexed) MCP schemas (src/tools/setup-rls.ts, bundle-deploy.ts): - Zod enum replaced with the three current names - New optional i_understand_this_is_unrestricted field - Refinement enforced at handler boundary before network call (MCP SDK accepts only ZodRawShape, so superRefine runs via an internal schema used in the handler's safeParse) - 14 new unit tests covering enum rejection, ACK refinement, and request-body wiring Docs (SKILL.md, openclaw/SKILL.md, cli/llms-cli.txt, CLI --help): - New preamble: prefer user_owns_rows for anything user-scoped - Safety copy per template, warning glyph on UNRESTRICTED - Manifest examples include the ACK field - Permission matrix updated OpenSpec: full change artifacts under openspec/changes/rls-template-rename/ (proposal, design, tasks, rls-templates spec with 6 requirements / 17 scenarios). Version bumped to 1.36.0 (breaking change to accepted MCP inputs). Tests: 287 unit + 98 CLI e2e, 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 54b0044 commit 93b130f

19 files changed

Lines changed: 823 additions & 57 deletions

SKILL.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -674,10 +674,10 @@ Use `run_sql` to apply RLS if users should only see their own rows:
674674
run_sql(project_id: "prj_...", sql: "-- Use the /projects/v1/admin/:id/rls endpoint via HTTP for RLS templates")
675675
```
676676

677-
Three RLS templates are available via the API:
678-
- **`user_owns_rows`** — Users can only access rows where `owner_column = auth.uid()`. Best for user-scoped data.
679-
- **`public_read`** — Anyone can read. Only authenticated users can write.
680-
- **`public_read_write`** — Anyone can read and write. Use for guestbooks, public logs.
677+
Three RLS templates are available via the API. **Prefer `user_owns_rows` for anything user-scoped.**
678+
- **`user_owns_rows`** — Users can only access rows where the owner column matches `auth.uid()`. Best for user-scoped data (todos, workouts, messages). `uuid` owner columns get an index-friendly policy; other types fall back to a `::text` cast (the response includes a warning). The endpoint auto-creates a btree index on the owner column.
679+
- **`public_read_authenticated_write`** — Anyone can read. **Any authenticated user can INSERT/UPDATE/DELETE any row** (not just their own). Appropriate for collaborative content like shared boards or announcements; do not use where users should only edit their own rows.
680+
- **`public_read_write_UNRESTRICTED`**⚠ Fully open. Anyone (including `anon_key`) can read, insert, update, or delete any row. Only appropriate for intentionally public tables (guestbooks, waitlists, feedback forms). This template **requires** `"i_understand_this_is_unrestricted": true` in the request body and logs an audit line on the gateway.
681681

682682
### Step 4: Insert data
683683

cli-e2e.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ describe("CLI e2e happy path", () => {
657657
it("projects rls", async () => {
658658
const { run } = await import("./cli/lib/projects.mjs");
659659
captureStart();
660-
await run("rls", ["prj_test123", "public_read", '[{"table":"items"}]']);
660+
await run("rls", ["prj_test123", "public_read_authenticated_write", '[{"table":"items"}]']);
661661
captureStop();
662662
assert.ok(captured().includes("ok"), "should apply RLS");
663663
});

cli-integration.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,10 @@ describe("CLI integration (live API, no mocks)", { timeout: 180_000 }, () => {
252252
it("projects rls", async () => {
253253
const { run } = await import("./cli/lib/projects.mjs");
254254
captureStart();
255-
await run("rls", [projectId, "public_read_write", '[{"table":"items"}]']);
255+
// 3-arg CLI form cannot send the UNRESTRICTED ACK, so use the
256+
// collaborative template here. UNRESTRICTED is exercised via the
257+
// deploy-manifest path elsewhere.
258+
await run("rls", [projectId, "public_read_authenticated_write", '[{"table":"items"}]']);
256259
captureStop();
257260
assert.ok(captured().includes("ok") || captured().includes("updated"), "should apply RLS");
258261
});

cli/lib/deploy.mjs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ Manifest format (JSON):
9494
"migrations": "CREATE TABLE items (...)",
9595
"migrations_file": "setup.sql",
9696
"rls": {
97-
"template": "public_read_write",
98-
"tables": [{ "table": "items" }]
97+
"template": "public_read_write_UNRESTRICTED",
98+
"tables": [{ "table": "items" }],
99+
"i_understand_this_is_unrestricted": true
99100
},
100101
"secrets": [{ "key": "OPENAI_API_KEY", "value": "sk-..." }],
101102
"functions": [{
@@ -128,10 +129,20 @@ Manifest format (JSON):
128129
Paths are resolved relative to the manifest file's directory.
129130
Binary files (images, fonts, etc.) are auto-detected and base64-encoded.
130131
131-
RLS templates:
132-
user_owns_rows — users see only their rows (requires owner_column per table)
133-
public_read — anyone reads, authenticated users write
134-
public_read_write — anyone reads and writes
132+
RLS templates (prefer user_owns_rows for anything user-scoped):
133+
user_owns_rows users see only their own rows (requires
134+
owner_column per table; uuid columns get
135+
index-friendly policies automatically)
136+
public_read_authenticated_write anyone reads; any authenticated user can
137+
INSERT/UPDATE/DELETE any row (not just
138+
their own). For collaborative content
139+
like shared boards or announcements.
140+
public_read_write_UNRESTRICTED ⚠ fully open — anon_key can read AND
141+
write any row. Only for intentionally
142+
public tables (guestbooks, waitlists,
143+
feedback forms). REQUIRES the manifest's
144+
rls block to include
145+
"i_understand_this_is_unrestricted": true.
135146
136147
⚠️ Without RLS, tables are read-only via anon_key. If your app writes
137148
data from the browser, you almost certainly need an rls block.

cli/lib/projects.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Examples:
3636
run402 projects rest abc123 users "limit=10&select=id,name"
3737
run402 projects usage abc123
3838
run402 projects schema abc123
39-
run402 projects rls abc123 public_read '[{"table":"posts"}]'
39+
run402 projects rls abc123 public_read_authenticated_write '[{"table":"posts"}]'
4040
run402 projects keys abc123
4141
run402 projects delete abc123
4242
@@ -45,7 +45,11 @@ Notes:
4545
- Most commands that take <id> default to the active project if omitted
4646
- 'rest' uses PostgREST query syntax (table name + optional query string)
4747
- 'provision' requires a funded allowance — payment is automatic via x402
48-
- RLS templates: user_owns_rows, public_read, public_read_write
48+
- RLS templates (prefer user_owns_rows for user-scoped data):
49+
user_owns_rows users access only their own rows (requires owner_column)
50+
public_read_authenticated_write anyone reads; any authenticated user writes any row
51+
public_read_write_UNRESTRICTED fully open (anon_key writes); use 'run402 deploy' with a manifest
52+
that includes "i_understand_this_is_unrestricted": true
4953
`;
5054

5155
async function quote() {

cli/llms-cli.txt

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,9 @@ Create a manifest file `app.json` (use the `project_id` from provision):
112112
"migrations": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false); INSERT INTO items (title) VALUES ('Buy groceries'), ('Read a book');",
113113
"migrations_file": "setup.sql",
114114
"rls": {
115-
"template": "public_read_write",
116-
"tables": [{ "table": "items" }]
115+
"template": "public_read_write_UNRESTRICTED",
116+
"tables": [{ "table": "items" }],
117+
"i_understand_this_is_unrestricted": true
117118
},
118119
"secrets": [{ "key": "OPENAI_API_KEY", "value": "sk-..." }],
119120
"functions": [{
@@ -154,16 +155,16 @@ END $$;
154155

155156
This pattern is safe to re-run on every deploy. Put your `CREATE TABLE IF NOT EXISTS` first, then one `DO` block per new column (or group them in a single block).
156157

157-
RLS templates:
158-
- `user_owns_rows` — users see only their rows (requires `owner_column` per table)
159-
- `public_read` — anyone reads, authenticated users write
160-
- `public_read_write` — anyone reads and writes
158+
RLS templates (prefer `user_owns_rows` for anything user-scoped):
159+
- `user_owns_rows` — users access only their own rows (requires `owner_column` per table; `uuid` columns are index-friendly, others fall back to `::text` cast with a warning; a btree index is auto-created)
160+
- `public_read_authenticated_write` — anyone reads; **any authenticated user can INSERT/UPDATE/DELETE any row** (not just their own). For collaborative content (shared boards, announcements).
161+
- `public_read_write_UNRESTRICTED` — ⚠ fully open; `anon_key` can read AND write any row. For intentionally public tables only (guestbooks, waitlists, feedback forms). **Requires** `"i_understand_this_is_unrestricted": true` in the request body; logs an audit line on the gateway.
161162

162163
| Template | anon_key reads | anon_key writes | authenticated reads | authenticated writes |
163164
|----------|---------------|-----------------|---------------------|----------------------|
164-
| `public_read` | all rows | no | all rows | all rows |
165-
| `public_read_write` | all rows | all rows | all rows | all rows |
166165
| `user_owns_rows` | no | no | own rows only | own rows only |
166+
| `public_read_authenticated_write` | all rows | no | all rows | all rows (any row) |
167+
| `public_read_write_UNRESTRICTED` | all rows | all rows | all rows | all rows |
167168

168169
⚠️ **Without RLS, tables are read-only via anon_key.** If your app writes data from the browser, you almost certainly need an `rls` block.
169170

@@ -190,7 +191,10 @@ run402 projects sql <project_id> "CREATE TABLE items (id serial PRIMARY KEY, tit
190191
run402 projects sql <project_id> "INSERT INTO items (title) VALUES ('Buy groceries'), ('Read a book')"
191192

192193
# 4. Set up Row-Level Security
193-
run402 projects rls <project_id> public_read_write '[{"table":"items"}]'
194+
# (The 3-arg CLI does not accept the UNRESTRICTED ACK; for that,
195+
# use `run402 deploy` with a manifest including
196+
# "i_understand_this_is_unrestricted": true.)
197+
run402 projects rls <project_id> public_read_authenticated_write '[{"table":"items"}]'
194198

195199
# 5. Deploy a static site (uses active project automatically)
196200
run402 sites deploy --manifest site.json
@@ -232,7 +236,8 @@ run402 subdomains claim my-app
232236
- `run402 projects demote-user <id> <email>` — demote a user from project_admin role
233237
- `run402 projects <usage|schema> <id>`
234238
- `run402 projects delete <id>` — **cascade deletes** all project resources: Lambda functions, subdomains, S3 site files, deployments, secrets, and published app versions. The schema slot is dropped and recreated. This is irreversible.
235-
- `run402 projects rls <id> <user_owns_rows|public_read|public_read_write> '<tables_json>'`
239+
- `run402 projects rls <id> <user_owns_rows|public_read_authenticated_write|public_read_write_UNRESTRICTED> '<tables_json>'`
240+
(The 3-arg form cannot set the UNRESTRICTED ACK. For UNRESTRICTED, use `run402 deploy` with a manifest that includes `"i_understand_this_is_unrestricted": true` in the `rls` block.)
236241

237242
Provisioning automatically sets the new project as the **active project**. Other commands that take `<id>` default to the active project when omitted.
238243

@@ -565,7 +570,7 @@ The CLI's `run402 projects rest` command is great for terminal use. But when gen
565570
**Base URL**: `https://api.run402.com/rest/v1/{table}`
566571

567572
**Auth header**: `apikey: {key}` — the gateway auto-forwards as `Authorization: Bearer` to PostgREST. Any valid project JWT works:
568-
- `anon_key` → read-only by default (SELECT). Safe to embed in frontend code. **No expiry** -- permanent project identifier. If you apply `public_read_write` RLS to a table, anon_key gains INSERT/UPDATE/DELETE on that table — use this for browser-side writes without login.
573+
- `anon_key` → read-only by default (SELECT). Safe to embed in frontend code. **No expiry** -- permanent project identifier. If you apply `public_read_write_UNRESTRICTED` RLS to a table, anon_key gains INSERT/UPDATE/DELETE on that table — use this for browser-side writes without login (only on intentionally public tables).
569574
- `service_key` → full admin (bypasses RLS). Server-side only. **No expiry** -- lease enforcement server-side.
570575
- `access_token` (from login) → user-scoped read/write (subject to RLS).
571576

@@ -620,7 +625,7 @@ await fetch(API + '/rest/v1/items?id=eq.5', {
620625

621626
### Complete HTML example
622627

623-
A working single-file app. Uses `public_read_write` RLS so the `anon_key` handles all reads and writes — no login required.
628+
A working single-file app. Uses `public_read_write_UNRESTRICTED` RLS so the `anon_key` handles all reads and writes — no login required. (This template is intentionally open; only apply it to tables where anyone on the internet is allowed to write anything, like guestbooks.)
624629

625630
```html
626631
<!DOCTYPE html>
@@ -650,7 +655,7 @@ A working single-file app. Uses `public_read_write` RLS so the `anon_key` handle
650655

651656
<script>
652657
const API = 'https://api.run402.com';
653-
const ANON_KEY = 'YOUR_ANON_KEY'; // safe to embed — read-only by default, write-enabled via public_read_write RLS
658+
const ANON_KEY = 'YOUR_ANON_KEY'; // safe to embed — read-only by default, write-enabled here via public_read_write_UNRESTRICTED RLS
654659

655660
async function loadEntries() {
656661
const rows = await fetch(API + '/rest/v1/guestbook?order=created_at.desc&limit=50', {
@@ -686,8 +691,12 @@ A working single-file app. Uses `public_read_write` RLS so the `anon_key` handle
686691
# Create table
687692
run402 projects sql $PROJECT_ID "CREATE TABLE guestbook (id serial PRIMARY KEY, name text NOT NULL, message text NOT NULL, created_at timestamptz DEFAULT now())"
688693

689-
# Enable public_read_write so anon_key can insert
690-
run402 projects rls $PROJECT_ID public_read_write '[{"table":"guestbook"}]'
694+
# Enable public_read_write_UNRESTRICTED so anon_key can insert.
695+
# The 3-arg `projects rls` CLI cannot set the required ACK flag; use
696+
# `run402 deploy` with a manifest that includes:
697+
# "rls": { "template": "public_read_write_UNRESTRICTED",
698+
# "tables": [{"table":"guestbook"}],
699+
# "i_understand_this_is_unrestricted": true }
691700
```
692701

693702
---
@@ -838,7 +847,7 @@ const refreshed = await fetch(API + '/auth/v1/token?grant_type=refresh_token', {
838847
9. **Workout Log** -- Track exercises, sets, reps, weight with progress over time.
839848
10. **Flash Cards** -- Spaced-repetition study app. Pre-load 50 phrases.
840849

841-
Provision first so you have the `anon_key` to embed in your frontend HTML. The manifest should include `project_id` (from provision), `migrations` (CREATE TABLE + INSERT seed data), `rls` (almost always `public_read_write` for browser-writable apps), `files` (array of files), and `subdomain`. Your human gets a live URL they can use immediately.
850+
Provision first so you have the `anon_key` to embed in your frontend HTML. The manifest should include `project_id` (from provision), `migrations` (CREATE TABLE + INSERT seed data), `rls` (for browser-writable apps: `public_read_write_UNRESTRICTED` + `"i_understand_this_is_unrestricted": true`; for user-scoped apps use `user_owns_rows`), `files` (array of files), and `subdomain`. Your human gets a live URL they can use immediately.
842851

843852
## Make It Great
844853

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "run402",
3-
"version": "1.35.4",
3+
"version": "1.36.0",
44
"description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
55
"type": "module",
66
"bin": {

openclaw/SKILL.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,24 +355,28 @@ GET /storage/v1/object/list/assets
355355

356356
## Row-Level Security (RLS)
357357

358-
Three templates. Applied via `POST /projects/v1/admin/:id/rls` with `service_key`.
358+
Three templates. Applied via `POST /projects/v1/admin/:id/rls` with `service_key`. **Prefer `user_owns_rows` for anything user-scoped.**
359359

360360
### `user_owns_rows`
361-
Users access only rows where `owner_column = auth.uid()`. Best for user-scoped data.
361+
Users access only rows where the owner column matches `auth.uid()`. Best for user-scoped data (todos, workouts, messages). `uuid` owner columns get an index-friendly policy; other types fall back to a `::text` cast with a warning. The endpoint auto-creates a btree index on the owner column.
362362
```json
363363
{ "template": "user_owns_rows", "tables": [{ "table": "todos", "owner_column": "user_id" }] }
364364
```
365365

366-
### `public_read`
367-
Anyone can read (anon_key works). Only authenticated users can write.
366+
### `public_read_authenticated_write`
367+
Anyone can read (including `anon_key`). **Any authenticated user can INSERT/UPDATE/DELETE any row** (not just their own). Appropriate for collaborative content like shared boards or announcements; do not use where users should only edit their own rows.
368368
```json
369-
{ "template": "public_read", "tables": [{ "table": "announcements" }] }
369+
{ "template": "public_read_authenticated_write", "tables": [{ "table": "announcements" }] }
370370
```
371371

372-
### `public_read_write`
373-
Anyone can read and write. For guestbooks, public logs, open data.
372+
### `public_read_write_UNRESTRICTED`
373+
⚠ Fully open. Anyone (including `anon_key`) can read, insert, update, or delete any row. Only appropriate for intentionally public tables (guestbooks, waitlists, feedback forms). **Requires** `"i_understand_this_is_unrestricted": true` in the request body and logs an audit line on the gateway.
374374
```json
375-
{ "template": "public_read_write", "tables": [{ "table": "guestbook" }] }
375+
{
376+
"template": "public_read_write_UNRESTRICTED",
377+
"tables": [{ "table": "guestbook" }],
378+
"i_understand_this_is_unrestricted": true
379+
}
376380
```
377381

378382
---

0 commit comments

Comments
 (0)