229 lines
8.4 KiB
Go
229 lines
8.4 KiB
Go
package pier
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
)
|
|
|
|
// scopeFixtureController is the one admin controller cabana.Activate needs
|
|
// before it publishes the backend resolver BackendPrincipal reads.
|
|
type scopeFixtureController struct{}
|
|
|
|
func (scopeFixtureController) ID() string { return "acme.scopes.items" }
|
|
func (scopeFixtureController) ModelName() string { return "Item" }
|
|
func (scopeFixtureController) ConfigDir() string { return "controllers/items" }
|
|
func (scopeFixtureController) NewRecord() any { return &scopeItem{} }
|
|
|
|
// scopeItem is the fixture controller's model.
|
|
type scopeItem struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
}
|
|
|
|
func (scopeItem) TableName() string { return "acme_scope_items" }
|
|
|
|
type scopeFixturePlugin struct{}
|
|
|
|
func (scopeFixturePlugin) ID() string { return "acme.scopes" }
|
|
func (scopeFixturePlugin) Requires() []string { return nil }
|
|
func (scopeFixturePlugin) Register(*backpack.App) error { return nil }
|
|
func (scopeFixturePlugin) Boot(*backpack.App) error { return nil }
|
|
func (scopeFixturePlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{scopeFixtureController{}}
|
|
}
|
|
func (scopeFixturePlugin) AdminFS() fs.FS {
|
|
return fstest.MapFS{
|
|
"controllers/items/config_list.yaml": {Data: []byte("list: ~/plugins/acme/scopes/models/item/columns.yaml\nmodelClass: Item\ntitle: Items\n")},
|
|
"models/item/columns.yaml": {Data: []byte("columns:\n id:\n label: ID\n")},
|
|
}
|
|
}
|
|
|
|
// okHost serves every compiled route with 200 {"ok":true}.
|
|
type okHost struct{}
|
|
|
|
func (okHost) Routes() []RouteInfo { return nil }
|
|
func (okHost) Handler(string, string) (http.Handler, bool) {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}), true
|
|
}
|
|
func (okHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
|
func (okHost) MountJSON(string, string, http.Handler) error { return nil }
|
|
func (okHost) Owner(string, string) (string, bool) { return "", false }
|
|
|
|
// scopeApp starts Postgres, migrates the backend admin tables and activates
|
|
// cabana on a fresh app so cabana.BackendPrincipal and ScopeLimitFor are real.
|
|
func scopeApp(t *testing.T) (*backpack.App, *sql.DB) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
|
postgres.WithDatabase("pier"), postgres.WithUsername("pier"), postgres.WithPassword("pier"),
|
|
postgres.BasicWaitStrategies())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
|
|
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
gdb, err := lagoon.Use(ctx, db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lagoon.Migrate(gdb, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
files := map[string]string{
|
|
"app.yaml": "name: acme\nurl: http://acme.test\nlocale: en\nfallback_locale: en\n",
|
|
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, db, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cabana.Activate(app, []party.Plugin{scopeFixturePlugin{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return app, db
|
|
}
|
|
|
|
func seedScopeAdmin(t *testing.T, db *sql.DB, login string, active bool) uint {
|
|
t.Helper()
|
|
var id uint
|
|
if err := db.QueryRow(`INSERT INTO backend_users (login, email, password, permissions, is_activated, is_superuser)
|
|
VALUES ($1, $1 || '@acme.test', 'x', '{}', $2, TRUE) RETURNING id`, login, active).Scan(&id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// TestBackendScopeUnavailable pins RA-IN-05 against the real cabana: an
|
|
// admin personal token whose stored scopes resolve to no registered scope
|
|
// is refused with errScopeUnavailable (not errSubjectUnavailable), both at
|
|
// dispatch and in the listing check.
|
|
func TestBackendScopeUnavailable(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("requires testcontainers postgres")
|
|
}
|
|
app, db := scopeApp(t)
|
|
active := seedScopeAdmin(t, db, "active", true)
|
|
inactive := seedScopeAdmin(t, db, "inactive", false)
|
|
g := backendGuard{app: app}
|
|
ctx := context.Background()
|
|
gone := []string{"removed.plugin:scope"}
|
|
pat := func(user uint, scopes []string) pact.MCPSubject {
|
|
return pact.MCPSubject{Guard: BackendGuard, UserID: user, TokenID: 9, Scopes: scopes}
|
|
}
|
|
|
|
schema := &jsonschema.Schema{Type: "object"}
|
|
resolved, err := schema.Resolve(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tl := &tool{name: "acme_items", method: http.MethodGet, pattern: "/api/acme/items", guardName: BackendGuard, guard: g, schema: schema, resolved: resolved}
|
|
d := &dispatcher{host: okHost{}, app: app, cfg: Config{CallTimeout: 10 * time.Second, MaxResultBytes: DefaultMaxResultBytes}}
|
|
call := func(subject pact.MCPSubject) *mcp.CallToolResult {
|
|
return d.call(ctx, Identity{Backend: &subject}, callInfo{}, tl, json.RawMessage(`{}`))
|
|
}
|
|
text := func(res *mcp.CallToolResult) string {
|
|
if len(res.Content) == 0 {
|
|
return ""
|
|
}
|
|
tc, _ := res.Content[0].(*mcp.TextContent)
|
|
if tc == nil {
|
|
return ""
|
|
}
|
|
return tc.Text
|
|
}
|
|
|
|
t.Run("unknown scopes refuse with errScopeUnavailable", func(t *testing.T) {
|
|
_, err := g.MCPDispatch(ctx, pat(active, gone), http.MethodGet)
|
|
if !errors.Is(err, errScopeUnavailable) || errors.Is(err, errSubjectUnavailable) {
|
|
t.Fatalf("MCPDispatch err = %v", err)
|
|
}
|
|
res := call(pat(active, gone))
|
|
if !res.IsError || text(res) != "pier: the token's scopes no longer grant this route" {
|
|
t.Fatalf("tool call = %+v %q", res, text(res))
|
|
}
|
|
ok, err := g.MCPAllowsMethod(ctx, pat(active, gone), pact.MCPRequirements{}, nil, http.MethodGet)
|
|
if ok || err != nil {
|
|
t.Fatalf("MCPAllowsMethod = %v, %v, want false, nil", ok, err)
|
|
}
|
|
})
|
|
|
|
t.Run("a known scope dispatches", func(t *testing.T) {
|
|
disp, err := g.MCPDispatch(ctx, pat(active, []string{"admin:read"}), http.MethodGet)
|
|
if err != nil || disp.Principal == nil {
|
|
t.Fatalf("MCPDispatch = %+v, %v", disp, err)
|
|
}
|
|
ok, err := g.MCPAllowsMethod(ctx, pat(active, []string{"admin:read"}), pact.MCPRequirements{}, nil, http.MethodGet)
|
|
if !ok || err != nil {
|
|
t.Fatalf("MCPAllowsMethod = %v, %v", ok, err)
|
|
}
|
|
if res := call(pat(active, []string{"admin:read"})); res.IsError {
|
|
t.Fatalf("tool call = %q", text(res))
|
|
}
|
|
})
|
|
|
|
t.Run("a deactivated or missing admin is still unavailable", func(t *testing.T) {
|
|
for _, user := range []uint{inactive, 999999} {
|
|
for _, scopes := range [][]string{gone, {"admin:read"}} {
|
|
if _, err := g.MCPDispatch(ctx, pat(user, scopes), http.MethodGet); !errors.Is(err, errSubjectUnavailable) {
|
|
t.Fatalf("user %d scopes %v: err = %v", user, scopes, err)
|
|
}
|
|
if res := call(pat(user, scopes)); text(res) != "pier: the caller's account is unknown or deactivated" {
|
|
t.Fatalf("user %d tool call = %q", user, text(res))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an OAuth subject skips the scope check", func(t *testing.T) {
|
|
subject := pact.MCPSubject{Guard: BackendGuard, UserID: active, ClientID: "c1", TokenID: 9, Scopes: gone}
|
|
if _, err := g.MCPDispatch(ctx, subject, http.MethodGet); err != nil {
|
|
t.Fatalf("MCPDispatch err = %v", err)
|
|
}
|
|
ok, err := g.MCPAllowsMethod(ctx, subject, pact.MCPRequirements{}, nil, http.MethodGet)
|
|
if !ok || err != nil {
|
|
t.Fatalf("MCPAllowsMethod = %v, %v", ok, err)
|
|
}
|
|
})
|
|
}
|