# `PhoenixKit.Users.Permissions`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.15.1/lib/phoenix_kit/users/permissions.ex#L1)

Context for module-level permissions in PhoenixKit.

Controls which roles can access which admin sections and feature modules.
Uses an allowlist model: row present = granted, absent = denied.
Owner role always has full access (enforced in code, no DB rows needed).

## Permission Keys

Core sections: dashboard, users, media, settings, modules
Feature modules: billing, shop, emails, entities, tickets, posts, comments,
  ai, publishing, sitemap, crawlers, maintenance, storage,
  languages, connections, legal, db, jobs

## Sub-Permissions (fine-grained)

A module may declare fine-grained permissions under its base key via the
optional `sub_permissions` field of `permission_metadata/0`. They live in
the same table as composed dotted keys (`"calendar.view_others"`):

- The base key gates the module's admin pages; sub-keys are additive
  grants the module checks itself via `Scope.can?/2`.
- A sub-key implies its base: granting a sub auto-grants the base,
  revoking the base cascades its subs, and `set_permissions/3` normalizes
  the desired set — no path can persist an orphan sub-key row.
- A sub-key is enabled iff its parent module is enabled.

    Permissions.sub_permission_keys()          # ["calendar.edit_others", ...]
    Permissions.sub_permissions_for("calendar") # [%{key:, label:, description:}]
    Permissions.parent_key("calendar.view_others") # "calendar"
    Permissions.expand_with_parents(keys)      # keys ∪ implied base keys

## Constants & Metadata

    Permissions.all_module_keys()        # 25 built-in + any custom keys
    Permissions.core_section_keys()      # 5 core keys
    Permissions.feature_module_keys()    # 20 feature keys
    Permissions.enabled_module_keys()    # Core + enabled features + custom keys
    Permissions.valid_module_key?("ai")  # true
    Permissions.feature_enabled?("ai")   # true/false based on module status
    Permissions.module_label("shop")     # "E-Commerce"
    Permissions.module_icon("shop")      # "hero-shopping-cart"
    Permissions.module_description("shop") # "Product catalog, orders, ..."

## Query API

    Permissions.get_permissions_for_user(user)          # User's keys via roles
    Permissions.get_permissions_for_role(role_uuid)      # Keys for a role
    Permissions.role_has_permission?(role_uuid, "billing") # Single check
    Permissions.get_permissions_matrix()                 # All roles → MapSet
    Permissions.roles_with_permission("billing")         # Role UUIDs with key
    Permissions.users_with_permission("billing")         # User UUIDs with key
    Permissions.count_permissions_for_role(role_uuid)    # Efficient count
    Permissions.diff_permissions(role_a, role_b)        # Compare two roles

## Mutation API

    Permissions.grant_permission(role_uuid, "billing", granted_by_uuid)
    Permissions.revoke_permission(role_uuid, "billing")
    Permissions.set_permissions(role_uuid, ["dashboard", "users"], granted_by_uuid)
    Permissions.grant_all_permissions(role_uuid, granted_by_uuid)
    Permissions.revoke_all_permissions(role_uuid)
    Permissions.copy_permissions(source_role_uuid, target_role_uuid, granted_by_uuid)

## Custom Keys API

Parent apps can register custom permission keys for custom admin tabs:

    Permissions.register_custom_key("analytics", label: "Analytics", icon: "hero-chart-bar")
    Permissions.unregister_custom_key("analytics")
    Permissions.custom_keys()              # List of registered custom key strings
    Permissions.custom_view_permissions()   # %{ViewModule => "key"} mapping

Custom keys are always treated as "enabled" (no module toggle) and appear
in the permission matrix UI under a "Custom" group.

## Edit Protection

    Permissions.can_edit_role_permissions?(scope, role) :: :ok | {:error, String.t()}

Enforces: users cannot edit their own role, only Owner can edit Admin,
system roles cannot have `is_system_role` changed.

