i don't want another boolean from my authorization system

every check endpoint returns one bit. what openfga, spicedb, cedar and the rest actually return, what i do instead, and what returning routes looks like on postgres 19 beta 3.

•Karol Broda•12 min read

at the end of postgres has graphs now i said i had a longer complaint about authorization systems that hand back a bare boolean and throw the reason away. this is that complaint.

openfga, the one i end up reaching for, returns exactly this from its check endpoint:

{"allowed": true}

the question being answered is always some version of "can this principal do this thing to that resource", and the answer that crosses the wire is one bit. i have seen that response enough times to know what happens next. somebody asks why, and the why is not in the response, so somebody goes and finds it.

what i do when i need the why

i query the tables myself, and i doubt i am the only one. in the demo schema from last post, rob has read access to ledger, and the reason is a row in collaborators plus what review means, and finding it by hand is a handful of joins. the plain sql version is in that post. it is not hard.

what bothers me is not that the manual query exists. it is that the authorization system had the answer to it in memory while producing allowed. openfga did not fail to compute why rob can read ledger. it computed it, then returned one bit. the reason is a thing i rebuild afterwards, with worse tools, at whatever time of day the question comes in.

what the engines return

i have not run any of these in production. this section is from their documentation and their protos, which is a limited kind of knowing. protos do not usually lie about wire formats, at least.

openfga's check response message has two fields. the boolean, and a string called resolution marked "for internal use only". the tree of reasons exists as a separate endpoint, expand, which returns a userset tree, and the docs say it is for debugging a relationship. zanzibar, which openfga descends from, is blunt about it in the paper: checks are evaluated by "converting check requests to boolean expressions", and expand exists because clients needed "to reason about the complete set of users and groups that have access to their objects". the paper calls expand crucial for its clients. their word, and still not part of the check.

spicedb does the most here. its check response is a three-valued permissionship rather than a bit, and it can come back conditional with a structured list of missing context fields, which is close to answering "yes, if". the response also has a debug_trace field: a recursive tree of every sub-check, with per-branch results, populated when you set with_tracing on the request. the cli spells that mode --explain. but the field is named debug_trace, the flag is documented as "useful for debugging" and noted to add compute overhead, and the permissionship enum is still the real answer. the reason is in the response the way a stack trace is in an error.

cedar returns the decision plus the ids of the policies that determined it, which is closer than a bit. two problems. a policy id points at a reason without being one, you still have to go read the policy. and the deny most requests actually get is the implicit one, where nothing matched, and for that case the list of determining policies comes back empty.

the rest, quickly. casbin has EnforceEx, which returns the first matched rule and nothing at all for a deny. opa does not force a shape on your decision document at all, so teams return reasons by convention, each with their own schema, and there is a trace mode behind an explain query parameter for when the convention was not followed. aws iam has the best explanation of any of them, matched statements with line and column numbers into the policy document, in a simulator api, so not in production. production returns AccessDenied.

i do not think any of this is stinginess. the tree gets computed on every check in every one of these systems, and shipping it costs a second response shape that has to be designed and kept consistent with the first. each vendor drew the line somewhere and put the tree behind a flag, or a second endpoint, or nothing. spicedb's debug flag is as generous as it gets.

the boolean is a projection

what i keep coming back to is that the product should be the set of routes between a principal and a resource, with the boolean derived from it. allowed means "that set is not empty". in sql terms, literally, exists().

ordered that way, the check, the explanation, the listing and the audit log all read the same object, so they cannot disagree with each other. and since the routes are computed from the tables the application already writes, there is no second store to sync, which is the failure mode i would be most worried about otherwise. (the spicedb people have a good writeup of exactly that problem, from people who ran zanzibar and spicedb at google and canva: the dual-write problem.)

so i built the small version of this on postgres 19, because the tables and the model live in the same place there. beta 3 came out on august 13, and everything below is real output from it.

the same tables, plus a team tree

