mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## 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>/ coder/coder#21890 / Pylon [#2758](<https://github.com/coder/coder/issues/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 coder/coder#27270). Full tables: <br>[B/op and allocs/op](<https://github.com/coder/coder/pull/27244#issuecomment-4984523720>). * 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.
497 lines
17 KiB
Rego
497 lines
17 KiB
Rego
package authz
|
|
|
|
import rego.v1
|
|
|
|
# Check the POLICY.md file before editing this!
|
|
#
|
|
# https://play.openpolicyagent.org/
|
|
#
|
|
|
|
#==============================================================================#
|
|
# Site level rules #
|
|
#==============================================================================#
|
|
|
|
# Site level permissions allow the subject to use that permission on any object.
|
|
# For example, a site-level workspace.read permission means that the subject can
|
|
# see every workspace in the deployment, regardless of organization or owner.
|
|
|
|
default site := 0
|
|
|
|
site := check_site_permissions(input.subject.roles)
|
|
|
|
default scope_site := 0
|
|
|
|
scope_site := check_site_permissions([input.subject.scope])
|
|
|
|
check_site_permissions(roles) := vote if {
|
|
allow := {is_allowed |
|
|
# Iterate over all site permissions in all roles, and check which ones match
|
|
# the action and object type.
|
|
perm := roles[_].site[_]
|
|
perm.action in [input.action, "*"]
|
|
perm.resource_type in [input.object.type, "*"]
|
|
|
|
# If a negative matching permission was found, then we vote to disallow it.
|
|
# If the permission is not negative, then we vote to allow it.
|
|
is_allowed := bool_flip(perm.negate)
|
|
}
|
|
vote := to_vote(allow)
|
|
}
|
|
|
|
#==============================================================================#
|
|
# User level rules #
|
|
#==============================================================================#
|
|
|
|
# User level rules apply to all objects owned by the subject which are not also
|
|
# owned by an org. Permissions for objects which are "jointly" owned by an org
|
|
# instead defer to the org member level rules.
|
|
|
|
default user := 0
|
|
|
|
user := check_user_permissions(input.subject.roles)
|
|
|
|
default scope_user := 0
|
|
|
|
scope_user := check_user_permissions([input.subject.scope])
|
|
|
|
check_user_permissions(roles) := vote if {
|
|
# The object must be owned by the subject.
|
|
input.subject.id = input.object.owner
|
|
|
|
# If there is an org, use org_member permissions instead
|
|
input.object.org_owner == ""
|
|
not input.object.any_org
|
|
|
|
allow := {is_allowed |
|
|
# Iterate over all user permissions in all roles, and check which ones match
|
|
# the action and object type.
|
|
perm := roles[_].user[_]
|
|
perm.action in [input.action, "*"]
|
|
perm.resource_type in [input.object.type, "*"]
|
|
|
|
# If a negative matching permission was found, then we vote to disallow it.
|
|
# If the permission is not negative, then we vote to allow it.
|
|
is_allowed := bool_flip(perm.negate)
|
|
}
|
|
vote := to_vote(allow)
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Org level rules #
|
|
#==============================================================================#
|
|
|
|
# Org level permissions are similar to `site`, except we need to iterate over
|
|
# each organization that the subject is a member of, and check against the
|
|
# organization that the object belongs to.
|
|
# For example, an organization-level workspace.read permission means that the
|
|
# subject can see every workspace in the organization, regardless of owner.
|
|
|
|
# org_memberships is the set of organizations the subject is apart of.
|
|
org_memberships := {org_id |
|
|
input.subject.roles[_].by_org_id[org_id]
|
|
}
|
|
|
|
# TODO: Should there be a scope_org_memberships too? Without it, the membership
|
|
# is determined by the user's roles, not their scope permissions.
|
|
#
|
|
# If an owner (who is not an org member) has an org scope, that org scope will
|
|
# fail to return '1', since we assume all non-members return '-1' for org level
|
|
# permissions. Adding a second set of org memberships might affect the partial
|
|
# evaluation. This is being left until org scopes are used.
|
|
|
|
# check_all_org_permissions creates a map from org ids to votes at each org
|
|
# level, for each org that the subject is a member of. It doesn't actually check
|
|
# if the object is in the same org; the callers do that:
|
|
# - `org_ids_with_vote` picks the org ids with a given vote, and the known-org
|
|
# rules test the object's org id for membership in that set, and
|
|
# - the `any_org` clauses take the `max` vote.
|
|
# For example, the map will look something like this:
|
|
#
|
|
# {"<org_id_a>": 1, "<org_id_b>": 0, "<org_id_c>": -1}
|
|
#
|
|
# We build the whole map, rather than just the vote for the object's org,
|
|
# because the org id _might_ be unknown during partial evaluation. To keep this
|
|
# policy compressible to simple queries we need to keep unknown values out of
|
|
# comprehensions.
|
|
#
|
|
# This is a helper function shared by the memoized vote-map rules below, so its
|
|
# per-call cost is paid at most once per (roles, key) combination.
|
|
check_all_org_permissions(roles, key) := {org_id: vote |
|
|
org_id := org_memberships[_]
|
|
allow := {is_allowed |
|
|
# Iterate over all site permissions in all roles, and check which ones match
|
|
# the action and object type.
|
|
perm := roles[_].by_org_id[org_id][key][_]
|
|
perm.action in [input.action, "*"]
|
|
perm.resource_type in [input.object.type, "*"]
|
|
|
|
# If a negative matching permission was found, then we vote to disallow it.
|
|
# If the permission is not negative, then we vote to allow it.
|
|
is_allowed := bool_flip(perm.negate)
|
|
}
|
|
vote := to_vote(allow)
|
|
}
|
|
|
|
# The vote maps below are complete rules with no arguments, so OPA evaluates
|
|
# each once per query and caches the result. A function is instead re-evaluated
|
|
# at every call site, so reading org votes through these rules keeps the policy
|
|
# from rebuilding the same vote map for the org, member, and scope paths on
|
|
# every authorization check.
|
|
role_org_votes := check_all_org_permissions(input.subject.roles, "org")
|
|
|
|
role_member_votes := check_all_org_permissions(input.subject.roles, "member")
|
|
|
|
scope_org_votes := check_all_org_permissions([input.subject.scope], "org")
|
|
|
|
scope_member_votes := check_all_org_permissions([input.subject.scope], "member")
|
|
|
|
# org_ids_with_vote returns the set of org ids in a vote map whose vote equals
|
|
# `wanted`. It depends only on the (fully known) vote map, never on the object's
|
|
# org id, so its result is ground during partial evaluation. The known-org
|
|
# rules test the object's org id for membership in this set, which lets the
|
|
# query compile to `organization_id = ANY(ARRAY[...])` instead of fanning out to
|
|
# one query per org.
|
|
org_ids_with_vote(votes, wanted) := {org_id |
|
|
some org_id, vote in votes
|
|
vote == wanted
|
|
}
|
|
|
|
default org := 0
|
|
|
|
# Known org: only ever votes to allow. See POLICY.md "Known-org asymmetry". The
|
|
# count guard keeps an empty allow set from emitting an unsatisfiable
|
|
# `org_owner in set()` residual during partial evaluation (OPA drops the whole
|
|
# branch instead).
|
|
org := 1 if {
|
|
not input.object.any_org
|
|
allow := org_ids_with_vote(role_org_votes, 1)
|
|
count(allow) > 0
|
|
input.object.org_owner in allow
|
|
}
|
|
|
|
# any_org: the highest org-level vote across every org. Unlike the known-org
|
|
# clause this can vote -1, which the allow rules honor via `not org = -1`.
|
|
org := vote if {
|
|
input.object.any_org
|
|
vote := max({v | some v in role_org_votes})
|
|
}
|
|
|
|
default scope_org := 0
|
|
|
|
scope_org := 1 if {
|
|
not input.object.any_org
|
|
allow := org_ids_with_vote(scope_org_votes, 1)
|
|
count(allow) > 0
|
|
input.object.org_owner in allow
|
|
}
|
|
|
|
scope_org := vote if {
|
|
input.object.any_org
|
|
vote := max({v | some v in scope_org_votes})
|
|
}
|
|
|
|
# is_org_member checks if the subject belong to the same organization as the
|
|
# object.
|
|
is_org_member if {
|
|
not input.object.any_org
|
|
input.object.org_owner != ""
|
|
input.object.org_owner in org_memberships
|
|
}
|
|
|
|
# ...if 'any_org' is set to true, we check if the subject is a member of any
|
|
# org.
|
|
is_org_member if {
|
|
input.object.any_org
|
|
count(org_memberships) > 0
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Org member level rules #
|
|
#==============================================================================#
|
|
|
|
# Org member level permissions apply to all objects owned by the subject _and_
|
|
# the corresponding org. Permissions for objects which are not owned by an
|
|
# organization instead defer to the user level rules.
|
|
#
|
|
# The rules for this level mirror the organization level rules and read from the
|
|
# same memoized vote maps (`role_member_votes`, `scope_member_votes`,
|
|
# `role_org_votes`, `scope_org_votes`).
|
|
|
|
default org_member := 0
|
|
|
|
# Known org: allow when the subject owns the object and a member-level
|
|
# permission allows it. The allowed set folds in the org-level deny as a ground
|
|
# set difference (see POLICY.md "Known-org asymmetry"), and its value is fully
|
|
# known at partial-evaluation time, so the unknown org id appears in only one
|
|
# positive membership test and the decision never branches on it. The count
|
|
# guard keeps an empty set from emitting an unsatisfiable residual.
|
|
org_member := 1 if {
|
|
# Object must be jointly owned by the user
|
|
input.object.owner != ""
|
|
input.subject.id = input.object.owner
|
|
not input.object.any_org
|
|
|
|
# Org-level deny is folded in as a ground set difference so a known org never
|
|
# needs an org-level -1 vote (see POLICY.md "Known-org asymmetry").
|
|
allowed := org_ids_with_vote(role_member_votes, 1) - org_ids_with_vote(role_org_votes, -1)
|
|
count(allowed) > 0
|
|
input.object.org_owner in allowed
|
|
}
|
|
|
|
# any_org: the highest member-level vote across every org. Org-level deny is
|
|
# applied by the `not org = -1` gate in the allow rules rather than folded in
|
|
# here, because `org` votes -1 in the any_org case.
|
|
org_member := vote if {
|
|
# Object must be jointly owned by the user
|
|
input.object.owner != ""
|
|
input.subject.id = input.object.owner
|
|
input.object.any_org
|
|
vote := max({v | some v in role_member_votes})
|
|
}
|
|
|
|
default scope_org_member := 0
|
|
|
|
# Known org: like org_member, scoped to the subject's current scope.
|
|
scope_org_member := 1 if {
|
|
# Object must be jointly owned by the user
|
|
input.object.owner != ""
|
|
input.subject.id = input.object.owner
|
|
not input.object.any_org
|
|
|
|
allowed := org_ids_with_vote(scope_member_votes, 1) - org_ids_with_vote(scope_org_votes, -1)
|
|
count(allowed) > 0
|
|
input.object.org_owner in allowed
|
|
}
|
|
|
|
scope_org_member := vote if {
|
|
# Object must be jointly owned by the user
|
|
input.object.owner != ""
|
|
input.subject.id = input.object.owner
|
|
input.object.any_org
|
|
vote := max({v | some v in scope_member_votes})
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Role rules #
|
|
#==============================================================================#
|
|
|
|
# role_allow specifies all of the conditions under which a role can grant
|
|
# permission. These rules intentionally use the "unification" operator rather
|
|
# than the equality and inequality operators, because those operators do not
|
|
# work on partial values.
|
|
# https://www.openpolicyagent.org/docs/policy-language#unification-
|
|
|
|
# Site level authorization
|
|
role_allow if {
|
|
site = 1
|
|
}
|
|
|
|
# User level authorization
|
|
role_allow if {
|
|
not site = -1
|
|
|
|
user = 1
|
|
}
|
|
|
|
# Org level authorization
|
|
role_allow if {
|
|
not site = -1
|
|
|
|
org = 1
|
|
}
|
|
|
|
# Org member authorization
|
|
role_allow if {
|
|
not site = -1
|
|
|
|
# For a known org this is always true: `org` never votes -1 for a known org,
|
|
# because org-level deny is folded into `org_member`. It only blocks here in
|
|
# the any_org case, where `org` can be -1 via `max`.
|
|
not org = -1
|
|
|
|
org_member = 1
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Scope rules #
|
|
#==============================================================================#
|
|
|
|
# scope_allow specifies all of the conditions under which a scope can grant
|
|
# permission. These rules intentionally use the "unification" (=) operator
|
|
# rather than the equality (==) and inequality (!=) operators, because those
|
|
# operators do not work on partial values.
|
|
# https://www.openpolicyagent.org/docs/policy-language#unification-
|
|
|
|
# Site level scope enforcement
|
|
scope_allow if {
|
|
object_is_included_in_scope_allow_list
|
|
scope_site = 1
|
|
}
|
|
|
|
# User level scope enforcement
|
|
scope_allow if {
|
|
# User scope permissions must be allowed by the scope, and not denied
|
|
# by the site. The object *must not* be owned by an organization.
|
|
object_is_included_in_scope_allow_list
|
|
not scope_site = -1
|
|
|
|
scope_user = 1
|
|
}
|
|
|
|
# Org level scope enforcement
|
|
scope_allow if {
|
|
# Org member scope permissions must be allowed by the scope, and not denied
|
|
# by the site. The object *must* be owned by an organization.
|
|
object_is_included_in_scope_allow_list
|
|
not scope_site = -1
|
|
|
|
scope_org = 1
|
|
}
|
|
|
|
# Org member level scope enforcement
|
|
scope_allow if {
|
|
# Org member scope permissions must be allowed by the scope, and not denied
|
|
# by the site or org. The object *must* be owned by an organization.
|
|
object_is_included_in_scope_allow_list
|
|
not scope_site = -1
|
|
|
|
# As with `not org = -1` above, this only blocks in the any_org case; for a
|
|
# known org, scope org-level deny is folded into `scope_org_member`.
|
|
not scope_org = -1
|
|
|
|
scope_org_member = 1
|
|
}
|
|
|
|
# If *.* is allowed, then all objects are in scope.
|
|
object_is_included_in_scope_allow_list if {
|
|
{"type": "*", "id": "*"} in input.subject.scope.allow_list
|
|
}
|
|
|
|
# If <type>.* is allowed, then all objects of that type are in scope.
|
|
object_is_included_in_scope_allow_list if {
|
|
{"type": input.object.type, "id": "*"} in input.subject.scope.allow_list
|
|
}
|
|
|
|
# Check if the object type and ID match one of the allow list entries.
|
|
object_is_included_in_scope_allow_list if {
|
|
# Check that the wildcard rules do not apply. This prevents partial inputs
|
|
# from needing to include `input.object.id`.
|
|
not {"type": "*", "id": "*"} in input.subject.scope.allow_list
|
|
not {"type": input.object.type, "id": "*"} in input.subject.scope.allow_list
|
|
|
|
# Check which IDs from the allow list match the object type
|
|
allowed_ids_for_object_type := {it.id |
|
|
some it in input.subject.scope.allow_list
|
|
it.type in [input.object.type, "*"]
|
|
}
|
|
|
|
# Check if the input object ID is in the set of allowed IDs for the same
|
|
# object type. We do this at the end to keep `input.object.id` out of the
|
|
# comprehension because it might be unknown.
|
|
input.object.id in allowed_ids_for_object_type
|
|
}
|
|
|
|
#==============================================================================#
|
|
# ACL rules #
|
|
#==============================================================================#
|
|
|
|
# ACL for users
|
|
acl_allow if {
|
|
# The subject must be a member of the object's organization for a
|
|
# user ACL grant to apply.
|
|
is_org_member
|
|
perms := input.object.acl_user_list[input.subject.id]
|
|
|
|
# Check if either the action or * is allowed
|
|
some action in [input.action, "*"]
|
|
action in perms
|
|
}
|
|
|
|
# ACL for groups
|
|
acl_allow if {
|
|
# If there is no organization owner, the object cannot be owned by an
|
|
# org-scoped group.
|
|
is_org_member
|
|
some group in input.subject.groups
|
|
perms := input.object.acl_group_list[group]
|
|
|
|
# Check if either the action or * is allowed
|
|
some action in [input.action, "*"]
|
|
action in perms
|
|
}
|
|
|
|
# ACL for the special "Everyone" groups
|
|
acl_allow if {
|
|
# If there is no organization owner, the object cannot be owned by an
|
|
# org-scoped group.
|
|
is_org_member
|
|
perms := input.object.acl_group_list[input.object.org_owner]
|
|
|
|
# Check if either the action or * is allowed
|
|
some action in [input.action, "*"]
|
|
action in perms
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Allow #
|
|
#==============================================================================#
|
|
|
|
# The `allow` block is quite simple. Any check that voted no will cascade down.
|
|
# Authorization looks for any `allow` statement that is true. Multiple can be
|
|
# true! Note that the absence of `allow` means "unauthorized". An explicit
|
|
# `"allow": true` is required.
|
|
#
|
|
# We check both the subject's permissions (given by their roles or by ACL) and
|
|
# the subject's scope. (The default scope is "*:*", allowing all actions.) Both
|
|
# a permission check (either from roles or ACL) and the scope check must vote to
|
|
# allow or the action is not authorized.
|
|
|
|
# A subject can be given permission by a role
|
|
permission_allow if role_allow
|
|
|
|
# A subject can be given permission by ACL
|
|
permission_allow if acl_allow
|
|
|
|
allow if {
|
|
# Must be allowed by the subject's permissions
|
|
permission_allow
|
|
|
|
# ...and allowed by the scope
|
|
scope_allow
|
|
}
|
|
|
|
#==============================================================================#
|
|
# Utilities #
|
|
#==============================================================================#
|
|
|
|
# bool_flip returns the logical negation of a boolean value. You can't do
|
|
# 'x := not false', but you can do 'x := bool_flip(false)'
|
|
bool_flip(b) := false if {
|
|
b
|
|
}
|
|
|
|
bool_flip(b) if {
|
|
not b
|
|
}
|
|
|
|
# to_vote gives you a voting value from a set or list of booleans.
|
|
# {false,..} => deny (-1)
|
|
# {} => abstain (0)
|
|
# {true} => allow (1)
|
|
|
|
# Any set which contains a `false` should be considered a vote to deny.
|
|
to_vote(set) := -1 if {
|
|
false in set
|
|
}
|
|
|
|
# A set which is empty should be considered abstaining.
|
|
to_vote(set) := 0 if {
|
|
count(set) == 0
|
|
}
|
|
|
|
# A set which only contains true should be considered a vote to allow.
|
|
to_vote(set) := 1 if {
|
|
not false in set
|
|
true in set
|
|
}
|