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}) }