test(16.3-04): pin deny-by-default contract at every layer
Scope match/Allows tables, route classification for every phase09Routes entry, SPA plaintext lifetime backstops, and Postgres owner lifecycle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
131
modules/cabana/token_postgres_test.go
Normal file
131
modules/cabana/token_postgres_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
func TestBackendTokenPostgres(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAdminHandler(t)
|
||||
user := insertAdmin(t, gdb, "tok-life", "tok-life@example.test", "secret-pass", true, false)
|
||||
listPath := adminAPI("/acme/demo/widgets")
|
||||
|
||||
plain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// D-10: deactivate → 401, reactivate → 200.
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("deactivated: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = true WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("reactivated: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Password reset / tokens_valid_after leave the personal API token working.
|
||||
reset := commandByName(t, cabana.RuntimeCommands(commandApp(t, gdb)), "admin:reset-password")
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(nil, &buf, &buf)
|
||||
if err := reset.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{"password": "new-secret-pass"},
|
||||
}, out); err != nil {
|
||||
t.Fatalf("admin:reset-password: %v output=%s", err, buf.String())
|
||||
}
|
||||
var afterReset cabana.BackendUser
|
||||
if err := gdb.First(&afterReset, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterReset.TokensValidAfter == nil {
|
||||
t.Fatal("reset did not set tokens_valid_after")
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("after password reset: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(time.Hour)
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("after tokens_valid_after cutoff: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Revoked → 401.
|
||||
now := time.Now().UTC()
|
||||
if err := gdb.Exec(`UPDATE backend_api_tokens SET revoked_at = ? WHERE backend_user_id = ?`, now, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Fresh token, expired → 401.
|
||||
expired := now.Add(-time.Hour)
|
||||
plain2, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, &expired)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain2); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Soft-delete owner → 401.
|
||||
plain3, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Delete(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain3); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// last_used_at stamped once within a minute for two requests.
|
||||
user2 := insertAdmin(t, gdb, "tok-stamp", "tok-stamp@example.test", "secret-pass", true, false)
|
||||
plain4, err := cabana.IssueTestAPIToken(gdb, user2.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tokenID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, user2.ID).Scan(&tokenID).Error; err != nil || tokenID == 0 {
|
||||
t.Fatalf("token id: %d err=%v", tokenID, err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK {
|
||||
t.Fatalf("stamp first: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK {
|
||||
t.Fatalf("stamp second: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var row cabana.BackendAPIToken
|
||||
if err := gdb.First(&row, tokenID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.LastUsedAt == nil {
|
||||
t.Fatal("last_used_at not set")
|
||||
}
|
||||
// Ensure the stamp path does not write plaintext scopes oddly.
|
||||
if !row.Scopes.Valid || len(row.Scopes.Get()) == 0 {
|
||||
t.Fatalf("scopes = %#v", row.Scopes)
|
||||
}
|
||||
}
|
||||
348
modules/cabana/token_routes_test.go
Normal file
348
modules/cabana/token_routes_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
170
modules/cabana/token_scopes_test.go
Normal file
170
modules/cabana/token_scopes_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
func TestScopeMatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
required string
|
||||
want int
|
||||
}{
|
||||
{"exact full same", "a.b.c", "a.b.c", matchFull},
|
||||
{"exact full via a.b.*", "a.b.*", "a.b.c", matchFull},
|
||||
{"exact full via a.*", "a.*", "a.b.c", matchFull},
|
||||
{"exact none a.x.*", "a.x.*", "a.b.c", matchNone},
|
||||
{"exact none a.b.d", "a.b.d", "a.b.c", matchNone},
|
||||
{"wild full same", "a.b.*", "a.b.*", matchFull},
|
||||
{"wild full broader a.*", "a.*", "a.b.*", matchFull},
|
||||
{"wild partial narrower pattern", "a.b.*", "a.*", matchPartial},
|
||||
{"wild partial exact narrower", "a.b.c", "a.*", matchPartial},
|
||||
{"wild none a.x.*", "a.x.*", "a.b.*", matchNone},
|
||||
{"wild none x.*", "x.*", "a.b.*", matchNone},
|
||||
{"leading partial exact", "*.manage", "a.manage", matchPartial},
|
||||
{"leading none wild required", "*.manage", "a.manage.*", matchNone},
|
||||
{"leading none wild pattern required", "*.manage", "*.manage", matchNone},
|
||||
{"empty pattern", "", "a.b.c", matchNone},
|
||||
{"empty required", "a.b.c", "", matchNone},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := scopeMatch(tc.pattern, tc.required); got != tc.want {
|
||||
t.Fatalf("scopeMatch(%q, %q) = %d, want %d", tc.pattern, tc.required, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowsScope(t *testing.T) {
|
||||
dev := &bouncer.Principal{
|
||||
ID: 1,
|
||||
Backend: true,
|
||||
PermissionGrants: map[string]bool{
|
||||
"acme.demo.access": true,
|
||||
"acme.demo.reports": true,
|
||||
},
|
||||
}
|
||||
super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p *bouncer.Principal
|
||||
scope *bouncer.ScopeLimit
|
||||
required []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "JWT nil scope keeps empty-list shortcut",
|
||||
p: dev, scope: nil, required: nil, want: true,
|
||||
},
|
||||
{
|
||||
name: "JWT nil scope permission grant",
|
||||
p: dev, scope: nil, required: []string{"acme.demo.access"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "JWT nil scope missing grant",
|
||||
p: dev, scope: nil, required: []string{"acme.other.x"}, want: false,
|
||||
},
|
||||
{
|
||||
name: "JWT superuser empty list",
|
||||
p: super, scope: nil, required: nil, want: true,
|
||||
},
|
||||
{
|
||||
name: "full * every method empty list",
|
||||
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true,
|
||||
},
|
||||
{
|
||||
name: "full * permission as owner",
|
||||
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: []string{"acme.demo.access"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "full admin:read empty list",
|
||||
p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true,
|
||||
},
|
||||
{
|
||||
name: "plugin scope with owner grant",
|
||||
p: dev,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
|
||||
required: []string{"acme.demo.access"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "plugin scope without owner grant",
|
||||
p: &bouncer.Principal{
|
||||
ID: 3, Backend: true,
|
||||
PermissionGrants: map[string]bool{"acme.other.x": true},
|
||||
},
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
|
||||
required: []string{"acme.demo.access"}, want: false,
|
||||
},
|
||||
{
|
||||
name: "superuser with scoped token limited",
|
||||
p: super,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}},
|
||||
required: []string{"acme.demo.access"}, want: false,
|
||||
},
|
||||
{
|
||||
name: "superuser scoped token matching scope code",
|
||||
p: super,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}},
|
||||
required: []string{"acme.demo.reports"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "empty requirement list refused unless full",
|
||||
p: dev,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
|
||||
required: nil, want: false,
|
||||
},
|
||||
{
|
||||
name: "multi-code literal: one scope must match every code",
|
||||
p: dev,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
|
||||
required: []string{"acme.demo.access", "acme.demo.reports"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "multi-code literal: two scopes each matching part fails",
|
||||
p: dev,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{
|
||||
{"acme.demo.access"},
|
||||
{"acme.demo.reports"},
|
||||
}},
|
||||
required: []string{"acme.demo.access", "acme.demo.reports"}, want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard requirement broader pattern partial via ownerHas(pattern)",
|
||||
p: &bouncer.Principal{
|
||||
ID: 4, Backend: true,
|
||||
PermissionGrants: map[string]bool{"acme.demo.*": true},
|
||||
},
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}},
|
||||
required: []string{"acme.demo.*"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard requirement equal pattern full",
|
||||
p: dev,
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}},
|
||||
required: []string{"acme.demo.*"}, want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard requirement narrower pattern none for Allows owner side",
|
||||
p: &bouncer.Principal{
|
||||
ID: 5, Backend: true,
|
||||
PermissionGrants: map[string]bool{"acme.demo.access": true},
|
||||
},
|
||||
scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access.extra"}}},
|
||||
required: []string{"acme.demo.*"}, want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := *tc.p
|
||||
p.Scope = tc.scope
|
||||
if got := Allows(&p, tc.required); got != tc.want {
|
||||
t.Fatalf("Allows = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user