mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
b901ec629c
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)
Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.
Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)
Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)
Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.
describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)
Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.
The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
128 lines
4.4 KiB
Go
128 lines
4.4 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TASK-3866: both doors that mint an API key accept exactly the scope
|
|
// values tokenScopeAllows recognizes, store them compact, and refuse
|
|
// anything that would mint a key the server then refuses everywhere, or
|
|
// one in the legacy unrestricted [] form.
|
|
func TestTASK3866_CreateTokenScopes(t *testing.T) {
|
|
t.Parallel()
|
|
env := setupTokenAuthEnv(t)
|
|
doors := map[string]string{
|
|
"user": "/api/v1/auth/tokens",
|
|
"workspace": "/api/v1/workspaces/" + env.ws.Slug + "/tokens",
|
|
}
|
|
|
|
accepted := []struct {
|
|
name string
|
|
scopes any // omitted when nil
|
|
stored string
|
|
}{
|
|
{"omitted keeps the default", nil, `["*"]`},
|
|
{"read", `["read"]`, `["read"]`},
|
|
{"wildcard", `["*"]`, `["*"]`},
|
|
{"padded and mixed are stored compact", `[ "read" , "pad:write" ]`, `["read","pad:write"]`},
|
|
{"every vocabulary value", `["*","read","write","pad:read","pad:write","pad:admin"]`, `["*","read","write","pad:read","pad:write","pad:admin"]`},
|
|
}
|
|
// Not strings: the field itself is null, an array or a number. A JSON
|
|
// null used to decode as "" and mint a full-access key (Codex r1).
|
|
refusedRaw := []struct {
|
|
name string
|
|
scopes json.RawMessage
|
|
named string
|
|
}{
|
|
{"a JSON null field", json.RawMessage(`null`), "got null"},
|
|
{"an array, not a string", json.RawMessage(`["read"]`), `got ["read"]`},
|
|
{"a number", json.RawMessage(`1`), "got 1"},
|
|
{"a spaced JSON null field", json.RawMessage(" null "), "got null"},
|
|
{"an empty string", json.RawMessage(`""`), "omit scopes"},
|
|
}
|
|
refused := []struct {
|
|
name, scopes, named string
|
|
}{
|
|
{"null", `null`, `"null"`},
|
|
{"unparseable", `read`, `"read"`},
|
|
{"an object", `{}`, `"{}"`},
|
|
{"a bare string", `"read"`, `"\"read\""`},
|
|
{"a non-string element", `["read",1]`, `"[\"read\",1]"`},
|
|
{"empty array", `[]`, "omit scopes"},
|
|
{"padded empty array", `[ ]`, "omit scopes"},
|
|
{"unknown value", `["read-only"]`, `"read-only"`},
|
|
{"case matters", `["Read"]`, `"Read"`},
|
|
{"unknown beside a known one", `["read","docs"]`, `"docs"`},
|
|
{"null element", `["read",null]`, `""`},
|
|
}
|
|
|
|
for door, path := range doors {
|
|
for i, tc := range accepted {
|
|
body := map[string]any{"name": door + "-ok-" + string(rune('a'+i))}
|
|
if tc.scopes != nil {
|
|
body["scopes"] = tc.scopes
|
|
}
|
|
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok, body)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("%s door, %s: %d %s, want 201", door, tc.name, rr.Code, rr.Body.String())
|
|
continue
|
|
}
|
|
var got struct {
|
|
Scopes string `json:"scopes"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Scopes != tc.stored {
|
|
t.Errorf("%s door, %s: stored %q, want %q", door, tc.name, got.Scopes, tc.stored)
|
|
}
|
|
}
|
|
for _, tc := range refused {
|
|
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
|
|
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
|
|
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
|
|
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
|
|
continue
|
|
}
|
|
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
|
|
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
for door, path := range doors {
|
|
for _, tc := range refusedRaw {
|
|
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
|
|
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
|
|
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
|
|
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
|
|
continue
|
|
}
|
|
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
|
|
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing refused was minted.
|
|
tokens, err := env.srv.store.ListUserAPITokens(env.user.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tok := range tokens {
|
|
if strings.HasSuffix(tok.Name, "-bad") {
|
|
t.Errorf("a refused create minted %q with scopes %q", tok.Name, tok.Scopes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// jsonEscapedForBody is s as it appears inside a JSON string value, the
|
|
// form the error message takes in the response body.
|
|
func jsonEscapedForBody(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b[1 : len(b)-1])
|
|
}
|