mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +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.
124 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|