Pin bouncer Scope serialization, party Activated order, lagoon migration, compile/overlap/mint/expiry/validation, guard/debounce/resolver, interactive-only, account token API and CLI on Postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
402 lines
17 KiB
Go
402 lines
17 KiB
Go
package cabana
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
// tokenRouteClass is the D-05/D-06 inventory class for a phase09Routes entry.
|
|
type tokenRouteClass string
|
|
|
|
const (
|
|
classPublic tokenRouteClass = "public"
|
|
classOpenAnyToken tokenRouteClass = "open_any_token"
|
|
classInteractive tokenRouteClass = "interactive_only"
|
|
classExplicitGate tokenRouteClass = "explicit_gate"
|
|
classAllowsProtect tokenRouteClass = "allows_protect"
|
|
classAllowsNav tokenRouteClass = "allows_navigation"
|
|
classAllowsSettings tokenRouteClass = "allows_settings"
|
|
)
|
|
|
|
// tokenRouteClasses maps every phase09Routes key to its personal-token class.
|
|
// Adding a route without a class fails TestTokenRouteGates.
|
|
var tokenRouteClasses = map[string]tokenRouteClass{
|
|
"POST /auth/login": classPublic,
|
|
"POST /auth/refresh": classInteractive, // public to JWT, but interactive-only for tokens
|
|
"GET /lang": classPublic,
|
|
"POST /auth/logout": classInteractive,
|
|
"GET /auth/me": classOpenAnyToken,
|
|
|
|
"PUT /account": classInteractive,
|
|
"POST /account/avatar": classInteractive,
|
|
"DELETE /account/avatar": classInteractive,
|
|
"GET /account/tokens": classInteractive,
|
|
"POST /account/tokens": classInteractive,
|
|
"GET /account/tokens/scopes": classInteractive,
|
|
"DELETE /account/tokens/{id}": classInteractive,
|
|
|
|
"GET /navigation": classAllowsNav,
|
|
"GET /field-types": classExplicitGate,
|
|
"GET /settings": classAllowsSettings,
|
|
"GET /settings/{code}/schema": classAllowsSettings,
|
|
"GET /settings/{code}": classAllowsSettings,
|
|
"PUT /settings/{code}": classAllowsSettings,
|
|
"POST /markdown/preview": classExplicitGate,
|
|
|
|
"GET /{vendor}/{plugin}/{controller}/schema/list": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/schema/form": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/partials/{name}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/fields/{field}/options": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/bulk-delete": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/bulk/{action}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
|
|
"DELETE /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect,
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect,
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder": classAllowsProtect,
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect,
|
|
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": classAllowsProtect,
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect,
|
|
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": classAllowsProtect,
|
|
"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": classAllowsProtect,
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": classAllowsProtect,
|
|
|
|
"GET /assets/{vendor}/{plugin}/{file...}": classPublic,
|
|
"GET ": classPublic,
|
|
"GET /{path...}": classPublic,
|
|
}
|
|
|
|
func TestTokenRouteGates(t *testing.T) {
|
|
for _, route := range phase09Routes {
|
|
if _, ok := tokenRouteClasses[route.key]; !ok {
|
|
t.Errorf("phase09Routes key %q has no token class", route.key)
|
|
}
|
|
}
|
|
for key := range tokenRouteClasses {
|
|
found := false
|
|
for _, route := range phase09Routes {
|
|
if route.key == key {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("token class map has unknown key %q not in phase09Routes", key)
|
|
}
|
|
}
|
|
if t.Failed() {
|
|
t.Fatal("token route classification incomplete")
|
|
}
|
|
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{
|
|
ID: 1,
|
|
Backend: true,
|
|
PermissionGrants: map[string]bool{
|
|
"acme.demo.access": true,
|
|
"acme.demo.reports": true,
|
|
},
|
|
}
|
|
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
|
|
guard := newCompositeGuard(&stubJWTGuard{}, store, users, reg)
|
|
mint := func(id uint, scopes []string) string {
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.put(&BackendAPIToken{
|
|
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
|
})
|
|
return plain
|
|
}
|
|
starTok := mint(1, []string{"*"})
|
|
adminReadTok := mint(2, []string{"admin:read"})
|
|
demoTok := mint(3, []string{"demo"})
|
|
|
|
authPrincipal := func(r *http.Request, token string) (*bouncer.Principal, any, error) {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
return guard.AuthenticateCredential(r)
|
|
}
|
|
|
|
// open_any_token: GET /auth/me — any valid token passes the guard.
|
|
for name, tok := range map[string]string{"*": starTok, "admin:read": adminReadTok, "demo": demoTok} {
|
|
r := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
|
|
p, _, err := authPrincipal(r, tok)
|
|
if err != nil || p == nil {
|
|
t.Fatalf("open_any_token %s: auth err=%v", name, err)
|
|
}
|
|
}
|
|
|
|
// interactive_only: credential or raw g15_ Bearer → 403 interactive_only.
|
|
interactivePaths := []string{
|
|
"PUT /account", "POST /account/avatar", "DELETE /account/avatar",
|
|
"GET /account/tokens", "POST /account/tokens", "GET /account/tokens/scopes",
|
|
"DELETE /account/tokens/{id}", "POST /auth/refresh", "POST /auth/logout",
|
|
}
|
|
for _, key := range interactivePaths {
|
|
if tokenRouteClasses[key] != classInteractive {
|
|
t.Fatalf("%s class = %s, want interactive_only", key, tokenRouteClasses[key])
|
|
}
|
|
method, path, _ := strings.Cut(key, " ")
|
|
path = strings.ReplaceAll(path, "{id}", "1")
|
|
h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
tokens := map[string]string{"*": starTok, "demo": demoTok}
|
|
if method == http.MethodGet || method == http.MethodHead {
|
|
tokens["admin:read"] = adminReadTok
|
|
}
|
|
for name, tok := range tokens {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
p, cred, err := guard.AuthenticateCredential(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s auth: %v", key, name, err)
|
|
}
|
|
ctx := bouncer.WithUser(req.Context(), p)
|
|
if cred != nil {
|
|
ctx = bouncer.WithCredential(ctx, cred)
|
|
}
|
|
h(rec, req.WithContext(ctx))
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("%s %s: want 403, got %d", key, name, rec.Code)
|
|
}
|
|
assertReason(t, rec, "interactive_only")
|
|
}
|
|
// admin:read cannot authenticate on non-GET interactive routes (method filter).
|
|
if method != http.MethodGet && method != http.MethodHead {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+adminReadTok)
|
|
if _, _, err := guard.AuthenticateCredential(req); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("%s admin:read: want ErrTokenScope, got %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// explicit_gate: only Full scopes pass (admin:read on GET).
|
|
svc := &service{reg: reg}
|
|
runGate := func(method, path, tok string, handler http.HandlerFunc) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(method, path, nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
p, cred, err := guard.AuthenticateCredential(r)
|
|
if err != nil || p == nil {
|
|
t.Fatalf("auth %s: %v", tok[:8], err)
|
|
}
|
|
ctx := bouncer.WithUser(r.Context(), p)
|
|
if cred != nil {
|
|
ctx = bouncer.WithCredential(ctx, cred)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, r.WithContext(ctx))
|
|
return rec
|
|
}
|
|
if rec := runGate(http.MethodGet, "/field-types", demoTok, svc.fieldTypesList); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("demo field-types: want 403, got %d", rec.Code)
|
|
} else {
|
|
assertReason(t, rec, "token_scope")
|
|
}
|
|
if rec := runGate(http.MethodGet, "/field-types", adminReadTok, svc.fieldTypesList); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := runGate(http.MethodGet, "/field-types", starTok, svc.fieldTypesList); rec.Code != http.StatusOK {
|
|
t.Fatalf("* field-types: want 200, got %d", rec.Code)
|
|
}
|
|
if rec := runGate(http.MethodPost, "/markdown/preview", demoTok, svc.markdownPreview); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("demo markdown: want 403, got %d", rec.Code)
|
|
} else {
|
|
assertReason(t, rec, "token_scope")
|
|
}
|
|
// admin:read is GET/HEAD only → POST method refusal before handler.
|
|
r := httptest.NewRequest(http.MethodPost, "/markdown/preview", nil)
|
|
r.Header.Set("Authorization", "Bearer "+adminReadTok)
|
|
if _, _, err := guard.AuthenticateCredential(r); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("admin:read POST markdown: want ErrTokenScope, got %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodPost, "/markdown/preview", strings.NewReader(`{"markdown":"hi"}`))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Authorization", "Bearer "+starTok)
|
|
pStar, _, err := guard.AuthenticateCredential(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recMD := httptest.NewRecorder()
|
|
svc.markdownPreview(recMD, r.WithContext(bouncer.WithUser(r.Context(), pStar)))
|
|
if recMD.Code == http.StatusForbidden {
|
|
var body struct {
|
|
Error struct {
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
_ = json.Unmarshal(recMD.Body.Bytes(), &body)
|
|
if body.Error.Details["reason"] == "token_scope" {
|
|
t.Fatalf("* markdown refused with token_scope: %s", recMD.Body.String())
|
|
}
|
|
}
|
|
|
|
// allows_protect: scoped demo token passes Allows for acme.demo.access; reports scope fails.
|
|
protectH := func(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := bouncer.User(r.Context())
|
|
if !Allows(p, []string{"acme.demo.access"}) {
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
for name, tok := range map[string]string{"*": starTok, "demo": demoTok} {
|
|
r := httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
p, cred, err := guard.AuthenticateCredential(r)
|
|
if err != nil {
|
|
t.Fatalf("protect %s: %v", name, err)
|
|
}
|
|
ctx := bouncer.WithUser(r.Context(), p)
|
|
if cred != nil {
|
|
ctx = bouncer.WithCredential(ctx, cred)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
protectH(rec, r.WithContext(ctx))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("protect %s: want 200, got %d", name, rec.Code)
|
|
}
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil)
|
|
r.Header.Set("Authorization", "Bearer "+adminReadTok)
|
|
p, _, err := guard.AuthenticateCredential(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
protectH(rec, r.WithContext(bouncer.WithUser(r.Context(), p)))
|
|
// admin:read is Full → Allows uses owner grants → 200 for developer.
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("protect admin:read: want 200, got %d", rec.Code)
|
|
}
|
|
|
|
// Class expectations summary for *, admin:read (GET), demo.
|
|
type expect struct {
|
|
star, adminRead, demo string // "ok", "interactive_only", "token_scope", "public"
|
|
}
|
|
byClass := map[tokenRouteClass]expect{
|
|
classPublic: {star: "public", adminRead: "public", demo: "public"},
|
|
classOpenAnyToken: {star: "ok", adminRead: "ok", demo: "ok"},
|
|
classInteractive: {star: "interactive_only", adminRead: "interactive_only", demo: "interactive_only"},
|
|
classExplicitGate: {star: "ok", adminRead: "ok", demo: "token_scope"},
|
|
classAllowsProtect: {star: "ok", adminRead: "ok", demo: "ok"},
|
|
classAllowsNav: {star: "ok", adminRead: "ok", demo: "ok"},
|
|
classAllowsSettings: {star: "ok", adminRead: "ok", demo: "ok"},
|
|
}
|
|
for key, class := range tokenRouteClasses {
|
|
exp, ok := byClass[class]
|
|
if !ok {
|
|
t.Fatalf("no expect for class %s on %s", class, key)
|
|
}
|
|
_ = exp // exercised via class representatives above; map completeness is the gate
|
|
if class == "" {
|
|
t.Fatalf("empty class for %s", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertReason(t *testing.T, rec *httptest.ResponseRecorder, reason string) {
|
|
t.Helper()
|
|
var body struct {
|
|
Error struct {
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if body.Error.Details["reason"] != reason {
|
|
t.Fatalf("reason = %v, want %s body=%s", body.Error.Details["reason"], reason, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInteractiveOnly(t *testing.T) {
|
|
h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
// Token credential refused.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/account", nil)
|
|
req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 1}))
|
|
h(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("cred: %d", rec.Code)
|
|
}
|
|
assertReason(t, rec, "interactive_only")
|
|
|
|
// Raw g15_ Bearer refused even without credential.
|
|
plain, _, _, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []struct {
|
|
method, path string
|
|
}{
|
|
{http.MethodPut, "/account"},
|
|
{http.MethodPost, "/account/avatar"},
|
|
{http.MethodDelete, "/account/avatar"},
|
|
{http.MethodGet, "/account/tokens"},
|
|
{http.MethodPost, "/account/tokens"},
|
|
{http.MethodGet, "/account/tokens/scopes"},
|
|
{http.MethodDelete, "/account/tokens/1"},
|
|
{http.MethodPost, "/auth/refresh"},
|
|
{http.MethodPost, "/auth/logout"},
|
|
} {
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(path.method, path.path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+plain)
|
|
h(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("%s %s: %d", path.method, path.path, rec.Code)
|
|
}
|
|
assertReason(t, rec, "interactive_only")
|
|
}
|
|
|
|
// JWT session (non-g15_ bearer, no token credential) passes.
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPut, "/account", nil)
|
|
req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true}))
|
|
h(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("jwt session: %d", rec.Code)
|
|
}
|
|
}
|