Malachi. Console. Access
(malachi v0.22.7)
View Source
Who may reach which HTTP route, decided in one place for both HTTP endpoints, the legacy dashboard
(Malachi.Dashboard) and the console (Malachi.Console.Router). A test that changes a rule here sees
both endpoints follow, and no route decides access on its own.
Console roles, not wire permissions
A route requires a console role (Malachi.Auth.ConsoleRole): :viewer reads, :editor adds the
mutations that are not security (storage policies), :admin adds users, ACLs and diagnostics. The wire
permissions :produce and :consume grant no console role: an account that publishes records cannot
read the cluster's operational state through either endpoint. The one bridge is the superuser
(Malachi.Auth.Authorization.superuser?/1): a wire :admin is a console :admin as well, so an
existing admin keeps everything (effective_role/2).
The route table
routes/0 lists every route with the level it requires: :public (no credentials), :authenticated
(any valid session, with or without a role), or a role. required_role/2 reads it; a route missing
from it requires :admin, so forgetting a row denies rather than leaks. The read API (#230) adds its
/api/v1 rows here.
Resolving a request
resolve/3 turns the credentials of a request into a subject. The session only proves identity: the
role and the permissions are read from the user registry on every request
(Malachi.Auth.UserStore.get_principal/1, a query against the replica on the node that received the
request), so a role change or a removed account takes effect on the next request each node serves once
its replica has applied the change, not on the next login. That read is eventually consistent: a
follower lagging the leader answers with the old role for the replication lag, and a node cut off from
the leader keeps answering with it until it rejoins. A Server-Sent Events stream is authorized once,
when it opens; a role taken away while it is open takes effect when it reconnects or its session
expires.
The read needs only the local replica, not a leader or a quorum. When that replica is not running (a
member restarting, or not yet joined at boot), the request is answered 503 (:principal_unavailable)
rather than served from the session, which is the price of the registry being the authority.
Two rate limit buckets are spent, split by what the request has proven. A token that validates spends
its session's own :dashboard_api budget, keyed by a digest of the token. A token that does not is
charged to the address's :dashboard_auth bucket, the one logins use, before it is validated,
since validation is what writes the expiry and hijack audit events.
With authentication disabled (MALACHI_DASHBOARD_AUTH_ENABLED=false) every request is the anonymous
subject from anonymous/0, which holds :admin: the switch exists for local work, and it lets every
route through as it always has. GET /api/v1/me reports it with authenticated: false.
Summary
Types
What a route requires.
A path pattern: literal segments, :param for any one non-empty segment, or :any for any path.
Why a request was not resolved to a subject.
Who is asking.
Functions
The whole decision for one request to a route that requires level (not :public): resolves token,
authorizes the subject, and records the outcome, so both endpoints audit and count alike. Returns
{:ok, subject}, or the refusal resolve/3 or authorize/2 gave. With authentication disabled the
subject is anonymous/0 and nothing is spent or recorded.
The subject of every request while authentication is disabled: no user, and every role.
Whether authentication is enabled (MALACHI_DASHBOARD_AUTH_ENABLED, on unless set to false).
Whether subject may reach a route that requires level: :ok, {:error, :authentication_required}
for no subject, or {:error, {:missing_role, level, role}} naming what is missing.
What role lets an operator do, as the console renders it: the capabilities of the role and of every
role below it. No role has none.
Spends one unit of client_ip's login bucket: :ok or {:error, :rate_limit_exceeded, retry_after_ms}.
The console role permissions and a stored role add up to: the stored role, raised to :admin for a
superuser. The wire permissions :produce and :consume add nothing.
The login bucket's limit, read in one place for the two checks that spend it and for /rate_limits.
Unlike :dashboard_api it has no off switch: a limit of 0 does not disable brute force protection.
The level method on path requires. path is the request path without its query, and method the
upper case method name (an atom is accepted too). A path no row matches requires :admin.
Resolves token (from token/2) to the subject behind it, from client_ip and user_agent, spending
the rate limit buckets described in the module documentation. The role and permissions come from the
registry, not the session. Every error says what happened; Malachi.HTTP.Problem.from_error/1 names its
response. A request refused for lack of a role still spent its session's budget: it is authenticated work.
Every route as {method, pattern, level}, in the order required_role/2 matches them.
What the API bucket is keyed by: a digest of the session token, never the token. The limiter's table is
public, and its blocked identifiers are printed by /rate_limits, so a raw token there would hand a live
session to whoever reads it. 128 bits of SHA-256 cannot be reversed or collide in practice, and it is
what the :dashboard_api_rate_limited audit event records, so the two can be matched.
The session token in a request: the Authorization: Bearer header first, the primary mechanism for
every client, then the malachi_token cookie, which a browser login sets as a convenience. nil when
neither is present. authorization and cookie are the raw header values, or nil.
Types
@type level() :: :public | :authenticated | Malachi.Auth.ConsoleRole.t()
What a route requires.
@type pattern() :: [String.t() | :param] | :any
A path pattern: literal segments, :param for any one non-empty segment, or :any for any path.
@type request() :: %{ ip: String.t(), user_agent: String.t(), method: String.t() | atom(), path: String.t() }
The request an admit/3 decision is about, for its audit event.
@type resolve_error() :: {:error, :authentication_required} | {:error, :session_expired | :session_hijack_attempt | :invalid_session | :principal_unavailable} | {:error, :rate_limit_exceeded, non_neg_integer()} | {:error, :api_rate_limit_exceeded, non_neg_integer(), String.t(), String.t()}
Why a request was not resolved to a subject.
@type subject() :: %{ username: String.t() | nil, role: Malachi.Auth.ConsoleRole.t() | nil, permissions: [atom()], authenticated: boolean(), session: String.t() | nil }
Who is asking.
Functions
@spec admit(String.t() | nil, level(), request()) :: {:ok, subject()} | resolve_error() | {:error, {:missing_role, Malachi.Auth.ConsoleRole.t(), Malachi.Auth.ConsoleRole.t() | nil}}
The whole decision for one request to a route that requires level (not :public): resolves token,
authorizes the subject, and records the outcome, so both endpoints audit and count alike. Returns
{:ok, subject}, or the refusal resolve/3 or authorize/2 gave. With authentication disabled the
subject is anonymous/0 and nothing is spent or recorded.
What is recorded: :dashboard_access on success; :dashboard_auth_failure and the failure counter for
an invalid session or a missing role; :dashboard_api_rate_limited for a session over its budget and
:dashboard_auth_failure for an address over the login budget, each with its blocked counter. A
request with no credentials at all records nothing, as a page load before login is not an attack.
@spec anonymous() :: subject()
The subject of every request while authentication is disabled: no user, and every role.
@spec auth_enabled?() :: boolean()
Whether authentication is enabled (MALACHI_DASHBOARD_AUTH_ENABLED, on unless set to false).
@spec authorize(subject() | nil, level()) :: :ok | {:error, :authentication_required} | {:error, {:missing_role, Malachi.Auth.ConsoleRole.t(), Malachi.Auth.ConsoleRole.t() | nil}}
Whether subject may reach a route that requires level: :ok, {:error, :authentication_required}
for no subject, or {:error, {:missing_role, level, role}} naming what is missing.
@spec capabilities(Malachi.Auth.ConsoleRole.t() | nil) :: [atom()]
What role lets an operator do, as the console renders it: the capabilities of the role and of every
role below it. No role has none.
Examples
iex> Malachi.Console.Access.capabilities(:editor)
[:read_cluster, :manage_policies]
iex> Malachi.Console.Access.capabilities(nil)
[]
@spec check_login_bucket(term()) :: :ok | {:error, :rate_limit_exceeded, non_neg_integer()}
Spends one unit of client_ip's login bucket: :ok or {:error, :rate_limit_exceeded, retry_after_ms}.
@spec effective_role([atom()], Malachi.Auth.ConsoleRole.t() | nil) :: Malachi.Auth.ConsoleRole.t() | nil
The console role permissions and a stored role add up to: the stored role, raised to :admin for a
superuser. The wire permissions :produce and :consume add nothing.
Examples
iex> Malachi.Console.Access.effective_role([:produce], nil)
nil
iex> Malachi.Console.Access.effective_role([:admin], :viewer)
:admin
iex> Malachi.Console.Access.effective_role([], :editor)
:editor
@spec login_bucket_config() :: %{limit: non_neg_integer(), window_ms: pos_integer()}
The login bucket's limit, read in one place for the two checks that spend it and for /rate_limits.
Unlike :dashboard_api it has no off switch: a limit of 0 does not disable brute force protection.
The level method on path requires. path is the request path without its query, and method the
upper case method name (an atom is accepted too). A path no row matches requires :admin.
Examples
iex> Malachi.Console.Access.required_role("GET", "/metrics")
:viewer
iex> Malachi.Console.Access.required_role(:PUT, "/topics/orders/policy")
:editor
iex> Malachi.Console.Access.required_role("GET", "/not-a-route")
:admin
@spec resolve(String.t() | nil, term(), String.t()) :: {:ok, subject()} | resolve_error()
Resolves token (from token/2) to the subject behind it, from client_ip and user_agent, spending
the rate limit buckets described in the module documentation. The role and permissions come from the
registry, not the session. Every error says what happened; Malachi.HTTP.Problem.from_error/1 names its
response. A request refused for lack of a role still spent its session's budget: it is authenticated work.
Every route as {method, pattern, level}, in the order required_role/2 matches them.
What the API bucket is keyed by: a digest of the session token, never the token. The limiter's table is
public, and its blocked identifiers are printed by /rate_limits, so a raw token there would hand a live
session to whoever reads it. 128 bits of SHA-256 cannot be reversed or collide in practice, and it is
what the :dashboard_api_rate_limited audit event records, so the two can be matched.
The session token in a request: the Authorization: Bearer header first, the primary mechanism for
every client, then the malachi_token cookie, which a browser login sets as a convenience. nil when
neither is present. authorization and cookie are the raw header values, or nil.