A structural trap found while implementing #8095. The instance is fixed in that PR; the trap is general and the next author to do the same thing will hit it the same way.
The shape
member_default is bound to the everyone anchor, so it resolves for every authenticated principal — org owners and admins included. RLS policies OR-combine across resolved sets, so an admin stays unnarrowed only while some policy of theirs still admits the rows a new member_default policy excludes. Two mechanisms are supposed to provide that, and on the defaultsingle posture neither does:
- The superuser short-circuit.
computeLayeredRlsFilter skips Layer 1 entirely when the caller holds viewAllRecords on a posture-permitting object. ADR-0105 D4 withholds exactly those bits from a wall-less deployment — auto-org-admin-grant hands out organization_admin_no_bypass instead of organization_admin whenever no wall is enforced. - The per-object
_org carve-out.organization_admin ships sys_member_org, sys_invitation_org, sys_team_org, each organization_id == current_user.organization_id. ADR-0105 D3 has collectRLSPoliciesstrip platform tenant policies when org isolation is inactive — correctly, since the token means nothing without a wall.
So on a stock single boot an org admin reaches those objects through no policy at all, and "no applicable policy" is what RLSCompiler.compileFilter turns into a null filter, i.e. unrestricted. The admin's whole read is riding on an absence. Introduce one narrowing policy on that object in member_default and the absence becomes a predicate — for the admin too.
Measured
Implementing #8095 (row-scope sys_invitation to its addressee) with only the member-side policy in place, on a stock showcase boot:
posture: single, isolationActive: false
owner grants: [organization_admin_no_bypass]
GET /api/v1/data/sys_invitation as the org OWNER -> 200, total: 0
The Setup app's Invitations page would have gone blank for the one persona entitled to it, on the default posture, as a side effect of a member-side security fix. Caught only because that PR's fixture asserts the owner control alongside the two narrowing assertions.
Currently latent elsewhere, not currently broken
sys_member and sys_team are in the same state today — widened for admins only by a strippable _org policy, narrowed by nothing in member_default. Nothing is wrong right now precisely because no narrowing exists for them. If one is ever added (a plausible follow-on to #8095: sys_member was explicitly left alone there as a legitimate staff directory, but sys_team has had no such ruling), it will reproduce this exactly.
What #8095 did about it
Stated the admin admission in its own right rather than relying on the absence — a second policy on organization_admin, positions-domained to org_owner / org_admin, carrying no tenant token for the strip to key on. The domain is on the widening so it can only add: a principal it does not match keeps the narrow scope and fails closed. Putting the domain on the narrowing instead inverts that, because "no matching position" means "no policy" means no row filter at all.
Worth deciding
That fix is per-object and hand-rolled. Options a maintainer may want to weigh:
- Leave it per-object. Each future narrowing pays the same two-policy cost, and each author has to know why. Cheapest today, and the trap is at least now documented in
default-permission-sets.ts. - A general rule — e.g. have
deriveWallLessOrgAdmin synthesise a non-tenant-token admission for every object its parent scopes with an _org policy, so the wall-less variant is not silently thinner than the walled one on the row axis. - A gate that fails when
member_default gains a policy for an object that organization_admin covers only by a strippable tenant policy. This is the class check:* gates exist for, and it is exactly the kind of composition mistake no single file review catches.
Related: #3539 (ADR-0105 F1 to D3 — the strip swallowing app-authored policies) is the same mechanism seen from the authored-policy side.
Filed from the #8095 implementation. Not assigned; observation-class.
A structural trap found while implementing #8095. The instance is fixed in that PR; the trap is general and the next author to do the same thing will hit it the same way.
The shape
member_defaultis bound to theeveryoneanchor, so it resolves for every authenticated principal — org owners and admins included. RLS policies OR-combine across resolved sets, so an admin stays unnarrowed only while some policy of theirs still admits the rows a newmember_defaultpolicy excludes. Two mechanisms are supposed to provide that, and on the defaultsingleposture neither does:computeLayeredRlsFilterskips Layer 1 entirely when the caller holdsviewAllRecordson a posture-permitting object. ADR-0105 D4 withholds exactly those bits from a wall-less deployment —auto-org-admin-granthands outorganization_admin_no_bypassinstead oforganization_adminwhenever no wall is enforced._orgcarve-out.organization_adminshipssys_member_org,sys_invitation_org,sys_team_org, eachorganization_id == current_user.organization_id. ADR-0105 D3 hascollectRLSPoliciesstrip platform tenant policies when org isolation is inactive — correctly, since the token means nothing without a wall.So on a stock
singleboot an org admin reaches those objects through no policy at all, and "no applicable policy" is whatRLSCompiler.compileFilterturns into a null filter, i.e. unrestricted. The admin's whole read is riding on an absence. Introduce one narrowing policy on that object inmember_defaultand the absence becomes a predicate — for the admin too.Measured
Implementing #8095 (row-scope
sys_invitationto its addressee) with only the member-side policy in place, on a stockshowcaseboot:The Setup app's Invitations page would have gone blank for the one persona entitled to it, on the default posture, as a side effect of a member-side security fix. Caught only because that PR's fixture asserts the owner control alongside the two narrowing assertions.
Currently latent elsewhere, not currently broken
sys_memberandsys_teamare in the same state today — widened for admins only by a strippable_orgpolicy, narrowed by nothing inmember_default. Nothing is wrong right now precisely because no narrowing exists for them. If one is ever added (a plausible follow-on to #8095:sys_memberwas explicitly left alone there as a legitimate staff directory, butsys_teamhas had no such ruling), it will reproduce this exactly.What #8095 did about it
Stated the admin admission in its own right rather than relying on the absence — a second policy on
organization_admin,positions-domained toorg_owner/org_admin, carrying no tenant token for the strip to key on. The domain is on the widening so it can only add: a principal it does not match keeps the narrow scope and fails closed. Putting the domain on the narrowing instead inverts that, because "no matching position" means "no policy" means no row filter at all.Worth deciding
That fix is per-object and hand-rolled. Options a maintainer may want to weigh:
default-permission-sets.ts.deriveWallLessOrgAdminsynthesise a non-tenant-token admission for every object its parent scopes with an_orgpolicy, so the wall-less variant is not silently thinner than the walled one on the row axis.member_defaultgains a policy for an object thatorganization_admincovers only by a strippable tenant policy. This is the classcheck:*gates exist for, and it is exactly the kind of composition mistake no single file review catches.Related: #3539 (ADR-0105 F1 to D3 — the strip swallowing app-authored policies) is the same mechanism seen from the authored-policy side.
Filed from the #8095 implementation. Not assigned; observation-class.