Files
summercms/modules/pier/events.go
Jakub Zych e7630e6762 fix(19.7): RA-WR-03 a guard error while re-filtering no longer narrows a session
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.
2026-10-23 20:07:51 +02:00

315 lines
10 KiB
Go

package pier
import (
"context"
"sync"
"git.golem15.com/golem15/summercms/modules/pact"
)
// listenEvents republishes every pact.MCPSubjectChanged fired on the
// application through the store, so every instance (this one included)
// applies it in handleEvent; when publishing fails the event is applied
// locally at least.
func (p *Pier) listenEvents() {
if p.app == nil || p.app.Events == nil {
return
}
p.app.Events.Listen(PluginID, func(ctx context.Context, ev *pact.MCPSubjectChanged) error {
if ev == nil || ev.Kind == "" {
return nil
}
event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance, TokenID: ev.TokenID, ClientID: ev.ClientID}
if err := p.store.Publish(ctx, event); err != nil {
p.handleEvent(event)
}
return nil
})
}
// handleEvent applies one event on this instance (D-26, D-27): a session
// ended elsewhere is closed here; a permission or link change recomputes
// the tools and prompts of every local session and stateless server of
// that subject, which makes the SDK send the list_changed notifications; a
// revocation or deletion closes those sessions, drops their records and
// evicts the stateless servers. A revocation naming a TokenID or a ClientID
// closes only the sessions that token or client opened (revokedMatch). A
// link event also drops the link cache.
func (p *Pier) handleEvent(ev Event) {
ctx := context.Background()
switch ev.Kind {
case EventEnded:
if ev.SessionID != "" && ev.Instance != p.instance {
p.sessions.closeLocal(ev.SessionID)
}
case pact.MCPSubjectRevoked, pact.MCPSubjectDeleted:
match := revokedMatch(ev)
for _, ls := range p.sessions.matchingFunc(match) {
_ = p.store.Delete(ctx, ls.id)
p.sessions.closeLocal(ls.id)
logSession(logger(), "revoked", ls.id, ls.binding)
}
if p.stateless != nil {
p.stateless.evictFunc(match)
}
case pact.MCPSubjectLink, pact.MCPSubjectPermissions:
if ev.Kind == pact.MCPSubjectLink {
if ev.Guard == BackendGuard {
p.identities.forgetAllLinks()
} else {
p.identities.forgetLink(ev.UserID)
}
}
for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) {
p.recomputeSession(ctx, ls, ev.Kind == pact.MCPSubjectLink)
}
if p.stateless != nil {
for _, e := range p.stateless.matching(ev.Guard, ev.UserID) {
p.recomputeStateless(ctx, e, ev.Kind == pact.MCPSubjectLink)
}
}
}
}
// revokedMatch selects the identities a revoked or deleted event ends
// (P-31). A revoked event with a TokenID matches the identities whose
// subject under ev.Guard is ev.UserID with that TokenID and no ClientID (the
// sessions of one personal token); one with a ClientID matches the subject
// with that ClientID (one OAuth client); otherwise, and for every deleted
// event, any identity that includes the subject.
func revokedMatch(ev Event) func(Identity) bool {
if ev.Kind == pact.MCPSubjectRevoked && (ev.TokenID != 0 || ev.ClientID != "") {
return func(id Identity) bool {
s := id.subjectFor(ev.Guard)
if s == nil || s.UserID != ev.UserID {
return false
}
if ev.TokenID != 0 {
return s.TokenID == ev.TokenID && s.ClientID == ""
}
return s.ClientID == ev.ClientID
}
}
return func(id Identity) bool { return id.has(ev.Guard, ev.UserID) }
}
// recomputeSession re-filters a local session's identity (re-resolving
// the backend link after a link event) and applies the difference with
// AddTool, RemoveTools, AddPrompt and RemovePrompts.
func (p *Pier) recomputeSession(ctx context.Context, ls *localSession, relink bool) {
ctx, cancel := p.guardContext(ctx)
defer cancel()
ls.mu.Lock()
id := ls.identity.get()
if relink {
p.identities.refreshBackend(ctx, &id)
ls.identity.set(id)
}
tools, prompts, err := p.catalog.allowedReport(ctx, id)
applyDiff(ls.server, p, id, tools, prompts, ls.tools, ls.prompts)
ls.fingerprint = recomputedFingerprint(id, err)
ls.mu.Unlock()
}
// recomputeStateless does the same for one per-identity stateless server.
func (p *Pier) recomputeStateless(ctx context.Context, e *statelessEntry, relink bool) {
ctx, cancel := p.guardContext(ctx)
defer cancel()
e.mu.Lock()
id := e.identity.get()
if relink {
p.identities.refreshBackend(ctx, &id)
e.identity.set(id)
}
tools, prompts, err := p.catalog.allowedReport(ctx, id)
applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = recomputedFingerprint(id, err)
e.widened = nil
e.mu.Unlock()
}
// recomputedFingerprint is the fingerprint a recompute records: the
// identity's, or none when a guard check failed (the failing items stay
// hidden, which is safe for an event), so the next request of the binding
// re-filters instead of keeping them hidden until another event (RA-WR-03).
func recomputedFingerprint(id Identity, err error) string {
if err != nil {
logger().Warn("pier: recompute incomplete, re-filtered on the next request", "binding", id.Binding, "error", err)
return ""
}
return id.fingerprint()
}
// 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 {
return
}
// A client that hangs up mid-check must not leave the server filtered
// by cancelled guard checks (an error hides the item), but the checks
// still end within mcp.call_timeout: the caller holds a lock.
ctx, cancel := p.guardContext(ctx)
defer cancel()
// A failed check is not a refusal: the server and *fp stay as they are,
// so the next request retries instead of the tools staying hidden until
// the fingerprint changes again (RA-WR-03).
tools, prompts, err := p.catalog.allowedReport(ctx, id)
if err != nil {
logger().Warn("pier: widening deferred", "binding", id.Binding, "error", err)
return
}
applyDiff(srv, p, id, tools, prompts, haveTools, havePrompts)
*fp = next
}
// guardContext is the context of guard checks run while a session's or
// entry's mutex is held (widening and fan-out recomputes): not cancelled
// with the request that triggered them, but bounded by mcp.call_timeout,
// so a slow provider never holds that mutex indefinitely (RA-WR-01).
func (p *Pier) guardContext(ctx context.Context) (context.Context, context.CancelFunc) {
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) {
wantTools := map[string]bool{}
for _, t := range tools {
wantTools[t.name] = true
if !haveTools[t.name] {
srv.AddTool(t.desc, p.toolHandler(t, id))
haveTools[t.name] = true
}
}
var dropTools []string
for name := range haveTools {
if !wantTools[name] {
dropTools = append(dropTools, name)
delete(haveTools, name)
}
}
if len(dropTools) > 0 {
srv.RemoveTools(dropTools...)
}
wantPrompts := map[string]bool{}
for _, pr := range prompts {
wantPrompts[pr.name] = true
if !havePrompts[pr.name] {
srv.AddPrompt(pr.desc, p.promptHandler(pr))
havePrompts[pr.name] = true
}
}
var dropPrompts []string
for name := range havePrompts {
if !wantPrompts[name] {
dropPrompts = append(dropPrompts, name)
delete(havePrompts, name)
}
}
if len(dropPrompts) > 0 {
srv.RemovePrompts(dropPrompts...)
}
}
// matching lists the local sessions whose identity includes the subject.
func (sr *sessionRouter) matching(guard string, userID uint) []*localSession {
return sr.matchingFunc(func(id Identity) bool { return id.has(guard, userID) })
}
// matchingFunc lists the local sessions whose identity satisfies match.
// The sessions are snapshotted under the router lock and matched after it
// is released, through each session's identity box, which is never held
// across I/O: an event never waits on a session that is re-filtering, and
// never while it holds the router lock (RA-WR-01).
func (sr *sessionRouter) matchingFunc(match func(Identity) bool) []*localSession {
sr.mu.Lock()
all := make([]*localSession, 0, len(sr.local))
for _, ls := range sr.local {
all = append(all, ls)
}
sr.mu.Unlock()
var out []*localSession
for _, ls := range all {
if match(ls.identity.get()) {
out = append(out, ls)
}
}
return out
}
// identityBox holds the identity of a session or stateless entry behind a
// mutex of its own that is never held across I/O, so fan-out matching
// reads it without waiting for a guard check (RA-WR-01).
type identityBox struct {
mu sync.Mutex
id Identity
}
func (b *identityBox) get() Identity {
b.mu.Lock()
defer b.mu.Unlock()
return b.id
}
func (b *identityBox) set(id Identity) {
b.mu.Lock()
b.id = id
b.mu.Unlock()
}
// refreshBackend re-resolves an OAuth identity's backend subject through
// the link resolver (after a link event), adding or dropping it.
func (r *identityResolver) refreshBackend(ctx context.Context, id *Identity) {
if id.TokenKind != TokenKindOAuth || id.Frontend == nil {
return
}
id.Backend = nil
for _, s := range id.Scopes {
if s != "backend" {
continue
}
if link, ok := r.linkedBackend(ctx, id.Frontend.UserID); ok {
id.Backend = &pact.MCPSubject{
Guard: BackendGuard,
UserID: link.ID,
Scopes: id.Scopes,
ClientID: id.Frontend.ClientID,
TokenID: id.Frontend.TokenID,
}
}
return
}
}
// forgetAllLinks drops the whole link cache.
func (r *identityResolver) forgetAllLinks() {
r.mu.Lock()
r.links = map[uint]linkEntry{}
r.mu.Unlock()
}