mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
d01bbf6bf1
Closes the third leg of PLAN-943's OAuth permission model:
(token capability tier) × (live workspace role) × (consent allow-list)
The first two were already in place — TASK-1027 wired the tier
scope check (pad:read / pad:write / pad:admin via tokenScopeAllows)
and RequireWorkspaceAccess does the live role lookup. This PR adds
the third gate: the workspace-allow-list set at consent time
(TASK-952) actually denies workspaces NOT in the user's selection.
## What's new
- `oauth.Session.AllowedWorkspaces()` / `SetAllowedWorkspaces()` —
typed accessors on session.Extra. Handle BOTH the in-memory
[]string shape (consent-decide path) AND the JSON-decoded
[]interface{} shape (post-storage round-trip path).
- `WithTokenAllowedWorkspaces` / `TokenAllowedWorkspacesFromContext` —
context helpers in internal/server with defensive copies so
callers can't corrupt the per-request token state.
- `MCPBearerAuth` (OAuth path) reads the token's allow-list from
session.Extra and stashes it in context.
- `RequireWorkspaceAccess` checks the allow-list against the
resolved workspace's slug. Three behaviours match
TokenAllowedWorkspacesFromContext's return shapes:
- nil → no token-level gate (PAT auth, pre-TASK-952 OAuth
tokens). Standard membership applies.
- ["*"] → wildcard. Every membership the user has passes.
- [slug-a, slug-b, ...] → only listed slugs. Anything else
gets 403 permission_denied BEFORE the membership check.
## Live role + revocation
Membership revocation takes effect immediately. RequireWorkspaceAccess
calls GetWorkspaceMember on every request — if the user lost
membership in workspace X, the token's allow-list including X no
longer helps; the request is rejected at the standard membership
gate. Tested explicitly via TestWorkspaceAllowList_LiveMembershipRevocation.
## Tier × role
The natural intersection of tokenScopeAllows (tier-based HTTP-method
gate) and per-handler role checks (e.g. requireEditPermission) handles
the tier × role table from the PLAN-943 spec:
- pad:write tier passes tokenScopeAllows for POST.
- But Viewer role fails requireEditPermission's role check.
- Net: 403 — tested explicitly via
TestWorkspaceAllowList_TierTimesRole_WriteByViewer.
## Tests
Unit (no I/O):
- TestTokenAllowedWorkspaceMatches — policy table for the helper.
- TestWithTokenAllowedWorkspaces_DefensiveCopy + 1 reader counterpart.
- TestSession_AllowedWorkspaces_*: setter/getter, nil-clear, defensive
copy, JSON round-trip ([]string + []interface{} branches),
wildcard JSON round-trip, not-set, nil-session.
Integration (full chain, real OAuth flow):
- TestWorkspaceAllowList_AllowsListedSlug — listed workspace passes.
- TestWorkspaceAllowList_DeniesUnlistedSlug — unlisted gets 403
even though user is owner.
- TestWorkspaceAllowList_WildcardAllowsAnyMembership — wildcard
passes for every membership.
- TestWorkspaceAllowList_LiveMembershipRevocation — token works,
then membership revoked, then same token denied.
- TestWorkspaceAllowList_PATPathUnaffected — PAT regression: PATs
don't carry an allow-list, must NOT hit the gate.
- TestWorkspaceAllowList_TierTimesRole_WriteByViewer — pad:write
tier × Viewer role on POST item → 403.
175 lines
7.6 KiB
Go
175 lines
7.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
|
|
"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.
|
|
//
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|