PhoenixKitProjects.Authz (PhoenixKitProjects v0.21.1)

Copy Markdown View Source

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 flag

can?/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_status on their own assignment), and the per-project "who can X" floors in settings["authz"].
  • The split (2026-08-07): the admin path is no longer bare module access. projects means "may enter the module"; the projects.admin_all SUB-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

actions()

@spec actions() :: [atom()]

The frozen action vocabulary.

admin_all?(subject)

@spec admin_all?(term()) :: boolean()

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.

admin_all_key()

@spec admin_all_key() :: String.t()

The sub-permission that grants power over projects you don't belong to.

can?(subject, project, action, record \\ nil, opts \\ [])

@spec can?(term(), map() | binary() | nil, atom(), map() | nil, keyword()) ::
  boolean()

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.

can_use_templates?(scope)

@spec can_use_templates?(term()) :: boolean()

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.

current_overrides(project)

@spec current_overrides(map()) :: %{required(String.t()) => String.t()}

The choice a project currently resolves to for each overridable action — its stored override, or the default floor's choice when unset.

effective_role(project, user_uuid)

@spec effective_role(map() | binary(), binary()) :: atom() | nil

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.

overridable_actions()

@spec overridable_actions() :: [map()]

The overridable "who can X" catalog for the settings UI: [{action, settings_key, [{label_choice, value}]}]-shaped data.

roles()

@spec roles() :: [atom()]

Project roles, strongest first.

set_overrides(project, overrides, opts \\ [])

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

subject_user_uuid_of(subject)

@spec subject_user_uuid_of(term()) :: binary() | nil

The user uuid behind a scope, a user struct, or a bare uuid.

visibilities()

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

visibility_of(project)

@spec visibility_of(map()) :: String.t()