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 }