PhoenixKitProjects.Grants (PhoenixKitProjects v0.21.0)

Copy Markdown View Source

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.

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

grant(project, subject_type, subject_uuid, role, opts \\ [])

@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.

group_role_of(project, user_uuid)

@spec group_role_of(map() | binary(), binary()) :: String.t() | nil

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.

list_grants(project_uuid)

Every grant on a project, strongest role first.

project_roles_for_user(user_uuid)

@spec project_roles_for_user(binary()) :: %{required(binary()) => String.t()}

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.

provenance(project, user_uuid)

@spec provenance(map() | binary(), binary()) :: [{String.t(), binary(), String.t()}]

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.

revoke(grant_uuid, opts \\ [])

@spec revoke(
  binary(),
  keyword()
) ::
  {:ok, PhoenixKitProjects.Schemas.ProjectSubjectGrant.t()} | {:error, term()}

Removes a grant.

subject_reach(arg1, role_uuid)

@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.

subjects_for_user(user_uuid)

@spec subjects_for_user(binary()) :: [{String.t(), binary()}]

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.