mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
5c9c650ac3
* feat(server): a read-scoped token may run a playbook (TASK-2863)
POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.
Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.
* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)
* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
153 lines
5.9 KiB
Go
153 lines
5.9 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TASK-2863: a read-scoped token may POST exactly one side-effect-free
|
|
// path, /api/v1/workspaces/{ws}/playbooks/{ref}/run. Every other POST stays
|
|
// refused (lead ruling: option A, one entry, not /match, /claim or
|
|
// /release).
|
|
|
|
func TestTASK2863_ReadScopePOSTPathTable(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{"/api/v1/workspaces/docapp/playbooks/ship/run", true},
|
|
{"/api/v1/workspaces/docapp/playbooks/PLAYB-1405/run", true},
|
|
{"/api/v1/workspaces/0b7c9e1a-3f00-4c1e-9d2a-5a6b7c8d9e0f/playbooks/my_playbook.v2/run", true},
|
|
|
|
{"/api/v1/workspaces/docapp/playbooks/match", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/ship/run/", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/ship/run/x", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/ship", false},
|
|
{"/api/v1/workspaces/docapp/items/TASK-1/claim", false},
|
|
{"/api/v1/workspaces/docapp/collections/tasks/items", false},
|
|
{"/api/v1/workspaces//playbooks/ship/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks//run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/../run", false},
|
|
{"/api/v1/workspaces/./playbooks/ship/run", false},
|
|
{"api/v1/workspaces/docapp/playbooks/ship/run", false},
|
|
{"/api/v2/workspaces/docapp/playbooks/ship/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/a/b/run", false},
|
|
{"", false},
|
|
// The MCP dispatcher passes the string it built from caller input,
|
|
// before http.NewRequest parses it: each of these would route
|
|
// somewhere other than run.
|
|
{"/api/v1/workspaces/docapp/playbooks/match?/run", false},
|
|
{"/api/v1/workspaces/docapp?/playbooks/ship/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/match#/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/match%3F/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/sh ip/run", false},
|
|
{"/api/v1/workspaces/docapp/playbooks/shïp/run", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isReadScopePOSTPath(c.path); got != c.want {
|
|
t.Errorf("isReadScopePOSTPath(%q) = %v, want %v", c.path, got, c.want)
|
|
}
|
|
}
|
|
|
|
// The predicate widens only the read scopes, and only for POST.
|
|
run := "/api/v1/workspaces/docapp/playbooks/ship/run"
|
|
for _, c := range []struct {
|
|
scopes, method string
|
|
want bool
|
|
}{
|
|
{`["read"]`, http.MethodPost, true},
|
|
{`["pad:read"]`, http.MethodPost, true},
|
|
{`["read"]`, http.MethodPut, false},
|
|
{`["read"]`, http.MethodPatch, false},
|
|
{`["read"]`, http.MethodDelete, false},
|
|
{`["read-only"]`, http.MethodPost, false},
|
|
{`null`, http.MethodPost, false},
|
|
} {
|
|
if got := tokenScopeAllows(c.scopes, c.method, run); got != c.want {
|
|
t.Errorf("tokenScopeAllows(%s, %s, run) = %v, want %v", c.scopes, c.method, got, c.want)
|
|
}
|
|
}
|
|
// A read token still cannot write anywhere else, and the empty path
|
|
// (what callers asking "can this token write at all" pass) stays a no.
|
|
if tokenScopeAllows(`["read"]`, http.MethodPost, "") {
|
|
t.Error(`a read token may POST with an empty path`)
|
|
}
|
|
}
|
|
|
|
// Through the router with real PATs: the read token's run answers the
|
|
// handler's 200; its other POSTs get TokenAuth's scope refusal, matched
|
|
// on its message; a write token's same POSTs are not refused for scope.
|
|
func TestTASK2863_ReadPATThroughTheRouter(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
createItem(t, srv, slug, "playbooks", map[string]interface{}{
|
|
"title": "Ship",
|
|
"content": "Step 1",
|
|
"fields": `{"status":"active","invocation_slug":"ship"}`,
|
|
})
|
|
|
|
user, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "reader@example.com", Name: "Reader", Password: "correct-horse-battery-staple",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ws, err := srv.store.GetWorkspaceBySlug(slug)
|
|
if err != nil || ws == nil {
|
|
t.Fatalf("GetWorkspaceBySlug: %v", err)
|
|
}
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, user.ID, "owner"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mint := func(scopes string) string {
|
|
tok, err := srv.store.CreateAPIToken(user.ID, models.APITokenCreate{Name: scopes, Scopes: scopes}, 90, 365)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok.Token
|
|
}
|
|
readTok, writeTok := mint(`["read"]`), mint(`["write"]`)
|
|
as := func(tok, method, path string, body any) (int, string) {
|
|
rr := doRequestWithHeaders(srv, method, path, body, map[string]string{"Authorization": "Bearer " + tok})
|
|
var env struct {
|
|
Error struct{ Code, Message string } `json:"error"`
|
|
}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &env)
|
|
// TokenAuth's own refusal is the only 403 that names the scope, so
|
|
// for a path no route serves (where a write token gets 404) this
|
|
// is what says the read token was refused by the scope check
|
|
// (codex round 1).
|
|
if rr.Code == http.StatusForbidden && env.Error.Message == "Token scope does not permit this action" {
|
|
return rr.Code, "scope"
|
|
}
|
|
return rr.Code, env.Error.Code
|
|
}
|
|
|
|
base := "/api/v1/workspaces/" + slug
|
|
if code, _ := as(readTok, "POST", base+"/playbooks/ship/run", map[string]any{}); code != http.StatusOK {
|
|
t.Fatalf("read PAT run: %d, want 200", code)
|
|
}
|
|
|
|
refused := []struct {
|
|
name, path string
|
|
body any
|
|
}{
|
|
{"match", base + "/playbooks/match", map[string]any{"text": "ship it"}},
|
|
{"item create", base + "/collections/tasks/items", map[string]any{"title": "x"}},
|
|
{"run with a trailing slash", base + "/playbooks/ship/run/", map[string]any{}},
|
|
{"run plus a segment", base + "/playbooks/ship/run/x", map[string]any{}},
|
|
{"dot-dot ref", base + "/playbooks/../run", map[string]any{}},
|
|
}
|
|
for _, c := range refused {
|
|
if code, ecode := as(readTok, "POST", c.path, c.body); code != http.StatusForbidden || ecode != "scope" {
|
|
t.Errorf("read PAT %s: %d %q, want TokenAuth's scope refusal", c.name, code, ecode)
|
|
}
|
|
if _, ecode := as(writeTok, "POST", c.path, c.body); ecode == "scope" {
|
|
t.Errorf("write PAT %s is refused for scope too, so the read leg measures the route, not the scope", c.name)
|
|
}
|
|
}
|
|
}
|