Historical: pg_permissions: Decision guide
Status: this page is a historical record. The user chose D on 2026-10-29, and D is now the extension file
pg_permissions--0.0.1.sql. All six phases of./plan.mdare done (as of 2026-10-30). The extension has 56 spec files and 3,638 assertions on PG 16, 17 and 18. The user closed the last open question (question 11, the covering index) on 2026-11-01.Note, 2026-11-02: the later
cedar_*model that this page mentions is now implemented (extension 0.1.0). Its design isdesigns/e-cedar.md, and its user docs are../docs/models/cedar/overview.md. Statements below that Cedar is “for later” are the state at the time of the decision.The sections that compare A, B and C describe the state before D was chosen, and they are not changed. The usage docs that this page names are now the user docs in
../docs/.
This guide picks no winner among the three designs A, B and C. (See “Session 4 direction” for the direction that the user took after it.) It lays out what the author built and measured. Every claim cites its source.
These are the sources:
- OPT is
./benchmarks/OPTIMIZATIONS.md. It has the numbers of all three designs after the session 3 optimization pass (Postgres 18.6 in Docker). Most numbers in this guide come from OPT. - VER is
./benchmarks/VERSIONS.md. It has the results across Postgres versions. - CMP is
./benchmarks/COMPARISON.md. It has the numbers from before the optimization (local Postgres 16.4). - D.md is
./benchmarks/D.md. It has the benchmark of design D. - The test results come from
./goals.md. The weaknesses come from the design docs A, B and C. A reference such as “A, section 13.1” means a section of the design doc of A.
The text gives section names where a file is long.
“Large” is 1M documents, 10k users and 500 teams (OPT, Large default). “Medium” is 100k documents. “Deep” and “wide” are stress shapes at medium scale. In the deep shape, teams nest 9 levels. In the wide shape, each user is in 50 teams (VER, shape descriptions).
Session 4 direction: per-model tables
Section titled “Session 4 direction: per-model tables”On 2026-10-29 (session 4) the direction changed: the single generic API is dropped. The A/B/C comparison below stays as the evidence for the change. It no longer decides between three designs for one API.
-
Per-model table sets. The
permissionsschema holds a separate set of tables for each model. Each set follows an existing spec as closely as possible.rbac_*tables are the blessed path. They are first-class tables, not wrappers over a core.fga_*tables follow OpenFGA.- An app can use more than one model at the same time, for different things. The models compose in Postgres RLS: permissive policies combine with OR, and restrictive policies combine with AND.
- This is also the graduation path. An app adds the FGA model next to RBAC. It does not migrate off RBAC.
-
Why.
- (a) The findings of the usage docs come from the use of one API for different semantics. The
rbacpreset is not fully identical across designs, and forbid means something different in each design. A forbids only against the own tuple of the subject. C forbids for the same type only. B covers groups. (See “Findings from the usage docs” below, C section 9 and B, Deviations 4.) - (b) The
rbacpreset is flat across designs (under 1 ms, within about 0.1 ms; OPT, Ranking). The designs differ only on sharing shapes, and the optimizations there are specific to a model (the closures of B, the materialized memberships of C). Tables for each model let the author optimize each model for itself. - (c) A spec lets developers and tools reuse what they know already. It also lets the project follow updates of the spec, and a generic layer makes that hard.
- (a) The findings of the usage docs come from the use of one API for different semantics. The
-
OpenFGA, not SpiceDB and not a generic “Zanzibar-style” model. The two have different schema languages, so to follow a spec is to choose one. The user chose OpenFGA for two reasons. The public test suite of OpenFGA can serve as a test oracle, and OpenFGA has CEL conditions on tuples.
-
First spike. The spike has the
rbac_*tables and a core subset of OpenFGA (direct relations, computed usersets, tuple-to-userset, wildcardsuser:*). It runs against the test cases of OpenFGA. It also has one table whose RLS combines RBAC and FGA. The benchmark compares it with the numbers in this guide.- OpenFGA conditions (CEL) are a second stage after the core.
- In pg_tle, CEL means a parser and a compiler written in SQL or PL/pgSQL. This is the largest single cost.
-
Cedar is for later (
cedar_*), with B as the base. Full Cedarwhenandunlessover arbitrary attributes do not compile to index lookups (C, section 9). B already refuses shapes such asresource.owner_id == principal.id(B, Weaknesses). So Cedar will probably be a documented subset, tested against the Cedar corpus. -
No separate ABAC model. ABAC is a category, not a spec. XACML is the only formal standard, and nobody builds new systems on it.
- RLS covers row attributes (
status = 'published', tenant and owner columns). - OpenFGA conditions cover attributes on a relationship (expiry, IP range, amount).
- Cedar will cover policy-language ABAC later.
- One gap stays. The check functions cannot see row attributes that exist only in RLS (A, section 12.4:
check_forreturns true for a draft that RLS hides). - The usage docs must say one of two things. Either the check functions are advisory and RLS is final, or the app passes the row attributes into the check.
- RLS covers row attributes (
-
Shared code is a small toolkit, not an API. It has these parts:
- the subject identity (G1)
- the guard against plan-time evaluation (the dummy last argument with a VOLATILE default
_unfolded_null(), see result 1 of “What the optimization pass taught” below) collate "C"on text ids- the install and pg_tle code (G14)
-
The prototypes. A is closest to OpenFGA, and it is the base for
fga_*. The materialized memberships of C stay a possible optimization if wide reads need them. B is the base for Cedar later.The generic compiled model of C is the part that this direction drops. The prototypes stay in the repo as measured history. The spike was valid, and D is now
pg_permissions--0.0.1.sql(see “Decisions at the end of session 4”, point 1).
The open questions below have notes on what this direction changes. The sections that compare the three designs as candidates for one API are not changed. They are “Where things stand” through “Hybrid ideas (untested)”.
Decisions at the end of session 4
Section titled “Decisions at the end of session 4”The user made these decisions on 2026-10-29, after the OpenFGA report and the opt2 benchmarks. They settle the question of the design for the extension. The work plan is in ./plan.md, “Next work (session 5 onward)”. That work is done: all six phases are done as of 2026-10-30.
- D is the design, and it becomes the extension. In phase 1 of session 5,
prototypes/d-per-model.sqlbecamepg_permissions--0.0.1.sql. The prototype file was removed. The extension file is the source of truth. A, B and C stay in the repo as measured history. The comparison above is not changed. - Names. The schema is
permissions. The table and function prefixes arerbac_*andfga_*. This resolves Q10. - One implicit OpenFGA store for each database. The latest model is the default. A function that receives an older model id still evaluates with that model.
- Result caps on the API functions follow OpenFGA.
- ListObjects and ListUsers return at most 1,000 results.
- Write takes at most 100 tuples for each call.
- Read has pages with
page_sizeandcontinuation_token. - When a call reaches a cap, the behavior follows the documented behavior of OpenFGA.
- The RLS helper functions (
fga_list_objects_current,fga_list_object_ids_current, and any other variant for RLS) have no cap. A cap there would cut off access in silence: a user would lose rows with no error.
- Errors are descriptive.
- The numeric code of OpenFGA stays in
DETAILasopenfga:<code> <name>. - The
MESSAGEsays what was wrong, with the actual wrong values (type, relation, user string, model id). - A
HINTis always present, and it says how to fix the error. It gives the user types that are valid for that relation, the nearest relation name that exists, or the call to make first. - This applies to the
rbac_*errors also, not only to thefga_*errors.
- The numeric code of OpenFGA stays in
- Cycle semantics. D uses the least fixpoint for positive recursion below an exclusion (done in
d/31_fga_cycles). This differs from OpenFGA v1.22.1. The user reported this to the OpenFGA team (./upstream/openfga-cycle-but-not-inconsistency.md). If upstream decides differently, D changes again. - pg_tle is not a further goal. The plan drops the pg_tle test on a managed host (the user told the author not to worry about pg_tle). The spec
03_tle_installfor the Docker pg_tle install still passes. There is no other pg_tle work.
Resolved at the end of session 4 (the open questions below have notes also): Q2, Q3, Q9 and Q10.
Decisions for conditions (session 5)
Section titled “Decisions for conditions (session 5)”The user and the orchestrator made these decisions on 2026-10-29 and 2026-10-30. They answer the open questions of designs/d-conditions.md (section 11), before steps 3 to 6 of phase 4. Phase 4 implemented them, and the design doc records how.
- Storage S1. The design compiles the conditions of a model when it writes the model. It stores them as a jsonb AST in
fga_conditions. There is no parse at Check time, and no DDL at run time (no generated functions). - Evaluator. A PL/pgSQL interpreter walks the tree of the expression over typed values, with the error semantics of CEL.
||and&&absorb an error when the other side decides, in either order, as the probed server does. A walk step evaluates a condition one time for each distinct pair of condition and tuple context. - Fast path. A flag
conditionalon each relation keeps the statements of before when no conditioned edge can be involved. Edges and type restrictions carry aconditioncolumn. - The RLS helpers (
fga_check_current,fga_list_objects_current,fga_list_object_ids_current) get no request context.- They deny a conditioned edge that they cannot evaluate. They never take such an edge, and they never fail.
- An answer that depends on an evaluation error is a deny (an example is a
but notwhose subtract errors). ../docs/rls.mddocuments this (“Conditions in policies”), with a spec.
- Error rule. The rule is the top-down rule of OpenFGA. Check fails with 2000 when a condition that it evaluates errors and no path of true edges exists. This is also true for an edge that is not on the path of the user (as the probed server does). ListObjects and ListUsers fail with 2000 if any edge that their walk takes errors.
- Stale stored contexts. A tuple whose condition or context no longer fits the model is ignored in every API. This is a documented deviation for ListObjects and ListUsers, where OpenFGA fails with 2000.
- Cost limit. An approximate counter counts the nodes that the evaluator evaluates and the iterations of comprehensions. The limit is 100, like OpenFGA. Past the limit, the error is 2000.
- The author calibrated the limit so that
l.exists(e, e == "zzz")over 50 strings (and 20) exceeds it, and every abac case stays well below it. - The docs say that the threshold is approximate.
- The author calibrated the limit so that
- Inline
$expressionconditions are out of scope. The design refuses them with 2000, as the default server does. The 9 abac cases stay skipped asinline-expression. - A rewrite of a key with a different condition under
on_duplicate: ignoreis 2017 with a HINT. OpenFGA answers HTTP 409Aborted, which has no code. bytes,type()anddyn()are refused at model write (2056), with a HINT.- The
0A000 conditions_not_supportederror is removed. Every new raise site has a descriptive MESSAGE, a HINT and a row intest/spec/d/35_error_hints.sql. - The 8
test/fga_langcases that were skipped asconditionsrun where the server and the expectation agree (4 now pass). They stay skipped with a more exact reason where they do not agree (2server-accepts, 2inline-expression).
API naming (session 5)
Section titled “API naming (session 5)”The user made these decisions on 2026-10-30, after phase 5. They are in the commit “rbac_ prefix on RBAC functions, named-argument parameter names”. The spec test/spec/d/04_api_naming.sql pins them. Point 1 is point 2 of session 4, which the RBAC functions had never followed.
- A prefix means a function for one model. No prefix means a function for both models.
- The RBAC functions take the prefix
rbac_and keep their verbs:rbac_create_role,rbac_drop_role,rbac_assign_role,rbac_unassign_role,rbac_has_role,rbac_has_permission,rbac_user_permissions,rbac_users_with_roleandrbac_scopes_with_permission. - The
fga_*names do not change. - Only the functions that both models share have no prefix:
current_subject_id,current_subject_typeanddelete_subject. - A
_at the start of a name means an internal function. - Prototypes A, B and C keep the old names (frozen history). The shared specs in
test/spec/*.sqlstay in that API for them. The extension runs its own copies intest/spec/d/.
- The RBAC functions take the prefix
- Parameter names read well in named notation.
- There is no
p_prefix. Both models use one vocabulary. The names arerole,permissions,inherits,permission,scope,user_id,subject,subject_type,subject_id,object,relation,type,model,model_id,writes,deletes,contextual_tuples,context,user_filters,page_sizeandcontinuation_token. - No name is a reserved, type_func_name or col_name keyword. So no name needs quotes in
name => value. - The positional order of every function does not change.
- Named notation costs nothing at run time. The orchestrator checked this: the same expression in EXPLAIN, and the same time over 1M calls.
- A parameter can have the same name as a column in a function body. Then the code qualifies the parameter with the function name, and the column with its alias. This follows the default
variable_conflict = errorof PL/pgSQL. - The ignored last argument of
rbac_scopes_with_permission(Q9) is namedplan_time_guard. continuation_tokenoffga_readisinout, because the input and the output column share the name of OpenFGA.
- There is no
useris a reserved word, so the user of OpenFGA issubject.fga_check(user => ...)does not parse without quotes.subjectmatchescurrent_subject_*. The docs say that it is theuserfield of OpenFGA, and the JSON tuple keys keep"user". Theauthorization_model_idof OpenFGA ismodel_id.- Every usage example and RLS example passes arguments by name (the usage docs in
../docs/, theirtest/spec/d/90_usage_*specs and the examples in the design doc). The HINTs that show a call show it in the same way. Calls inside the extension and in the other specs can stay positional.
Where things stand
Section titled “Where things stand”Three designs are built as prototypes in ../prototypes/. A is the tuple core: Zanzibar-style tuples and a recursive walk at read time. B is policy-first: Cedar-style policies, and closures that the write precomputes. C is the compiled model: a JSON model and materialized memberships. (This text was written before D became the extension file in phase 1 of session 5.)
All three pass the shared goals G1 to G14 and their own design-specific specs. The counts are A 18/18 files and 199 assertions, B 18/18 and 189, C 21/21 and 229 (see ./goals.md). They return identical visible-document sets for every read policy, at every scale, shape and Postgres version that the benchmark measured. The optimizations changed no checksum (OPT, Checksums).
The rbac preset is the same API and gives the same answers on all three designs. Its p50 latency stays under 1 ms and within about 0.1 ms across designs (OPT, Ranking: rbac_check 0.30 / 0.27 / 0.24, rbac_select_page 0.76 / 0.73 / 0.67). So the choice matters only for apps that graduate to sharing for each object.
The optimization pass moved the trade-offs. The list below compares the designs with the guide from before the pass (same Docker setup, PG 18; OPT, Before to after):
- The nest write of B went from 107 to 0.62 ms (4 clients, large). B is now in the range of A on writes, not in the range of C. The nest write of C went from 300 to 57.7 ms. C is still the slowest by two orders of magnitude, but it is 5 times better.
- The storage of B halved (763 to 378 MB at large). It is now below A (404 MB). C is the lowest at large (182 MB).
nest_checkis a tie between the three designs (0.31 / 0.32 / 0.30 ms at large).- The wide reads of A are no longer an outlier. Wide
share_select_pagewent from 125 to 14.4 ms. That is 1.8 times the 8.20 ms of C, where it was 12 times. A beats B there (B 17.8 ms) on 17 and 18, but not on 16 (OPT, Ranking). - The gaps on the single-branch parents policy are smaller. The page is 45.6 / 13.7 / 4.94 ms for A / B / C (it was 240 / 102 / 9.26).
- Budgets. At large, A misses 3, B misses 1 and C misses 2 (it was 3, 3 and 1; OPT, Budgets).
What still separates the designs:
- the cost to nest a team (A writes one row, B writes closure rows, C writes membership rows under a global lock)
- the single-branch policy and wide reads (C leads)
- storage, which depends on the shape (C is the largest at medium wide)
- the cost of the policy shape and of the data model
pg_tle now installs on PG 16, 17 and 18 for all three designs (G14). The author measured everything on one laptop, in Docker, with one run for each configuration.
Scorecard
Section titled “Scorecard”The latencies are p50 ms from OPT (PG 18.6 Docker, 4 clients, after the optimization), unless a note says otherwise. There is one run for each configuration. The p50 agrees within about 5% between repeats for workloads under 1 ms, and within about 20% for the heavy workloads. So smaller gaps are noise (OPT, Repeatability and replaced runs).
Nobody ran medium default and small scale again after the optimizations (OPT, Caveats). The lines of code come from wc -l of prototypes/*.sql. The function counts are case-insensitive create function statements: public (no _ at the start of the name) plus internal.
| Criterion (plan, section 7) | A: tuple core | B: policy first | C: compiled model |
|---|---|---|---|
| Model flexibility | RBAC, ReBAC, parents (from_parent), user:*, expiry. A forbid works only against the own tuple of the subject, not against a group. Each relation must opt in with unless, or it ignores forbids in silence (A, section 12.3). Conditions are RLS composition, so check_for says true for a draft that RLS hides (A, section 12.4). |
The widest. Forbid with group principals. The design evaluates conditions and compiles them into RLS (B, Schema principal_in_type/id; Deviations 4 and 6). It refuses shapes that it cannot split, such as resource.owner_id == principal.id (B, Weaknesses). |
RBAC, ReBAC, typed model, parents, user:*. Forbid is deny-overrides along the chain, and for the same type only (C, section 9). Conditions are RLS composition. Group memberships cannot expire (C, sections 9 and 12.9). |
Read cost, default shape (large): rbac_check, share_select_page, share_list_objects |
0.30, 1.03, 0.29 | 0.27, 1.00, 0.29 | 0.24, 0.93, 0.25 |
Read cost, deep (medium deep, 9 levels): share_select_page, share_list_objects |
0.76, 0.31 (was 1.42, 0.42) | 0.72, 0.32 (was 1.68, 0.48) | 0.66, 0.28 (was 0.77, 0.28) |
Read cost, wide (medium wide, 50 teams for each user): share_select_page, share_list_objects |
14.4, 4.44 (was 125, 29.1) | 17.8, 8.41 (was 36.9, 11.4) | 8.20, 3.41 (was 10.2, 4.25) |
| Parents, two-arm projection policy (large): page; count | 1.33; 8.42 | 1.15; 8.20 | 1.05; 7.88 |
| Parents, single-branch policy (large): page; count | 45.6; 75.1 (page about 34 times the projection) | 13.7; 21.6 (about 12 times) | 4.94; 11.9 (about 4.7 times) |
| Parents, single-branch policy (medium wide): page; count | 14.7; 13.3 | 13.7; 12.2 | 8.06; 6.61 |
Write cost: write_nest_team (4 clients, rolled back; large / deep / wide) |
0.15 / 0.15 / 0.15 (23.6k tps at large) | 0.62 / 0.63 / 1.47 (6.1k tps at large) | 57.7 / 75.1 / 61.2 (67 tps at large) |
| Write cost: one row (large) | write_assign_role 0.15, write_share 0.15 |
0.14, 0.14 | 0.17, 0.17 |
Check through a nested team: nest_check (large / deep / wide) |
0.31 / 0.34 / 0.37 | 0.32 / 0.34 / 0.34 | 0.30 / 0.30 / 0.31 (a tie between the three) |
| Storage and seed, per-document parent links (large; cumulative size after the parents phase; parents seed time) | 404 MB, 31.0 s (+385 MB for the phase) | 378 MB, 44.8 s (+357 MB) | 182 MB, 17.6 s (+160 MB) |
| Storage and seed, wide sharing (medium wide; size after sharing, sharing seed) | 97.4 MB, 5.88 s | 105.3 MB, 12.56 s | 191.6 MB, 24.71 s |
| Concurrency | No locks. | One advisory lock for closure writes, and a second for role definitions. repeatable read raises 0A000 for those writes, and serializable works (B, Closure maintenance). A nest of users holds the lock about 0.2 ms. A nest of a sub-tree with many inner nodes still writes inner descendants x ancestors rows under the lock (B, Remaining hard limits; not benchmarked). |
One global advisory lock for userset grants, container moves, model changes and rebuild(). A nest holds it about 10 ms (C, section 15.7). repeatable read raises 0A000. Object grants take no lock (C, section 5). |
| Drift risk | None: nothing is derived (A, section 9). | Closure tables can drift. The design and the prototype have no verify or rebuild. Two differential fuzzers compared the optimized closure with the original (168k closure operations, 119k read comparisons, 0 differences). They are not committed as specs (B, Optimizations: correctness checks). |
memberships and ancestors can drift. verify() and rebuild() exist. verify() shares no code with the incremental path. The specs caught each of four copies of the delta that the author broke on purpose (C, section 15.5, c/33). |
| Known open correctness gaps | Reserved name check. parent is a reserved relation, and a typo gives no access and no error. A stored id that is not a uuid aborts the whole ::uuid query. There are no FKs, so orphan tuples accumulate (A, sections 7, 9, 11 and 12.1). The id arrays from list_objects_for have no order. A caller who passes the dummy third argument as a constant enables plan-time evaluation again (A, section 13.1). Plans are pinned generic, so the walk never picks a hash join for a huge frontier (A, section 13.3). |
ids_for as a list does not subtract conditioned forbids or forbids that are based on folders (RLS is exact). can() raises 22023 if the inputs of a forbid are not in resource. The app must generate RLS again when policies change, and lint() is text-based. can() trusts its resource argument (B, Weaknesses). A latent depth-cap bug on delete: the removal of a shortcut edge can leave a shortest remaining path that is longer than 10. The delete drops that pair in silence, so the closure gives a partial answer. The design keeps it for compatibility. A fix is a decision about semantics (B, Remaining hard limits). Policies that the app created before the session 3 install still fold ids() at plan time. RLS from the older apply_rls does the same. This stays until the app generates them again (B, Optimizations, steps 6 and 7). |
The global lock. repeatable read is refused. The ids are uuid-only, and the design assumes they are unique across principal types. The who_can null row for public does not subtract forbids. Model changes that touch usersets rebuild all memberships. Deletes recompute the affected principals, not a delta (C, sections 12 and 15.7). Policy forms other than (select accessible(...))::uuid[] still run accessible() at plan time (C, section 15.8). |
| Lines of code | 871 | 1,810 | 2,012 (the version with 1,871 lines has about 590 lines of compiler, 350 of derived-data maintenance and 250 of preset; C, section 12.1) |
| Function count | 28 (24 public, 4 internal) | 57 (38, 19) | 65 (33, 32) |
Blessed path (rbac preset) |
G3 to G5 17/17, 13/13, 15/15; graduation 5/5; RLS 12/12 (goals page). Thin wrappers of about 50 lines. Roles are defined two times, app and tenant (A, section 6). |
The same results. Generated managed_by policies. Graduation introduces policies, a second concept (B, Blessed path). |
The same results. Each create_role is a new model version. Text signatures, with uuid below. A scope that is not a uuid gives false on reads and 22P02 on writes (C, section 8). |
| Correctness and safety | G2 14/14 on all. Cycles end through union (A, section 12.1). |
Cycles and a depth over 10 are rejected on write, but not on delete (see the gap above) (B, Closure maintenance). | Cycles are rejected on write (23514), and a depth over 10 raises 54001 (C, sections 5 and 9). The strongest validation: typed subjects, and unknown names raise an error on write (plan, section 8 table). |
| Independent identity | G1 11/11 | 11/11 | 11/11 |
| TLE fit | G14 9/9: non-superuser install, and pgtle.install_extension and create extension on PG 16, 17, 18 with pg_tle 1.5.3 (goals page, docker/README.md) |
The same | The same |
| Evolvability | The policy layer for conditions, CEL and Cedar is “the least designed part” (A, section 9). | The natural home: forbid, conditions, and later CEL and Cedar are policy shapes (plan, section 8 table). | CEL would compile to SQL text at model time. Cedar when and unless over arbitrary attributes do not compile to lookups (C, section 9). |
Budgets, --check, PG 18 optimized, p95 (large / medium deep / medium wide) |
Fails 3 / 0 / 0: parents_proj_select_count, parents_select_page, parents_select_count at large |
Fails 1 / 0 / 2: parents_proj_select_count at large; share_list_objects (15.8 against 13) and write_nest_team (1.81 against 1) at medium wide |
Fails 2 / 1 / 1: parents_proj_select_count and write_nest_team at large; write_nest_team at deep and wide |
Budgets, --check at small (before the optimization, not run again) |
Fails 4: both parents_proj_*, both single-branch parents_* |
Fails 4: single-branch parents_*, write_nest_team, nest_check |
Fails 1: write_nest_team |
All three designs pass every session 1 large budget (rbac_*, share_*, write_assign_role, write_share; OPT, Budgets). parents_proj_select_count at large fails for every design (p50 about 8 ms against a 9 ms p95 budget). Current minor releases read every heap page of that bitmap count. The author set the budget on 16.4, which skipped the reads (OPT, Budgets; VER, Calibration). Phase 6 re-baselined that budget to 20 ms for every design (see “Budget re-baseline” below).
The budget of 1 ms for write_nest_team comes from the one-tuple write of A (CMP, Budgets). C misses it by 50 to 100 times at every scale. B passes at large and deep on 18 (p95 0.78 and 0.73 ms), and misses at wide (p50 1.47 ms). On 16, B also misses at large (p95 1.29 ms). Only A still misses the single-branch budgets, which come from C (CMP, Budgets): p95 66 and 92 ms against 32 and 70.
Choose A if
Section titled “Choose A if”- Your sharing graphs are shallow and narrow, or you use mostly RBAC. At large with the default shape, the sharing page of A is 1.03 ms. This is within 10% of the other designs. A meets the session 1 budgets (OPT, Large default; Budgets).
- You want the smallest extension to maintain: 871 lines, 28 functions, no derived data, no drift, no locks and no limits on the isolation level. This is now the main advantage of A over B, not the speed of the nest. The nest of B is 0.62 ms against 0.15 ms for A (OPT, Ranking), and both are far under the 57.7 ms of C.
- Team and membership edits are frequent or bulk (directory syncs, nests of big teams), and you want no write amplification.
- A writes one tuple at 23.6k tps with 4 clients.
- B writes closure rows under a lock that it holds about 0.2 ms for a team of users (6.1k tps).
- A nest of a sub-tree of many teams in B writes the product of descendants and ancestors. Nobody benchmarked this (B, Remaining hard limits).
- You want the familiarity of OpenFGA and SpiceDB (A, section 1).
- You accept to write the two-arm projection policy (an
org_idorfolder_idarm and a shared-ids arm) for org or folder access. - A loses in these cases:
- The single-branch parents policy: 45.6 ms for the page and 75.1 ms for the count at large (9 and 6 times C). A is the only design over those budgets.
- Wide memberships: 14.4 ms at 50 teams for each user (1.8 times C and, on 17 and 18, ahead of B).
- Storage at large: 404 MB, the most of the three designs. This includes a 6.8 MB edge-kind index that the optimization added (A, section 13.1).
- The read cost still grows with what the subject reaches (A, section 13.3).
rbac_checkgot 10 to 20% slower in every cell (0.26 to 0.30 ms; OPT, Other differences).
- Before the optimization, the wide page of A was 125 ms (12 times C). The large single-branch page was 240 ms in Docker (221 and 509 ms on local 16.4). Docker runs these paths about 2 times faster (VER, Calibration).
Choose B if
Section titled “Choose B if”- You need forbid on groups, or conditions that the design evaluates, in the first year. B is the only design that has them (B, Deviations 4 and 6; plan, section 8, “The real decision”).
- Your roadmap is Cedar-like policies (CEL, attribute rules), and a policy table is the natural place.
- Your keys are text or bigint, not uuid (B, Schema: text ids).
- You want generated RLS (
rls_expression,apply_rls), and you can generate it again when policies change. - After the optimization, B is no longer the worst on writes or on storage, the axes where it was the worst before:
- The nest is 0.62 ms (it was 107).
- The storage at large is 378 MB, below the 404 MB of A (it was 763).
nest_checkis 0.32 ms (a tie).- The single-branch page is 13.7 ms (2.8 times C, and 3.3 times faster than A), and the count is 21.6 ms (OPT, Ranking).
- B is the slowest of the three on wide sharing reads (17.8 ms page, 8.41 ms list; C 8.20 and 3.41, A 14.4 and 4.44). It misses the budget of
share_list_objectsat medium wide (OPT, Budgets). It misseswrite_nest_teamat wide (1.47 ms). It is the second largest design (1,810 lines and 57 functions, after the 2,012 lines and 65 functions of C). can()andids()of B are two implementations of one semantics. They agreed on 2,400 random cases. This was an ad hoc check, and it is not committed as a spec (B, Partial evaluation). The optimized closure has the latent delete bug that the open questions describe.
Choose C if
Section titled “Choose C if”- Reads dominate and the groups are wide or deep. The reads of C barely move between shapes (
share_select_page0.66 ms deep, 8.20 ms wide at medium). On wide, C is 1.8 times faster than A and 2.2 times faster than B (8.20 against 14.4 and 17.8). The optimization reduced the lead over A on wide reads from 12 times to 1.8 times (OPT, Ranking). So this is now a smaller reason to pick C than it was. - You want the single-branch RLS policy (no hand-written
org_idarm) at large scale. The page is 4.94 ms for C, against 13.7 for B and 45.6 for A. The count is 11.9 / 21.6 / 75.1 ms. The projection is also about 4.7 times cheaper for C (1.05 ms; OPT, Large default). - Nests of teams are rare or an action of an admin, and a global write lock is acceptable.
- A nest costs 57.7 ms at large with 4 clients (67 tps; it was 300 ms).
- The wide sharing seed of C took 24.7 s (it was 176 s; A 5.9 s, B 12.6 s).
grant_many()loads the wide seed in about 8 s (C, section 15.4, local, not in the benchmark seed).
- Your ids are uuids, and you want typed validation and unknown names that raise an error on write.
- C has the smallest storage at large (182 MB). It has the largest storage at medium wide (191.6 MB against 97.4 for A and 105.3 for B), because it materializes memberships for each user.
- C loses in these cases:
- It has the largest and most complex write path (2,012 lines, 65 functions).
- Its nest is far over the 1 ms budget at every scale, and it is the only design where this is true (OPT, Budgets).
- It refuses
repeatable readfor writes that take a lock. - Its ids are uuid-only, and it has the global lock.
Hybrid ideas (untested)
Section titled “Hybrid ideas (untested)”Nobody built or measured any of these ideas.
- The design of A with the materialized memberships of C behind the same functions.
plan.md, section 8, notes that A and C share almost the same API.- This targets the wide and deep reads. But after the optimization the gap is 14.4 against 8.20 ms on the wide page (it was 125 against 10.2), so the case is weaker.
- It would also inherit the global lock of C and the drift risk. It would inherit the write path that is most of the code of C (C, section 12.1). It can end as C without the typed model.
- Object grants would still take no lock (C, section 5).
- The projection policy as the blessed RLS pattern, for every design. Write the two-arm form: shared ids or a parent column (
org_id,folder_id).- At large it is 1.33 / 1.15 / 1.05 ms (A / B / C). The single branch is 45.6 / 13.7 / 4.94 ms.
rls_expressionof B already generates this shape.- The cost is that org or folder access lives in the policy. So
checkandlist_subjectson the design side need the parent links to stay complete (A, section 12.1). - The projection matters less than before. The design is no longer the bottleneck for B and C on the single branch.
- Ship only the
rbacpreset first, and pick the sharing design later. The API and the answers of the preset are identical on all three designs, and its latency is flat (OPT, Ranking). So the author could release it on the smallest design and replace the design later.- The risk is graduation. In C, graduation is one
apply_model()call. In B, it introduces policies. In A, it is an extraorbranch (C, section 8; B, Blessed path; plan, section 6). - Nobody tested if the data must migrate between designs.
- The risk is graduation. In C, graduation is one
What the optimization pass taught
Section titled “What the optimization pass taught”Four results apply to any design. They are the reason that plan section 4 got four rules. The design docs measured each result locally (16.4, 1 client, interleaved). OPT measures their combined effect in Docker with 4 clients, and not each result alone.
- The planner runs STABLE functions at plan time. The planner estimates the row count of
array(select unnest(f('x'))::uuid). For that estimate, it evaluatesfwhen its arguments are constants (estimate_expression_value). So every RLS query ran the whole list function two times.- The numbers: the plan time of the medium wide sharing policy of A was 11.7-12.8 ms (3,564 buffers). Now it is 0.22 ms (112 buffers, catalog lookups only). B went from 3.9 to 0.24 ms, and C from 3.2-3.4 to 0.27 ms (A, section 13.1; B, step 7; C, section 15.8).
- A VOLATILE mark on the functions stops this, but it gives up the snapshot of the statement. Under read committed, a volatile function takes a new snapshot for each query that it runs. So the function can read grants at a later instant than the rows that it filters. B showed this, and the fix is in A and C.
- The fix that keeps the snapshot is a last argument whose default is a VOLATILE function that returns null (
_unfolded_null()). The call is then not constant, but the function stays STABLE. - The cost is an ignored extra parameter in the signature (
booleanin A and C,jsonbin B). A caller who passes every argument as a constant folds the call again. - Boolean quals (
has_permission,can) and(select f())::uuid[]are not evaluated early. - The first pass missed cases in B (
scopes_with_permission, and the constant resource condition thatrls_expressionemitted). An EXPLAIN and a call-count probe found them (B, step 7;22_no_plan_time_eval).
- Collation on text ids. The default database collation (
en_SG.UTF-8here) sent every B-tree comparison, join anddistinctsort of a uuid-as-text throughstrcoll.collate "C"on the id columns changed these numbers:- B, medium wide
share_list_objects: 24.5-25.3 to 6.8 ms - B,
nest_check: 2.2 to 0.79 ms (thedistinctsort of 6.7k ids was 19 of 29 ms) - A, wide page: 18.1 to 16.2 ms
- Equality does not change. Only the order of arrays changes.
- The
strcollof Linux is slower than that of macOS, so Docker must gain more (OPT did not isolate this). - C uses uuid columns, so the collation does not affect it.
- B, medium wide
- PL/pgSQL against SQL. A SQL function with a
SETclause orsecurity defineris never inlined. On Postgres 16, Postgres plans its body again on every call.- The move of the hot wrappers of B to PL/pgSQL took
rbac_checkfrom 0.39 to 0.29 ms. It took 0.08-0.2 ms off each call of_parent_keys. - In Docker, the RBAC reads of B are 1.2-1.7 times faster on 16 and 17, and 1.0-1.2 times faster on 18 (OPT, Other differences). The explanation that 18 caches those plans is the explanation of OPT. The author did not verify it here.
_reachof A and_memberships_forof C are PL/pgSQL for the same reason (prototypes/README; C, section 15.2).plan_cache_mode = force_generic_planfixed functions whose custom plans Postgres planned again on every call (Bcan()0.66 to 0.11 ms). The price is that the planner never picks a hash join for a huge frontier (A, section 13.3).- Set-returning helpers also need a
rowsestimate. The planner assumed that_parent_keysof B returned 1,000 rows, and it hash-joined a seq scan ofancestors. This tookcan()from about 1.3 to 17-22 ms.
- The move of the hot wrappers of B to PL/pgSQL took
- Closures over containers only. The closure rows for each document in B were the cost behind its nest write and its storage. B now stores rows only for entities that have children (C already did this).
- The nest went from 107 to 0.62 ms, the large storage from 763 to 378 MB, and the parents seed from 121 to 45 s.
- The groups of a user are then one hop further, so wide reads got 5 to 8% slower (B, step 4).
- The nest gain of C came from a different change: a delta insert instead of a recompute (C, section 15.1; 300 to 57.7 ms).
- One caveat: the optimized closure keeps a depth-cap bug on delete that was present before (open question 8).
Cross-design observations from OPT to keep:
- Docker and local numbers differ by up to 2 times for the paths that use much PL/pgSQL (VER, Calibration). So the optimization factors that the design docs measured locally are larger than the factors in Docker. An example is the single-branch page of A: 505 to 35 ms locally, and 240 to 45.6 ms in Docker. The values after the change reproduce within 1.3 times (OPT, Claims).
- Medium wide
share_list_objectsfor B improved on 16 (8.96 to 4.47 ms), but not on 17 (9.02 to 9.26). It improved only in part on 18. The author reproduced this and did not investigate it (OPT, Other differences).
Open questions for you
Section titled “Open questions for you”-
Do you need forbid on groups and conditions that the design evaluates in year one? Yes points at B. No leaves A and C. A has forbid on direct subjects, and C has deny-overrides. Both handle conditions in RLS (G12 9/9 and G13 6/6 pass on all three designs).
- Session 4: reframed. The group forbid of B goes to the later
cedar_*model. FGA conditions are the second stage offga_*. Neither is in the first spike. - Closed (phase 6): D has both. A group deny is
but notin the model (d/90_usage_fga). Conditions shipped in phase 4 (d/37,d/39,../docs/conditions.md). Their cost is measured (D.md, opt3, “What conditions cost”).
- Session 4: reframed. The group forbid of B goes to the later
-
Is uuid-only acceptable? C needs it, and it assumes that uuids do not collide across principal types (C, section 12.11). A and B take text and cast to
uuid[]in RLS (plan, section 4). Resolved at the end of session 4: D is the design and it takes text ids (text collate "C"), so uuid-only (C) does not apply. -
Is a global write lock acceptable? After the optimization this applies mainly to C: 67 tps with 4 clients at 58 ms for each nest (pg18 large report). The lock of B is held about 0.2 ms for a team of users (6.1k tps), and longer for the nest of a sub-tree. Does your app edit teams at the same time, and how big are the teams and sub-trees?
- Session 4: this applies only if the project adopts the materialized memberships of C later, as an optimization of
fga_*. - Resolved at the end of session 4: not applicable. C is not the design, and D materializes no memberships (a write is one row).
- Session 4: this applies only if the project adopts the materialized memberships of C later, as an optimization of
-
Is the refusal of
repeatable readacceptable? B and C raise0A000for writes that take a lock. ORMs that use it by default would need a different isolation level for permission writes (C, section 12.3). Closed (phase 6): not applicable to D. D takes no advisory locks and refuses no isolation level. -
Do you need single-branch RLS policies, or is the two-arm projection acceptable as the documented pattern? The answer decides how much the 4.94 ms of C matters (against 13.7 for B and 45.6 for A).
- Session 4: this still applies to the
fga_*tables and to the table that combines RBAC and FGA in the spike. - Closed (phase 6): the single-branch policy of D costs the same as the two-arm projection at large. The page p50 is 1.05 against 1.04 ms, and the count is 7.76 against 7.89 ms (
pg18-docker-d-large-default-p6.md). The reason is that D composes RBAC and FGA. It does not walk org roles. The app picks the shape when it writes the policy. The extension has nothing left to decide.
- Session 4: this still applies to the
-
Are per-document parent links acceptable? A mirror of a column adds +160 to +385 MB at 1M documents (OPT, Seed time and storage). Closed (phase 6): the parent links of D cost +305 MB at 1M documents. The parents seed took the size from 18.6 to 323.7 MB at large, and from 10.0 to 40.6 MB at 100k documents (
pg18-docker-d-{large,medium,small}-default-p6.md). This is the same range as A (+385) and B (+357). The extension stores parent links only when the app writesparenttuples. -
Do you want sharing in the first release at all, or
rbaconly? (Hybrid 3.) Session 4: resolved. The first spike hasrbac_*and a core subset of OpenFGA (Hybrid 3 is not the plan). -
For B: must a delete be rejected (
23514) when it would leave a shortest path over 10, or must the depth have no cap? Today the delete succeeds and drops the closure pair in silence. This is against the rule that the cap applies on write. The optimized design reproduces it on purpose, because the fuzzers compared against the original (B, Remaining hard limits). It is a change of semantics, so it is the decision of the user.- Session 4: this matters when the project builds Cedar on B. It does not matter for the first spike.
- Closed (phase 6): not applicable to D. D stores no closure (see “What is not measured” and the findings of the usage docs).
-
Is an ignored last parameter on
scopes_with_permissionand the list functions acceptable in the public API? It keeps the functions STABLE without plan-time evaluation (result 1 of “What the optimization pass taught”). Plan section 6 says that the API of therbacpreset is identical in every design. The one-argument call is identical, but the signatures now differ (booleanin A and C,jsonbin B), and the grants must name them.- Session 4: this still applies, to the shared toolkit (point 7). The signatures are not compared across designs any more, because there is no common API.
- Resolved at the end of session 4: the signatures are those of D. The FGA list functions return
setof textand take no dummy argument.scopes_with_permissionkeeps(text, boolean)because the shared specs pin it. - Session 5 (API names): it is
rbac_scopes_with_permission(permission text, plan_time_guard boolean).
-
Carried over from plan section 9: the schema name, and a convention for who can grant. The project built and tested public access (plan section 9, question 3) on all three designs (G11 7/7).
- Session 4: the schema name is still open. The per-model tables live in
permissions. - Resolved at the end of session 4: the schema is
permissions, with the prefixesrbac_*andfga_*. The convention for who can grant stays as built: writes are admin-only, and clients use the current-subject functions (G2).
- Session 4: the schema name is still open. The per-model tables live in
-
The covering user index
fga_tuples_user_idx ... include (object_id, condition_name). It makes the probes of the forward walk index-only. Phase 5 measured it (D.md, opt3, “Pending user decision”):- Storage at large: +120 MB (323.7 to 444.1 MB, +37%).
- Speed through pgbench: no gain (wide
share_list_objectsp50 4.90 against 4.85 ms). In the server the index is 7% faster. - Plan change: the index flips a plan, and large
plain_list_usersgoes from 0.35 to 12.95 ms until the app pins plans.
The recommendation was not to add it. Closed (2026-11-01): not adopted. The user decided not to add the index. It is not in the extension. If a later workload needs it, it is one
create indexline and a plan pin.
Decision after phase 6 (2026-11-01): no covering index
Section titled “Decision after phase 6 (2026-11-01): no covering index”The user closed open question 11 on 2026-11-01. The decision is not to adopt the covering index include (object_id, condition_name) on fga_tuples_user_idx. The reasons are the numbers in question 11:
- +120 MB at large (323.7 to 444.1 MB)
- no gain through pgbench (4.90 against 4.85 ms), and 7% faster in the server
- a plan flip that takes large
plain_list_usersfrom 0.35 to 12.95 ms
The extension keeps the index without the extra columns. No open question waits for the user now.
What is not measured
Section titled “What is not measured”The text of phase 6 of session 5 updated this section. Each item says what settled it, or why it stays open. The sources are OPT (./benchmarks/OPTIMIZATIONS.md), VER (./benchmarks/VERSIONS.md) and D.md (./benchmarks/D.md).
Closed in phases 5 and 6:
- Small and medium default after the optimizations. Measured for D, A, B and C on PG 18 in Docker (tag
p6; D.md, “Phase 6”). D misses no budget at either scale. It is within 1.1 times the best design on reads at medium and large (1.0-1.4 times at small). The result of--checkat small in the goals page stays as history from before the optimization. - The cost of a condition, and non-monotone relations (
but not,and). The benchmark measures them from phase 5 (D.md, opt3: conditions twins,block_*andintersect_*). Thebut notListObjects that took 2.1 s for each call went to 4.2 ms in opt2. The widebut not (blocked or muted)is 7.6 ms in opt3. - A nest of a sub-tree, and deletes, for D. A nest is one
fga_writerow, and D keeps no closure or membership to maintain. So there is no sub-tree cost to measure:write_nest_teamis 0.13-0.15 ms at every scale (D.md, Phase 6). The closure cost of B and the recompute of C stay history. The benchmark has no delete timings for D. - Single-client writes in Docker. Not applicable to D (no lock, so 4 clients do not queue). For C and B, the figures stay as the local 16.4 numbers of the design docs (C 15.5 ms, B 0.17 ms at large). Nobody will optimize those designs, so they are not worth a new measurement.
- The miss of the large
parents_proj_select_countbudget (every design, a floor of heap reads on current patch releases). The budget is now 20 ms (see below). - Large model changes, and the InitPlan count of B. Closed, not applicable to D. D has no
membershipsrebuild, and the InitPlan count came from the per-table policy functions of B. The author did not measure runtime role churn (rbac_create_rolein a loop). See the open list.
Still open (recorded, not planned):
-
pg_tle beyond the install test.
03_tle_installrunspgtle.install_extensionandcreate extensionas a non-superuser on PG 16, 17 and 18 with pg_tle 1.5.3. It passes for D (and for A, B, C). Not run: an install through database.dev (dbdev install) or on a hosted platform. The user dropped the managed-host test at the end of session 4. The benchmarks load the designs withpsql -f, not through pg_tle. -
Real hardware and durability. The benchmark ran on one Apple Silicon laptop with
fsync=offandfull_page_writes=off. Every write rolls back, so the commit cost is not measured. The host is never idle.Phase 6 ran next to two idle Supabase stacks, a freereps stack and an openfga container. The user used the laptop at the same time (load average 4-8). The author discarded one batch of small runs as disturbed (D.md, Phase 6).
-
Concurrency mixes. Each workload runs alone. The benchmark has no reads under concurrent writes, and no mixed read and write load.
-
PG versions. D is benchmarked on PG 18.6 only (opt, opt2, opt3,
p6). A, B and C also ran on 16.15 and 17.11, before and after the optimization (VER, OPT), with the same order except where noted. Not measured: D on 16 and 17, the optimized designs locally on 16.4 in the own setup of the benchmark, and a hosted platform. -
Repeat runs and confidence intervals. The runs are single runs with a window of 10 s for each workload. The spread is real. The large
parents_proj_select_countp95 for the same D file was 9.35, 13.25 and 8.24 ms in three runs (opt3,p6,p6check).The p50 was within 0.4 ms. Judge a change by p50 and by paired runs (opt3 did this). Treat a single p95 as noisy. Also not measured: bloat from churn, and checksums beyond 8 sample users.
-
A user who sees most of 1M documents, and the array of 1M elements that the RLS helper
fga_list_object_ids_currentwould return for that user. (This helper has no cap.) The sample users see about 20k at large. -
Runtime role churn.
rbac_create_roleandrbac_assign_rolein a loop. The benchmark seeds roles one time.
Budget re-baseline (session 5, phase 6)
Section titled “Budget re-baseline (session 5, phase 6)”Phase 6 made this change, and the header of bench/budgets.tsv records it. The large parents_proj_select_count budget moves from 9 to 20 ms for every design. There is no exemption for one design.
The reason is a floor of heap reads. The bitmap heap scan reads about 10,900 all-visible heap pages on Postgres 16.15, 17.11 and 18.6. (Version 16.4 skipped them, and the 9 ms budget was set there.) The floor is p50 7.5-9.4 ms for A, B, C and D alike. Over the 27 large reports on PG 16-18 that existed (D.md, Phase 6, from bench/baselines/pg*-docker-*-large-default*.md), the workload missed the budget 22 times. The worst p95 was 16.1 ms (A, p6).
20 ms is above every measured p95, and it is 2.1 times the worst p50. It is stricter than the 25 ms that the rule of the session (3 times the fastest p95) would give. --check at large passes for D (pg18-docker-d-large-default-p6check.md: p50 7.49, p95 8.24 ms against 20; exit 0). The single-branch rows of A and write_nest_team of C still miss their own budgets at large, as before.
Findings from the usage docs
Section titled “Findings from the usage docs”The author wrote a usage guide for each design, and ran every example as a spec (test/spec/90_usage_rbac.sql, a/90, b/90, c/90). That work gave these findings. The prototypes do not fix any of them. Each one is documented on the relevant usage page. (The usage pages for D are now in ../docs/.)
Phase 3 of session 5 checked each bullet against D. It did this when it wrote the usage pages of D (../docs/README.md, ../docs/fga.md, ../docs/rls.md; each example is a spec). Each bullet ends with its status:
- closed as not applicable to D
- closed as fixed in D (with the spec that shows it)
- carried (D has the problem, and the note says what to do)
The tally: 13 not applicable, 6 fixed, 3 carried (the recheck added one of them, the last bullet).
The rbac preset is not fully identical across designs (plan section 6 says that it must be):
current_subject_id()returnstexton A and B, butuuidon C. On C, a JWTsubthat is not a UUID (for exampleuser_2abc) means no subject. Closed: not applicable to D. There is one design, andcurrent_subject_id()returnstext, so anysubis a subject.- C raises
22P02inassign_rolefor a user or scope that is not a UUID. A and B accept any text. Closed: not applicable to D. The user ids and scopes of D aretext, and D never casts them. scopes_with_permissiontakes(text, boolean)on A and C, but(text, jsonb)on B, because of the dummy argument. C also hasuuidoverloads ofhas_permissionandhas_role. Closed: not applicable to D. There is one signature,(text, boolean), and there are nouuidoverloads (../docs/rbac.md).- There is no common cleanup call:
delete_subject(A),delete_entity(B),forget_subject(C). Closed: fixed in D.delete_subject(type, id)removes the role assignments of a subject and every FGA tuple that names it, in one call (design doc, section 11, item 5). The specs ared/30_fga_apiandd/90_usage_rls, where a trigger on a users table removes the role of Dave and his three tuples. - Under
repeatable read, B refusescreate_roleanddrop_role. A and C let them run. Closed: not applicable to D. D takes no advisory locks and refuses no isolation level.
Security-relevant RLS footguns:
- A: the recommended two-arm projection policy ignores a forbid on one document, because the folder arm never visits the document. A forbid on a document needs the single-branch shape or a forbid on a folder. Nobody checked if the two-arm policy of C behaves in the same way. Closed: fixed in D for the recommended policy.
- Parents are part of the OpenFGA model. So a single FGA arm on a relation with
but nothonors a deny on one document (can_view: viewer but not blocked, withviewerinheritedfrom parent). - The specs are
d/90_usage_fgaandd/32_fga_set_based. - In
d/90_usage_fga, Carl is blocked on a document that he reaches through two folders and a nested team, and he is not incan_view. d/32_fga_set_basedcompares the lists that the RLS helper returns with Check.- A projection arm that the app writes itself (
folder_id = any (...)), or an RBAC arm, still bypasses a deny of the model.../docs/rls.md(“Policies”) says to list the relation that contains the deny, or to use a restrictive policy, asd/90_usage_rlsdoes for Dave.
- Parents are part of the OpenFGA model. So a single FGA arm on a relation with
- B: RLS does not enforce a conditioned forbid until the app runs
apply_rlsagain, andlint()does not flag a newviatype or column. Closed: not applicable to D. D generates no RLS (the policies belong to the app, and they read the tables at query time). From phase 4, D has conditions. The RLS helpers evaluate them at query time like everything else, so a new condition or model applies to the next query. - B: a row with a null parent column is hidden when the user has a parent with a deny (
not (null = any(...))is null). Herecan()says yes, but RLS says no. Carried: D has it in its recommended restrictive policy on a column that can be null.org_id <> all (array(...))is null for a nullorg_id, so the row is hidden when the list is not empty.- What to do: write
org_id is null or org_id <> all (...). ../docs/rls.md(“Policies”) says so.d/90_usage_rlschecks the null result. It also checks that Dave keeps his personal document (no org) when a ban excludes him from an org.
- What to do: write
- B: conditions are only partly validated on write. An unknown operator raises an error at evaluation time, and then
rls_expressionemits SQL that does not work. Closed: fixed in D. From phase 4, the model write parses and type-checks the conditions of a model. An unknown operator, function or identifier, a type error, and an expression that is not bool are all 2056 (d/37_fga_conditions_model). A write validates the condition and the context of a tuple (d/39_fga_conditions_eval). - B: the depth cap drops a path after a delete (open question 8).
b/90reproduces this, and it asserts the current behavior. Closed: not applicable to D. D stores no closure. Walks run at read time with the depth limit of OpenFGA (d/33_fga_walk_pruning).
API gaps and quirks:
- A:
grantis an upsert, so if you leave outp_expires_at, it clears the expiry. Closed: not applicable to D. There is no upsert and no expiry. A write of a tuple that exists is 2017, unlesson_duplicateisignore(d/30_fga_api).grantdoes not validate the subject type. Closed: fixed in D. The call checks every written or contextual tuple against the type restrictions of the relation. The error is 2027, with the valid types in the HINT. The specs ared/30_fga_api,d/35_error_hintsandd/90_usage_fga.revokewithout the subject relation returns false in silence. Closed: fixed in D. A delete infga_writeof a tuple that does not exist is 2017, unlesson_missingisignore. An example isteam:engwhere the call meantteam:eng#member(d/30_fga_api).fga_delete_tuplestays idempotent by design and says nothing, as../docs/fga.mddocuments.- A typo in
from_parentgives no access in silence. Closed: fixed in D. A relation with a wrong name is 2056 at model write. In a call it is 2000 (Check and the current-subject functions, RLS included) or 2022 (ListObjects, ListUsers), with the nearest name in the HINT. The specs ared/35_error_hints,d/36_fga_model_validation,d/90_usage_fgaandd/90_usage_rls.
- B:
- No API can undefine a role, remove an implication or disable a policy. Carried: D has it for roles.
drop_roleexists, but nothing changes the permissions or the inherited roles of a role, anddrop_roleremoves its assignments. So a change is: drop, create and assign again (../docs/README.md,drop_role). What to do: analter_role-style call (follow-up). Policies: not applicable (OpenFGA models have versions; write a new one). apply_rlscannot handle keys that are not uuid. Closed: not applicable to D. D generates no RLS.explaincan return duplicate rows. Closed: not applicable to D. D has noexplain.
- No API can undefine a role, remove an implication or disable a policy. Carried: D has it for roles.
- C:
-
apply_modelloses some rows from the plan that it returns. Closed: not applicable to D.fga_write_authorization_modelreturns only the model id. -
Dry runs use up version numbers. Closed: not applicable to D. There is no dry run. A model that D refuses rolls back and does not become the latest (
d/36_fga_model_validation). -
apply_modelskips therepeatable readcheck. Closed: not applicable to D. There are no locks. A model is a set of new immutable rows. -
No API lists raw grants or purges expired grants. Closed: fixed in D for the list.
fga_readreturns the stored tuples, filtered and in pages (d/34_fga_limits,d/90_usage_fga). The purge is carried: D has it from phase 4.A grant that expires through a condition (
non_expired_grant,../docs/conditions.md) stays a tuple after it expires: false, but stored. What to do: purge from the app withfga_readandfga_writedeletes. A purge function must evaluate conditions with a chosencurrent_time.
-
Checked against D, not in the lists above:
- Row attributes that exist only in RLS are invisible to the check functions (session 4 direction, point 6; A, section 12.4). Carried: D has it.
fga_check_currentsays true for a draft that RLS hides. What to do: treat the check functions as advisory and RLS as final, or pass the attribute in from a backend as a contextual tuple.../docs/rls.md(“Row attributes that only RLS sees”) documents both, andd/90_usage_rlschecks both.
These are issues at the level of a prototype. Whichever design the user chooses, its list becomes the first work to make it stronger.