- 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)
205 lines
6.2 KiB
Go
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()
|
|
}
|