Indirect project access — a project role held by a staff team, a staff department, or a site role instead of by one named person.
This is the other half of PhoenixKitProjects.Members. Members answers
"who is on this project"; Grants answers "which GROUPS may work here",
so that inviting the Design team, or letting every contractor look, is
one row rather than a person-by-person chore that drifts the moment
somebody changes team.
Resolution (the 2026-08-07 four-AI quorum's rules)
A person's effective project role is the strongest role among every grant that matches them — their own membership row, their teams', their departments', and their site role's. Grants are purely additive:
effective_role = max(member_row, team_grants, department_grants, role_grants)A more specific subject deliberately does NOT win when it grants less. Making specificity win turns adding a grant into a revocation: someone holds manager through their team, an admin adds them explicitly as a viewer to "make sure they have access", and silently strips their rights. To reduce access, remove the broad grant — never encode demotion as precedence.
Subjects are resolved defensively
subject_uuid has no foreign key: it points into staff teams, staff
departments, or core roles, and staff is an OPTIONAL dependency that may
not be installed. Every lookup here degrades to "matches nothing" rather
than raising, so a half-installed site fails closed instead of crashing
the resolver.
Ownership is not grantable to a group — see the schema.
Summary
Functions
Grants role on project to a subject, or updates the role if the
subject already has a grant.
The strongest role a user holds on a project through GROUP grants alone
(their direct membership is Members.role_of/2's business), or nil.
Every grant on a project, strongest role first.
Every project uuid a user can reach through GROUP grants alone, with the strongest role each grant gives them.
Why does this person have access? Returns every matching grant as
{subject_type, subject_uuid, role}, so a UI can explain that removing
someone's direct membership will not revoke their team's access.
Removes a grant.
How many user accounts a grant would currently reach — the blast radius of "everyone with role X can see this". Counts CURRENT accounts; future ones matching the same subject gain access too, which the UI must say.
The {subject_type, subject_uuid} pairs a user matches: their site
roles, their staff teams, and the departments those teams belong to
(plus their primary department).
Functions
@spec grant(map() | binary(), String.t(), binary(), String.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.ProjectSubjectGrant.t()} | {:error, term()}
Grants role on project to a subject, or updates the role if the
subject already has a grant.
The strongest role a user holds on a project through GROUP grants alone
(their direct membership is Members.role_of/2's business), or nil.
Returns a string role so it composes with the members table's values.
@spec list_grants(binary()) :: [PhoenixKitProjects.Schemas.ProjectSubjectGrant.t()]
Every grant on a project, strongest role first.
Every project uuid a user can reach through GROUP grants alone, with the strongest role each grant gives them.
Kept as ONE query over the user's resolved subjects rather than a per-project check, so a list view can scope in SQL instead of loading everything and filtering in memory — the N+1 the quorum flagged as the main tax of indirect grants.
Why does this person have access? Returns every matching grant as
{subject_type, subject_uuid, role}, so a UI can explain that removing
someone's direct membership will not revoke their team's access.
@spec revoke( binary(), keyword() ) :: {:ok, PhoenixKitProjects.Schemas.ProjectSubjectGrant.t()} | {:error, term()}
Removes a grant.
@spec subject_reach(String.t(), binary()) :: non_neg_integer()
How many user accounts a grant would currently reach — the blast radius of "everyone with role X can see this". Counts CURRENT accounts; future ones matching the same subject gain access too, which the UI must say.
The {subject_type, subject_uuid} pairs a user matches: their site
roles, their staff teams, and the departments those teams belong to
(plus their primary department).
Every hop is optional — no staff person, no staff package, or an unreadable table all resolve to fewer pairs rather than an exception.