defmodule Attesto.Scope do @moduledoc """ Scope grant-form matching for OAuth-style `.` scopes. Attesto does not define *which* scopes exist - that catalog is your application's policy. It defines the *algebra*: given a catalog of concrete scope strings, what counts as a legal grant form, and whether a granted set covers a required scope. Build a catalog once with `new_catalog/1` and thread it through the matching functions. A scope is a dotted string of the form `.` (e.g. `trackers.read`). ## Grant forms A *granted* scope (one stored on a credential, or carried in a JWT's `scope` claim) may be: * a concrete catalog entry such as `trackers.read`; * the resource-level wildcard `.*` (e.g. `webhooks.*`), which grants every catalog action under that resource; * the full wildcard `*`, which grants every catalog scope. Reserved for system-issued credentials; customer-facing issuance must not surface or accept it. Two validators encode this asymmetry: * `valid_grant_form?/2` accepts `*` and is the right check for system-issued credentials. * `customer_grant_form?/2` rejects `*` and is the check a public token endpoint or credential changeset must use. Wildcard grants are parsed strictly: only `.*` (exactly one dot, no other segments) is accepted. Deep forms like `trackers.read.*` are rejected so a grant is never silently broadened past a single resource. ## Required scopes A *required* scope (one a protected endpoint declares) MUST be a concrete catalog entry - passing a wildcard form as the requirement returns `false`, since a wildcard requirement would be ambiguous. Even a `*`-granted credential only authorizes catalog entries, so an uncatalogued endpoint requirement is never granted. `grants_all?/3` raises `ArgumentError` on a nil or empty required-scope list so a misconfigured authorization declaration (an endpoint that forgot to declare its required scope) fails loudly instead of silently authorizing every caller. ## Why strings, not atoms Scopes round-trip through HTTP requests, JWT claims, and database columns as strings; they are never coerced to atoms (a denial-of-service vector for externally-influenced values). """ @full_wildcard "*" @enforce_keys [:entries, :resources] defstruct entries: MapSet.new(), resources: MapSet.new() @type t :: %__MODULE__{entries: MapSet.t(String.t()), resources: MapSet.t(String.t())} @doc """ Build a catalog from the list of concrete scope strings your API understands. Computes the distinct resources (left-of-dot segments) once so per-request matching is allocation-light. """ @spec new_catalog([String.t()]) :: t() def new_catalog(scopes) when is_list(scopes) do entries = MapSet.new(scopes) resources = scopes |> MapSet.new(&(&1 |> String.split(".", parts: 2) |> hd())) %__MODULE__{entries: entries, resources: resources} end @doc "The concrete scope strings in the catalog, sorted." @spec entries(t()) :: [String.t()] def entries(%__MODULE__{entries: entries}), do: entries |> MapSet.to_list() |> Enum.sort() @doc "The distinct resources present in the catalog, sorted." @spec resources(t()) :: [String.t()] def resources(%__MODULE__{resources: resources}), do: resources |> MapSet.to_list() |> Enum.sort() # RFC 6749 Appendix A scope-token ABNF: `1*NQCHAR`, where NQCHAR is # %x21 / %x23-5B / %x5D-7E - i.e. printable ASCII excluding space, # double-quote, and backslash. A scope string is space-delimited, so a # single token MUST NOT itself contain whitespace (or control characters # that could be misread downstream). @scope_token ~r/\A[\x21\x23-\x5B\x5D-\x7E]+\z/ @doc """ Returns `true` iff `value` is a syntactically-valid RFC 6749 scope-token: a non-empty string of printable ASCII excluding space, double-quote, and backslash. This is a *wire-format* check independent of any catalog: it rejects a value like `"documents.read positions.read"` that, embedded in a space-delimited `scope` claim, would be indistinguishable from two separate grants. """ @spec valid_token?(term()) :: boolean() def valid_token?(value) when is_binary(value), do: Regex.match?(@scope_token, value) def valid_token?(_), do: false @doc """ Returns `true` iff `scope` is a concrete catalog entry (no wildcards). """ @spec known?(t(), term()) :: boolean() def known?(%__MODULE__{entries: entries}, scope) when is_binary(scope), do: MapSet.member?(entries, scope) def known?(%__MODULE__{}, _), do: false @doc """ Returns `true` iff `scope` is a legal granted form for a **system-issued** credential: a concrete catalog entry, the full wildcard `*`, or a resource-level wildcard `.*` whose resource appears in the catalog. Customer-facing surfaces MUST use `customer_grant_form?/2` instead - it rejects the system-only `*` form. """ @spec valid_grant_form?(t(), term()) :: boolean() def valid_grant_form?(%__MODULE__{}, @full_wildcard), do: true def valid_grant_form?(%__MODULE__{} = catalog, scope), do: customer_grant_form?(catalog, scope) @doc """ Returns `true` iff `scope` is a legal granted form for a **customer-facing** credential: a concrete catalog entry or a resource-level wildcard `.*` whose resource appears in the catalog. The full wildcard `*` is rejected. """ @spec customer_grant_form?(t(), term()) :: boolean() def customer_grant_form?(%__MODULE__{}, @full_wildcard), do: false def customer_grant_form?(%__MODULE__{} = catalog, scope) when is_binary(scope) do known?(catalog, scope) or match?({:ok, _}, parse_resource_wildcard(catalog, scope)) end def customer_grant_form?(%__MODULE__{}, _), do: false @doc """ Returns `true` iff the `granted` scope list covers the `required` scope. `required` MUST be a concrete catalog entry; passing a wildcard form returns `false`. A nil or empty grant list returns `false`. Granted entries that are not valid grant forms are ignored - they cannot grant anything, even by accident. Even the full wildcard `*` only covers scopes actually in the catalog, so a typo or uncatalogued endpoint requirement is never authorized. """ @spec grants?(t(), [String.t()] | nil, String.t()) :: boolean() def grants?(%__MODULE__{} = catalog, granted, required) when is_binary(required) do known?(catalog, required) and Enum.any?(List.wrap(granted), &covers?(catalog, &1, required)) end def grants?(%__MODULE__{}, _, _), do: false @doc """ Returns `true` iff `granted` covers every entry in `required`. Raises `ArgumentError` on a nil or empty `required` list so a misconfigured authorization declaration fails loudly instead of silently authorizing every caller. """ @spec grants_all?(t(), [String.t()] | nil, [String.t(), ...]) :: boolean() def grants_all?(%__MODULE__{}, _granted, nil), do: raise_empty_required!("nil") def grants_all?(%__MODULE__{}, _granted, []), do: raise_empty_required!("[]") def grants_all?(%__MODULE__{} = catalog, granted, required) when is_list(required) do Enum.all?(required, &grants?(catalog, granted, &1)) end @doc """ Returns the subset of `requested` scopes that are NOT valid customer-facing grant forms. Used at a token endpoint to surface `invalid_scope` (RFC 6749 ยง5.2) without leaking which scopes are catalogued. Rejects the system-only `*` form. """ @spec unknown(t(), [String.t()] | nil) :: [String.t()] def unknown(%__MODULE__{}, nil), do: [] def unknown(%__MODULE__{} = catalog, requested) when is_list(requested), do: Enum.reject(requested, &customer_grant_form?(catalog, &1)) # ----- internal ----- defp covers?(catalog, @full_wildcard, required), do: known?(catalog, required) defp covers?(catalog, granted, required) when is_binary(granted) and is_binary(required) do case parse_resource_wildcard(catalog, granted) do {:ok, resource} -> scope_resource(required) == resource and known?(catalog, required) :error -> granted == required and known?(catalog, required) end end defp covers?(_catalog, _, _), do: false # Parses a resource-level wildcard `.*` strictly: exactly two # segments, the second being `*` and the first a non-empty resource that # appears in the catalog. Rejects deep wildcards like `trackers.read.*` # and degenerate forms like `.*` / `*.read`. defp parse_resource_wildcard(%__MODULE__{resources: resources}, scope) when is_binary(scope) do case String.split(scope, ".") do [resource, "*"] when resource != "" -> if MapSet.member?(resources, resource), do: {:ok, resource}, else: :error _ -> :error end end defp parse_resource_wildcard(_catalog, _), do: :error defp scope_resource(scope) when is_binary(scope) do scope |> String.split(".", parts: 2) |> hd() end defp raise_empty_required!(label) do raise ArgumentError, "grants_all?/3 requires at least one required scope; got #{label}. " <> "Every authenticated endpoint must declare a scope." end end