# `admin_baseline_exclusions`

```elixir
@spec admin_baseline_exclusions() :: [String.t()]
```

Every enabled key that a default Admin is NOT auto-granted — the built-in
opt-in keys PLUS any `auto_grant_admin: false` custom keys.
`Scope.holds_all_enabled_permissions?/1` subtracts this from
`enabled_module_keys/0` to form the "operator baseline": the set a default
Admin genuinely holds, so a default Admin (and any grant-all role) stays
Owner-equivalent for admin-view access no matter what opt-in/opt-out keys a
host registers.

# `all_module_keys`

```elixir
@spec all_module_keys() :: [String.t()]
```

Returns all built-in, sub-permission, and custom permission keys as a list (including the `"*"` superadmin key). See `enabled_module_keys/0` for filtered MapSet variant.

# `any_permissions_exist?`

> This function is deprecated. Use permissions_table_ready?/0 — count-based checks re-open the full-access fallback.

```elixir
@spec any_permissions_exist?() :: boolean()
```

# `auto_grant_new_keys_to_admin`

```elixir
@spec auto_grant_new_keys_to_admin() :: :ok
```

Grants every known built-in permission key (core sections, feature-module
keys, sub-permission keys) to the Admin system role, skipping keys that
were auto-granted before (per-key settings flag) so an Owner's later
revocation is never overridden. Custom keys go through the same mechanism
at `register_custom_key/2` time.

Called after module discovery. This is also the repair path for installs
whose V53 seeding predates newer modules: the first boot after upgrade
fills the Admin role's missing keys, after which revocations stick
per-key. Idempotent; safe when the table doesn't exist yet.

# `auto_grant_to_admin_roles`

```elixir
@spec auto_grant_to_admin_roles(String.t()) :: :ok
```

Auto-grants a permission key to the Admin system role.
Stores a flag in phoenix_kit_settings so that if Owner later revokes
the key, it won't be re-granted on next application restart.

# `cache_custom_view_permission`

```elixir
@spec cache_custom_view_permission(module(), String.t()) :: :ok
```

Caches a LiveView module → permission key mapping for custom admin tabs.
Used by the auth system to enforce permissions on custom admin LiveViews
without reading Application config on every mount.

# `can_edit_role_permissions?`

```elixir
@spec can_edit_role_permissions?(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Users.Role.t()
) ::
  :ok | {:error, atom()}
```

Checks if the given scope can edit the target role's permissions.

Returns `:ok` if allowed, or `{:error, reason}` if not.

Rules:
- Owner role cannot be edited (always has full access)
- Users cannot edit their own role (prevents self-lockout)
- Only Owner can edit Admin role (prevents privilege escalation)

# `clear_custom_keys`

```elixir
@spec clear_custom_keys() :: :ok
```

Clears all custom permission keys. For test isolation.

# `copy_permissions`

```elixir
@spec copy_permissions(
  integer() | String.t(),
  integer() | String.t(),
  integer() | String.t() | nil
) :: :ok | {:error, term()}
```

Copies all permissions from one role to another.

The target role will end up with the exact same set of permissions as the
source role. Existing permissions on the target that don't exist on the
source will be revoked.

# `core_section_keys`

```elixir
@spec core_section_keys() :: [String.t()]
```

Returns the 5 core section keys.

# `count_permissions_for_role`

```elixir
@spec count_permissions_for_role(integer() | String.t()) :: non_neg_integer()
```

Returns the number of permission keys granted to a role.
More efficient than `length(get_permissions_for_role(role_uuid))`.

# `custom_keys`

```elixir
@spec custom_keys() :: [String.t()]
```

Returns the list of custom permission key strings.

# `custom_keys_map`

```elixir
@spec custom_keys_map() :: %{required(String.t()) =&gt; map()}
```

Returns the map of registered custom permission keys and their metadata.

# `custom_view_permissions`

```elixir
@spec custom_view_permissions() :: %{required(module()) =&gt; String.t()}
```