the five tables from last post are unchanged. companies, users, memberships, projects, collaborators, same rows, grace is still a contractor at northwind, rob and ada still hold direct grants on a project owned by a company neither works for. i added three tables for nested teams, because they will cause trouble later:

create table teams (
  id         int primary key,
  company_id int not null references companies(id),
  parent_id  int references teams(id),
  name       text not null
);

create table team_members (
  user_id int not null references users(id),
  team_id int not null references teams(id),
  role    text not null,
  primary key (user_id, team_id)
);

create table team_access (
  team_id    int not null references teams(id),
  project_id int not null references projects(id),
  primary key (team_id, project_id)
);
 id | company_id | parent_id |    name
----+------------+-----------+-------------
  1 |          1 |           | engineering
  2 |          1 |         1 | platform
  3 |          1 |           | support
  4 |          1 |         2 | runtime

 user_id | team_id |  role
---------+---------+--------
       2 |       1 | lead
       4 |       4 | member

 team_id | project_id
---------+------------
       1 |          1

runtime rolls up into platform, platform into engineering, and engineering can use the compiler project. linus leads engineering, so he is one hop from its access. rob sits in runtime, two hops down.

the access property graph from last post takes the new tables without disturbing the old ones. i am not going to re-explain the syntax, it is in the previous post. the new parts are the last three edge tables:

CREATE PROPERTY GRAPH access
  VERTEX TABLES (
    users     LABEL principal PROPERTIES (id, name),
    companies LABEL tenant    PROPERTIES (id, name),
    projects  LABEL resource  PROPERTIES (id, name, visibility),
    teams     LABEL team      PROPERTIES (id, name)
  )
  EDGE TABLES (
    memberships AS member_of
      SOURCE users DESTINATION companies
      LABEL permits PROPERTIES (
        role AS via,
        (role IN ('owner', 'engineer')) AS can_write
      ),
    collaborators AS invited_to
      SOURCE users DESTINATION projects
      LABEL permits PROPERTIES (
        capability AS via,
        (capability = 'admin') AS can_write
      ),
    projects AS scopes
      SOURCE KEY (company_id) REFERENCES companies (id)
      DESTINATION KEY (id) REFERENCES projects (id)
      LABEL scopes NO PROPERTIES,
    team_members AS joins
      SOURCE users DESTINATION teams
      LABEL in_team PROPERTIES (role AS via),
    teams AS within
      SOURCE KEY (parent_id) REFERENCES teams (id)
      DESTINATION KEY (id) REFERENCES teams (id)
      LABEL part_of NO PROPERTIES,
    team_access AS maintains
      SOURCE teams DESTINATION projects
      LABEL works_on NO PROPERTIES
  );

teams is a vertex table and an edge table at the same time, the same trick as projects last post: the hierarchy exists only as parent_id, so the edge is the table joined to itself through that column.

the view that answers the real question

create view routes as
SELECT * FROM GRAPH_TABLE (access
  MATCH (p IS principal)-[g IS permits]->(r IS resource)
  COLUMNS (p.name AS principal, g.via, g.can_write,
           'direct'::text AS route, r.name AS target)
)
UNION ALL
SELECT * FROM GRAPH_TABLE (access
  MATCH (p IS principal)-[g IS permits]->(t IS tenant)
        -[IS scopes]->(r IS resource)
  COLUMNS (p.name AS principal, g.via, g.can_write,
           'tenant'::text AS route, r.name AS target)
);

create function can(who text, what text) returns boolean
language sql stable as $$
  select exists (
    select 1 from routes where principal = who and target = what
  )
$$;

the ::text casts were not in the first version i wrote. in beta 3, a bare string literal in COLUMNS comes through with no collation, and the create view fails with could not determine which collation to use for view column "route". you find these things in a beta.

with that, the questions:

select * from routes
where principal = 'rob' and target = 'ledger';
 principal |  via   | can_write | route  | target
-----------+--------+-----------+--------+--------
 rob       | review | f         | direct | ledger
(1 row)

