diff --git a/modules/pier/README.md b/modules/pier/README.md index 0347e37c..0d874a38 100644 --- a/modules/pier/README.md +++ b/modules/pier/README.md @@ -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` 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="/.well-known/oauth-protected-resource", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read. diff --git a/modules/pier/events.go b/modules/pier/events.go index 08f1f0dc..e9b0ebd9 100644 --- a/modules/pier/events.go +++ b/modules/pier/events.go @@ -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) { diff --git a/modules/pier/fingerprint_test.go b/modules/pier/fingerprint_test.go new file mode 100644 index 00000000..d1673f72 --- /dev/null +++ b/modules/pier/fingerprint_test.go @@ -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") + } +} diff --git a/modules/pier/identity.go b/modules/pier/identity.go index e04a689d..03518dec 100644 --- a/modules/pier/identity.go +++ b/modules/pier/identity.go @@ -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('|') } diff --git a/modules/pier/stateless.go b/modules/pier/stateless.go index be00c282..e4e77eb7 100644 --- a/modules/pier/stateless.go +++ b/modules/pier/stateless.go @@ -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. diff --git a/modules/pier/widening_test.go b/modules/pier/widening_test.go index ddee91a1..c03fb24b 100644 --- a/modules/pier/widening_test.go +++ b/modules/pier/widening_test.go @@ -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)) + } +}