catalog.allowedReport returns the allowed lists together with the first provider error. Widening (stateful) and the stateless refresh leave the cached server and its recorded fingerprint untouched on an error, so the next request retries instead of the tools staying hidden until the next rotation or event. A fan-out recompute still applies its fail-closed diff but records no fingerprint, so the binding's next request re-filters. TestWideningGuardError fails the guard during a widening on both paths and during a permissions recompute, then asserts that the next request lists every tool once the provider answers again.
258 lines
9.6 KiB
Go
258 lines
9.6 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// setGrant replaces (or adds) the grant a fake verifier answers for raw.
|
|
func (v *fakeVerifier) setGrant(raw string, g pact.MCPGrant) {
|
|
v.mu.Lock()
|
|
v.grants[raw] = g
|
|
v.mu.Unlock()
|
|
}
|
|
|
|
// TestScopeWidening pins plan 06's re-filtering of a returning binding
|
|
// (A-WR-04, P-26): a binding first seen with read and then with read write
|
|
// lists the write tool on its next request, on the stateless and on the
|
|
// stateful path; a backend subject added to the binding (the grant gains
|
|
// backend, the user is linked) adds the backend tools without any fan-out
|
|
// event; and a narrowed grant hides the tools again.
|
|
func TestScopeWidening(t *testing.T) {
|
|
links := newFakeLinks()
|
|
links.set(11, 42)
|
|
verifier := newFakeVerifier(nil)
|
|
front := &fakeGuard{}
|
|
back := &fakeGuard{name: "backend", tokenMW: "backend"}
|
|
acme := routePlugin{id: "test.acme",
|
|
routes: []testRoute{guarded(http.MethodPost, "/acme/echo/{id}"), guarded(http.MethodGet, "/acme/items/{id}")},
|
|
tools: []pact.MCPTool{
|
|
{Name: "echo", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}},
|
|
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}},
|
|
}}
|
|
office := routePlugin{id: "test.office", guard: "backend", middleware: "backend",
|
|
routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: noContent}},
|
|
tools: []pact.MCPTool{{Name: "posts", Method: "GET", Path: "/office/posts", Input: struct{}{}}}}
|
|
in := startInstance(t, newApp(t, appSpec{
|
|
mcp: "enabled: false\nsessions:\n store: memory\n",
|
|
guards: []pact.MCPGuard{front, back},
|
|
verifier: verifier,
|
|
links: links,
|
|
}), acme, office)
|
|
t.Cleanup(in.pier().StopReaper)
|
|
|
|
readOnly := []string{"acme_show"}
|
|
readWrite := []string{"acme_echo", "acme_show"}
|
|
withBackend := []string{"acme_echo", "acme_show", "office_posts"}
|
|
|
|
for _, path := range []struct {
|
|
name string
|
|
version string
|
|
token string
|
|
client string
|
|
}{
|
|
{"stateless", "newest", "oas_at_wide_stateless", "c5"},
|
|
{"stateful", "", "oas_at_wide_stateful", "c6"},
|
|
} {
|
|
t.Run(path.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
verifier.setGrant(path.token, grant(11, path.client, 11, "read"))
|
|
cs := sdkClient(t, ctx, in.url(), path.token, clientOpts{version: path.version})
|
|
if (cs.ID() == "") != (path.name == "stateless") {
|
|
t.Fatalf("%s client session id %q", path.name, cs.ID())
|
|
}
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readOnly) {
|
|
t.Fatalf("read grant lists %v", got)
|
|
}
|
|
|
|
verifier.setGrant(path.token, grant(11, path.client, 11, "read", "write"))
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readWrite) {
|
|
t.Fatalf("after widening to read write: %v", got)
|
|
}
|
|
|
|
// The same binding returns with the backend scope; the user is
|
|
// linked, so a backend subject joins the identity. No event is
|
|
// fired: the request itself re-filters the cached server.
|
|
verifier.setGrant(path.token, grant(11, path.client, 11, "read", "write", "backend"))
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, withBackend) {
|
|
t.Fatalf("after adding the backend subject: %v", got)
|
|
}
|
|
|
|
verifier.setGrant(path.token, grant(11, path.client, 11, "read"))
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readOnly) {
|
|
t.Fatalf("after narrowing to read: %v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// tokenBlockGuard is the acme guard whose first MCPAllows for the subject
|
|
// carrying token after arm blocks until release.
|
|
type tokenBlockGuard struct {
|
|
acmeGuard
|
|
token uint
|
|
armed atomic.Bool
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (g *tokenBlockGuard) MCPAllows(ctx context.Context, s pact.MCPSubject, _ pact.MCPRequirements, _ []string) (bool, error) {
|
|
if s.TokenID == g.token && g.armed.CompareAndSwap(true, false) {
|
|
select {
|
|
case g.entered <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-g.release:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// TestStatelessSharedBinding pins RA-WR-02: two live tokens of one binding
|
|
// with different scopes share the binding's stateless server. A request of
|
|
// the narrower token must not remove a tool the wider token is calling at
|
|
// that moment: the call finishes instead of failing as an unknown tool, and
|
|
// each token still lists only its own tools.
|
|
func TestStatelessSharedBinding(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
verifier := newFakeVerifier(map[string]pact.MCPGrant{
|
|
"oas_at_wide": grant(13, "c13", 131, "read", "write"),
|
|
"oas_at_narrow": grant(13, "c13", 132, "read"),
|
|
})
|
|
guard := &tokenBlockGuard{token: 131, entered: make(chan struct{}, 1), release: make(chan struct{})}
|
|
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}, verifier: verifier}),
|
|
acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}})
|
|
t.Cleanup(in.pier().StopReaper)
|
|
|
|
wide := sdkClient(t, ctx, in.url(), "oas_at_wide", clientOpts{version: "newest"})
|
|
narrow := sdkClient(t, ctx, in.url(), "oas_at_narrow", clientOpts{version: "newest"})
|
|
if got := listNames(t, ctx, wide); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
|
|
t.Fatalf("the wide token lists %v", got)
|
|
}
|
|
if got := listNames(t, ctx, narrow); !slices.Equal(got, []string{"acme_show"}) {
|
|
t.Fatalf("the narrow token lists %v", got)
|
|
}
|
|
// The wide token returns last, so its call re-filters nothing and blocks
|
|
// in the receiving middleware's check, after the server was chosen.
|
|
if got := listNames(t, ctx, wide); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
|
|
t.Fatalf("the wide token lists %v", got)
|
|
}
|
|
if n := in.pier().StatelessServers(); n != 1 {
|
|
t.Fatalf("stateless servers = %d, want one for the shared binding", n)
|
|
}
|
|
|
|
guard.armed.Store(true)
|
|
type answer struct {
|
|
res *mcp.CallToolResult
|
|
err error
|
|
}
|
|
called := make(chan answer, 1)
|
|
go func() {
|
|
res, err := wide.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 9, "text": "hey"}})
|
|
called <- answer{res, err}
|
|
}()
|
|
select {
|
|
case <-guard.entered:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the wide token's call never reached the guard")
|
|
}
|
|
if got := listNames(t, ctx, narrow); !slices.Equal(got, []string{"acme_show"}) {
|
|
t.Fatalf("the narrow token lists %v while the wide call runs", got)
|
|
}
|
|
close(guard.release)
|
|
a := <-called
|
|
if a.err != nil {
|
|
t.Fatalf("the wide token's call failed after the narrow token's request: %v", a.err)
|
|
}
|
|
if a.res.IsError {
|
|
t.Fatalf("the wide token's call answered a tool error: %s", resultText(t, a.res))
|
|
}
|
|
}
|
|
|
|
// flakyGuard is the acme guard whose checks fail (as a pool timeout would)
|
|
// while fail is set.
|
|
type flakyGuard struct {
|
|
acmeGuard
|
|
fail atomic.Bool
|
|
}
|
|
|
|
func (g *flakyGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
|
if g.fail.Load() {
|
|
return false, errors.New("pool timeout")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// TestWideningGuardError pins RA-WR-03: a guard error while a returning
|
|
// binding is re-filtered is not a refusal. The cached server is left as it
|
|
// is and the next request retries, so once the provider answers again the
|
|
// binding lists every tool it may use, on both paths. A fan-out recompute
|
|
// that fails hides the failing tools (safe for an event) and leaves the
|
|
// session to be re-filtered by its next request.
|
|
func TestWideningGuardError(t *testing.T) {
|
|
guard := &flakyGuard{}
|
|
verifier := newFakeVerifier(nil)
|
|
in := startInstance(t, newApp(t, appSpec{
|
|
mcp: "enabled: false\nsessions:\n store: memory\n",
|
|
guards: []pact.MCPGuard{guard},
|
|
verifier: verifier,
|
|
}), acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}})
|
|
t.Cleanup(in.pier().StopReaper)
|
|
readWrite := []string{"acme_echo", "acme_show"}
|
|
|
|
for _, path := range []struct {
|
|
name, version, token, client string
|
|
user uint
|
|
}{
|
|
{"stateless", "newest", "oas_at_flaky_stateless", "c30", 30},
|
|
{"stateful", "", "oas_at_flaky_stateful", "c31", 31},
|
|
} {
|
|
t.Run("widening on "+path.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
verifier.setGrant(path.token, grant(path.user, path.client, path.user, "read"))
|
|
cs := sdkClient(t, ctx, in.url(), path.token, clientOpts{version: path.version})
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_show"}) {
|
|
t.Fatalf("read grant lists %v", got)
|
|
}
|
|
verifier.setGrant(path.token, grant(path.user, path.client, path.user, "read", "write"))
|
|
guard.fail.Store(true)
|
|
listNames(t, ctx, cs) // the widening fails; this answer hides what errored
|
|
guard.fail.Store(false)
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readWrite) {
|
|
t.Fatalf("after the provider recovered: %v, want %v", got, readWrite)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("recompute on an event", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
verifier.setGrant("oas_at_flaky_event", grant(32, "c32", 32, "read", "write"))
|
|
changed := make(chan struct{}, 4)
|
|
cs := sdkClient(t, ctx, in.url(), "oas_at_flaky_event", clientOpts{changed: changed})
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readWrite) {
|
|
t.Fatalf("before = %v", got)
|
|
}
|
|
guard.fail.Store(true)
|
|
fireSubjectChanged(t, in.app, "acme", 32, pact.MCPSubjectPermissions)
|
|
waitChanged(t, changed)
|
|
guard.fail.Store(false)
|
|
if got := listNames(t, ctx, cs); !slices.Equal(got, readWrite) {
|
|
t.Fatalf("after the provider recovered: %v, want %v", got, readWrite)
|
|
}
|
|
})
|
|
}
|