Files
summercms/modules/pier/backend_guard.go
Jakub Zych c0ce07ecb7 fix(19.7): RA-IN-05 a token whose scopes no longer resolve gets its own error
The built-in backend guard's principal answers errScopeUnavailable, not a
nil principal, when an admin personal token's stored scopes all fail to
resolve (a scope's plugin was removed). MCPDispatch passes it through and
dispatch maps it to the fixed tool error "pier: the token's scopes no longer
grant this route" instead of claiming the administrator's account is
unknown or deactivated; the listing check still treats it as a refusal, not
as a failed check.
2026-10-23 20:21:05 +02:00

131 lines
5.1 KiB
Go

package pier
import (
"context"
"errors"
"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 errors.Is(err, errScopeUnavailable) {
return false, nil // a refusal, not a failed check
}
if err != nil {
return false, err
}
if principal == nil {
return false, nil
}
return cabana.Allows(principal, permissions), nil
}
// errSubjectUnavailable is what a dispatch answers when the subject's
// account no longer exists or is deactivated; its text is safe to show the
// MCP client.
var errSubjectUnavailable = errors.New("pier: the caller's account is unknown or deactivated")
// errScopeUnavailable is what a dispatch answers when an admin personal
// token's stored scopes no longer resolve to any registered scope (a
// scope's plugin was removed, for example); its text is safe to show the
// MCP client and, unlike errSubjectUnavailable, does not claim the account
// is gone (RA-IN-05).
var errScopeUnavailable = errors.New("pier: the token's scopes no longer grant this route")
// 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("%w (backend user %d)", errSubjectUnavailable, 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 errScopeUnavailable, 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, errScopeUnavailable
}
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})
}