asobi_ops_auth (asobi v0.72.6)

View Source

Ops-plane identity: one actor per request, one membership check (ADR 0007).

Every /api/v1/ops request resolves to an actor - #{id, display, source, caps, attested} - and is admitted only when the route's capability class is in the actor's caps. Nothing else in the plane authorises. The actor ships from the first release because it is the one part of an identity design that is expensive to retrofit: every audit row and every token consumer would have to be rewritten.

Two sources are built, and they are the two transports the same operator credential arrives on:

  • static_secret - the operator secret in the ops_secret application env, presented as a bearer token and compared in constant time. CI, the CLI and any server-side caller. There is no default credential: a deployment that has not configured one rejects every bearer request, so an install that never reads the docs is closed rather than wide open.
  • local_user - the console's session cookie plus its x-csrf-token header (asobi_console_session). A browser exchanges the secret for a session once and then never holds it again, so an XSS on the console cannot exfiltrate a credential that outlives the page.

A bearer token wins when both are present, because a caller that sent one meant to use it and silently answering as somebody else's session would be worse than a 403.

A player bearer token is not a credential here. This module never consults asobi_auth_cache, so no player - and no guest - can reach the plane, which is the whole point of taking these routes off the player-scoped check.

A session stands on its own once minted: it resolves without re-reading ops_secret, and it is revoked by logging out or by the node restarting. That is deliberate - a session has its own expiry and its own revocation, so tying it to a value that can change underneath it would end sessions at surprising moments without ending the bearer access that matters.

  • cloud - a short-lived, env-scoped token minted by asobi_saas after its own ownership check, presented as a bearer token and verified by asobi_ops_token. The tenant's role is mapped onto capability classes at mint time, so the string "owner" never reaches this plane, and the token carries only the classes it was minted with rather than every class.

A bearer token is tried as the operator secret first and as a minted token second. The two cannot be confused: a minted token is three dot-separated parts beginning v1., and the secret comparison is over a hash of the whole value, so neither can be mistaken for the other.

Every rejection is 403 with the same body whatever the cause, so a caller cannot tell "no secret configured" from "wrong secret" from "not authorised for this class".

Summary

Functions

The operator label from x-asobi-operator, or the default display name.

Resolve a request to an ops actor.

Nova security callback for the ops route group.

Whether Presented is the configured operator secret.

Types

actor()

-type actor() ::
          #{id := binary(),
            display := binary(),
            source := source(),
            caps := [asobi_ops_caps:class()],
            attested := boolean()}.

source()

-type source() :: static_secret | cloud | local_user | shell.

Functions

display(Req)

-spec display(cowboy_req:req()) -> binary().

The operator label from x-asobi-operator, or the default display name.

Attribution, never authority: it is read only here, after the credential has already been accepted, and it never reaches the capability check. Spoofing it buys a wrong name in the audit trail and nothing else, which is why the actor carries it with attested => false.

Dropped rather than trusted when it is multi-valued (cowboy joins repeated headers with a comma), empty, over 64 bytes, or carries anything outside printable ASCII - the value lands in audit rows and logs.

resolve(Req)

-spec resolve(cowboy_req:req()) -> {ok, actor()} | {error, atom()}.

Resolve a request to an ops actor.

Bearer first, then the console session cookie. Both fail closed: an unset or empty ops_secret rejects every bearer request, and a cookie without a matching x-csrf-token is not a credential.

verify(Req)

-spec verify(cowboy_req:req()) ->
                {true, #{ops_actor := actor()}} | {false, 403, #{binary() => binary()}, binary()}.

Nova security callback for the ops route group.

Admits with the actor in auth_data, or rejects with 403 and a flat error body.

verify_secret/1

-spec verify_secret(term()) -> boolean().

Whether Presented is the configured operator secret.

The one entry point the console login uses, so the constant-time comparison and the no-default rule are stated in exactly one place. false when nothing is configured, never true.