rob can read ledger because he was invited to it, with review, which cannot write. for linus, who has no route to ledger at all:

 principal | via | can_write | route | target
-----------+-----+-----------+-------+--------
(0 rows)

which is thinner than the rob row. it still says more than false, because the follow-up question, what routes does linus have, is the same view again, and an interface can render an answer from it rather than a grey button. and the boolean, when something wants just the boolean:

select can('rob', 'ledger')   as rob_ledger,
       can('linus', 'ledger') as linus_ledger;
 rob_ledger | linus_ledger
------------+--------------
 t          | f
(1 row)

the inverse question, the one that needed a second api in zanzibar, is a where clause:

select * from routes where target = 'ledger' order by principal;
 principal |  via   | can_write | route  | target
-----------+--------+-----------+--------+--------
 ada       | admin  | t         | direct | ledger
 grace     | owner  | t         | tenant | ledger
 rob       | review | f         | direct | ledger
(3 rows)

ada by direct invite with admin, grace because she owns beacon which owns the project, rob by invite with review only. that is the sharing dialog and the search question.

the audit log is the same view, written down at check time. the explanation row is the record. there is no logging middleware re-deriving anything after the fact:

insert into access_log (principal, via, can_write, route, target)
select * from routes
where principal = 'rob' and target = 'ledger'
returning checked_at, principal, via, route, target;
          checked_at           | principal |  via   | route  | target
-------------------------------+-----------+--------+--------+--------
 2026-08-20 09:44:38.809082+02 | rob       | review | direct | ledger
(1 row)

and the cost question: it is not free, it is whatever the joins cost, which is what the check would have cost anyway. the plan for rob's route:

                        QUERY PLAN
-----------------------------------------------------------
 Append
   ->  Nested Loop
         ->  Hash Join
               Hash Cond: (collaborators.project_id = projects.id)
               ->  Seq Scan on collaborators
               ->  Hash
                     ->  Seq Scan on projects
                           Filter: (name = 'ledger'::text)
         ->  Index Scan using users_pkey on users
               Index Cond: (id = collaborators.user_id)
               Filter: (name = 'rob'::text)
   ->  Nested Loop
         ->  Nested Loop
               ->  Hash Join
                     Hash Cond: (memberships.company_id = projects_1.company_id)
                     ->  Seq Scan on memberships
                     ->  Hash
                           ->  Seq Scan on projects projects_1
                                 Filter: (name = 'ledger'::text)
               ->  Index Scan using users_pkey on users users_1
                     Index Cond: (id = memberships.user_id)
                     Filter: (name = 'rob'::text)
         ->  Index Only Scan using companies_pkey on companies
               Index Cond: (id = memberships.company_id)

an append over two branches with indexed lookups. a boolean-only system answering the same check would run these joins and throw the rows away.

granting access to the explanation

one thing i did not expect. beta 3 gives property graphs their own grant target, and GRANT SELECT ON PROPERTY GRAPH access TO auditor parses and shows up in \z:

                                   Access privileges
 Schema |  Name  |      Type      |  Access privileges  | Column privileges | Policies
--------+--------+----------------+---------------------+-------------------+----------
 public | access | property graph | postgres=r/postgres+|                   |
        |        |                | auditor=r/postgres  |                   |

i assumed this would let me scope read access to the explanation: give the auditor the graph, keep the tables. it does not. querying as the auditor, with the graph grant and nothing else:

ERROR:  permission denied for table users

it fails the same way through the routes view, after grant select on routes to auditor. a view normally reads the base tables as its owner, that is the whole point of giving someone a view and not the tables. the graph rewrite does not inherit that. it checks the base tables as the querying user regardless. after granting select on the tables as well, the auditor finally gets the rows:

 principal |  via   | can_write | route  | target
-----------+--------+-----------+--------+--------
 rob       | review | f         | direct | ledger
(1 row)

