Files
pad/internal/server/context.go
T
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00

341 lines
15 KiB
Go

package server
import (
"context"
"net/http"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Exported context-key helpers for callers that need to synthesize an
// authenticated request without going through the auth middleware
// chain (e.g. the in-process MCP HTTP-handler dispatcher in
// internal/mcp/dispatch_http.go, which resolves OAuth users via the
// MCP middleware and then calls the API handler tree directly).
//
// The corresponding context keys (ctxCurrentUser, ctxIsAPIToken) are
// kept package-private so the rest of the codebase can't bypass the
// middleware accidentally — these functions are the controlled surface.
// WithCurrentUser returns ctx decorated with the resolved user, the
// same way TokenAuth / SessionAuth do during a normal authenticated
// request. Subsequent handler-tree code can reach the user via the
// existing currentUser(r) helper without any change.
//
// It does NOT record HOW the credential was established (`ctxAuthKind`,
// BUG-3007), because it cannot know: the caller resolved the principal
// by some means this package did not witness. A request carrying a user
// with no auth kind is therefore CLOSED by the long-lived-connection
// liveness predicate (`streamCredentialStillValid`) rather than exempted
// from revalidation — see the empty-kind branch in
// stream_credential_liveness.go. Nothing today dispatches a long-lived
// route through this helper; a caller that wants to must teach the
// predicate its credential's door first.
//
// Pass nil to clear (rare; mostly useful in tests).
func WithCurrentUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, ctxCurrentUser, user)
}
// WithAPITokenAuth marks ctx as authenticated via an API token, the
// same way TokenAuth does on the Bearer-token path. Required when
// dispatching through the in-process handler from the OAuth-protected
// /mcp endpoint, because the auth middleware's downstream checks
// (e.g. CSRF exemption for token-auth callers) read ctxIsAPIToken to
// distinguish from session-cookie traffic.
func WithAPITokenAuth(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxIsAPIToken, true)
}
// WithTokenWorkspaceID returns ctx decorated with a workspace-scope
// hint, mirroring TokenAuth's behaviour for workspace-scoped API
// tokens. Pass an empty string to clear (overrides any previous
// scope-binding to ""; downstream readers via tokenWorkspaceID(r)
// see the same "no scope" they would for a never-set context).
//
// The MCP dispatcher uses this to forward the OAuth-token's allowed
// workspace to the handler tree where existing access-control logic
// reads it via tokenWorkspaceID(r).
func WithTokenWorkspaceID(ctx context.Context, workspaceID string) context.Context {
// Always overwrite — passing "" must clear a stale scope set
// further up the context chain. Returning ctx unchanged on the
// empty path was a bug Codex caught in PR #343 review round 4.
return context.WithValue(ctx, ctxTokenWorkspaceID, workspaceID)
}
// CurrentUserFromContext returns the user attached by WithCurrentUser
// (or by the standard auth middleware), and a boolean signalling
// whether one was present. Read-only accessor — callers that need to
// set the user must use WithCurrentUser.
//
// Exported so out-of-package callers (notably internal/mcp's HTTP
// dispatcher tests) can verify the user round-trips correctly through
// the synthesized request without reaching into private helpers.
func CurrentUserFromContext(ctx context.Context) (*models.User, bool) {
v, ok := ctx.Value(ctxCurrentUser).(*models.User)
return v, ok && v != nil
}
// IsAPITokenFromContext reports whether the request was authenticated
// via an API token (vs. a session cookie or CLI session token).
// Mirrors the package-private isAPIToken(r) helper.
func IsAPITokenFromContext(ctx context.Context) bool {
v, _ := ctx.Value(ctxIsAPIToken).(bool)
return v
}
// WithTokenScopes returns ctx decorated with the API token's
// JSON-encoded scopes string (e.g. `["read"]`, `["*"]`). Stashed by
// the MCP Bearer middleware so the in-process dispatcher
// (internal/mcp/dispatch_http.go) can enforce per-tool scope checks
// — the dispatcher bypasses the standard TokenAuth chain by setting
// WithCurrentUser directly, so the chain-level scope check at
// middleware_auth.go:TokenAuth doesn't run for synthesized requests.
//
// Without this, a PAT with scope `["read"]` could drive write MCP
// tools because the in-process request looks pre-authenticated to
// the handler tree. Codex review #369 round 1 flagged the gap.
//
// Empty string clears any previously set scope.
func WithTokenScopes(ctx context.Context, scopes string) context.Context {
return context.WithValue(ctx, ctxTokenScopes, scopes)
}
// TokenScopesFromContext returns the JSON-encoded scopes attached by
// WithTokenScopes, or "" if none. Empty maps to "unrestricted" in
// TokenScopeAllows (the legacy behaviour) — callers wanting
// strict-deny on the unset path must check the empty-string branch
// before passing it on.
func TokenScopesFromContext(ctx context.Context) string {
v, _ := ctx.Value(ctxTokenScopes).(string)
return v
}
// TokenScopeAllows is the exported wrapper around the package-private
// tokenScopeAllows: returns true iff the scopes JSON permits the given
// HTTP method against path. Public so internal/mcp/dispatch_http.go
// can re-check scopes on each synthesized tool call without
// reimplementing the policy.
//
// Policy summary (see tokenScopeAllows for the full doc):
//
// - "" or `["*"]` → allow all methods (legacy / explicit wildcard).
// - `["read"]` → allow GET / HEAD / OPTIONS only.
// - `["write"]` → allow all methods.
// - explicit `[]` → allow all methods (legacy unrestricted form).
// - unparseable / null → deny.
// - unknown scope only → deny (policy-relevant unknowns are logged).
//
// Caller is expected to be the in-process MCP dispatcher; the
// chain-level enforcement on /api/v1/* still runs through tokenScopeAllows
// directly.
func TokenScopeAllows(scopesJSON, method, path string) bool {
return tokenScopeAllows(scopesJSON, method, path)
}
// WithTokenAllowedWorkspaces returns ctx decorated with the OAuth
// token's workspace allow-list set at consent time (TASK-952). The
// list is either a set of slugs (the user's specific selection) or
// `["*"]` (the wildcard checkbox). MCPBearerAuth's OAuth path stashes
// this on every request so RequireWorkspaceAccess can gate the
// resolved workspace against the allow-list (TASK-953) before
// running the standard membership check.
//
// nil clears any previously set allow-list — distinct from setting
// an empty slice, which would deny every workspace. Called for OAuth
// consent scoping, and for a user-owned PAT pinned to a workspace, whose
// allow-list is the one slug (BUG-3982).
//
// Exported so the in-process MCP dispatcher can forward the same
// allow-list onto synthesized requests via Apply, matching the
// pattern WithTokenScopes uses (sub-PR E TASK-1027 round 1).
func WithTokenAllowedWorkspaces(ctx context.Context, slugs []string) context.Context {
if slugs == nil {
return context.WithValue(ctx, ctxTokenAllowedWorkspaces, []string(nil))
}
// Defensive copy — caller mutating after the call must not
// corrupt the per-request token state.
cp := make([]string, len(slugs))
copy(cp, slugs)
return context.WithValue(ctx, ctxTokenAllowedWorkspaces, cp)
}
// WithMCPTokenIdentity stashes the bearer-token identity so the MCP
// audit middleware can record which connection drove the call.
//
// kind is the discriminator — "oauth" for fosite-issued access tokens,
// "pat" for personal access tokens. ref is the connection identifier:
//
// - For "oauth", ref is the OAuth request_id chain identifier
// (preserved across refresh-token rotations — see
// internal/store/oauth.go). One value per "connection" the user
// authorized via consent.
// - For "pat", ref is api_tokens.id.
//
// Only called from MCPBearerAuth's two branches (PAT + OAuth).
// Other auth middleware doesn't touch /mcp, so the audit middleware
// reads "" and skips the row when no MCP identity was attached
// (defense in depth — the audit middleware only mounts behind
// MCPBearerAuth, so the empty case shouldn't fire in practice).
//
// Empty kind or ref clears any previously set value, which would only
// happen in a synthesized test request.
func WithMCPTokenIdentity(ctx context.Context, kind, ref string) context.Context {
ctx = context.WithValue(ctx, ctxMCPTokenKind, kind)
ctx = context.WithValue(ctx, ctxMCPTokenRef, ref)
return ctx
}
// MCPTokenIdentityFromContext returns (kind, ref) attached by
// WithMCPTokenIdentity, or ("", "") if none was set. The audit
// middleware uses this to populate token_kind + token_ref columns.
func MCPTokenIdentityFromContext(ctx context.Context) (kind, ref string) {
k, _ := ctx.Value(ctxMCPTokenKind).(string)
r, _ := ctx.Value(ctxMCPTokenRef).(string)
return k, r
}
// TokenAllowedWorkspacesFromContext returns the token's workspace
// allow-list, or nil if none was attached. Three return shapes
// matter to callers:
//
// - nil — no allow-list set (an unpinned PAT, a session, or pre-TASK-952
// OAuth tokens; a workspace-pinned PAT carries [its slug], BUG-3982). Caller should NOT apply any token-level gate; rely
// on standard membership checks.
// - []string{"*"} — wildcard. Caller should not apply per-slug
// gating; standard membership applies.
// - []string{"slug-a", ...} — explicit allow-list. Caller MUST
// deny any workspace not in this set, even if the user is a
// member of it.
//
// Returns a copy of the stored slice — callers can mutate without
// risk to the per-request context value.
func TokenAllowedWorkspacesFromContext(ctx context.Context) []string {
v, _ := ctx.Value(ctxTokenAllowedWorkspaces).([]string)
if v == nil {
return nil
}
out := make([]string, len(v))
copy(out, v)
return out
}
// TokenAllowedWorkspaceSet returns the token's allow-list as a slug-set for
// filtering a multi-workspace result, or nil when no per-slug gate applies.
// It is the multi-slug companion to tokenAllowedWorkspaceMatches (single
// slug, hot path): use the matcher to gate one resolved workspace, use this
// to filter a list.
//
// Return contract mirrors TokenAllowedWorkspacesFromContext:
//
// - nil allow-list (unpinned PAT / pre-consent / local stdio) → nil set → NO filter.
// - wildcard ["*"] → nil set → NO filter.
// - explicit ["a","b"] → set{a,b}; caller MUST drop any workspace whose
// slug is not a key, even one the user is a member of.
//
// An empty (non-nil) allow-list yields an empty (non-nil) set — fail closed,
// every workspace dropped — matching tokenAllowedWorkspaceMatches. The consent
// flow rejects empty lists, so this shouldn't occur in practice.
func TokenAllowedWorkspaceSet(ctx context.Context) map[string]struct{} {
allowed := TokenAllowedWorkspacesFromContext(ctx)
if allowed == nil {
return nil
}
for _, entry := range allowed {
if entry == "*" {
return nil
}
}
set := make(map[string]struct{}, len(allowed))
for _, slug := range allowed {
set[slug] = struct{}{}
}
return set
}
// ctxStructuredEntryAuthor carries the created_by label for an implementation
// note or decision entry the item PATCH appends (BUG-3056). Set only by
// in-process callers through WithStructuredEntryAuthor; nothing reads it from
// a network request, so it cannot be spoofed by one.
const ctxStructuredEntryAuthor contextKey = "structured_entry_author"
// WithStructuredEntryAuthor sets the created_by the item PATCH stamps on an
// appended entry, in place of actorFromRequest's user/agent.
//
// It exists for the remote /mcp dispatcher, which has always attributed these
// entries to the requesting user's display name (falling back to email) so a
// shared MCP server's audit can tell its users apart. When the append moved
// server-side, that label could no longer ride in the entry the client built,
// and an in-process request carries no X-Pad-Agent, so without this the entry
// would read "user" — a change BUG-3056 had no business making. The general
// gap (remote /mcp writes carry no agent identity) is BUG-2772.
func WithStructuredEntryAuthor(ctx context.Context, label string) context.Context {
return context.WithValue(ctx, ctxStructuredEntryAuthor, label)
}
// structuredEntryAuthor is the created_by for an entry this request appends.
func structuredEntryAuthor(r *http.Request) string {
if label, ok := r.Context().Value(ctxStructuredEntryAuthor).(string); ok && label != "" {
return label
}
actor, _ := actorFromRequest(r)
return actor
}
// ctxRemoteMCPCaller marks an in-process request from the remote /mcp
// dispatcher (BUG-2772). Set only in-process; nothing reads it from the wire.
const ctxRemoteMCPCaller contextKey = "remote_mcp_caller"
// RemoteMCPCaller is a remote MCP client acting for the authenticated user.
type RemoteMCPCaller struct {
// LeaseHolder is the caller's default lease holder label: its declared
// client name, or a fallback. Display only since TASK-3613.
LeaseHolder string
// WorkerID is the caller's default lease worker id (TASK-3613): one it
// passed explicitly, else one derived from its MCP session, so two
// connections of one client do not share a lease. "" for a stateless
// request that sent none, which then claims name-only.
WorkerID string
}
// WithRemoteMCPCaller marks ctx as a remote MCP call. Every such write is an
// AGENT write (an app acting for the user), named by X-Pad-Agent when the
// client declared a name and unnamed when it did not, never the human
// (IDEA-2791's degradation contract).
func WithRemoteMCPCaller(ctx context.Context, c RemoteMCPCaller) context.Context {
return context.WithValue(ctx, ctxRemoteMCPCaller, c)
}
// remoteMCPCaller reports the remote MCP caller a request carries, if any.
func remoteMCPCaller(r *http.Request) (RemoteMCPCaller, bool) {
c, ok := r.Context().Value(ctxRemoteMCPCaller).(RemoteMCPCaller)
return c, ok
}
// ChatGPTDoorVersionsContent is the U1b guarantee the ChatGPT catalog's
// update_item description makes: a content write through the ChatGPT door
// is versioned first (handleUpdateItem, isChatGPTSurface). The ChatGPT
// mount refuses to be wired without it (TASK-3321 U2b);
// TestChatGPTDoorVersionsContentIsTrue keeps it honest.
const ChatGPTDoorVersionsContent = true
// WithChatGPTSurface marks ctx as a call from the ChatGPT MCP catalog
// (TASK-3321 U1b). The item update handler reads it to save a version
// before every content change from that door, so each one can be undone
// from History. It is set by the ChatGPT tool handler in-process, never
// from the wire.
func WithChatGPTSurface(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxChatGPTSurface, true)
}
func isChatGPTSurface(r *http.Request) bool {
return ChatGPTSurfaceFromContext(r.Context())
}
// ChatGPTSurfaceFromContext reports whether ctx carries the ChatGPT door's
// marker. Exported so the MCP side can prove the marker reaches its dispatch.
func ChatGPTSurfaceFromContext(ctx context.Context) bool {
v, _ := ctx.Value(ctxChatGPTSurface).(bool)
return v
}