Files
pad/internal/oauth/session.go
T
xarmian d01bbf6bf1 feat(oauth): live workspace allow-list + role enforcement (TASK-953) (#377)
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.
2026-05-02 14:29:17 -04:00

180 lines
6.9 KiB
Go

// Package oauth contains pad's OAuth 2.1 authorization-server
// integration with github.com/ory/fosite (PLAN-943 TASK-951 sub-PR B).
//
// This package wires fosite's compose pattern over pad's storage layer
// (internal/store/oauth.go from sub-PR A), adds an RFC 8707 audience-
// binding hook, and exposes NewServer returning a fosite.OAuth2Provider
// that sub-PR C's HTTP handlers consume.
//
// fosite version: pinned to v0.49.0 in go.mod. Bump deliberately —
// compose APIs are stable but storage interface signatures have
// changed across major versions.
package oauth
import (
"github.com/ory/fosite"
)
// Session is pad's concrete fosite.Session. We embed fosite.DefaultSession
// (which already implements the interface — SetExpiresAt / GetExpiresAt /
// GetUsername / GetSubject / Clone) and add typed accessors for the few
// pad-specific fields we care about.
//
// Why a wrapper rather than using DefaultSession directly:
//
// - Subject in OAuth context = pad user ID (UUID). DefaultSession.Subject
// is fine for that, but having a typed UserID() method makes call sites
// in sub-PR C's handlers + sub-PR E's MCPBearerAuth introspection branch
// read cleanly: token.UserID() instead of token.GetSession().GetSubject()
// with type assertions.
// - Future-proofs the place where extra OAuth-only fields live (workspace
// allow-list arrives in TASK-953; we'll add a WorkspaceIDs []string
// field here without touching every adapter call site).
// - JSON round-trip is via DefaultSession's existing tags, so the storage
// layer's session_data column doesn't need to know about pad-specific
// fields — they ride along in DefaultSession.Extra.
//
// fosite.Session contract:
//
// fosite calls Session.Clone() between requests; deepcopy on the embedded
// DefaultSession handles that correctly. fosite calls SetExpiresAt /
// GetExpiresAt to track per-token-type lifetimes; same handling.
type Session struct {
*fosite.DefaultSession
}
// NewSession constructs a Session with the given subject (pad user ID).
// Returns a non-nil DefaultSession so the embedded methods don't panic
// on a zero-value receiver.
func NewSession(subject string) *Session {
return &Session{
DefaultSession: &fosite.DefaultSession{
Subject: subject,
Extra: map[string]interface{}{},
},
}
}
// UserID returns the pad user ID this session was issued to. Equivalent
// to GetSubject() under our model where Subject is always the user ID;
// kept as a typed accessor so future fields (workspace allow-list,
// capability tier from TASK-953) can land without sprinkling
// GetSubject() across handler code.
func (s *Session) UserID() string {
if s == nil || s.DefaultSession == nil {
return ""
}
return s.DefaultSession.Subject
}
// allowedWorkspacesExtraKey is the session.Extra map key under which
// the consent UI (TASK-952) stores the workspace allow-list. Defined
// as a package constant so producers (handlers_oauth.go's
// /authorize/decide) and consumers (TASK-953's MCPBearerAuth gate)
// agree on the wire form.
const allowedWorkspacesExtraKey = "allowed_workspaces"
// AllowedWorkspaces returns the workspace allow-list stored in
// session.Extra at consent time (TASK-952). Three return shapes
// matter to callers:
//
// - nil — no allow-list set. Either a non-OAuth session (PAT auth
// never goes through this code path) or a pre-TASK-952 token
// issued before the consent UI shipped. Callers should treat
// this as "no token-level workspace constraint" and rely on the
// standard membership gate.
// - []string{"*"} — wildcard. The user explicitly granted access
// to any workspace they currently or later have access to;
// standard membership still applies, no extra restriction.
// - []string{"slug-a", "slug-b", ...} — explicit allow-list. Each
// workspace request must hit a slug in this set OR be denied
// before the membership check runs.
//
// JSON round-trip handling: fosite serializes session.Extra via
// json.Marshal and deserializes back through json.Unmarshal into a
// map[string]interface{}. After a round-trip the value is
// []interface{} (Go's untyped JSON array shape), not []string.
// We accept both so the helper works whether the session was just
// created in memory (handlers_oauth.go's decide flow) or hydrated
// from storage (sub-PR D's introspection path).
func (s *Session) AllowedWorkspaces() []string {
if s == nil || s.DefaultSession == nil || s.DefaultSession.Extra == nil {
return nil
}
raw, ok := s.DefaultSession.Extra[allowedWorkspacesExtraKey]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
out := make([]string, len(v))
copy(out, v)
return out
case []interface{}:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
// Distinguish "explicit empty list" from "no key set" — the
// former is unusual but if a future change persists []string{}
// for some reason we don't want a phantom nil meaning "no
// constraint." Return non-nil empty so callers see "list is
// set, but contains nothing"; current MCPBearerAuth would
// reject the request as no workspace can match.
if out == nil {
out = []string{}
}
return out
}
return nil
}
// SetAllowedWorkspaces is the symmetric writer used by
// /oauth/authorize/decide (TASK-952). Centralizing the key string
// here keeps producers + consumers in sync; nil clears the entry.
func (s *Session) SetAllowedWorkspaces(workspaces []string) {
if s == nil || s.DefaultSession == nil {
return
}
if s.DefaultSession.Extra == nil {
s.DefaultSession.Extra = map[string]interface{}{}
}
if workspaces == nil {
delete(s.DefaultSession.Extra, allowedWorkspacesExtraKey)
return
}
// Defensive copy — caller mutating the slice after the call
// shouldn't bleed through into the persisted session.
cp := make([]string, len(workspaces))
copy(cp, workspaces)
s.DefaultSession.Extra[allowedWorkspacesExtraKey] = cp
}
// Clone overrides DefaultSession.Clone so the returned value is a
// *Session, not a *DefaultSession. Without this override, fosite's
// internal Clone() calls during refresh-token rotation would lose the
// concrete Session type and subsequent type-assertions in handler code
// would fail.
//
// Implementation: deep-copy the embedded DefaultSession (its Clone
// already does the deep copy via mohae/deepcopy) and re-wrap.
func (s *Session) Clone() fosite.Session {
if s == nil {
return nil
}
if s.DefaultSession == nil {
return &Session{}
}
cloned, ok := s.DefaultSession.Clone().(*fosite.DefaultSession)
if !ok {
// Unreachable in practice — DefaultSession.Clone always
// returns *DefaultSession. Guard the assertion so a future
// fosite version-bump that changes the return type produces
// a clean nil rather than a panic.
return &Session{DefaultSession: &fosite.DefaultSession{}}
}
return &Session{DefaultSession: cloned}
}