Attesto.Scope (Attesto v0.5.0)

Copy Markdown View Source

Scope grant-form matching for OAuth-style <resource>.<action> 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 <resource>.<action> (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 <resource>.* (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:

Wildcard grants are parsed strictly: only <resource>.* (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).

Summary

Functions

Returns true iff scope is a legal granted form for a customer-facing credential: a concrete catalog entry or a resource-level wildcard <resource>.* whose resource appears in the catalog. The full wildcard * is rejected.

The concrete scope strings in the catalog, sorted.

Returns true iff the granted scope list covers the required scope.

Returns true iff granted covers every entry in required.

Returns true iff scope is a concrete catalog entry (no wildcards).

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.

The distinct resources present in the catalog, sorted.

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.

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 <resource>.* whose resource appears in the catalog.

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.

Types

t()

@type t() :: %Attesto.Scope{
  entries: MapSet.t(String.t()),
  resources: MapSet.t(String.t())
}

Functions

customer_grant_form?(catalog, scope)

@spec customer_grant_form?(t(), term()) :: boolean()

Returns true iff scope is a legal granted form for a customer-facing credential: a concrete catalog entry or a resource-level wildcard <resource>.* whose resource appears in the catalog. The full wildcard * is rejected.

entries(scope)

@spec entries(t()) :: [String.t()]

The concrete scope strings in the catalog, sorted.

grants?(catalog, granted, required)

@spec grants?(t(), [String.t()] | nil, String.t()) :: boolean()

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.

grants_all?(catalog, granted, required)

@spec grants_all?(t(), [String.t()] | nil, [String.t(), ...]) :: boolean()

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.

known?(scope, scope)

@spec known?(t(), term()) :: boolean()

Returns true iff scope is a concrete catalog entry (no wildcards).

new_catalog(scopes)

@spec new_catalog([String.t()]) :: t()

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.

resources(scope)

@spec resources(t()) :: [String.t()]

The distinct resources present in the catalog, sorted.

unknown(catalog, requested)

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

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.

valid_grant_form?(catalog, scope)

@spec valid_grant_form?(t(), term()) :: boolean()

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 <resource>.* whose resource appears in the catalog.

Customer-facing surfaces MUST use customer_grant_form?/2 instead - it rejects the system-only * form.

valid_token?(value)

@spec valid_token?(term()) :: boolean()

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.