Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ default when the submitted form blocks (reported in the verdict's `executed_sql`
optimistic native attempt otherwise. A gated `--force` runs the submitted form as-is under the
same budgets. Changes without an available backend get a structured refusal (exit code 2).
Desired-state execution — converging a live table onto a `CREATE TABLE` file, including
creating the table when it does not exist yet — is a Go API today: `migrate.RunDesired`
in [`pkg/migrate`](pkg/migrate/desired.go); the CLI's `migrate` verb takes one imperative
statement.
creating the table when it does not exist yet — is available through `migrate --desired schema.sql` and
`migrate.RunDesired` in [`pkg/migrate`](pkg/migrate/desired.go).
The design docs and the phased
build plan live in [docs/](docs/) — start with
[docs/README.md](docs/README.md); the vision — what pg-sprite is and is not —
Expand All @@ -67,8 +66,8 @@ refusal — never a silently wrong or incomplete result:
- **Copy-and-swap** (genuine table rewrites) is not yet available — those
changes refuse rather than fall through to a blocking rewrite.
- **Row security** is included in exports when present, with reviewable before/after differences. The
[atomic Go executor](docs/atomic-row-security.md) can apply RLS-only changes to
existing tables; CLI execution and mixed table/policy changes remain unsupported. See the workflow and roadmap in
[atomic executor](docs/atomic-row-security.md), also available through `migrate --desired`, applies RLS-only changes to
existing tables; mixed table/policy changes remain unsupported. See the workflow and roadmap in
[declarative-row-security.md](docs/declarative-row-security.md).
- **Foreign keys** are out of the declarative model in either direction:
desired files cannot declare them, and export refuses both a table that
Expand All @@ -79,10 +78,9 @@ refusal — never a silently wrong or incomplete result:
- **Unlogged tables and explicit column collations** are outside the
declarative model: converging either is a table (or column) rewrite, so
export and diff refuse rather than plan one.
- **Desired-state execution has no CLI verb yet** — `migrate.RunDesired`
(including the greenfield `CREATE TABLE` path for a table that does not
exist) is library-only; the CLI's `migrate` takes one imperative
statement.
- **Destructive desired-state plans are refused as a whole** — `migrate --desired`
creates missing tables and converges supported changes, but never infers permission
to discard live structure.
- **Invalid-index recovery has no CLI verb yet** — a failed concurrent index
build's leftover is reported with a typed state, and the proven removal
(`executor.RebuildAbandonedIndex`, or `executor.DropAbandonedIndex` when
Expand Down
6 changes: 6 additions & 0 deletions demo/desired-rls.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE demo_rls (
id bigint PRIMARY KEY,
owner_id bigint NOT NULL
);
ALTER TABLE demo_rls ENABLE ROW LEVEL SECURITY;
CREATE POLICY readers ON demo_rls FOR SELECT USING (owner_id = 7);
8 changes: 8 additions & 0 deletions demo/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ FROM generate_series(1, 5000) g;
-- the --accept-blocking acknowledgement. It lives on orders, which no
-- desired-state file describes, so the diff tour's plans do not see it.
CREATE INDEX orders_total_idx ON orders (total);

-- Dedicated fixture for atomic desired-state row security.
CREATE SCHEMA IF NOT EXISTS demo_security;
DROP TABLE IF EXISTS demo_security.demo_rls;
CREATE TABLE demo_security.demo_rls (
id bigint PRIMARY KEY,
owner_id bigint NOT NULL
);
31 changes: 31 additions & 0 deletions demo/tour.sh
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,39 @@ run_offline() {
fi
}

run_rls_apply() {
step "Apply a complete RLS definition"
local out status=0
out=$("$PGS" migrate --url "$PG_DSN" --schema demo_security --desired desired-rls.sql --dry-run --json) || status=$?
if [ "$CHECK" = 1 ]; then
assert_eq "RLS preview exit" 2 "$status"
assert_eq "RLS preview class" capability-boundary "$(jq -r '.class' <<<"$out")"
assert_eq "RLS preview changes" true "$(jq '.row_security_review.changes | length > 0' <<<"$out")"
else
printf '%s\n' "$out"
fi
status=0
out=$("$PGS" migrate --url "$PG_DSN" --schema demo_security --desired desired-rls.sql --json) || status=$?
if [ "$CHECK" = 1 ]; then
assert_eq "RLS apply exit" 0 "$status"
assert_eq "RLS apply outcome" executed-natively "$(jq -r '.outcome' <<<"$out")"
assert_eq "RLS committed statements" 3 "$(jq '.executed_sql | length' <<<"$out")"
else
printf '%s\n' "$out"
fi
status=0
out=$("$PGS" migrate --url "$PG_DSN" --schema demo_security --desired desired-rls.sql --json) || status=$?
if [ "$CHECK" = 1 ]; then
assert_eq "RLS repeat exit" 0 "$status"
assert_eq "RLS repeat statements" 0 "$(jq '.executed_sql // [] | length' <<<"$out")"
else
printf '%s\n' "$out"
fi
}

run_exec() {
heading "Real executions against the seeded tables (make demo reseeds each run)"
run_rls_apply
# steps fragment
execute_native 0 "" "ALTER TABLE users ADD COLUMN bio text"
execute_native 1 CONCURRENTLY "CREATE INDEX idx_users_email ON users (email)"
Expand Down
4 changes: 2 additions & 2 deletions docs/atomic-row-security.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Atomic row security changes

The dedicated Go executor converges the complete RLS definition of one existing
The dedicated executor, available through the Go API and `migrate --desired`, converges the complete RLS definition of one existing
ordinary table. It does not change columns, indexes, or constraints and does not
create missing tables. `diff` remains a preview; no new CLI flags are required.
create missing tables. `diff` remains a preview. See the [CLI workflow](declarative-row-security.md#apply-the-declaration) for commands and output.

## Call the Go API

Expand Down
11 changes: 6 additions & 5 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ refused form would take, what an operator who accepts a maintenance window can d
- [Deliberately operator-owned](#deliberately-operator-owned)

A ✅ marks an implemented capability; check the front-door columns for CLI access.
The atomic RLS executor is currently a Go API only, with no `migrate` or `diff` execution.
The atomic RLS executor is available through `migrate --desired` and the Go API;
`diff` shows RLS changes for review without emitting executable SQL.

## Query the matrix

Expand All @@ -52,8 +53,8 @@ pg-sprite capabilities --json | jq '.capabilities[] | select(.tier == "t2")'
# What is waiting on the copy engine, across tiers.
pg-sprite capabilities --json | jq '.capabilities[] | select(.engine_path == "copy_and_swap")'

# Everything the declarative door refuses. Both doors carry the same disposition on
# every row today; the map exists so they can diverge, so query the door you use.
# Everything diff refuses. Query the door you use: RLS changes are review-only
# in diff, but can execute through migrate --desired.
pg-sprite capabilities --json | jq '.capabilities[] | select(.front_doors.diff == "refused")'

# Rows another tool class owns: the ⚪ and 🔵 rows. The ❌ rows name no owner, because
Expand Down Expand Up @@ -272,8 +273,8 @@ review the object warrants) ·
| PL/pgSQL function bodies (`CREATE OR REPLACE FUNCTION`) | ⚪ | — | No — owner tooling | Transactional catalog work that takes no lock on any relation; nothing for an online engine to add. No peer online executor owns it either |
| Triggers (`CREATE TRIGGER`) | ⚪ | — | No — owner tooling | Catalog work — no scan, no rewrite — but it takes a brief `SHARE ROW EXCLUSIVE` on the table, queues behind long-running queries, and blocks writers while it waits — run it under a `lock_timeout` |
| Extensions (`CREATE EXTENSION`) | ⚪ | — | No — owner tooling | Same: catalog bootstrap, owner tooling |
| Grants, roles, and CLI policy DDL | 🔵 | — | No — provisioning / IaC | Access control changes remain with provisioning. Export includes RLS when present; explicit RLS declarations support comparison and review-only deltas with advisory access warnings. Plain table files leave access control separately managed. The separate [atomic RLS Go executor](atomic-row-security.md) supports RLS-only changes on existing supported tables; it does not route through these CLI front doors. See the [workflow and roadmap](declarative-row-security.md). See [engine-role.md](engine-role.md) for the engine's own role |
| Complete table-local RLS definition (Go API only) | ✅ | native, safer sequence | Yes | The [atomic RLS executor](atomic-row-security.md) locks an existing supported table, refuses structural changes, replaces policies/settings, and verifies convergence before committing. Lock waits and the whole transaction are bounded. No CLI execution or new diff flags |
| Grants, roles, and imperative policy DDL | 🔵 | — | No — provisioning / IaC | Access control changes remain with provisioning. Export includes RLS when present; explicit RLS declarations support comparison and review-only deltas with advisory access warnings. Plain table files leave access control separately managed. The separate [atomic RLS Go executor](atomic-row-security.md) supports RLS-only changes on existing supported tables; `migrate --desired` uses this executor; imperative policy statements remain refused. See the [workflow and roadmap](declarative-row-security.md). See [engine-role.md](engine-role.md) for the engine's own role |
| Complete table-local RLS definition (Go API and migrate --desired) | ✅ | native, safer sequence | Yes | The [atomic RLS executor](atomic-row-security.md) locks an existing supported table, refuses structural changes, replaces policies/settings, and verifies convergence before committing. Lock waits and the whole transaction are bounded. Available through `migrate --desired` without RLS-specific flags; `diff` remains a review-only surface |
| Standalone sequences | ⚪ | — | No — owner tooling | Transactional catalog work on an object with no readers-and-writers problem |
| Publications, subscriptions | 🔵 | — | No — replication provisioning / IaC | Replication provisioning, not table shape (`ALTER PUBLICATION ... ADD TABLE` also takes `SHARE UPDATE EXCLUSIVE` on the table) |
<!-- capabilities:end types_and_non_table_objects -->
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ in `detail`. The set is closed and pinned by test (`verdict.Reasons()`).
| `unsupported-statement` | No safe path is known for the statement — only `ALTER TABLE` and `CREATE INDEX` reach classification — or a greenfield create plan carries a shape the create path refuses (`PARTITION OF`, `INHERITS`, `LIKE`, `OF`, `IF NOT EXISTS`, or a duplicate claimed relation name; the [plan statement's `cause`](plan-report.md#causes-cause-greenfield-create-shape-refusals-only) names which — a create-shape vocabulary distinct from the verdict's budget `cause` below). These greenfield shapes refuse in the plan and are re-checked at apply. |
| `index-statement` | Index maintenance (`DROP INDEX`, `REINDEX`) has a native safe idiom (`CONCURRENTLY`) and is never attempted; the verdict's `safer_idiom` names it. |
| `not-native-safe-table-too-large` | The size guard skipped the optimistic attempt: the table exceeds the configured bound and the change is not provably metadata-only. |
| `insufficient-privileges` | The connected role lacks the access the change needs; `detail` names the exact missing GRANT (see [engine-role.md](engine-role.md)). |
| `insufficient-privileges` | The connected role lacks the access the change needs; `detail` names the missing grant for preflight checks (see [engine-role.md](engine-role.md)). RLS admission names the required table ownership or database CREATE privilege and its remedy; permission errors reported by PostgreSQL retain the server diagnostic, which may not identify an exact GRANT. |
| `unsupported-partitioned-parent` | The routed plan builds an index on a partitioned parent, where PostgreSQL cannot `CREATE INDEX CONCURRENTLY`. |
| `not-native-safe-budget-exceeded` | The optimistic attempt exceeded its lock or statement budget and was cancelled; the verdict's `cause` narrows which budget fired. The same `cause` field carries the partitioned-parent shape under `unsupported-partitioned-parent`; both are refusal causes, unrelated to the plan statement's create-shape `cause`. |
| `not-native-safe-rewrite-required` | The submitted form blocks and must run as a safer native sequence, but none could be constructed. |
Expand Down
63 changes: 58 additions & 5 deletions docs/declarative-row-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ You can export a table's RLS settings and policies, keep them alongside its SQL,
and verify that the live definition still matches. `pull` includes RLS when the
live table has settings or policies; ordinary tables get no extra SQL.
`diff` shows the captured definitions and refuses to emit execution SQL for RLS
changes. The dedicated [atomic Go executor](atomic-row-security.md) can apply an
RLS-only declaration to an existing supported table. No new CLI flags are needed.
changes. Use `migrate --desired` to apply an RLS-only declaration to an existing supported table
through the [atomic executor](atomic-row-security.md). No RLS-specific flags are needed.

## Export and compare

Expand Down Expand Up @@ -46,7 +46,60 @@ optional and defaults to `NO FORCE`.
Files without RLS declarations keep their table-only behavior: `diff` leaves
access control separately managed. Export preserves policies even when RLS is
disabled, and preserves enabled RLS even when there are no policies (default deny).
`fmt`, `lint`, and live desired-state execution do not accept the expanded format yet.
`fmt` and `lint` do not accept the expanded format yet.

## Apply the declaration

After reviewing the differences, apply the same file:

```sh
pg-sprite migrate --url "$PG_DSN" --schema public --desired schema/documents.sql
```

The command prints an executed verdict and the SQL that committed. A second apply
prints an already-converged verdict and runs no policy DDL. For scripts, add `--json`:

```sh
pg-sprite migrate --url "$PG_DSN" --schema public --desired schema/documents.sql --json
```

An already-converged response is:

```json
{
"outcome": "executed-natively",
"statement": "",
"table": "public.documents",
"detail": "already converged: row security matches; nothing to run"
}
```

On a change, `executed_sql` contains the ordered statements committed together.
The input owns the complete policy set: removing a policy from the file removes it
from the table. Disabling RLS or widening a policy changes access deliberately;
review that meaning and test application authorization before applying.

`--dry-run` uses the same review as `diff`: RLS differences exit 2 and show the
review without executing. That exit code describes the review-only preview,
not a claim that the atomic executor cannot apply an RLS-only difference.
Apply independently reads and validates the current table under its lock; it does
not execute a saved preview or pin the reviewed definition with a fingerprint.

Apply exits 0 after commit or a no-op and 2 for executor/target refusals.
Invalid or unsupported input declarations fail admission before execution and
exit 1 with a diagnostic, without a verdict. Operational failures also exit 1;
their JSON verdict includes the executor's `code`. Refusal verdicts use `reason`
and `class`, without a failure code.
`row-security-outcome-unknown` means the commit response was lost or failed:
inspect the live state before retrying; do not assume rollback. The whole RLS
attempt uses `--statement-timeout`, including scratch inspection and lock waits;
`--lock-timeout` bounds lock acquisition. RLS applies do not automatically retry.

Ordinary files use the existing desired-state sequence instead; their JSON is
`migrate.DesiredResult` with `plan`, `verdicts`, and overall `outcome`. Earlier
committed steps stay committed if a later step fails. Mixed table/RLS changes
are refused as a whole. `--desired` cannot be combined with `--alter`, `--force`,
or `--accept-blocking`.

## Keep the SQL people already use

Expand Down Expand Up @@ -77,7 +130,7 @@ CREATE POLICY "Create your documents"
WITH CHECK ((SELECT auth.uid()) = owner_id);
```

This file is accepted by `diff` for inspection. The role, helper,
This file is accepted by `diff` for inspection and by `migrate --desired` for execution. The role, helper,
and necessary table grants must already exist. These policies cover reads and
inserts, not updates or deletes. Application authorization tests remain necessary.

Expand Down Expand Up @@ -222,7 +275,7 @@ this table-scoped work.
creation remains refused.
3. **Execute transitions atomically.** The Go executor now locks, derives, applies,
and verifies one table's RLS state in one bounded transaction. Mixed changes and
policy relation dependencies remain unsupported. CLI integration is a follow-up.
policy relation dependencies remain unsupported. The CLI selects this executor for explicit RLS declarations through `migrate --desired`.
4. **Prove application behavior.** The local Supabase harness now checks real
PostgREST requests from two authenticated users and anonymous callers, allowed
and denied writes, changed visibility, and rollback after a cancelled apply.
Expand Down
Loading
Loading