- 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)
120 lines
4.6 KiB
Go
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})
|
|
}
|