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.
276 lines
8.9 KiB
Go
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()
|
|
}
|