Files
summercms/modules/pier/events.go
Jakub Zych cccf4025ea fix(19.7): RA-WR-01 never wait on a session lock under the router lock
Fan-out matching snapshots the sessions (and stateless entries) under the
router-wide lock, releases it, and reads each identity through a small
identity box that is never held across I/O, so an event no longer waits on
a session that is re-filtering while it holds sr.mu or sp.mu.

Widening and the fan-out recomputes run their guard checks with
guardContext: not cancelled with the triggering request, but bounded by
mcp.call_timeout, so a provider that never answers cannot hold a session
lock forever. README and docs/services/mcp.md describe the wider meaning of
mcp.call_timeout.

TestLockScopeUnderWidening blocks a widening guard check, fires a
permission event for that session and asserts that another session's call
still finishes, and that a never-answering check ends at call_timeout.
2026-10-23 20:02:13 +02:00

276 lines
8.9 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 := p.catalog.allowed(ctx, id)
applyDiff(ls.server, p, id, tools, prompts, ls.tools, ls.prompts)
ls.fingerprint = id.fingerprint()
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 := p.catalog.allowed(ctx, id)
applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = id.fingerprint()
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.
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()
tools, prompts := p.catalog.allowed(ctx, id)
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)
}
// 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()
}