Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7e708b2

Browse files
authored
perf(coderd/rbac): collapse org authorization to a set-membership test (#27244)
## Problem Authorization for users who belong to many organizations is slow. On the list <br>endpoints (`/api/v2/organizations`, `/users`, `/groups`) a user in hundreds of <br>orgs saw multi-second page loads <br>([DEVEX-608](https://linear.app/codercom/issue/DEVEX-608/performance-degrades-for-users-in-many-organizations-across-multiple) <br>/ #21890 / Pylon [#2758](<#2758>)). This is partial-evaluation bound: `rbac.Prepare` <br>scales with the number of org-scoped roles the subject carries. ## Root cause The known-org path in `check_org_permissions` indexed an N-entry vote map by the <br>object's org id: ```rego vote := allow_map[input.object.org_owner] ``` `input.object.org_owner` is unknown during partial evaluation. Indexing a map by <br>an unknown key cannot reduce to a single expression, so OPA emits one residual <br>query per org membership, and `newPartialAuthorizer` then calls `PrepareForEval` <br>once per residual, making `Prepare` O(N) in org count. The list endpoints <br>intentionally use partial eval; the fan-out is in partial eval itself. ## Change Test the object's org id for membership in a set that is fully known at <br>partial-evaluation time, so the query collapses to a single <br>`organization_id = ANY(ARRAY[...])` residual instead of N residuals: * The known-org clause only ever votes to allow, tested via <br>`org_owner in org_ids_with_vote(role_org_votes, 1)`. * Org-level denies are folded into the org-member level as a ground set <br>difference (`member_allow - org_deny`), so the unknown org id appears in only <br>one positive membership test and the decision never branches on it. * The per-org vote maps are computed once as memoized zero-arg rules <br>(`role_org_votes`, `role_member_votes`, `scope_org_votes`, <br>`scope_member_votes`) instead of through parametrized functions that OPA <br>re-evaluates at every call site. * `role_allow`/`scope_allow`, the `any_org` path, and full evaluation are <br>unchanged in behavior. Semantics are unchanged (see the equivalence argument below). The only <br>representational change is that a denied known org's intermediate `org` vote is <br>now `0` instead of `-1`, compensated by the set difference and not observable in <br>the final `allow` decision. ## Results Measured with `BenchmarkRBACManyOrgs` (added on `main` in #27270). Full tables: <br>[B/op and allocs/op](<#27244 (comment)>). * Residual queries: O(N) -> O(1). * `Prepare` / `PrepareAndCompile` memory changes from < />quadratic growth on `main` <br>(176 MiB, 7.08M allocs per op at 100 orgs) to near-linear (6.5 MiB, 258k <br>allocs), a < />96% reduction at 100 orgs, with similar wins in time. * Memoizing the vote maps removed an early single-org regression: at 1 org <br>`Prepare` now allocates < />7% fewer bytes and < />9% fewer objects than `main`. * `Authorize` (full evaluation) memory is marginally higher (+1-8%, largest at <br>1 org) and time-neutral. This is the inherent cost of the set-membership form <br>that keeps partial evaluation from fanning out; full evaluation builds an <br>allow set it would not otherwise need. * `go test ./coderd/rbac/...` passes, including `TestAuthorizeDomain` (full- vs <br>partial-eval equivalence) and the regosql suite. A second, independent bottleneck remains (out of scope here): the vote map is <br>still built in O(N^2) in `check_all_org_permissions` <br>(`roles[_].by_org_id[org_id]` scans all roles per org). Fixing it means <br>pre-merging roles' `by_org_id` into one org->perms map in the OPA input, and is <br>tracked as a follow-up. ## Testing * `OrgDenyBlocksMember` (`TestAuthorizeLevels`): an org-level deny blocks a <br>member-allowed action on an owned in-org object, while a clean org is allowed, <br>including an action-scoped deny. * `ScopeOrgDenyBlocksMember` (`TestAuthorizeScope`): the same fold at the scope <br>level. * The shared harness covers full and partial evaluation and asserts the partial <br>result compiles to SQL with zero support rules. <details><summary>Decision log and equivalence argument</summary> ### Why not deny-via-set-membership The first attempt expressed deny as a second set-membership clause (`:= -1 if org_owner in deny_set`). That makes `org`/`scope_org` multi-valued, and the `not org = -1` checks in `role_allow`/`scope_allow` then cause OPA to emit a `data.partial.__not__` support rule that regosql cannot compile (`TestAuthorizeDomain/UserACLList` failed). It failed even when the deny set was empty, purely because the `-1` clause exists. ### Why not deny-via-enumeration A follow-up enumerated only the (usually empty) deny set. It compiled and passed, but it branches on the unknown org id (one ground residual per denied org), which violates the "do not branch on the unknown" rule in `coderd/rbac/POLICY.md`. ### Final approach: allow-only + ground set difference The known-org clause votes only to allow, and the org-level deny gate is moved into the org-member level as `member_allow - org_deny`, a set difference over fully-known sets. The unknown org id is used only in positive `in` tests, so there is no enumeration, no negated membership, and no branching on the unknown. ### Empty-set residual pruning A naive set-membership left unsatisfiable residuals (`org_owner in set()`) for levels with no matching permissions (e.g. the org level for an org-member role, or scope-org for `ScopeAll`), each still costing a `PrepareForEval`. Guarding each membership with a ground `count(...) > 0` lets OPA drop those branches, flattening the residual count across org sizes. ### Memoized vote maps Profiling the single-org path showed the cost was repeated function evaluation: the parametrized helpers rebuilt the same vote map for the org, member, and scope paths on every check. Hoisting the maps into memoized zero-arg complete rules (which OPA evaluates once per query) removed that overhead and eliminated the single-org `Prepare` regression, while composition keeps the policy readable. ### Equivalence (known-org path, `site != -1`) * A: original `org == 1` <=> `org_owner in org_allow` (unchanged). * B: original `org != -1 and member == 1` <=> `org_owner not in org_deny and org_owner in member_allow` <=> `org_owner in (member_allow - org_deny)` = new `org_member == 1`. The critical case (`org` denies, member allows): old blocks it via `not org = -1`; new blocks it because `org_owner` is removed from `member_allow - org_deny`. Same outcome. Deny-wins aggregation is intact because `check_all_org_permissions` still nets an org to `-1` via `to_vote`, landing it in `org_deny`. </details> --- This PR was generated by Coder Agents on behalf of @jeremyruppel.
1 parent f9047e5 commit 7e708b2

3 files changed

Lines changed: 268 additions & 41 deletions

File tree

coderd/rbac/POLICY.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ Each of these checks gets a "vote", which must one of three values:
6868
If a level abstains, then the decision gets deferred to the next level. When
6969
there is no "next" level to defer to it is equivalent to being denied.
7070

71+
### Known-org asymmetry (org and org_member levels)
72+
73+
The org and org_member levels are evaluated differently depending on whether
74+
the object's org id is known.
75+
76+
When the org id is unknown (partial evaluation, e.g. filtering a list), the org
77+
id must be kept out of comprehensions and must not be branched on (see "Unknown
78+
values" below). To satisfy that, the known-org path tests the object's org id
79+
for membership in a set of allowed org ids instead of looking up its vote:
80+
81+
- The org level (`check_org_permissions`, known-org clause) only ever votes
82+
`1` (allow) or abstains; it never votes `-1` for a known org. The
83+
`not org = -1` / `not scope_org = -1` gates in the allow rules are therefore
84+
no-ops for a known org and only block in the `any_org` case.
85+
- Org-level deny is instead folded into the org_member level as a ground set
86+
difference (`member_allow - org_deny`), so an org-level deny still blocks a
87+
member-level allow.
88+
89+
The `any_org` path ("can the subject do this in any org?") still uses the full
90+
`-1`/`0`/`1` vote (the `max` over the vote map), because there is no specific
91+
object org id to be unknown. So do not assume `org == -1` signals an org-level
92+
deny for a known org; reconstruct it from `org_ids_with_vote(role_org_votes, -1)`
93+
if you need it.
94+
7195
### Scope
7296
Additionally, each input has a "scope" that can be thought of as a second set of permissions, where each permission belongs to one of the four levels–exactly the same as role permissions. An action is only allowed if it is allowed by both the subject's permissions _and_ their current scope. This is to allow issuing tokens for a subject that have a subset of the full subjects permissions.
7397

coderd/rbac/authz_internal_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,87 @@ func TestAuthorizeLevels(t *testing.T) {
10091009

10101010
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
10111011
}))
1012+
1013+
// Org-level deny must block a member-level allow, member-level deny must win
1014+
// within an org, and org-level allow must override a member-level deny, all on
1015+
// owned in-org objects. These exercise the known-org set-difference gate.
1016+
denyOrg := uuid.New() // member allows read; org denies read
1017+
memberDenyOrg := uuid.New() // member both allows and denies read (deny wins)
1018+
orgAllowOrg := uuid.New() // org allows read; member denies read
1019+
user = Subject{
1020+
ID: "me",
1021+
Scope: must(ExpandScope(ScopeAll)),
1022+
Roles: Roles{
1023+
{
1024+
Identifier: RoleIdentifier{Name: "member-allow", OrganizationID: defOrg},
1025+
ByOrgID: map[string]OrgPermissions{
1026+
defOrg.String(): {
1027+
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1028+
},
1029+
},
1030+
},
1031+
{
1032+
Identifier: RoleIdentifier{Name: "member-allow-org-deny", OrganizationID: denyOrg},
1033+
ByOrgID: map[string]OrgPermissions{
1034+
denyOrg.String(): {
1035+
// Org denies only read; member allows read and update, so the
1036+
// deny is action-scoped (update stays allowed).
1037+
Org: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1038+
Member: []Permission{
1039+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
1040+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
1041+
},
1042+
},
1043+
},
1044+
},
1045+
{
1046+
Identifier: RoleIdentifier{Name: "member-deny-wins", OrganizationID: memberDenyOrg},
1047+
ByOrgID: map[string]OrgPermissions{
1048+
memberDenyOrg.String(): {
1049+
Member: []Permission{
1050+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
1051+
{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
1052+
},
1053+
},
1054+
},
1055+
},
1056+
{
1057+
Identifier: RoleIdentifier{Name: "org-allow-member-deny", OrganizationID: orgAllowOrg},
1058+
ByOrgID: map[string]OrgPermissions{
1059+
orgAllowOrg.String(): {
1060+
Org: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1061+
Member: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1062+
},
1063+
},
1064+
},
1065+
},
1066+
}
1067+
1068+
testAuthorize(t, "OrgDenyBlocksMember", user,
1069+
cases(func(c authTestCase) authTestCase {
1070+
c.actions = []policy.Action{policy.ActionRead}
1071+
return c
1072+
}, []authTestCase{
1073+
// Member level allows the owned, in-org object.
1074+
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true},
1075+
// Org-level deny blocks the owned object even though member allows it.
1076+
{resource: ResourceWorkspace.InOrg(denyOrg).WithOwner(user.ID), allow: false},
1077+
// Member-level deny wins over a member-level allow in the same org.
1078+
{resource: ResourceWorkspace.InOrg(memberDenyOrg).WithOwner(user.ID), allow: false},
1079+
// Org-level allow overrides a member-level deny, regardless of owner.
1080+
{resource: ResourceWorkspace.InOrg(orgAllowOrg).WithOwner(user.ID), allow: true},
1081+
{resource: ResourceWorkspace.InOrg(orgAllowOrg).WithOwner("not-me"), allow: true},
1082+
// The member grant does not extend to objects the subject does not own.
1083+
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
1084+
// Not a member of this org at all.
1085+
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
1086+
}),
1087+
// The org-level deny is scoped to the action it names: update stays allowed
1088+
// in denyOrg because only read is denied.
1089+
[]authTestCase{
1090+
{resource: ResourceWorkspace.InOrg(denyOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionUpdate}, allow: true},
1091+
},
1092+
)
10121093
}
10131094

