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>
355 lines
14 KiB
Go
355 lines
14 KiB
Go
package cabana
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
)
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type scopePlugin struct {
|
|
id string
|
|
perms []pact.Permission
|
|
scopes []pact.TokenScope
|
|
}
|
|
|
|
func (p scopePlugin) ID() string { return p.id }
|
|
func (p scopePlugin) Requires() []string { return nil }
|
|
func (p scopePlugin) Register(*backpack.App) error { return nil }
|
|
func (p scopePlugin) Boot(*backpack.App) error { return nil }
|
|
func (p scopePlugin) Permissions() []pact.Permission {
|
|
if p.perms != nil {
|
|
return p.perms
|
|
}
|
|
return []pact.Permission{
|
|
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
|
{Code: "acme.demo.reports", Roles: []string{"developer"}},
|
|
}
|
|
}
|
|
func (p scopePlugin) TokenScopes() []pact.TokenScope { return p.scopes }
|
|
|
|
func TestTokenScopeCompile(t *testing.T) {
|
|
basePerms := []pact.Permission{
|
|
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
|
{Code: "acme.demo.reports", Roles: []string{"developer"}},
|
|
}
|
|
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
|
cases := []struct {
|
|
name string
|
|
scopes []pact.TokenScope
|
|
plugins []party.Plugin
|
|
want string
|
|
}{
|
|
{"bad name", []pact.TokenScope{{Name: "Bad", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
|
|
// "*" fails the name pattern before the reserved check.
|
|
{"reserved *", []pact.TokenScope{{Name: "*", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
|
|
{"reserved admin", []pact.TokenScope{{Name: "admin", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
|
|
{"reserved admin:x", []pact.TokenScope{{Name: "admin:x", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
|
|
{"empty permissions", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: nil, Methods: []string{"GET"}}}, nil, "empty permissions"},
|
|
{"empty methods", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: nil}}, nil, "empty methods"},
|
|
{"lower-case method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"get"}}}, nil, "invalid method"},
|
|
{"unknown method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "TRACE"}}}, nil, "invalid method"},
|
|
{"missing GET", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"POST"}}}, nil, "must include GET"},
|
|
{"unknown permission", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.missing.x"}, Methods: []string{"GET"}}}, nil, "unknown permission"},
|
|
{"empty label", []pact.TokenScope{{Name: "demo", Label: "", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty label"},
|
|
{"empty description", []pact.TokenScope{{Name: "demo", Label: "L", Description: " ", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty description"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
plugins := tc.plugins
|
|
if plugins == nil {
|
|
plugins = []party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: tc.scopes}}
|
|
}
|
|
_, err := compileTokenCatalog(plugins)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("err = %v, want substring %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
t.Run("duplicate naming both plugins", func(t *testing.T) {
|
|
a := scopePlugin{id: "acme.a", perms: []pact.Permission{{Code: "acme.a.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
|
|
Name: "demo", Label: "Demo", Description: "D",
|
|
Permissions: []string{"acme.a.access"}, Methods: all,
|
|
}}}
|
|
b := scopePlugin{id: "acme.b", perms: []pact.Permission{{Code: "acme.b.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
|
|
Name: "demo", Label: "Demo", Description: "D",
|
|
Permissions: []string{"acme.b.access"}, Methods: all,
|
|
}}}
|
|
_, err := compileTokenCatalog([]party.Plugin{a, b})
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
|
|
t.Fatalf("duplicate must name both plugins: %v", err)
|
|
}
|
|
})
|
|
t.Run("valid order framework then plugins", func(t *testing.T) {
|
|
first := scopePlugin{id: "acme.first", perms: basePerms, scopes: []pact.TokenScope{{
|
|
Name: "first", Label: "First", Description: "D",
|
|
Permissions: []string{"acme.demo.access"}, Methods: all,
|
|
}}}
|
|
second := scopePlugin{id: "acme.second", perms: []pact.Permission{
|
|
{Code: "acme.other.x", Roles: []string{"developer"}},
|
|
}, scopes: []pact.TokenScope{{
|
|
Name: "second", Label: "Second", Description: "D",
|
|
Permissions: []string{"acme.other.x"}, Methods: []string{"GET", "HEAD"},
|
|
}}}
|
|
reg, err := compileTokenCatalog([]party.Plugin{first, second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := make([]string, 0, len(reg.tokenScopes.ordered))
|
|
for _, s := range reg.tokenScopes.ordered {
|
|
names = append(names, s.Name)
|
|
}
|
|
want := []string{"*", "admin:read", "first", "second"}
|
|
if !stringSlicesEqual(names, want) {
|
|
t.Fatalf("order = %v, want %v", names, want)
|
|
}
|
|
})
|
|
t.Run("unresolved phrase key", func(t *testing.T) {
|
|
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: []pact.TokenScope{{
|
|
Name: "demo", Label: "acme.demo::lang.missing", Description: "plain",
|
|
Permissions: []string{"acme.demo.access"}, Methods: all,
|
|
}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := phrasebook.NewTranslator(phrasebook.NewCatalog(), phrasebook.Options{Locale: "en", Fallback: "en"})
|
|
if err := validateTokenScopeMessageKeys(reg, tr); err == nil || !strings.Contains(err.Error(), "missing phrase key") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestScopeOverlap(t *testing.T) {
|
|
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
|
|
Name: "demo", Label: "Demo", Description: "D",
|
|
Permissions: []string{"acme.demo.*"}, Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
|
|
}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
|
none := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
|
|
super := &bouncer.Principal{ID: 3, Backend: true, IsSuperuser: true}
|
|
if !scopeOverlaps(reg, dev, "*") {
|
|
t.Fatal("* always overlaps")
|
|
}
|
|
if !scopeOverlaps(reg, none, "*") {
|
|
t.Fatal("* always overlaps even without grants")
|
|
}
|
|
if !scopeOverlaps(reg, super, "admin:read") || !scopeOverlaps(reg, dev, "admin:read") {
|
|
t.Fatal("admin:read for superuser or any catalog grant")
|
|
}
|
|
if scopeOverlaps(reg, &bouncer.Principal{ID: 4, Backend: true}, "admin:read") {
|
|
t.Fatal("admin:read without grants or superuser")
|
|
}
|
|
if !scopeOverlaps(reg, dev, "demo") {
|
|
t.Fatal("demo should overlap for developer")
|
|
}
|
|
if scopeOverlaps(reg, none, "demo") {
|
|
t.Fatal("demo must not overlap without matching grant")
|
|
}
|
|
}
|
|
|
|
func TestScopeCoversAll(t *testing.T) {
|
|
full := &bouncer.ScopeLimit{Full: true}
|
|
if !scopeCoversAll(full, []string{"a.b"}) || !scopeCoversAll(nil, []string{"a.b"}) {
|
|
t.Fatal("full/nil cover everything")
|
|
}
|
|
limit := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}
|
|
if !scopeCoversAll(limit, []string{"acme.demo.access", "acme.demo.reports"}) {
|
|
t.Fatal("full match cover")
|
|
}
|
|
if scopeCoversAll(limit, []string{"acme.other.x"}) {
|
|
t.Fatal("none")
|
|
}
|
|
partial := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}}
|
|
if !scopeCoversAll(partial, []string{"acme.demo.*"}) {
|
|
t.Fatal("partial cover of broader required")
|
|
}
|
|
if scopeCoversAll(limit, nil) {
|
|
t.Fatal("empty cover refused for non-full")
|
|
}
|
|
}
|
|
|
|
func stringSlicesEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|