Files
summercms/modules/pier/backend_guard.go
Jakub Zych cfdbaf0e47 feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions
- modules/pier: Host contract, mcp.* config, catalog compiled from
  pact.HasMCP (prefixing, route binding, guard resolution, schema
  reflection, annotations), per-request identity through the published
  verifier and resolvers, built-in backend guard provider, in-process
  dispatch with the bouncer marker, per-identity SDK server with the
  per-request re-filter, stateful session router with rehydration over a
  memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
  pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
  before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
  through surf's compiled guard chain; README and root table row
2026-10-22 18:15:59 +02:00

114 lines
4.3 KiB
Go

package pier
import (
"context"
"fmt"
"net/http"
"slices"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// BackendGuard is the name of the built-in guard provider for routes behind
// cabana's backend token guard (middleware "backend"): a backend tool runs
// as the admin the subject names, with the admin's real cabana permissions
// (superuser included) and, for an admin personal token, the token's scope
// limit for the dispatched route's method.
const BackendGuard = "backend"
// backendMiddleware is cabana's backend guard middleware name.
const backendMiddleware = "backend"
// methodAllower is the method-aware listing check pier uses for the backend
// provider: an admin personal token's scope limit depends on the method of
// the route a tool is bound to (Pitfall 7).
type methodAllower interface {
MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error)
}
// backendGuard is the built-in provider registered as BackendGuard unless a
// plugin already owns the name.
type backendGuard struct {
app *backpack.App
}
func (backendGuard) MCPGuardName() string { return BackendGuard }
// MCPTokenGuarded reports whether the route carries cabana's backend guard.
func (backendGuard) MCPTokenGuarded(middleware []string) bool {
return slices.Contains(middleware, backendMiddleware)
}
// MCPRequirements is empty: the backend guard's middleware implies no
// scope or permission; routes check cabana.Allows themselves and tools
// declare Permissions for the listing.
func (backendGuard) MCPRequirements([]string) (pact.MCPRequirements, error) {
return pact.MCPRequirements{}, nil
}
// MCPAllows answers with cabana.Allows for the admin's principal. For an
// admin personal token the scope limit is evaluated as for a GET request;
// pier itself calls MCPAllowsMethod with the tool's method.
func (g backendGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
return g.MCPAllowsMethod(ctx, subject, req, permissions, http.MethodGet)
}
// MCPAllowsMethod answers cabana.Allows for the admin's principal with the
// personal token's scope limit computed for method.
func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, _ pact.MCPRequirements, permissions []string, method string) (bool, error) {
principal, err := g.principal(ctx, subject, method)
if err != nil {
return false, err
}
if principal == nil {
return false, nil
}
return cabana.Allows(principal, permissions), nil
}
// MCPDispatch builds the admin principal for one in-process call.
func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, method string) (pact.MCPDispatch, error) {
principal, err := g.principal(ctx, subject, method)
if err != nil {
return pact.MCPDispatch{}, err
}
if principal == nil {
return pact.MCPDispatch{}, fmt.Errorf("pier: backend user %d is unknown or deactivated", subject.UserID)
}
return pact.MCPDispatch{Principal: principal, Guards: []string{BackendGuard}}, nil
}
// principal loads the admin through cabana.BackendPrincipal (nil when the
// admin does not exist or is deactivated) and, for a personal-token subject
// (no OAuth client), sets the scope limit for method; a token whose stored
// scopes are all unknown answers nil, as the HTTP guard refuses it.
func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, method string) (*bouncer.Principal, error) {
principal, err := cabana.BackendPrincipal(ctx, g.app, subject.UserID)
if err != nil || principal == nil {
return nil, err
}
if subject.ClientID == "" && subject.TokenID != 0 {
limit, known, _ := cabana.ScopeLimitFor(g.app, subject.Scopes, method)
if known == 0 {
return nil, nil
}
if limit == nil {
limit = &bouncer.ScopeLimit{}
}
principal.Scope = limit
}
return principal, nil
}
// registerBackendGuard adds the built-in provider unless a plugin already
// owns the backend name.
func registerBackendGuard(app *backpack.App, reg *pact.MCPGuards) error {
if _, taken := reg.Lookup(BackendGuard); taken {
return nil
}
return reg.Register(backendGuard{app: app})
}