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.

The request an admit/3 decision is about, for its audit event.

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

level()

@type level() :: :public | :authenticated | Malachi.Auth.ConsoleRole.t()

What a route requires.

pattern()

@type pattern() :: [String.t() | :param] | :any

A path pattern: literal segments, :param for any one non-empty segment, or :any for any path.

request()

@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.

resolve_error()

@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.

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

admit(token, level, request)

@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.

anonymous()

@spec anonymous() :: subject()

The subject of every request while authentication is disabled: no user, and every role.

auth_enabled?()

@spec auth_enabled?() :: boolean()

Whether authentication is enabled (MALACHI_DASHBOARD_AUTH_ENABLED, on unless set to false).

authorize(arg1, required)

@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.

capabilities(role)

@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)
[]

check_login_bucket(client_ip)

@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}.

effective_role(permissions, role)

@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

login_bucket_config()

@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.

required_role(method, path)

@spec required_role(String.t() | atom(), String.t()) :: level()

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

resolve(token, client_ip, user_agent)

@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.

routes()

@spec routes() :: [{String.t(), pattern(), level()}]

Every route as {method, pattern, level}, in the order required_role/2 matches them.

session_digest(token)

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

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.

token(authorization, cookie)

@spec token(String.t() | nil, String.t() | nil) :: String.t() | nil

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.