Skip to content

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/postgres

The 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:

sql
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 ​

sh
rowfence init --schema app --role app_backend

It 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:

authz
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                  : edit

shared 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:

authz
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 $p

user 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 ​

sh
rowfence dev --once

Without --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:

text
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:

sql
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:

text
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  author

The same from a terminal, as anyone, without writing anything:

sh
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.

python
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:

sh
printf '[migrations]\ntool = "sql"\ndir = "migrations"\n' >> rowfence.toml
rowfence migrate
text
  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/ and examples/filemanager/: complete apps with no permission checks in their backends.
  • docs/operations.md: upgrading, backups, retention, monitoring.