Skip to content

Atlas CE Doesn't Manage Postgres RLS. Ptah Does.

Atlas Community leaves PostgreSQL row-level security outside schema management. We tested Ptah Compat to manage tables, RLS policies, and grants together.

Ptah helps you plan, review, and apply database migrations. Try in your browser

dmikalova/tasks uses PostgreSQL row-level security to keep users’ tasks separate. Its schema workflow had a split: Atlas applied the tables from HCL, then a TypeScript wrapper ran db/rls.sql through psql. A comment in the wrapper explained the reason: “Atlas community edition does not manage RLS.”

We replaced that sequence with Ptah Compat in our fork. The existing policies and grants now sit beside the tables in db/schema.hcl. A schema preview includes policy changes, and the ordinary apply restores RLS drift without a separate SQL step. No login or subscription is required for these objects in Ptah.

The examples below use Ptah Compat 0.11.4 and PostgreSQL 16.15. The application PR and its shared deployment workflow dependency are open proposals. The application PR remains a draft until that dependency is available; this is not an announcement of upstream adoption. Full inputs and recorded results are in the supporting files.

The original RLS file enabled and forced row-level security on eight tables. It declared 32 policies and granted application roles access to the schema and tables. Reapplying the file dropped and recreated the policies even when their definitions had not changed.

That file made the database work, but the Atlas schema preview did not cover its security declarations. A table plan could be empty while a policy had drifted. Moving those declarations into the desired schema makes the same comparison responsible for both.

The fork keeps the existing table columns, indexes, and constraints. It adds row_security blocks with enabled = true and enforced = true to the same eight tables. The policy names, commands, and expressions stay the same. For example, the existing SELECT policy on projects becomes this HCL block:

examples/snippets/policy.hcl
policy "projects_user_select" {
on = table.projects
for = SELECT
using = "user_id = current_setting('app.user_id')::uuid"
}

The application still sets app.user_id in each transaction. Ptah manages the database declaration; the application’s authentication and transaction context remain responsible for supplying the user identity.

HCL permission blocks declare schema USAGE and the existing table privileges. The app_role variable selects tasks_app locally and tasks-role in production. The existing bootstrap continues to create roles and credentials. The original SQL also granted access to all existing sequences, but this schema has no sequences, so there were no sequence grants to transfer.

With the local application role already created by the project’s bootstrap, we applied the fork’s schema to a disposable database. From the fork root, DATABASE_URL names the migration connection:

examples/commands/apply.txt
ptah-compat schema apply \
--url "$DATABASE_URL" \
--to file://db/schema.hcl \
--schema tasks \
--var app_role=tasks_app \
--auto-approve

The result includes all 11 tables, all 32 policies, and ENABLE/FORCE RLS on the eight intended tables. The local application role has the declared schema and table permissions. Ptah reads this HCL directly, so these commands do not need a separate dev database.

The preview uses the same command with --dry-run:

examples/commands/preview.txt
ptah-compat schema apply \
--url "$DATABASE_URL" \
--to file://db/schema.hcl \
--schema tasks \
--var app_role=tasks_app \
--dry-run

After apply, it reports:

examples/measured/repeat-preview.txt
Schema is synced, no changes to be made

The project’s deno task db:apply and deno task db:diff commands remain the entry points for developers. The wrapper now invokes ptah-compat; diff uses schema apply --dry-run so its inputs and planning path match apply. We removed the standalone rls subcommand because schema apply now owns those objects. The existing seed and truncate commands still use psql.

A successful first apply does not show whether the tool will notice a changed policy. On the disposable database, we changed the projects SELECT predicate to false and disabled FORCE RLS:

examples/sql/drift.sql
ALTER POLICY projects_user_select ON tasks.projects USING (false);
ALTER TABLE tasks.projects NO FORCE ROW LEVEL SECURITY;

The same preview command now includes both repairs:

examples/measured/drift-preview.txt
Planned schema changes:
-- Apply the policies of tasks.projects to its owner
ALTER TABLE "tasks"."projects" FORCE ROW LEVEL SECURITY;
-- Modify RLS policy projects_user_select on table tasks.projects: using_expression
DROP POLICY IF EXISTS "projects_user_select" ON "tasks"."projects";
CREATE POLICY "projects_user_select" ON "tasks"."projects" FOR SELECT
USING (user_id = current_setting('app.user_id')::uuid);

The plan explains the effect of FORCE: it subjects the table owner to the policies too. For the changed predicate, Ptah emits a drop and recreate of that policy. It does not repeat the old SQL file’s drop and recreate for every unchanged policy.

After preview, a catalog query still reported qual = false and forced = false. Preview had left the database untouched. We reran the apply command, then confirmed that the original user predicate and FORCE setting were restored.

For the final check, we requested the plan as JSON:

examples/commands/settled.txt
ptah-compat schema apply \
--url "$DATABASE_URL" \
--to file://db/schema.hcl \
--schema tasks \
--var app_role=tasks_app \
--dry-run --format '{{ json . }}'
examples/measured/settled.txt
{"Changes":{}}

The empty Changes object means there is no remaining schema change to apply. A regression in the fork performs the same drift, preview, repair, and empty-plan checks through the project’s wrapper.

Check the handover against the old workflow

Section titled “Check the handover against the old workflow”

We also created a separate database using Atlas Community 1.3.1 with the original HCL, then applied the original RLS SQL. Ptah’s plan against that database was empty: the new declarations described the state the previous workflow had already created.

PostgreSQL schema-only dumps matched between that database and a fresh Ptah apply, including policies, grants, indexes, and constraints. The comparison excluded only pg_dump’s random restriction tokens. We also checked a database with grants to both application roles; selecting one role did not revoke the other role’s existing grants.

The project’s full test suite passed: 327 tests, 274 steps, no failures. That includes the existing RLS and ownership tests and the new schema-drift regression. Type checks and lint passed too. A separate production-role check used tasks-role without relying on the local bootstrap’s default grants.

The local wrapper was only part of the integration. Production calls a reusable workflow from project-standards, and that workflow installs and runs the database tool directly. Replacing the local executable would leave that deployment path using Atlas.

The companion PR adds an optional database configuration. The tasks config selects Ptah and the production role:

examples/snippets/deployment.json
{
"database": {
"tool": "ptah",
"vars": {
"app_role": "tasks-role"
}
}
}

The shared workflow installs the pinned Ptah Compat release under the atlas command name. It forwards the same role variable to preview and apply and retains its existing guard against destructive changes. Projects that do not select Ptah keep their current Atlas path. This belongs in the shared workflow because the project’s caller workflow is generated and maintained by conformance automation.

We ran the actual deployment shell against a disposable PostgreSQL database. It created the schema with the production role’s grants, then produced an empty plan on repetition. When we added an extra table containing a row, the existing guard refused the proposed drop and left the row in place. These checks cover the deployment commands; the proposed integration has not been deployed to the project’s live Cloud Run environment.

To try the same workflow, see the Ptah documentation or open the browser playground without installing a binary.

Put Ptah to work

Example files