Skip to content

Per-caller authentication on the MCP endpoint #32

Description

@plusky

Depends on #27. Direction settled 2026-08-04: bearer tokens, minted per
(caller, RRID) pair.

Today an HTTP client authenticates to Bugzilla, not to bugwarden: it presents a
Bugzilla API key per request and the server has no notion of caller identity of
its own. Once the server holds the key instead (#27), that incidental identity
disappears — clients present nothing, and any client that can reach the port is
served. The network boundary becomes the only access control, and audit records
can attribute a call to a session and a source address but not to a caller.

The audit schema reserves the slot for the answer: ClientInfo.principal is
defined, always None today, and must never be filled from anything the client
declares about itself.

Which bearer scheme, and why not the other two

A static shared secret was declined here as the end state, because it
authenticates a deployment rather than a caller, so principal stays empty
and the acceptance criteria below stay unmet. Superseded in part
2026-08-18:
a ruoqa-mcp-compatible scoped bearer pair
(BUGWARDEN_HTTP_TOKEN write / BUGWARDEN_HTTP_READ_TOKEN read, env-only,
deny-by-default at startup) lands NOW as the transport-level gate, for fleet
alignment with ruoqa-mcp. It is the degenerate case this issue already
anticipated: same Authorization: Bearer wire shape, and only the validator
changes when (caller, RRID) tokens arrive. The declined part stands where it
matters — a deployment credential must never fill principal, and this issue
stays open until a verified caller identity does.

A token bound to an RRID alone — the identifier of one maintenance update —
authenticates a work context, not a caller: every agent container working that
update presents the same secret. It is worth having (verified, revocable when the
update closes, and the join key with mtui's per-RRID state and the testreport),
but there is still no caller to name. The failure to avoid is filling principal
with the RRID anyway: that conflates "who called" with "what work this was for",
and the audit schema would freeze the conflation.

A token bound to (caller, RRID) carries both facts, separately and verified:
principal is the agent identity, and the RRID rides alongside as the work
context. That is the decision.

Why the finer grain, given that today the Bug Reviewer is the only agent that
talks to bugwarden and the caller half is nearly constant:

  • It survives the move to OAuth. In OAuth 2.1 terms the caller is the subject
    and the resource is a scope/audience claim; the per-RRID-only form has no
    subject and would have to be re-modelled. All three schemes are
    Authorization: Bearer <token> on the wire, so what changes later is the
    validator — not the request shape, the refusal path, the audit fields, or the
    rule that principal is only ever server-established. That rule is the actual
    security surface, and this is how it gets built and tested.
  • It is the hook per-caller policy needs. Varying the guard's capability set
    by caller is impossible today (one operator policy per process, I1) and stays
    unreachable forever without a verified caller identity to attach a scope to.
  • The plumbing exists. The fleet Dispatcher already hands each agent
    container its own role credentials; minting per container instead of per update
    is the same mechanism one loop deeper.
  • Blast radius. A leaked per-update token exposes that update's whole scope to
    whoever holds it; a per-caller one is confined to a role.

The choice is recoverable in the cheap direction only: a per-RRID token is the
degenerate case where each update has one caller, so starting fine and collapsing
later is configuration, while starting coarse and splitting later means
re-issuing credentials and touching the schema twice.

Binding constraints

  1. The token is not the RRID, nor derived from it. RRIDs are enumerable and
    effectively public; a guessable bearer token is not authentication. The
    credential is an unguessable secret bound server-side to the pair, or a signed
    token carrying both as claims.

  2. Scope is tightening-only. A token must never widen what the server-wide
    guard policy allows (I1, I9). An RRID scope may narrow what a caller sees, and
    fails closed. Note bugwarden has no RRID→bug mapping and no route to
    SMELT/IBS: a design needing one is a much larger change than one where the
    issuer asserts the scope.

  3. An agent bound to no RRID must still authenticate. Fleet-level agents do
    not belong to an update, so the work context is optional and independent of the
    principal: absent means "not scoped to an update", which must be
    distinguishable from "scoped, but the scope is unknown" — the latter must be
    impossible. An unscoped token gets exactly the server-wide policy, which is
    today's behaviour and widens nothing.

  4. Token material is secret material under I12 — never in logs, errors, audit
    records or tool results, with something structural enforcing it rather than
    reviewers remembering.

  5. Refusals must be recorded, and must not become an oracle (I2). Verification
    at the transport boundary means an unauthenticated request never reaches
    call_tool, where records are written — so recording it is a mechanism to
    design, not a property that comes for free.

  6. Delivery in containers is by environment. Settled 2026-08-18: container
    deployments configure bugwarden entirely through environment variables, e.g.

    docker run -e BUGZILLA_SERVER=https://bugzilla.suse.com \
               -e BUGZILLA_API_KEY_FILE=/run/secrets/bz-key \
               -e BUGWARDEN_HTTP_TOKEN=$(openssl rand -hex 32) \
               -p 8000:8000 <image>

    Every existing flag already has a clap env binding, so this adds one
    requirement here: the verifier's secret must be readable from a
    BUGWARDEN_-prefixed env var with hide_env_values, like
    BUGZILLA_API_KEY. Custody is then the runtime's — anyone who can
    docker inspect the container or read /proc/<pid>/environ holds the
    token — which is the same exposure already accepted for the server-held
    Bugzilla key delivered the same way.

Delivered 2026-08-18 (PR #105) — and what it does not close

The transport gate landed: BUGWARDEN_HTTP_TOKEN (write) /
BUGWARDEN_HTTP_READ_TOKEN (read), env-only, deny-by-default before the
port binds, constant-time both-token compare, one byte-identical empty
401 for every refusal cause (I2), read scope derived from I13's
WRITE_TOOLS, --insecure-no-auth as the CLI-only escape hatch. Three
things that were open above are now settled by it:

  • server/discover does not stay open once auth exists — the layer
    wraps the whole router. Recorded in DESIGN.md: the revision is unserved
    today, and a pre-request identity surface is not worth opening ahead of
    it.
  • Constraint 6's hide_env_values is moot, in the stronger direction:
    the tokens never enter Cli at all (env-only, no flag — argv is
    world-readable), so there is nothing for a Debug to redact.
  • Constraint 5 is partially met: transport-layer refusals are counted
    and logged at powers of two (no token/path/peer material); a full audit
    record needs a new event kind, which belongs to Adopt the 2026-07-28 MCP revision — audit schema v1 assumes three things it removes #34's v2 schema freeze.
    A read-scope caller's refused write call IS fully audited.

CLOSED 2026-08-18 by decision: the deployment-level bearer gate is
accepted as bugwarden's end state for HTTP authentication. Per-caller
identity and the RRID mapping will be solved outside bugwarden; if that
ever changes, the wire shape shipped here is the one a finer-grained
validator would reuse, and this issue is the design record to reopen.
ClientInfo.principal stays None — still never filled from anything
self-declared — and the acceptance criteria below are deliberately left
unmet rather than reworded.

Open, and not blocking anything

Bound-secret vs signed-claims; who issues and holds the signing key; storage and
constant-time comparison; whether verification sits in the axum layer or the
handler; whether server/discover — a mandatory unauthenticated pre-request
surface under the 2026-07-28 revision (#34 §3) — stays open once auth exists; and
whether the openSUSE package defaults to auth on.

What this unblocks

  • Filling principal with a verified identity, so the log answers "which caller"
    and not only "which session".
  • Per-caller policy, if the guard's capability set should ever vary by caller.

Acceptance criteria

  • Identity is verified by the server; nothing self-declared is ever promoted
    into principal.
  • An unauthenticated request is refused uniformly, with no signal about
    whether a bug, tool, or policy exists (I2).
  • Refusals are recorded.
  • An agent with no RRID authenticates and is recorded with a principal and no
    work context.

See also #34 — the audit schema must reserve room for both facts before it
is frozen at v2, or landing this needs a v3.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestsecurityGuard, key custody, or disclosure surface

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions