Getting started
From an empty folder to access rules enforced by Postgres, tested, in about fifteen minutes. You need Docker and Python 3.11 or newer. (This guide runs as written in the tests: core/tests/docs_test.sh.)
1. Postgres with rowfence
From the repository root:
docker build -t rowfence:16 --build-arg PG_MAJOR=16 -f core/Dockerfile .
docker run -d --name pga -e POSTGRES_PASSWORD=secret -p 5432:5432 rowfence:16
export PATH="$PWD/core/cli:$PATH" # the rowfence command
export DATABASE_URL=postgresql://postgres:secret@localhost:5432/postgresThe image is the stock Postgres 16 (or 17, 18 with PG_MAJOR) with the rowfence command in it. rowfence needs nothing installed in the database: the rowfence command compiles your policy and applies plain SQL, connected as the owner of your tables (DATABASE_URL; here the postgres user). Your app will connect as its own role. Work in an empty folder from here on. For SQL, docker exec -it pga psql -U postgres.
2. Your tables, and the role your app connects as
rowfence governs tables you already have. A small example:
CREATE SCHEMA app;
CREATE TABLE app.users (id bigint PRIMARY KEY, name text);
CREATE TABLE app.teams (id bigint PRIMARY KEY, name text);
CREATE TABLE app.team_members (team_id bigint REFERENCES app.teams, user_id bigint REFERENCES app.users,
PRIMARY KEY (team_id, user_id));
CREATE TABLE app.projects (id bigserial PRIMARY KEY, owner_id bigint NOT NULL REFERENCES app.users, name text);
CREATE TABLE app.notes (id bigserial PRIMARY KEY, project_id bigint NOT NULL REFERENCES app.projects,
author_id bigint NOT NULL REFERENCES app.users, body text);
CREATE INDEX ON app.team_members (user_id); -- the columns the rules will look rows up by
CREATE INDEX ON app.projects (owner_id); -- (rowfence dev names the ones that are missing)
CREATE INDEX ON app.notes (project_id);
CREATE INDEX ON app.notes (author_id);
CREATE ROLE app_backend LOGIN PASSWORD 'change me' NOSUPERUSER NOBYPASSRLS; -- the role your app connects as
ALTER ROLE app_backend SET jit = off;
GRANT USAGE ON SCHEMA app TO app_backend;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_backend;
GRANT INSERT, UPDATE, DELETE ON app.projects, app.notes TO app_backend; -- the tables the policy governs
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_backend;
INSERT INTO app.users VALUES (1, 'ada'), (2, 'bo'), (3, 'cy');
INSERT INTO app.teams VALUES (10, 'writers');
INSERT INTO app.team_members VALUES (10, 2);
INSERT INTO app.projects (owner_id, name) VALUES (1, 'Novel');
INSERT INTO app.notes (project_id, author_id, body) VALUES (1, 1, 'Chapter one');3. A first draft: rowfence init
rowfence init --schema app --role app_backendIt reads your tables and foreign keys and writes three files: db/policy.authz (a policy that compiles), db/tests/first.authz (a first test) and rowfence.toml (where things are, for the other commands). Tables became types, foreign keys became relations (owner_id is owner : user = owner_id; project_id is project : project = project_id, and a note inherits view and edit from its project), and app.team_members became member : user = app.team_members(team_id -> user_id) on teams. Every line marked -- decide: is a choice only you can make.
Here, projects are shared: people give others access with authz.share(). And only the tables the app writes need rules. Edit the draft into this:
app role app_backend
type user = app.users
type team = app.teams
member : user = app.team_members(team_id -> user_id)
type project = app.projects
owner : user = owner_id
editor : user, team#member shared -- people share projects with authz.share()
viewer : user, team#member shared
can share = owner
can edit = share or editor
can view = edit or viewer
type note = app.notes
project : project = project_id
author : user = author_id
can edit = author or project.edit
can view = edit or project.view
rules app.projects
select : view
insert : owner
update : edit
update owner_id : share
delete : share
rules app.notes
select : view
insert : project.edit and author
update : edit
update project_id after : project.edit -- the project it moves to (the row after the change)
update author_id : project.share -- or anyone who may edit could make themselves the author
delete : editshared relations hold what people give each other with authz.share(); who may give them is the type's can share. The layout is rowfence fmt's: run it on your own files, and rowfence fmt --check in CI.
With an editor that speaks the Language Server Protocol, mistakes show while you type, and hovering project.edit shows what it means (editor/README.md).
4. Tests that bring their own data
A test sets up the rows it needs, checks what people may do, and is rolled back. $name is what a given returned:
test "a project's owner and editors write notes; nobody else sees them"
given ada = {INSERT INTO app.users VALUES (101, 'ada') RETURNING id}
given bo = {INSERT INTO app.users VALUES (102, 'bo') RETURNING id}
given cy = {INSERT INTO app.users VALUES (103, 'cy') RETURNING id}
given p = {INSERT INTO app.projects (owner_id, name) VALUES ($ada, 'Novel') RETURNING id}
given n = {INSERT INTO app.notes (project_id, author_id, body) VALUES ($p, $ada, 'Chapter one') RETURNING id}
user $ada can edit note $n
user $cy cannot view note $n
as user $cy sees 0 {SELECT FROM app.notes WHERE project_id = $p}
as user $bo refused {INSERT INTO app.notes (project_id, author_id, body) VALUES ($p, $bo, 'mine')}
as user $ada allowed {SELECT authz.share('project', $p, 'editor', 'user', $bo)}
as user $bo allowed {INSERT INTO app.notes (project_id, author_id, body) VALUES ($p, $bo, 'mine')}
user $bo cannot share project $puser X can PERM TYPE ID asks the policy. as user X allowed|refused|sees N {SQL} runs the statement as your app's role, signed in as X, so it tests row-level security itself.
5. The edit loop: rowfence dev
rowfence dev --onceWithout --once, it watches the policy and the tests. On every save it checks the policy, shows who would gain or lose access, applies it, runs the tests and writes your clients:
rowfence dev: db/policy.authz -> postgres on localhost:5432
10:04:31 start
ok compiles
ok applied in 0.12 s (the whole policy)
ok 7 check(s) pass; 7 branches no test reaches (line 13: owner, line 14: share, ...)The last line counts the branches of the policy no test makes true yet (rowfence test --coverage lists them). From the second run on, a line says who gains or loses access (~ access: nobody gains or loses anything), and a lookup that no index serves is named, with the index to add.
A mistake stops it before anything is applied (db/policy.authz: line 14: project has no relation or permission 'edtor' (it has: edit, editor, owner, share, view, viewer) [AZ203]), and a failing check prints why, from the database. Every mistake ends with its code: rowfence help AZ203 says what it means and shows it fixed (docs/errors/).
6. Ask as a user
Every transaction starts by saying who is asking, with authz.act_as, then plain SQL is filtered by row-level security. The sign-in is signed and lasts one transaction, so the app can't change it later by setting authz.user_id, and a transaction that forgets it gets an error, not an empty page:
SET ROLE app_backend;
BEGIN;
SELECT authz.act_as('user', '3'); -- cy
SELECT * FROM app.notes; -- nothing
COMMIT;
BEGIN;
SELECT authz.act_as('user', '1'); -- ada shares the project with the writers team
SELECT authz.share('project', 1, 'editor', 'team', 10, 'member');
COMMIT;
BEGIN;
SELECT authz.act_as('user', '2'); -- bo is a writer
SELECT * FROM app.notes; -- Chapter one
UPDATE app.notes SET body = 'Chapter one, again'; -- allowed: he may edit the project
SELECT authz.can('project', 1, 'share'); -- false
SELECT * FROM authz.explain('project', 1, 'share'); -- why not
COMMIT;
RESET ROLE;When the database refuses a write, the error says which rule refused it and why:
ERROR: permission denied: user 3 may not insert this row into app.notes
DETAIL: no insert : project.edit and author (line 33)
no project.edit
no you have no access to project 1
yes authorThe same from a terminal, as anyone, without writing anything:
rowfence explain-rule --as user:3 app.notes insert --row '{"project_id": 1, "author_id": 3}'
rowfence sql --as user:2 "SELECT id, body FROM app.notes"7. From your app
rowfence.toml can name clients to write on every change ([clients] py = "app/authz_client.py"), or: rowfence client py > authz_client.py (or ts). Commit it next to your code.
import psycopg
from authz_client import Authz, NotFound, Refused, refusal
with psycopg.connect("postgresql://app_backend:change%20me@localhost/postgres") as conn:
with conn.transaction():
az = Authz(conn)
az.sign_in(current_user_id) # authz.act_as('user', ...)
notes = conn.execute("SELECT id, body FROM app.notes WHERE project_id = %s", (project_id,)).fetchall()
# an UPDATE that changed nothing: NotFound (you can't see it) or Refused (you may not, and why)
row = conn.execute("UPDATE app.notes SET body = %s WHERE id = %s RETURNING id", (body, note_id)).fetchone()
az.expect(row, "app.notes", "update", note_id)
# in your web framework's error handler: a refused write is a 403 with the database's reason
def on_error(e):
r = refusal(e) if not isinstance(e, Refused) else e
if r:
return 403, {"detail": "you may not do that", "why": r.why}
if isinstance(e, NotFound):
return 404, {"detail": "not found"}Keep every request in one transaction that starts with sign_in: the setting ends with the transaction, so a pooled connection never carries one user's identity into another's request.
8. Ship it: migrations
rowfence dev applies straight to your development database. Production takes the policy as migrations, written for the tool your app already uses (alembic, prisma, drizzle, sql, goose, dbmate or flyway). Name it in rowfence.toml, then write the first one:
printf '[migrations]\ntool = "sql"\ndir = "migrations"\n' >> rowfence.toml
rowfence migrate the whole policy (29 lines)
wrote migrations/20261003120000_authz_policy.sql
wrote db/policy.lock
wrote .gitattributes (reviews show the generated files collapsed)Commit all three. From then on each change to the policy is the next migration, holding only what changed (a new permission is a few statements) and starting with what changed, as comments. rowfence dev writes it once you stop editing, and rowfence migrate --check in CI fails a policy change that has no migration. Deploy them as you deploy the others: there is no extra step.
Next
docs/cookbook.md: patterns (teams, folders that inherit, sharing, denies, bots, blocking...), each tested.docs/troubleshooting.md: what people run into, by symptom.docs/reference/: the whole language, every function and every command.examples/messenger/andexamples/filemanager/: complete apps with no permission checks in their backends.docs/operations.md: upgrading, backups, retention, monitoring.