Files
pad/internal/oauth/session_test.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

124 lines
4.2 KiB
Go

package oauth
import (
"encoding/json"
"testing"
"github.com/ory/fosite"
)
// TestSession_AllowedWorkspaces_Setter_Getter pins the in-memory
// round-trip: SetAllowedWorkspaces([...]) then AllowedWorkspaces()
// returns the same list. This is the path /oauth/authorize/decide
// uses immediately after consent (TASK-952) before the session is
// JSON-serialized into storage.
func TestSession_AllowedWorkspaces_Setter_Getter(t *testing.T) {
s := NewSession("user-1")
s.SetAllowedWorkspaces([]string{"alpha", "beta"})
got := s.AllowedWorkspaces()
if len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
t.Errorf("round-trip failed: got %v, want [alpha beta]", got)
}
}
// TestSession_AllowedWorkspaces_NilInput pins the "clear" semantics:
// SetAllowedWorkspaces(nil) removes the entry from Extra so a
// subsequent AllowedWorkspaces() returns nil (rather than the
// previous value or an empty slice).
func TestSession_AllowedWorkspaces_NilInput(t *testing.T) {
s := NewSession("user-1")
s.SetAllowedWorkspaces([]string{"alpha"})
s.SetAllowedWorkspaces(nil)
if got := s.AllowedWorkspaces(); got != nil {
t.Errorf("nil set should clear; got %v", got)
}
}
// TestSession_AllowedWorkspaces_DefensiveCopy verifies the setter
// copies the input — caller-side mutation of the slice after the
// call must not bleed into the persisted session.
func TestSession_AllowedWorkspaces_DefensiveCopy(t *testing.T) {
s := NewSession("user-1")
original := []string{"alpha", "beta"}
s.SetAllowedWorkspaces(original)
original[0] = "MUTATED"
got := s.AllowedWorkspaces()
if got[0] != "alpha" {
t.Errorf("setter should defensive-copy; got[0] = %q after caller mutation", got[0])
}
}
// TestSession_AllowedWorkspaces_JSONRoundTrip pins the critical
// integration path: the value survives JSON marshal+unmarshal
// (which is what storage.go's session_data column round-trips
// through). After unmarshal, json.Unmarshal turns []string into
// []interface{}; the AllowedWorkspaces accessor MUST handle both
// shapes.
//
// This is why the session helper does the type-switch — production
// reads via MCPBearerAuth's introspection branch always go through
// JSON deserialization, and would silently fail to read the
// workspace allow-list without []interface{} support.
func TestSession_AllowedWorkspaces_JSONRoundTrip(t *testing.T) {
src := NewSession("user-1")
src.SetAllowedWorkspaces([]string{"alpha", "beta"})
bytes, err := json.Marshal(src)
if err != nil {
t.Fatalf("marshal: %v", err)
}
dst := &Session{DefaultSession: &fosite.DefaultSession{}}
if err := json.Unmarshal(bytes, dst); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := dst.AllowedWorkspaces()
if len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
t.Errorf("JSON round-trip lost values: got %v, want [alpha beta]", got)
}
}
// TestSession_AllowedWorkspaces_WildcardJSONRoundTrip pins the
// wildcard form — same JSON path, but the value is the single-
// element ["*"] form the consent UI emits when the user picks
// "any workspace".
func TestSession_AllowedWorkspaces_WildcardJSONRoundTrip(t *testing.T) {
src := NewSession("user-1")
src.SetAllowedWorkspaces([]string{"*"})
bytes, _ := json.Marshal(src)
dst := &Session{DefaultSession: &fosite.DefaultSession{}}
_ = json.Unmarshal(bytes, dst)
got := dst.AllowedWorkspaces()
if len(got) != 1 || got[0] != "*" {
t.Errorf("wildcard round-trip: got %v, want [*]", got)
}
}
// TestSession_AllowedWorkspaces_NotSet covers the legacy /
// pre-TASK-952 path: a session without an allowed_workspaces key
// must return nil so RequireWorkspaceAccess treats it as "no
// token-level constraint" rather than "explicit empty list".
func TestSession_AllowedWorkspaces_NotSet(t *testing.T) {
s := NewSession("user-1")
if got := s.AllowedWorkspaces(); got != nil {
t.Errorf("session with no allow-list set should return nil; got %v", got)
}
}
// TestSession_AllowedWorkspaces_NilSession is a paranoid nil-safety
// check for callers that might invoke the accessor on a freshly-
// declared zero-value Session pointer.
func TestSession_AllowedWorkspaces_NilSession(t *testing.T) {
var s *Session
if got := s.AllowedWorkspaces(); got != nil {
t.Errorf("nil session should return nil, not panic; got %v", got)
}
}