fix(19.7): RA-WR-02 stop a shared stateless server flapping its tool set
The identity fingerprint no longer includes token ids: guards decide on scopes, subjects and the ceiling, and an OAuth access token's id changes on every refresh, so a rotation alone no longer re-filters a cached server. The stateless path's refresh only adds tools and prompts for a fingerprint new to the entry (remembered until a fan-out recompute) and never removes: one server serves every live token of a binding, and removing on each alternation between a wider and a narrower token pulled a tool from under the wider token's call. The receiving middleware still filters every list and call per request; fan-out recomputes still narrow. The stateful path keeps its exact widen, a session serves one client. TestFingerprintIgnoresTokenID pins the fingerprint; TestStatelessSharedBinding blocks the wide token's call after its server was chosen, sends a request of the narrow token and asserts the call still succeeds (it failed with "unknown tool" before).
This commit is contained in:
@@ -41,7 +41,7 @@ Use cases:
|
||||
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once. A revocation naming `TokenID` ends only the sessions whose subject under the event's guard is that user with that token id and no client id (one personal token); one naming `ClientID` ends only that OAuth client's sessions of the user; one naming neither, and every deletion, ends all sessions of the subject.
|
||||
- Client address: before resolving a bearer, pier puts the client address on the resolvers' context with `towel.WithClientIP`, taken from the host when it offers one (surf applies `http.trusted_proxies`) or from the request's `RemoteAddr`, so a resolver can log a failed attempt with it.
|
||||
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. A server evicted from the LRU closes its sessions, and a stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
|
||||
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event.
|
||||
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one.
|
||||
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
|
||||
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
|
||||
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
|
||||
|
||||
@@ -124,15 +124,18 @@ func (p *Pier) recomputeStateless(ctx context.Context, e *statelessEntry, relink
|
||||
tools, prompts := p.catalog.allowed(ctx, id)
|
||||
applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts)
|
||||
e.fingerprint = id.fingerprint()
|
||||
e.widened = nil
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// widen re-filters a cached server for the identity of the request that
|
||||
// reached it when that identity's fingerprint differs from the one the
|
||||
// server was last filtered for (A-WR-04): a binding that returns with more
|
||||
// scope, or with a backend subject added, gains the newly allowed tools and
|
||||
// prompts at once, and one that returns with less loses them. The caller
|
||||
// holds the session's or entry's own mutex, never a router-wide one.
|
||||
// widen re-filters a stateful session's server for the identity of the
|
||||
// request that reached it when that identity's fingerprint differs from
|
||||
// the one the server was last filtered for (A-WR-04): a binding that
|
||||
// returns with more scope, or with a backend subject added, gains the newly
|
||||
// allowed tools and prompts at once, and one that returns with less loses
|
||||
// them. A session serves one client; the stateless path, whose server every
|
||||
// token of a binding shares, only adds (statelessPath.refresh). The caller
|
||||
// holds the session's own mutex, never a router-wide one.
|
||||
func (p *Pier) widen(ctx context.Context, srv serverFeatures, id Identity, fp *string, haveTools, havePrompts map[string]bool) {
|
||||
next := id.fingerprint()
|
||||
if *fp == next {
|
||||
@@ -156,6 +159,23 @@ func (p *Pier) guardContext(ctx context.Context) (context.Context, context.Cance
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), p.cfg.CallTimeout)
|
||||
}
|
||||
|
||||
// addMissing adds the allowed tools and prompts a server lacks, updating
|
||||
// the tracking maps in place; it removes nothing.
|
||||
func addMissing(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {
|
||||
for _, t := range tools {
|
||||
if !haveTools[t.name] {
|
||||
srv.AddTool(t.desc, p.toolHandler(t, id))
|
||||
haveTools[t.name] = true
|
||||
}
|
||||
}
|
||||
for _, pr := range prompts {
|
||||
if !havePrompts[pr.name] {
|
||||
srv.AddPrompt(pr.desc, p.promptHandler(pr))
|
||||
havePrompts[pr.name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyDiff brings a server's tool and prompt sets to the allowed ones,
|
||||
// updating the tracking maps in place.
|
||||
func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {
|
||||
|
||||
34
modules/pier/fingerprint_test.go
Normal file
34
modules/pier/fingerprint_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package pier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestFingerprintIgnoresTokenID pins RA-WR-02: an OAuth refresh changes the
|
||||
// access-token id only, which must not re-filter a cached server, while a
|
||||
// change of scopes, subjects or ceiling still does.
|
||||
func TestFingerprintIgnoresTokenID(t *testing.T) {
|
||||
id := func(token uint, scopes ...string) Identity {
|
||||
return Identity{
|
||||
Scopes: scopes,
|
||||
Binding: "oauth:c1:7",
|
||||
TokenKind: TokenKindOAuth,
|
||||
ceiling: true,
|
||||
Frontend: &pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "c1", TokenID: token, Scopes: scopes},
|
||||
Backend: &pact.MCPSubject{Guard: BackendGuard, UserID: 42, ClientID: "c1", TokenID: token, Scopes: scopes},
|
||||
}
|
||||
}
|
||||
if a, b := id(1, "read", "backend").fingerprint(), id(2, "read", "backend").fingerprint(); a != b {
|
||||
t.Fatalf("a token rotation changed the fingerprint:\n%s\n%s", a, b)
|
||||
}
|
||||
if a, b := id(1, "read", "backend").fingerprint(), id(1, "read", "write", "backend").fingerprint(); a == b {
|
||||
t.Fatal("a scope change kept the fingerprint")
|
||||
}
|
||||
noBackend := id(1, "read", "backend")
|
||||
noBackend.Backend = nil
|
||||
if noBackend.fingerprint() == id(1, "read", "backend").fingerprint() {
|
||||
t.Fatal("dropping the backend subject kept the fingerprint")
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,13 @@ func (id Identity) writes() bool {
|
||||
}
|
||||
|
||||
// fingerprint encodes the authorization-relevant parts of the identity
|
||||
// (P-26): the sorted scopes, both subjects (guard, user, client, token and
|
||||
// their sorted scopes), the ceiling and the token kind. Two requests of one
|
||||
// (P-26): the sorted scopes, both subjects (guard, user, client and their
|
||||
// sorted scopes), the ceiling and the token kind. Two requests of one
|
||||
// binding with different fingerprints may see different tools, so a cached
|
||||
// server is re-filtered when it changes (A-WR-04).
|
||||
// server is re-filtered when it changes (A-WR-04). Token ids are left out:
|
||||
// a guard decides on scopes, subjects and the ceiling, not on which token
|
||||
// row carried them, and an OAuth access token's id changes with every
|
||||
// refresh, so a rotation alone re-filters nothing (RA-WR-02).
|
||||
func (id Identity) fingerprint() string {
|
||||
var b strings.Builder
|
||||
sorted := func(in []string) string {
|
||||
@@ -100,8 +103,6 @@ func (id Identity) fingerprint() string {
|
||||
b.WriteByte(':')
|
||||
b.WriteString(s.ClientID)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(strconv.FormatUint(uint64(s.TokenID), 10))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(sorted(s.Scopes))
|
||||
b.WriteByte('|')
|
||||
}
|
||||
|
||||
@@ -29,13 +29,16 @@ type statelessEntry struct {
|
||||
server *mcp.Server
|
||||
elem *list.Element
|
||||
|
||||
// mu serializes re-filtering and guards fingerprint, tools and prompts
|
||||
// (see localSession); identity has its own box.
|
||||
// mu serializes re-filtering and guards fingerprint, widened, tools and
|
||||
// prompts (see localSession); identity has its own box.
|
||||
mu sync.Mutex
|
||||
identity identityBox
|
||||
fingerprint string // of the identity the tool set was last filtered for
|
||||
tools map[string]bool
|
||||
prompts map[string]bool
|
||||
// widened lists the other fingerprints whose tools and prompts were
|
||||
// added since (RA-WR-02); a recompute clears it.
|
||||
widened map[string]bool
|
||||
tools map[string]bool
|
||||
prompts map[string]bool
|
||||
}
|
||||
|
||||
// statelessPath serves clients of protocol 2026-07-28 and later (D-32,
|
||||
@@ -182,13 +185,30 @@ func (sp *statelessPath) hit(binding string) *statelessEntry {
|
||||
return e
|
||||
}
|
||||
|
||||
// refresh stores the request's identity on an entry and re-filters its
|
||||
// server when the identity's fingerprint changed (A-WR-04).
|
||||
// refresh stores the request's identity on an entry and, when the
|
||||
// identity's fingerprint is new to the entry, adds the tools and prompts
|
||||
// it allows (A-WR-04). It never removes any (RA-WR-02): one server serves
|
||||
// every live token of the binding, and two of them may differ in reach (two
|
||||
// devices, an older narrower token next to a re-consented one), so removing
|
||||
// on every alternation would pull a tool from under another token's call in
|
||||
// flight. The receiving middleware hides, per request, whatever the
|
||||
// request's identity may not use; a fan-out recompute still narrows.
|
||||
func (sp *statelessPath) refresh(ctx context.Context, e *statelessEntry, id Identity) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.identity.set(id)
|
||||
sp.p.widen(ctx, e.server, id, &e.fingerprint, e.tools, e.prompts)
|
||||
e.mu.Unlock()
|
||||
fp := id.fingerprint()
|
||||
if fp == e.fingerprint || e.widened[fp] {
|
||||
return
|
||||
}
|
||||
ctx, cancel := sp.p.guardContext(ctx)
|
||||
defer cancel()
|
||||
tools, prompts := sp.p.catalog.allowed(ctx, id)
|
||||
addMissing(e.server, sp.p, id, tools, prompts, e.tools, e.prompts)
|
||||
if e.widened == nil {
|
||||
e.widened = map[string]bool{}
|
||||
}
|
||||
e.widened[fp] = true
|
||||
}
|
||||
|
||||
// closeSessions closes every session of a server.
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"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.
|
||||
@@ -91,3 +93,89 @@ func TestScopeWidening(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user