10141095
func TestAuthorizeScope(t *testing.T) {
@@ -1341,6 +1422,61 @@ func TestAuthorizeScope(t *testing.T) {
13411422
{resource: ResourceUser.WithOwner(user.ID), allow: false, actions: []policy.Action{policy.ActionUpdate}},
13421423
},
13431424
)
1425+
1426+
// Scope-level org deny must block a member-level allow, mirroring
1427+
// OrgDenyBlocksMember but through the scope's org/member permissions (the
1428+
// scope_org_member member_allow - org_deny fold). The roles allow both
1429+
// objects, so the scope is the deciding factor.
1430+
scopeAllowOrg := uuid.New()
1431+
scopeDenyOrg := uuid.New()
1432+
user = Subject{
1433+
ID: "me",
1434+
Roles: Roles{
1435+
must(RoleByName(RoleMember())),
1436+
{
1437+
Identifier: RoleIdentifier{Name: "member-allow-a", OrganizationID: scopeAllowOrg},
1438+
ByOrgID: map[string]OrgPermissions{
1439+
scopeAllowOrg.String(): {
1440+
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1441+
},
1442+
},
1443+
},
1444+
{
1445+
Identifier: RoleIdentifier{Name: "member-allow-b", OrganizationID: scopeDenyOrg},
1446+
ByOrgID: map[string]OrgPermissions{
1447+
scopeDenyOrg.String(): {
1448+
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1449+
},
1450+
},
1451+
},
1452+
},
1453+
Scope: Scope{
1454+
Role: Role{
1455+
Identifier: RoleIdentifier{Name: "scope-org-deny"},
1456+
ByOrgID: map[string]OrgPermissions{
1457+
scopeAllowOrg.String(): {
1458+
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1459+
},
1460+
scopeDenyOrg.String(): {
1461+
Org: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1462+
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
1463+
},
1464+
},
1465+
},
1466+
AllowIDList: []AllowListElement{AllowListAll()},
1467+
},
1468+
}
1469+
1470+
testAuthorize(t, "ScopeOrgDenyBlocksMember", user,
1471+
cases(func(c authTestCase) authTestCase {
1472+
c.actions = []policy.Action{policy.ActionRead}
1473+
return c
1474+
}, []authTestCase{
1475+
// Scope member-allow permits the owned in-org object.
1476+
{resource: ResourceWorkspace.InOrg(scopeAllowOrg).WithOwner(user.ID), allow: true},
1477+
// Scope org-level deny blocks it even though scope member allows.
1478+
{resource: ResourceWorkspace.InOrg(scopeDenyOrg).WithOwner(user.ID), allow: false},
1479+
}))
13441480
}
13451481

