Files
summercms/modules/pier/backend_guard.go
Jakub Zych 104db9959f fix(pier): refuse path values the mux never delivers and harden dispatch
- a path field must be required in the reflected schema (boot error), and
  a missing value, an empty, . or .. segment, or a / inside one segment is
  a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
  only an invalid token and an unknown or deactivated account keep their
  own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
  keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
  X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
  guard is refused (A-IN-08)
2026-10-23 16:05:32 +02:00

120 lines
4.6 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 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")
// 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 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})
}