(the GRANT ... ON TABLE access spelling, which worked in beta 1 because a graph is technically a relation, now errors with "access" is a property graph, HINT: Use GRANT ... ON PROPERTY GRAPH instead.)

as of beta 3, a property graph grants nothing and scopes nothing. it is a reading of tables, and you are privileged exactly as if you had queried them directly. a security definer variant is not implemented yet. until it is, the graph is a way to query the tables, not a way to gate them, and the thing i wanted here does not work yet.

what changed since beta 1

the delta since the last post, which was written against beta 1. beta 2 was fixes: a crash in GRAPH_TABLE when a multi-label pattern gets rewritten into a union, dependencies now recorded for the labels and properties a view references, protection against dropping the last label off an element, FOR UPDATE on a graph alias rejected with a real message instead of unrecognized RTE type: 8.

beta 3 kept hardening. duplicate properties and labels fail with real errors instead of unique-index violations:

ERROR:  property "id" specified more than once

and aggregates in COLUMNS are rejected outright:

ERROR:  aggregate functions in GRAPH_TABLE COLUMNS are not supported

that second one is not cosmetic. in beta 1, that query made it past the parser and died in the executor, because the rewriter copied the aggregate into the subquery without telling the planner about it, so no aggregation node got built. the fix turned a crash into an error. no new capability arrived in either beta. the feature is the same one from june.

still no variable-length paths

the missing piece from last post is still missing:

ERROR:  element pattern quantifier is not supported

and the team tree is where that starts to hurt, which is why i added it. engineering can use compiler. linus reaches that grant through zero part_of hops, rob through two. a fixed-depth pattern cannot cover both, and this one, asking for exactly one hop, covers neither:

SELECT * FROM GRAPH_TABLE (access
  MATCH (p IS principal)-[j IS in_team]->(t IS team)
        -[IS part_of]->(anc IS team)-[IS works_on]->(r IS resource)
  COLUMNS (p.name AS principal, t.name AS joined,
           anc.name AS through, r.name AS target)
);
 principal | joined | through | target
-----------+--------+---------+--------
(0 rows)

the general version is the recursive CTE you would have written without any of this:

with recursive chain(start_id, anc_id, hops, path) as (
  select id, id, 0, array[name] from teams
  union all
  select c.start_id, parent.id, c.hops + 1, c.path || parent.name
  from chain c
  join teams t on t.id = c.anc_id
  join teams parent on parent.id = t.parent_id
)
select u.name as principal, c.path as through, c.hops, p.name as target
from users u
join team_members tm on tm.user_id = u.id
join chain c on c.start_id = tm.team_id
join team_access ta on ta.team_id = c.anc_id
join projects p on p.id = ta.project_id
order by principal;
 principal |            through             | hops |  target
-----------+--------------------------------+------+----------
 linus     | {engineering}                  |    0 | compiler
 rob       | {runtime,platform,engineering} |    2 | compiler
(1 row)

the CTE contains no graph syntax and still returns the full path as a column. the property graph's contribution is that the reading of the tables, memberships mean permits, parent_id means part_of, lives in the schema where the engine checks it, instead of in the head of whoever wrote the CTE. when quantifiers show up, the graph catches up, and the CTE becomes the special case.

the zanzibar objection

google did not build zanzibar for fun. at their check volume, route sets on demand would be ruinous, and folding early is what makes checks cacheable, which is most of what zanzibar is. if you have google's traffic, keep the fold. i am not arguing with them.

but most systems are not at that volume, and the pattern i keep seeing is a system that bought the boolean shape by default, then built the explanation back afterwards as logs, or a sync job, or a script someone runs before audits. the manual query i started this post with is the small version of that.

what i do think is true is smaller than "replace your authorization system". checks are a few joins over indexed tables for most applications. the next system that hands you an allowed field computed the routes and dropped them, and the question worth asking of it is whether you can have the rows instead.

if you want to poke at this:

nix shell nixpkgs#postgresql_19
initdb -D ./data
pg_ctl -D ./data -l ./log start
psql -d postgres

sources