13461482
func TestScopeAllowList(t *testing.T) {

coderd/rbac/policy.rego

Lines changed: 108 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -99,28 +99,23 @@ org_memberships := {org_id |
9999
# permissions. Adding a second set of org memberships might affect the partial
100100
# evaluation. This is being left until org scopes are used.
101101

102-
default org := 0
103-
104-
org := check_org_permissions(input.subject.roles, "org")
105-
106-
default scope_org := 0
107-
108-
scope_org := check_org_permissions([input.subject.scope], "org")
109-
110102
# check_all_org_permissions creates a map from org ids to votes at each org
111103
# level, for each org that the subject is a member of. It doesn't actually check
112-
# if the object is in the same org. Instead we look up the correct vote from
113-
# this map based on the object's org id in `check_org_permissions`.
114-
# For example, the `org_map` will look something like this:
104+
# if the object is in the same org; the callers do that:
105+
# - `org_ids_with_vote` picks the org ids with a given vote, and the known-org
106+
# rules test the object's org id for membership in that set, and
107+
# - the `any_org` clauses take the `max` vote.
108+
# For example, the map will look something like this:
115109
#
116110
# {"<org_id_a>": 1, "<org_id_b>": 0, "<org_id_c>": -1}
117111
#
118-
# The caller then uses `output[input.object.org_owner]` to get the correct vote.
119-
#
120-
# We have to create this map, rather than just getting the vote of the object's
121-
# org id because the org id _might_ be unknown. In order to make sure that this
122-
# policy compresses down to simple queries we need to keep unknown values out of
112+
# We build the whole map, rather than just the vote for the object's org,
113+
# because the org id _might_ be unknown during partial evaluation. To keep this
114+
# policy compressible to simple queries we need to keep unknown values out of
123115
# comprehensions.
116+
#
117+
# This is a helper function shared by the memoized vote-map rules below, so its
118+
# per-call cost is paid at most once per (roles, key) combination.
124119
check_all_org_permissions(roles, key) := {org_id: vote |
125120
org_id := org_memberships[_]
126121
allow := {is_allowed |
@@ -137,34 +132,62 @@ check_all_org_permissions(roles, key) := {org_id: vote |
137132
vote := to_vote(allow)
138133
}
139134

140-
# This check handles the case where the org id is known.
141-
check_org_permissions(roles, key) := vote if {
142-
# Disallow setting any_org at the same time as an org id.
143-
not input.object.any_org
135+
# The vote maps below are complete rules with no arguments, so OPA evaluates
136+
# each once per query and caches the result. A function is instead re-evaluated
137+
# at every call site, so reading org votes through these rules keeps the policy
138+
# from rebuilding the same vote map for the org, member, and scope paths on
139+
# every authorization check.
140+
role_org_votes := check_all_org_permissions(input.subject.roles, "org")
141+
142+
role_member_votes := check_all_org_permissions(input.subject.roles, "member")
144143

145-
allow_map := check_all_org_permissions(roles, key)
144+
scope_org_votes := check_all_org_permissions([input.subject.scope], "org")
146145

147-
# Return only the vote of the object's org.
148-
vote := allow_map[input.object.org_owner]
146+
scope_member_votes := check_all_org_permissions([input.subject.scope], "member")
147+
148+
# org_ids_with_vote returns the set of org ids in a vote map whose vote equals
149+
# `wanted`. It depends only on the (fully known) vote map, never on the object's
150+
# org id, so its result is ground during partial evaluation. The known-org
151+
# rules test the object's org id for membership in this set, which lets the
152+
# query compile to `organization_id = ANY(ARRAY[...])` instead of fanning out to
153+
# one query per org.
154+
org_ids_with_vote(votes, wanted) := {org_id |
155+
some org_id, vote in votes
156+
vote == wanted
157+
}
158+
159+
default org := 0
160+
161+
# Known org: only ever votes to allow. See POLICY.md "Known-org asymmetry". The
162+
# count guard keeps an empty allow set from emitting an unsatisfiable
163+
# `org_owner in set()` residual during partial evaluation (OPA drops the whole
164+
# branch instead).
165+
org := 1 if {
166+
not input.object.any_org
167+
allow := org_ids_with_vote(role_org_votes, 1)
168+
count(allow) > 0
169+
input.object.org_owner in allow
149170
}
150171

151-
# This check handles the case where we want to know if the user has the
152-
# appropriate permission for any organization, without needing to know which.
153-
# This is used in several places in the UI to determine if certain parts of the
154-
# app should be accessible.
155-
# For example, can the user create a new template in any organization? If yes,
156-
# then we should show the "New template" button.
157-
check_org_permissions(roles, key) := vote if {
158-
# Require `any_org` to be set
172+
# any_org: the highest org-level vote across every org. Unlike the known-org
173+
# clause this can vote -1, which the allow rules honor via `not org = -1`.
174+
org := vote if {
159175
input.object.any_org
176+
vote := max({v | some v in role_org_votes})
177+
}
160178

161-
allow_map := check_all_org_permissions(roles, key)
179+
default scope_org := 0
180+
181+
scope_org := 1 if {
182+
not input.object.any_org
183+
allow := org_ids_with_vote(scope_org_votes, 1)
184+
count(allow) > 0
185+
input.object.org_owner in allow
186+
}
162187

163-
# Since we're checking if the subject has the permission in _any_ org, we're
164-
# essentially trying to find the highest vote from any org.
165-
vote := max({vote |
166-
some vote in allow_map
167-
})
188+
scope_org := vote if {
189+
input.object.any_org
190+
vote := max({v | some v in scope_org_votes})
168191
}
169192

170193
# is_org_member checks if the subject belong to the same organization as the
@@ -190,25 +213,62 @@ is_org_member if {
190213
# the corresponding org. Permissions for objects which are not owned by an
191214
# organization instead defer to the user level rules.
192215
#
193-
# The rules for this level are very similar to the rules for the organization
194-
# level, and so we reuse the `check_org_permissions` function from those rules.
216+
# The rules for this level mirror the organization level rules and read from the
217+
# same memoized vote maps (`role_member_votes`, `scope_member_votes`,
218+
# `role_org_votes`, `scope_org_votes`).
195219

196220
default org_member := 0
197221

222+
# Known org: allow when the subject owns the object and a member-level
223+
# permission allows it. The allowed set folds in the org-level deny as a ground
224+
# set difference (see POLICY.md "Known-org asymmetry"), and its value is fully
225+
# known at partial-evaluation time, so the unknown org id appears in only one
226+
# positive membership test and the decision never branches on it. The count
227+
# guard keeps an empty set from emitting an unsatisfiable residual.
228+
org_member := 1 if {
229+
# Object must be jointly owned by the user
230+
input.object.owner != ""
231+
input.subject.id = input.object.owner
232+
not input.object.any_org
233+
234+
# Org-level deny is folded in as a ground set difference so a known org never
235+
# needs an org-level -1 vote (see POLICY.md "Known-org asymmetry").
236+
allowed := org_ids_with_vote(role_member_votes, 1) - org_ids_with_vote(role_org_votes, -1)
237+
count(allowed) > 0
238+
input.object.org_owner in allowed
239+
}
240+
241+
# any_org: the highest member-level vote across every org. Org-level deny is
242+
# applied by the `not org = -1` gate in the allow rules rather than folded in
243+
# here, because `org` votes -1 in the any_org case.
198244
org_member := vote if {
199245
# Object must be jointly owned by the user
200246
input.object.owner != ""
201247
input.subject.id = input.object.owner
202-
vote := check_org_permissions(input.subject.roles, "member")
248+
input.object.any_org
249+
vote := max({v | some v in role_member_votes})
203250
}
204251

205252
default scope_org_member := 0
206253

254+
# Known org: like org_member, scoped to the subject's current scope.
255+
scope_org_member := 1 if {
256+
# Object must be jointly owned by the user
257+
input.object.owner != ""
258+
input.subject.id = input.object.owner
259+
not input.object.any_org
260+
261+
allowed := org_ids_with_vote(scope_member_votes, 1) - org_ids_with_vote(scope_org_votes, -1)
262+
count(allowed) > 0
263+
input.object.org_owner in allowed
264+
}
265+
207266
scope_org_member := vote if {
208267
# Object must be jointly owned by the user
209268
input.object.owner != ""
210269
input.subject.id = input.object.owner
211-
vote := check_org_permissions([input.subject.scope], "member")
270+
input.object.any_org
271+
vote := max({v | some v in scope_member_votes})
212272
}
213273

214274
#==============================================================================#
@@ -243,6 +303,10 @@ role_allow if {
243303
# Org member authorization
244304
role_allow if {
245305
not site = -1
306+
307+
# For a known org this is always true: `org` never votes -1 for a known org,
308+
# because org-level deny is folded into `org_member`. It only blocks here in
309+
# the any_org case, where `org` can be -1 via `max`.
246310
not org = -1
247311

248312
org_member = 1
@@ -290,6 +354,9 @@ scope_allow if {
290354
# by the site or org. The object *must* be owned by an organization.
291355
object_is_included_in_scope_allow_list
292356
not scope_site = -1
357+
358+
# As with `not org = -1` above, this only blocks in the any_org case; for a
359+
# known org, scope org-level deny is folded into `scope_org_member`.
293360
not scope_org = -1
294361

295362
scope_org_member = 1

0 commit comments

Comments
 (0)