Files
pad/internal/server/context.go
T
xarmian 6118f7065c feat(mcp): mount the ChatGPT catalog at /mcp/chatgpt, OAuth-only, off by default (TASK-3321 U2b) (#1719)
The ChatGPT projection (U1) gets its own transport at <MCP URL>/chatgpt,
behind PAD_CHATGPT_MCP_ENABLED=true and served only while OAuth is
available. It is its own OAuth resource (U2a's second canonical): its
path-aware protected-resource document is
/.well-known/oauth-protected-resource/mcp/chatgpt, its 401s name that
document, and tokens do not cross between the two mounts. Personal access
tokens are refused there with a 401 naming OAuth-only, ahead of the PAT
branch that would accept them without the audience check.

The consent screen names the ChatGPT surface when the requested resource
is the ChatGPT one. The mount is wired only while the door versions
content (ChatGPTDoorVersionsContent, held to the behaviour by a test) and
every catalog tool has a response shape (ChatGPTCatalogReady); otherwise
startup logs and skips it. /mcp is byte-identical.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 03:34:43 -04:00

310 lines
14 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. PAT auth never
// calls this; the helper exists for the OAuth path only.
//
// 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 (PAT auth, or pre-TASK-952 OAuth
// tokens). 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 (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
}
// 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
}