Lockspire.Protocol.DcrPolicy (lockspire v1.0.0)

Copy Markdown

Resolves the effective DCR policy for an inbound RFC 7591 client registration request as the intersection of:

  1. ServerPolicy DCR allowlists (the operator-configured envelope)
  2. InitialAccessToken policy_overrides (when an IAT was redeemed; nil otherwise)
  3. Inbound RFC 7591 client metadata (what the registrant requested)

Intersection-only: the resolver never widens any allowlist. IAT overrides are assumed already-narrowed to ⊆ server allowlist at IAT-mint time (Phase 28 admin path enforces this). If an out-of-allowlist override slips through (e.g. policy was tightened after IAT mint), MapSet.intersection/2 naturally drops it — never widens.

Returns: {:ok, %Resolved{}} — the effective policy bound to this request {:error, :invalid_client_metadata, %{field, reason, allowed}} — first inbound value

not in the server allowlist; field names the offending axis (e.g. `:scope`,
`:grant_types`, `:redirect_uri_scheme`, `:redirect_uri_host`,
`:token_endpoint_auth_method`).

Note on redirect_uris data carriage: the Resolved substruct exposes the deduplicated allowed_redirect_uri_schemes and allowed_redirect_uri_hosts axes (the resolver's job is bound-checking, not data carriage). The validated redirect_uris list itself is NOT carried on Resolved.t(). Phase 26's intake validator and Phase 27's controller take the original inbound["redirect_uris"] list directly once the resolver returns :ok.

Mirrors Lockspire.Protocol.ParPolicy shape (the only existing resolver precedent in the repo).

Empty allowlist semantics (operator UX hazard)

Migration A defaults every dcr_allowed_* axis to [] (Postgres DEFAULT '{}'). A host app that runs the migration but never runs an operator-mint script will, by default, reject every DCR request with {:error, :invalid_client_metadata, %{reason: :not_in_allowlist, allowed: []}}. The empty allowed list tells the registrant "no values are allowed" — not "the operator hasn't configured DCR yet."

This is intended secure-by-default behaviour (D-06: operator must explicitly populate) but is operator-confusing if paired with registration_policy != :disabled. Phase 26's intake validator should detect "registrationpolicy != :disabled AND every dcr_allowed* is []" and emit a more informative error (reason: :dcr_unconfigured) to disambiguate from a legitimate per-axis allowlist rejection. Until then, operators diagnosing "every DCR request returns :not_in_allowlist with allowed: []" should check the server-policy DCR fields are populated.

Summary

Types

error_detail()

@type error_detail() :: %{field: atom(), reason: atom(), allowed: list()}

Functions

resolve(server_policy, iat_overrides, inbound_metadata)

@spec resolve(Lockspire.Domain.ServerPolicy.t(), map() | keyword() | nil, map()) ::
  {:ok, struct()} | {:error, :invalid_client_metadata, error_detail()}