Files
summercms/modules/cabana/subject_events.go
Jakub Zych 24cf6cd931 fix(pier): end only the revoked token's or client's MCP sessions and log the client address (B-WR-04, B-IN-04)
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a
  narrowed revoked event closes only matching sessions and stateless servers
- cabana fires the admin token revoke with the token row id
- towel.WithClientIP/ClientIP; pier puts the address (surf applies
  http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
2026-10-23 16:21:37 +02:00

66 lines
2.5 KiB
Go

package cabana
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// backendSubjectGuard is the guard name backend subject events carry: the
// admin's own bouncer guard.
const backendSubjectGuard = "backend"
// fireSubjectChanged fires one pact.MCPSubjectChanged of the given kind for
// every administrator id through the application's event bus, so the MCP
// endpoint re-lists a linked admin's tools or ends their sessions. The event
// is a *pact.MCPSubjectChanged, the type pier listens for. Inside a
// transaction it fires only after the commit, so a rolled-back save fires
// nothing; outside one it fires at once. A missing application or bus is a
// no-op and a listener's error is logged: the save that caused the event
// never fails because of it.
func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adminIDs ...uint) {
if app == nil || app.Events == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
tx, _ := TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
for _, id := range adminIDs {
if id == 0 {
continue
}
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err)
}
}
})
}
// fireTokenRevoked fires one pact.MCPSubjectRevoked for an administrator's
// personal token, narrowed to that token with TokenID: the MCP endpoint ends
// only the sessions opened with this token, not the admin's other sessions
// (another token, or an OAuth grant acting as the linked admin). It fires
// after the commit like fireSubjectChanged and never fails the revoke.
func fireTokenRevoked(ctx context.Context, app *backpack.App, adminID, tokenID uint) {
if app == nil || app.Events == nil || adminID == 0 || tokenID == 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
tx, _ := TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: adminID, Kind: pact.MCPSubjectRevoked, TokenID: tokenID}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", pact.MCPSubjectRevoked, "admin_id", adminID, "token_id", tokenID, "error", err)
}
})
}