MishkaCmsCore.Accounts.Checks.AccessPermissions (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Whether an actor holds what a gate requires — the one matcher every policy, plug, page guard, sidebar entry, widget, setting and notification audience asks.

Names

A permission name is a plain string of colon-separated, lower snake_case segments, written where it is used:

  • admin:dashboard:<area>[:<sub>] — the admin screens and the data they read and write, such as admin:dashboard:runtime:page, admin:dashboard:blog:post or admin:dashboard:code.
  • admin:api:<area>[:<sub>] — the admin JSON:API and GraphQL, such as admin:api:runtime:page.
  • * only as the whole last segment: admin:dashboard:* is the whole dashboard and nothing on the API, admin:api:* the whole API and nothing on the dashboard, admin:dashboard:runtime:* every runtime section. admin:* and * are both surfaces.

A held name ending in * covers every name below it, and a leaf covers only itself: admin:dashboard:code does not cover admin:dashboard:code:x. A narrow name never meets a required wildcard: a required admin:dashboard:* is met only by admin:dashboard:*, admin:* or *.

What may be required

A list where holding ANY entry is enough. An entry is:

  • a name — held when a held name covers it. A required admin:dashboard:… name counts only when the request came from the dashboard, and a required admin:api:… name only when it came from the API (surface/1). Any other name counts on both.
  • {:under, prefix} — held when the actor holds anything at or under prefix: a name starting with prefix <> ":", or a wildcard covering prefix <> ":*". *, admin:*, admin:dashboard:* and admin:dashboard:blog:post are all under "admin:dashboard". It counts wherever the request came from.
  • {:under, :surface} — {:under, "admin:dashboard"} or {:under, "admin:api"}, for the surface the request came from.

Anything else grants nothing.

As a policy check it takes :permissions and :under, and the entries they name are added together. A policy lists the name of each surface it answers on:

authorize_if {AccessPermissions, permissions: ["admin:dashboard:runtime:page", "admin:api:runtime:page"]}
authorize_if {AccessPermissions, permissions: [{:under, "admin:dashboard"}]}
authorize_if {AccessPermissions, under: "admin:dashboard"}

The admin JSON:API, GraphQL and the GraphQL socket put admin_surface: :api in the shared context (MishkaCmsCore.Runtime.Plugs.AdminSurfacePlug); every other request is the dashboard's. Called directly, pass a context carrying that surface to ask about the API:

AccessPermissions.match?(user, %{shared: %{admin_surface: :api}}, permissions: ["admin:api:*"])

Sites

* is not master status. A master is a user whose site_id is nil (MishkaCmsCore.Accounts.Helper.master_user?/1); a site user holding * holds every permission within its own site and nothing beyond it.

As a policy check it asks about the action the policy is authorizing, with the actor and tenant it RUNS with. A site user holds none of its permissions under another site's tenant, nor for a master's action (one that declares TenantContext with require_master: true). A master holds its permissions under any tenant and for any action. Called directly, with no action to ask about, it answers for the permissions alone.

Loading

An actor whose :permissions are not loaded has them read with no tenant and no authorization: which names a user holds is not a question of the site a request runs under.

Attribution

Originally developed based on the core implementation by Mr. Toomaj Boloorian (https://github.com/toomaj). Original source available at: https://github.com/mishka-group/mishka_developer_tools/blob/master/lib/modules/permission_access.ex

Summary

Types

One entry of a required list: a name, or anything under a prefix.

Where an admin works: the screens, or the admin JSON:API and GraphQL.

Functions

The names among names that nothing actor holds covers (covers?/2): what the actor would gain by granting them, or by taking over an account that holds them. Every name for no actor.

Whether holding permission grants action, with the same wildcard semantics every gate uses: admin:dashboard:* covers admin:dashboard:blog:post, * covers everything, and a narrower admin:dashboard:runtime:page does NOT cover admin:dashboard:*. The surface plays no part.

What the check asks, in words, for a policy breakdown.

Whether actor holds anything at or under prefix, such as "admin:dashboard". See {:under, prefix} above.

Callback implementation for Ash.Policy.Check.init/1.

Whether actor holds any of what opts requires (:permissions, :under) on the request's surface, within its own site when context carries the policy's subject. false for no actor or nothing required.

The surface a request came from: :api when the shared context says admin_surface: :api, otherwise :dashboard. Takes a policy context (with its :subject), a query, changeset or action input, or a context map.

Callback implementation for Ash.Policy.Check.type/0.

actor with its :permissions loaded — read with no tenant and no authorization when they are not. A map that is not a resource is returned as it is.

Types

requirement()

@type requirement() :: String.t() | {:under, String.t() | :surface}

One entry of a required list: a name, or anything under a prefix.

surface()

@type surface() :: :dashboard | :api

Where an admin works: the screens, or the admin JSON:API and GraphQL.

Functions

beyond(actor, names)

@spec beyond(map() | nil, [String.t()]) :: [String.t()]

The names among names that nothing actor holds covers (covers?/2): what the actor would gain by granting them, or by taking over an account that holds them. Every name for no actor.

AccessPermissions.beyond(actor, ["admin:dashboard:runtime:page", "*"])
#=> ["*"]

covers?(permission, action)

@spec covers?(String.t(), String.t()) :: boolean()

Whether holding permission grants action, with the same wildcard semantics every gate uses: admin:dashboard:* covers admin:dashboard:blog:post, * covers everything, and a narrower admin:dashboard:runtime:page does NOT cover admin:dashboard:*. The surface plays no part.

Public because granting permissions asks the same question in the other direction — "may this actor hand out that permission" is "does what they hold cover it".

describe(opts)

What the check asks, in words, for a policy breakdown.

eager_evaluate?()

Callback implementation for Ash.Policy.Check.eager_evaluate?/0.

holds_under?(actor, prefix)

@spec holds_under?(map() | nil, String.t()) :: boolean()

Whether actor holds anything at or under prefix, such as "admin:dashboard". See {:under, prefix} above.

init(opts)

Callback implementation for Ash.Policy.Check.init/1.

match?(actor, context, opts)

Whether actor holds any of what opts requires (:permissions, :under) on the request's surface, within its own site when context carries the policy's subject. false for no actor or nothing required.

prefer_expanded_description?()

Callback implementation for Ash.Policy.Check.prefer_expanded_description?/0.

requires_original_data?(_, _)

Callback implementation for Ash.Policy.Check.requires_original_data?/2.

strict_check(actor, context, opts)

Callback implementation for Ash.Policy.Check.strict_check/3.

surface(arg1)

@spec surface(map() | nil) :: surface()

The surface a request came from: :api when the shared context says admin_surface: :api, otherwise :dashboard. Takes a policy context (with its :subject), a query, changeset or action input, or a context map.

type()

Callback implementation for Ash.Policy.Check.type/0.

with_permissions(actor)

@spec with_permissions(map() | nil) :: map() | nil

actor with its :permissions loaded — read with no tenant and no authorization when they are not. A map that is not a resource is returned as it is.