Policies (RLS) — ASL
Policies
Section titled “Policies”A policy inside a type body declares a Postgres row-level security policy on
that table. Axel emits a CREATE POLICY and enables RLS on the table
(ALTER TABLE … ENABLE ROW LEVEL SECURITY). Policies filter which rows a role
can read or write — the classic use being to hide rows a query shouldn’t see.
type KV { required key: str { constraint exclusive; }; required value: json; expires_at: datetime;
# Hide rows past their TTL from SELECT (visible = not-yet-expired) policy hide_expired for select using ( .expires_at is null or .expires_at >= now() );}lowers to:
ALTER TABLE "kv" ENABLE ROW LEVEL SECURITY;CREATE POLICY "hide_expired" ON "kv" FOR SELECT USING (expires_at IS NULL OR expires_at >= now());Syntax
Section titled “Syntax”policy <name> for <command>, … [to <role>, …] [using ( … )] [with check ( … )];for <command>, …— one or more ofselect,insert,update,delete, orall. Postgres allows a single command perCREATE POLICY, so a list likefor update, deleteexpands to one policy per command — the generated policies are suffixed (<name>_update,<name>_delete) to keep their names unique. A single-command policy keeps its declared name.to <role>, …— the roles the policy applies to. Omit forPUBLIC.using ( … )— predicate for existing rows: which rows are visible toSELECT/UPDATE/DELETE. A row is visible when the predicate is true.with check ( … )— predicate for new/updated rows onINSERT/UPDATE; a write is rejected when it’s false.
At least one of using / with check is required.
A multi-command policy lowers to one statement per command:
type Event { required topic: str; policy append_only for update, delete using ( false );}CREATE POLICY "append_only_update" ON "event" FOR UPDATE USING (false);CREATE POLICY "append_only_delete" ON "event" FOR DELETE USING (false);The predicate
Section titled “The predicate”Predicates are native AQL expressions — the same language used in
query filter clauses — resolved and type-checked against the type. A .field
reference resolves to that field’s column; and/or, comparisons, is null /
is not null, ??, casts (<uuid>), and function calls (now(),
current_user) all work as in any AQL filter.
global current_user: uuid;
type Doc { required owner: uuid; required title: str;
policy owner_only for all to app_user using ( .owner = global current_user ) with check ( .owner = global current_user );}lowers to (see Globals for how global current_user becomes a
session read):
CREATE POLICY "owner_only" ON "doc" FOR ALL TO app_user USING (owner = current_setting('app.current_user', true)::UUID) WITH CHECK (owner = current_setting('app.current_user', true)::UUID);Traversing links
Section titled “Traversing links”A predicate can follow links, not just read the policy’s own columns.
To-one chains — .organization.owner, .organization.owner.email — lower to a
correlated subquery over the linked table:
type User { required email: str; }type Organization { link owner: User; }
type Workflow { required name: str; link organization: Organization;
policy owner_only for all to app_user using ( .organization.owner = global current_user );}lowers the USING clause to:
(SELECT o.owner FROM "organization" o WHERE o.id = "workflow".organization LIMIT 1) = current_setting('app.current_user', true)::UUIDMembership — <value> in .<multi-link> — tests whether a value is among the rows
reached through a multi-link, lowered to an IN (SELECT …) over the junction table:
type User { required email: str; }type Organization { required name: str; multi members: User;
policy member_can_read for select to app_user using ( global current_user in .members );}lowers the USING clause to:
current_setting('app.current_user', true)::UUID IN ( SELECT u.id FROM "organization_members" jt JOIN "user" u ON u.id = jt.user WHERE jt.organization = "organization".id)A multi-link can only appear as the right side of in (it’s a set, not a value);
using one in a scalar path — .members.email = … — is an error.
One limit remains:
- No bind parameters. A policy can’t take a
$param; pull request-scoped values in through aglobalinstead.
Policies are inherited from abstract parents, so a soft-delete guard can live on a base type:
abstract type Soft { deleted_at: datetime; policy not_deleted for select using ( .deleted_at is null );}type Note extends Soft { required body: str; }The table owner bypasses RLS
Section titled “The table owner bypasses RLS”ENABLE ROW LEVEL SECURITY applies to ordinary roles, but the table owner (and
superusers) bypass it by default. If your application connects as a non-owner
role (the recommended setup), policies apply as written. If it connects as the
owner, the policies are silently ignored — you’d need FORCE ROW LEVEL SECURITY,
which axel does not emit today.
This is usually what you want for a TTL/GC pattern: reads from the app role see only live rows, while a privileged cleanup job (e.g. a pg_cron sweep) still sees expired rows to delete them.
Pairing with a one-time setup: @for
Section titled “Pairing with a one-time setup: @for”The cleanup job above is registered once with the @for <Type> function
directive — a function that axel invokes a single time in the migration that
first creates it (after the type’s table exists), and tags to that type for
tracking:
use extension 'pg_cron';
@for KVfunction kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', 'DELETE FROM kv WHERE expires_at < now()');};The swept SQL can also be written as AQL and compiled in place, so the job is checked against the schema — see Inline AQL:
@for KVfunction kv_gc() -> int64 { return cron.schedule('kv-gc', '0 * * * *', aql`delete KV filter .expires_at < now()`);};emitting, in that migration:
CREATE OR REPLACE FUNCTION "kv_gc"() RETURNS BIGINT AS $$ … $$ LANGUAGE plpgsql;SELECT "kv_gc"();Migrations are diffed by name and run in dependency order (extensions → tables → functions → policies), so the extension exists before the function and the table exists before its policy.