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:
2026-10-11 13:17:41 +02:00
parent c385dc79eb
commit 91830e506f
4 changed files with 839 additions and 0 deletions

View 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)
}
}

View 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())
}
}

View 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)
}
})
}
}