Accrue.Entitlements.Guard (accrue v1.2.0)

Copy Markdown View Source

The always-compiled, LiveView-runtime-free decision engine shared by both enforcement surfaces (Accrue.Plug.RequireEntitlement and the cond-compiled Accrue.Live.Entitlements on_mount guard).

This module owns ALL guard decision logic so the two surface modules stay thin transport adapters:

  • Billable resolution (D-14/D-15) — one shared 1-arity convention.
  • Resolve-once (D-17) — the billable is resolved exactly once per request/mount and stashed billable-only (never the boolean decision).
  • Fail-closed delegation (D-08) — the allow/deny decision is delegated to the Phase 123 gate (Accrue.Entitlements.entitled?/3 / has_active_plan?/3); the Guard NEVER makes its own allow decision and NEVER re-queries subscriptions or reads .status.
  • Tiered on_deny resolution (D-11) — per-guard opt → config global → built-in :forbidden.
  • Bounded, no-PII ctx (D-12) — %{guard:, required:, reason:, billable:, surface:}.
  • surface: telemetry dimension (D-18) — :plug | :live is carried onto the inherited [:accrue, :entitlements, :check] span.

It deliberately holds NO LiveView/socket runtime reference (none of the LiveView, Component, Socket, or Controller Phoenix modules) so it passes the Plan 06 "core stays runtime-LiveView-free" merge gate — it lives OUTSIDE lib/accrue/live/ and reads only an opaque container term (a %Plug.Conn{} for :plug, a socket / %{assigns: ...} map for :live).

Security

The billable is resolved from server-side assigns onlycontainer.assigns via the host billable fn or the default current_scope.user/current_user probe — NEVER from request params or headers. The accept header is read for content negotiation only, never for authorization. The deny path is the fail-closed default: a nil billable, a raising billable fn, or any resolution exception all collapse to a deny.

Deny reason

The host-facing ctx.reason is coarse by design (WARNING 2 / D-12). It defaults to :not_entitled and is best-effort :no_active_subscription / :error only where cheaply known, because the boolean predicate the Guard delegates to returns NO reason and we deliberately do NOT make a second gate call to fetch one (D-08/D-17 — exactly one gate call per check). The PRECISE Phase 123 reason atom (:not_entitled | :no_active_subscription | :unmapped_plan | :error) lives in the inherited [:accrue, :entitlements, :check] telemetry span metadata, NOT in ctx. This converts the silent narrowing into a documented contract: read the span for the precise reason, read ctx.reason for a coarse host-facing bucket.

Summary

Types

The opaque transport container: a %Plug.Conn{} (:plug) or a socket / %{assigns: ...} (:live).

Bounded, no-PII deny context (D-12).

The tiered, host-resolvable deny form (D-11).

Functions

Resolves the billable (precedence + resolve-once + fail-closed), delegates the allow/deny decision to the Phase 123 gate carrying surface:, and returns either {:allow, container} or {:deny, deny_form, ctx}.

Returns the configured deny path (config :accrue, :entitlements, deny_path:, default "/") for the LiveView surface's deny_live/3 to redirect to.

Translates a resolved deny_form on the Plug surface and halts the conn. The wire body is OPAQUE (D-10) — ctx.required/feature/plan is NEVER echoed into the response body; it reaches the host only via ctx passed to host on_deny fns.

Resolves the billable for container, total and never raising. Precedence

Types

container()

@type container() :: term()

The opaque transport container: a %Plug.Conn{} (:plug) or a socket / %{assigns: ...} (:live).

ctx()

@type ctx() :: %{
  guard: :feature | :plan,
  required: atom() | String.t(),
  reason: atom(),
  billable: term() | nil,
  surface: :plug | :live
}

Bounded, no-PII deny context (D-12).

deny_form()

@type deny_form() ::
  :forbidden
  | {:redirect, String.t()}
  | {non_neg_integer(), iodata()}
  | (container(), map() -> term())
  | {module(), atom(), list()}

The tiered, host-resolvable deny form (D-11).

Functions

check(surface, container, opts)

@spec check(:plug | :live, container(), keyword()) ::
  {:allow, container()} | {:deny, deny_form(), ctx()}

Resolves the billable (precedence + resolve-once + fail-closed), delegates the allow/deny decision to the Phase 123 gate carrying surface:, and returns either {:allow, container} or {:deny, deny_form, ctx}.

opts keys:

  • :feature | :plan — exactly one; selects the gate predicate.

  • :billable — a 1-arity fn container -> billable | nil override (wins over the config global and the default probe).

  • :on_deny — a per-guard deny_form/0 override (wins over the config global and the built-in :forbidden).
  • :status — a per-guard plug status override (default 403).

For the :plug surface the resolved billable is stashed once on the conn via Plug.Conn.assign(conn, :accrue_billable, billable) (read first; resolved at most once). For the :live surface the resolved billable is stashed onto the returned container's assigns via a plain map update (NO Component.assign reference here) so the cond-compiled surface can mirror it with assign_new (read first; resolved at most once).

deny_path()

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

Returns the configured deny path (config :accrue, :entitlements, deny_path:, default "/") for the LiveView surface's deny_live/3 to redirect to.

deny_plug(conn, deny_form, ctx, opts \\ [])

@spec deny_plug(Plug.Conn.t(), deny_form(), ctx(), keyword()) :: Plug.Conn.t()

Translates a resolved deny_form on the Plug surface and halts the conn. The wire body is OPAQUE (D-10) — ctx.required/feature/plan is NEVER echoed into the response body; it reaches the host only via ctx passed to host on_deny fns.

  • :forbidden → content-negotiated opaque 403 ({"error":"forbidden"} for JSON accept, else "Forbidden"); honors a status: opt override.
  • {:redirect, path}302 with a location header (pure Plug).
  • {status, body}send_resp(status, body).
  • a 2-arity fn → fun.(conn, ctx).
  • an MFA {m, f, a}apply(m, f, a ++ [conn, ctx]).

resolve_billable(surface, container, opts)

@spec resolve_billable(:plug | :live, container(), keyword()) :: term() | nil

Resolves the billable for container, total and never raising. Precedence:

  1. per-guard billable: opt (1-arity fn),
  2. config :accrue, :entitlements, billable: (1-arity fn or nil),
  3. the default probe — container.assigns.current_scope.usercontainer.assigns.current_usernil.

The host billable fn call is wrapped in the same rescue/catch → nil as Accrue.Entitlements.resolve/1, so a raising host fn fails closed (nil flows to the gate, which denies).