Files
summercms/modules/pier/events.go
Jakub Zych 240457291f fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
2026-10-23 15:59:54 +02:00

205 lines
6.2 KiB
Go

package pier
import (
"context"
"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}
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 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:
for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) {
_ = p.store.Delete(ctx, ls.id)
p.sessions.closeLocal(ls.id)
logSession(logger(), "revoked", ls.id, ls.binding)
}
if p.stateless != nil {
p.stateless.evict(ev.Guard, ev.UserID)
}
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)
}
}
}
}
// 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) {
ls.mu.Lock()
id := ls.identity
if relink {
p.identities.refreshBackend(ctx, &id)
ls.identity = 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) {
e.mu.Lock()
id := e.identity
if relink {
p.identities.refreshBackend(ctx, &id)
e.identity = 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).
tools, prompts := p.catalog.allowed(context.WithoutCancel(ctx), id)
applyDiff(srv, p, id, tools, prompts, haveTools, havePrompts)
*fp = next
}
// 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 {
sr.mu.Lock()
defer sr.mu.Unlock()
var out []*localSession
for _, ls := range sr.local {
ls.mu.Lock()
hit := ls.identity.has(guard, userID)
ls.mu.Unlock()
if hit {
out = append(out, ls)
}
}
return out
}
// 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()
}