package handler import ( "net/http" ) // RequireHumanActor is a chi-style middleware that rejects requests // authenticated via a machine credential — currently mat_ task tokens // and mcn_ cloud-node PATs. It exists for endpoints whose // authorization model is "the human owner authorized this", not // "anyone holding the owner's credentials authorized this". // // Why this guard is needed (read carefully — auth here is subtle): // // The general Auth middleware (server/internal/middleware/auth.go) // turns four different bearer formats into the same shape — a stamped // `X-User-ID` header — so downstream handlers don't have to care which // token kind the caller used: // // - JWT cookie / mul_ PAT → X-User-ID = the human's user id. // X-Actor-Source is left empty. // - mat_ task token → X-User-ID = the OWNING human's user id, // plus X-Agent-ID, X-Task-ID, and the // authoritative server-set header // `X-Actor-Source: task_token`. // - mcn_ cloud-node PAT → X-User-ID = the OWNING human's user id, // plus `X-Actor-Source: cloud_pat`. // The token authenticates a cloud-runtime // EC2 node operating on the owner's // behalf — same conceptual category as // mat_ (machine running owner-scoped // code) for authorization purposes. // // The mat_ and mcn_ designs (MUL-2600 and the cloud-node PAT story // respectively) were both deliberately built this way: every request // the agent / node makes is treated as the owner's, so they can // post comments, claim issues, register runtimes, etc., as if the // owner had done it. That is correct for issue / comment / chat // scopes — those are bounded by workspace membership and by the // task or runtime binding. // // It is NOT correct for account-level scopes: // // - Billing balance / transactions / batches / topups list // are user-scoped. A running agent or a cloud node could read // its owner's wallet state without the owner ever having // approved a billing query. // // - Checkout / portal session creation can move money. A machine // credential that gets compromised — by a prompt injection, a // bad MCP tool, an escaped quote in scratch data, or a node // escape — could spin up a checkout for an attacker-controlled // email or open a Billing Portal session that leaks subscription // / payment-method state. // // `X-Actor-Source` is server-set only. The Auth middleware deletes any // client-supplied value first (see auth.go: `r.Header.Del("X-Actor-Source")`), // then re-sets it ONLY on the mat_ and mcn_ branches. So checking // this header is the safe, fast, single-source-of-truth way to know // "is the request from a machine credential?" — without re-querying // the token table. // // We deliberately do NOT use h.resolveActor() here: // // - resolveActor's primary job is "agent vs member" classification // for ownership / authorship attribution (issue creator, comment // author, etc.). It also has a fallback path that trusts // X-Agent-ID + X-Task-ID for legacy CLI flows; that fallback is // valid for resolving authorship but is irrelevant here. Billing // authorization needs the strict "machine credential → forbidden" // gate, nothing else. // - resolveActor takes a workspaceID parameter; billing routes have // no workspace context, so threading one through just to call it // would be misleading. // - resolveActor doesn't currently classify mcn_ cloud-node PATs // because cloud nodes don't act on workspace-scoped resources // where author attribution matters. Bolting that classification // into resolveActor solely to reuse it here would be the wrong // coupling. // // Apply via `r.Use(handler.RequireHumanActor)` on a chi route group. // The middleware is intentionally NOT wired in via the router's main // Auth chain — the default contract elsewhere (issues, chat, etc.) is // "agent and human are interchangeable", and adding a global gate // would break legitimate agent traffic. Only attach it where the // scope is truly human-only. // // To extend: any new machine-credential auth branch added to // auth.go (e.g. a hypothetical service-account token) MUST stamp a // distinct X-Actor-Source value AND get reviewed against this gate // at the same time. The denylist below is intentionally explicit — // silently passing an unknown actor source is a feature, not a bug // (see TestRequireHumanActor_IgnoresUnknownActorSource), but the // addition of a new value is the moment to decide whether it's // human-equivalent or machine-equivalent. func RequireHumanActor(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // X-Actor-Source is server-set only. The auth middleware // strips any client-supplied value before stamping its own, // so a non-empty value here is authoritative. switch r.Header.Get("X-Actor-Source") { case "task_token", "cloud_pat": writeError(w, http.StatusForbidden, "this endpoint is only available to human actors") return } next.ServeHTTP(w, r) }) }