Returns the cached custom view → permission mapping.

# `diff_permissions`

```elixir
@spec diff_permissions(integer() | String.t(), integer() | String.t()) :: %{
  only_a: MapSet.t(),
  only_b: MapSet.t(),
  common: MapSet.t()
}
```

Compares permissions between two roles and returns a diff map.

Returns `%{only_a: MapSet.t(), only_b: MapSet.t(), common: MapSet.t()}`
where `only_a` are keys role_a has but role_b doesn't, `only_b` is the
inverse, and `common` are keys both roles share.

# `enabled_module_keys`

```elixir
@spec enabled_module_keys() :: MapSet.t()
```

Returns module keys that are currently enabled (core sections + enabled feature modules + custom keys)
as a `MapSet` for efficient membership checks. Core sections and custom keys are always included.
Feature modules are included only if their module reports enabled status.

Returns `MapSet.t()` unlike `all_module_keys/0` which returns a list — callers use this
primarily for `MapSet.member?/2` and `MapSet.intersection/2` checks.

# `expand_with_parents`

```elixir
@spec expand_with_parents(Enumerable.t()) :: MapSet.t()
```

Expands a set of permission keys with the base keys its sub-permissions
imply (a sub-permission is meaningless without module access). Used by the
grant paths to keep the "no orphan sub-key" invariant, and by the admin
UIs to compute the full set a grant would create before authorizing it.

# `feature_enabled?`

```elixir
@spec feature_enabled?(String.t()) :: boolean()
```

Checks whether a feature module is currently enabled.

Core section keys always return `true`. Feature module keys return the
result of calling the module's `enabled?/0` (or equivalent) function.
Sub-permission keys are enabled iff their parent module is enabled.
Custom permission keys are always enabled (no module toggle).
Returns `false` for unknown keys.

# `feature_module_keys`

```elixir
@spec feature_module_keys() :: [String.t()]
```

Returns the feature module keys from the registry.

# `get_permissions_for_role`

```elixir
@spec get_permissions_for_role(String.t()) :: [String.t()]
```

Returns the list of module_keys granted to a specific role.

# `get_permissions_for_user`

```elixir
@spec get_permissions_for_user(PhoenixKit.Users.Auth.User.t() | nil) :: [String.t()]
```

Returns the list of module_keys the given user has access to.
Joins through role_assignments → role_permissions.

# `get_permissions_matrix`

```elixir
@spec get_permissions_matrix() :: %{required(String.t()) =&gt; MapSet.t()}
```

Returns a matrix of role_uuid → MapSet of granted keys for all roles.

# `grant_all_permissions`

```elixir
@spec grant_all_permissions(integer() | String.t(), integer() | String.t() | nil) ::
  :ok | {:error, term()}
```

Grants all permission keys to a role.

`all_module_keys/0` includes the `"*"` superadmin key, so this confers
Owner-equivalent, drift-immune access (any future module key is reachable
without a re-grant) — not merely the currently-registered keys. Reserve it
for roles that are genuinely meant to be all-powerful.

# `grant_permission`

```elixir
@spec grant_permission(
  integer() | String.t(),
  String.t(),
  integer() | String.t() | nil
) ::
  {:ok, PhoenixKit.Users.RolePermission.t()}
  | {:error, Ecto.Changeset.t() | :role_not_found}
```

Grants a single permission to a role. Uses upsert to be idempotent.

Granting a sub-permission key (`"calendar.view_others"`) also grants its
base module key in the same transaction — a sub-permission row must never
exist without module access, regardless of which code path grants it.

# `integration_keys`

```elixir
@spec integration_keys() :: [String.t()]
```

The two independent integration permission keys (`integrations`, `integrations_system`).

# `localized_module_label`

```elixir
@spec localized_module_label(String.t()) :: String.t()
```

Returns `module_label/1` translated for the current Gettext locale.

Backend resolution mirrors `PhoenixKit.Dashboard.Tab.localized_label/1`
(the label string is the msgid):

* core sections and registered feature modules translate through the
  library's own `PhoenixKitWeb.Gettext` (`"default"` domain) — the same
  msgids the admin sidebar tabs already carry — unless the module
  declares its own `gettext_backend`/`gettext_domain` in
  `permission_metadata/0`;
* sub-permission keys inherit the parent module's backend;
* custom keys use the backend passed to `register_custom_key/2`, or fall
  back to the raw label when none was registered.

When the msgid has no translation for the active locale, gettext returns
the msgid itself, so this never renders worse than `module_label/1`.

> #### Labels are runtime msgids {: .info}
>
> `mix gettext.extract` scans for `gettext`/`gettext_noop` call sites, so
> it cannot see a label that only exists as data in `permission_metadata/0`
> or a `register_custom_key/2` option. A module's label translates only if
> its exact string is already a msgid in the target backend's `.po` files —
> most core labels are, because the admin sidebar tabs mark the same
> strings with `gettext_noop/1`. When adding a module whose label is not
> yet a tab label, add the msgid to the backend's `.po` files by hand.

# `module_description`

```elixir
@spec module_description(String.t()) :: String.t()
```

Returns a short description for a module key (sub-permission keys resolve to the sub's own description).

# `module_icon`

```elixir
@spec module_icon(String.t()) :: String.t()
```

Returns a Heroicon name for a module key (sub-permission keys inherit the parent module's icon).

# `module_label`

```elixir
@spec module_label(String.t()) :: String.t()
```

Returns a human-readable label for a module key (sub-permission keys resolve to the sub's own label).

# `opt_in_admin_keys`

```elixir
@spec opt_in_admin_keys() :: [String.t()]
```

Keys that are opt-in — never auto-granted to Admin, and excluded from the
"operator baseline" that `Scope.holds_all_enabled_permissions?/1` compares
against (so a default Admin, which never holds them, is still Owner-equivalent
for admin-view access). Currently just the personal `integrations` page.

# `opt_out_custom_keys`

```elixir
@spec opt_out_custom_keys() :: [String.t()]
```

Custom keys registered with `auto_grant_admin: false` — never auto-granted to
Admin, so they must be excluded from the operator baseline too.

# `parent_key`

```elixir
@spec parent_key(String.t()) :: String.t() | nil
```

Returns the base module key a composed sub-permission key belongs to, or
`nil` when the key is not a registered sub-permission. Registry-driven —
never inferred by string splitting.

# `permissions_table_ready?`

```elixir
@spec permissions_table_ready?() :: boolean()
```

Whether the permissions table is present (migrated), regardless of how many
rows it holds.

This is the ONLY signal that unlocks the Admin full-access fallback: a
genuinely unmigrated install (pre-V53, table missing) returns `false`, and
scope treats that as "not yet seeded → grant Admin everything". Once the
table exists, zero rows for an Admin means an Owner deliberately revoked
them — no access — and that must stick; row COUNT must never re-open the
fallback (the old `any_permissions_exist?` did, so stripping every role
bare ironically restored full access).

Fails CLOSED: a transient/other query error returns `true` (table assumed
present), so a DB blip de-privileges an Admin to their explicit rows rather
than escalating them to full access.

# `register_custom_key`

```elixir
@spec register_custom_key(
  String.t(),
  keyword()
) :: :ok
```

Registers a custom permission key with metadata.

Custom keys extend the built-in 25 permission keys, allowing parent apps
to define new permission scopes for custom admin tabs. Custom keys are
always treated as "enabled" (no module toggle) and appear in the
permission matrix UI under "Custom".

Raises `ArgumentError` if the key collides with a built-in key or has
an invalid format. Logs a warning on duplicate override.

## Options

- `:label` - Human-readable label (default: capitalized key)
- `:icon` - Heroicon name (default: `"hero-squares-2x2"`)
- `:description` - Short description (default: `""`)
- `:gettext_backend` - Optional Gettext backend module; when set,
  `localized_module_label/1` translates the label with it (the label
  string is the msgid), mirroring `PhoenixKit.Dashboard.Tab`
- `:gettext_domain` - Gettext domain for the label (default: `"default"`;
  only meaningful together with `:gettext_backend`)
- `:auto_grant_admin` - Auto-grant this key to the Admin role on first
  registration (default: `true`). Set `false` for sensitive keys that should
  require an explicit Owner grant.

## Examples

    Permissions.register_custom_key("analytics", label: "Analytics", icon: "hero-chart-bar")

    Permissions.register_custom_key("analytics",
      label: "Analytics",
      gettext_backend: MyAppWeb.Gettext,
      gettext_domain: "admin"
    )

# `revoke_all_permissions`

```elixir
@spec revoke_all_permissions(integer() | String.t()) :: :ok | {:error, term()}
```

Revokes all permissions from a role.

# `revoke_permission`

```elixir
@spec revoke_permission(integer() | String.t(), String.t(), keyword()) ::
  :ok | {:error, :not_found | :unauthorized}
```

Revokes a single permission from a role.

Revoking a base module key also revokes all of its sub-permission keys in
the same statement — a sub-permission row must never outlive module access.
Revoking a sub-permission key removes only that key.

Pass `authorized_keys: MapSet.t()` to restrict the revoke to keys the caller
is allowed to manage: the role's currently-held keys among the cascade are
read **inside the transaction under an advisory lock**, and if any falls
outside `authorized_keys` the whole revoke is rejected with `:unauthorized`.
This closes the race where a caller's cached view misses a concurrently
granted sub-key it doesn't hold. Omitted (or `:all`) skips the check — for
system/internal callers.

Pass `actor_uuid:` to record who performed the revoke in the audit trail
(`permission.revoked` activity). Omitted ⇒ no audit entry (internal callers).

# `role_has_permission?`

```elixir
@spec role_has_permission?(String.t(), String.t()) :: boolean()
```

Checks if a specific role has a specific permission.

# `roles_with_permission`

```elixir
@spec roles_with_permission(String.t()) :: [String.t()]
```

Returns a list of role_ids that have been granted the given module_key.

# `set_permissions`

```elixir
@spec set_permissions(
  integer() | String.t(),
  [String.t()],
  integer() | String.t() | nil
) ::
  :ok | {:error, term()}
```

Syncs permissions for a role: grants missing keys, revokes extras.
Runs in a transaction.

The desired set is normalized before applying: every sub-permission key in
it pulls in its base module key (a sub-permission implies module access),
so no code path can persist an orphan sub-key row.

# `sub_permission_keys`

```elixir
@spec sub_permission_keys() :: [String.t()]
```

Returns all composed sub-permission keys (`"calendar.view_others"`)
declared by registered modules.

# `sub_permissions_for`

```elixir
@spec sub_permissions_for(String.t()) :: [map()]
```

Returns the sub-permission metadata declared under a base module key.
Each entry is `%{key: composed_key, label: label, description: description}`.

# `superadmin_key`

```elixir
@spec superadmin_key() :: String.t()
```

The wildcard superadmin permission key (`"*"`). Holding it is a blanket grant honored by `Scope.has_module_access?/2` / `Scope.can?/2`.

# `unregister_custom_key`

```elixir
@spec unregister_custom_key(String.t()) :: :ok
```

Unregisters a custom permission key. Stale DB rows are harmless.

# `users_with_permission`

```elixir
@spec users_with_permission(String.t()) :: [String.t()]
```

Returns a list of user_ids that have access to the given module_key
(through any of their assigned roles).

# `valid_module_key?`

```elixir
@spec valid_module_key?(String.t()) :: boolean()
```

Checks whether `key` is a known permission key (built-in, sub-permission, or custom).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
