The hub's authorization vocabulary and single resolver.
Defined BEFORE enforcement threading (2026-08-05 panel amendment #3) so every call site binds to one stable signature whose internals deepen as later layers land — call sites are never rewritten.
The intersection
A project action is allowed iff every applicable term passes:
site permission ∧ project role ∧ relationship grant ∧ extension enabled ∧ feature flagcan?/5 resolves the first three terms; extension/flag gating composes at
the call site via PhoenixKitProjects.Extensions.enabled?/3 and (Step 3)
Features.on?/2 — they answer "is this capability present on this
project", which is orthogonal to "may this caller use it".
Resolution stages (implementation honesty)
- v1: site-permission based — module access meant "do everything".
- Members: role + relationship terms — owner/manager/member/viewer
from the members table, relationship grants (an assignee may
:update_statuson their own assignment), and the per-project "who can X" floors insettings["authz"]. - The split (2026-08-07): the admin path is no longer bare module
access.
projectsmeans "may enter the module"; theprojects.admin_allSUB-permission means "administer projects you are not a member of". Before this, granting a role the module so its people could see their own projects handed them every project on the site — the resolver short-circuited on module access before membership was consulted.
Vocabulary (frozen)
Roles: :owner > :manager > :member > :viewer (roles/0, ordered).
Actions (actions/0): :view, :create_tasks, :edit_tasks,
:delete_tasks, :assign_tasks, :update_status, :log_time,
:comment, :upload_files, :manage_members, :manage_modules,
:edit_settings, :set_health, :archive_project, :delete_project.
Extensions may declare additional action keys via permission_actions;
unknown actions resolve fail-closed for non-admin callers.
Context
opts[:context] is :admin (default) or :public — the future
public-portal surface resolves under :public, where the admin override
does NOT apply (a site admin browsing the public portal is a visitor).
Threaded now so portal work is additive, not a refactor.
Summary
Functions
The frozen action vocabulary.
Does this subject hold projects.admin_all — the site-wide "administer
projects you are not a member of" grant? Exposed so listing queries can
skip narrowing for admins instead of re-deriving the rule.
The sub-permission that grants power over projects you don't belong to.
May subject perform action on project (optionally about record)?
May this subject reach the TEMPLATE library?
The choice a project currently resolves to for each overridable action — its stored override, or the default floor's choice when unset.
The role a user effectively holds on a project: the STRONGEST of their
direct membership and every group grant that matches them (their teams,
their departments, their site roles). Always an ATOM from roles/0, or
nil when nothing matches.
The overridable "who can X" catalog for the settings UI:
[{action, settings_key, [{label_choice, value}]}]-shaped data.
Project roles, strongest first.
Writes the per-project "who can X" floors (settings["authz"]).
The user uuid behind a scope, a user struct, or a bare uuid.
Project visibility — who can see it AT ALL, before any role question
Functions
@spec actions() :: [atom()]
The frozen action vocabulary.
Does this subject hold projects.admin_all — the site-wide "administer
projects you are not a member of" grant? Exposed so listing queries can
skip narrowing for admins instead of re-deriving the rule.
@spec admin_all_key() :: String.t()
The sub-permission that grants power over projects you don't belong to.
May subject perform action on project (optionally about record)?
subject is a %Scope{} (admin surfaces) — later stages also accept a
bare user for member-facing surfaces. record carries the relationship
term's object (an assignment for :update_status, a member row for
:manage_members edge cases); pass nil when the action has no object.
Fail-closed: nil subject, unknown project, or an unrecognized action for a non-admin subject all resolve false.
May this subject reach the TEMPLATE library?
Templates are library objects with no membership rows, so can?/5's
per-project resolution has nothing to work with and would lock everyone
out. The rule is therefore module-level: hold the projects permission.
This exists because "templates stay behind the admin route" is not a
rule the module can rely on. Every one of these pages is also the root
LiveView of an EMBED, mounted off-router with a host-supplied session,
where core's admin on_mount never runs — so an exemption phrased as
"skip the check for templates" meant no check at all, for anyone,
including a session with no identity.
The choice a project currently resolves to for each overridable action — its stored override, or the default floor's choice when unset.
The role a user effectively holds on a project: the STRONGEST of their
direct membership and every group grant that matches them (their teams,
their departments, their site roles). Always an ATOM from roles/0, or
nil when nothing matches.
Additive by design — a group grant can raise someone's role, but a
direct row never silently lowers what a group already gave them.
Specificity-wins would make ADDING a grant a revocation. See
PhoenixKitProjects.Grants.
@spec overridable_actions() :: [map()]
The overridable "who can X" catalog for the settings UI:
[{action, settings_key, [{label_choice, value}]}]-shaped data.
@spec roles() :: [atom()]
Project roles, strongest first.
@spec set_overrides(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, :not_found}
Writes the per-project "who can X" floors (settings["authz"]).
Only known action keys with known choice values are accepted; anything
else is dropped rather than stored, and an unknown value would in any
case resolve back to the default floor in floor_for/2.
Like Features.set_flags/3 this is an ATOMIC targeted merge, not a
read-merge-write of the whole settings map: the column is shared with
the features key, and writing a map built from a possibly-stale struct
silently clobbers concurrent writes to its siblings.
The user uuid behind a scope, a user struct, or a bare uuid.
@spec visibilities() :: [String.t()]
Project visibility — who can see it AT ALL, before any role question:
"private"(default) — the people and groups on the project, plus site admins."everyone"— anyone who can reach the Projects module. They come in as a VIEWER, so the same "who can do what" floors apply; being able to see a project is not being able to change it.
Stored on the project (settings["visibility"]) and resolved by
effective_role/2, so every surface that asks the resolver — the index,
the counters, the page gates — honors it without its own special case.