fix(09): WR-01 match wildcard required permissions and treat several codes as any, like Winter
This commit is contained in:
@@ -19,7 +19,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
||||
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
|
||||
|
||||
@@ -121,9 +121,12 @@ func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
|
||||
return setting, ok && setting != nil
|
||||
}
|
||||
|
||||
// Allows reports whether principal satisfies every required permission code.
|
||||
// A nil principal fails. Superusers pass. An empty requirement list allows
|
||||
// any authenticated principal. Grants ending in ".*" match by prefix.
|
||||
// Allows reports whether principal satisfies any of the required permission
|
||||
// codes, the way Winter's hasAnyAccess does. A nil principal fails. Superusers
|
||||
// pass. An empty requirement list allows any authenticated principal. Both
|
||||
// sides may use a wildcard: a grant ending in ".*" covers every code with that
|
||||
// prefix, and a required code ending in ".*" (or starting with "*") is met by
|
||||
// any granted code that matches it.
|
||||
func Allows(principal *bouncer.Principal, required []string) bool {
|
||||
if principal == nil {
|
||||
return false
|
||||
@@ -132,23 +135,43 @@ func Allows(principal *bouncer.Principal, required []string) bool {
|
||||
return true
|
||||
}
|
||||
for _, code := range required {
|
||||
if !granted(principal.PermissionGrants, code) {
|
||||
return false
|
||||
if granted(principal.PermissionGrants, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// granted ports Winter's User::hasPermission for one code. Only enabled
|
||||
// grants are in the map, so the "(int) $value === 1" test is already applied.
|
||||
func granted(grants map[string]bool, code string) bool {
|
||||
switch {
|
||||
case len(code) > 1 && strings.HasSuffix(code, "*"):
|
||||
prefix := strings.TrimSuffix(code, "*")
|
||||
for key, on := range grants {
|
||||
if on && key != prefix && strings.HasPrefix(key, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case len(code) > 1 && strings.HasPrefix(code, "*"):
|
||||
suffix := strings.TrimPrefix(code, "*")
|
||||
for key, on := range grants {
|
||||
if on && key != suffix && strings.HasSuffix(key, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if grants[code] {
|
||||
return true
|
||||
}
|
||||
for key, on := range grants {
|
||||
if !on || !strings.HasSuffix(key, ".*") {
|
||||
if !on || len(key) < 2 || !strings.HasSuffix(key, "*") {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSuffix(key, "*")
|
||||
if strings.HasPrefix(code, prefix) {
|
||||
if prefix != code && strings.HasPrefix(code, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
51
modules/cabana/permissions_test.go
Normal file
51
modules/cabana/permissions_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// TestAllowsFollowsWinterHasAnyAccess pins the permission check to Winter's
|
||||
// User::hasAnyAccess: wildcards match on both sides and several required codes
|
||||
// are an OR.
|
||||
func TestAllowsFollowsWinterHasAnyAccess(t *testing.T) {
|
||||
grant := func(codes ...string) *bouncer.Principal {
|
||||
grants := map[string]bool{}
|
||||
for _, code := range codes {
|
||||
grants[code] = true
|
||||
}
|
||||
return &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: grants}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
principal *bouncer.Principal
|
||||
required []string
|
||||
want bool
|
||||
}{
|
||||
{"nil principal", nil, []string{"a.b.c"}, false},
|
||||
{"superuser", &bouncer.Principal{Backend: true, IsSuperuser: true}, []string{"a.b.c"}, true},
|
||||
{"empty requirement is open", grant(), nil, true},
|
||||
{"exact grant", grant("a.b.c"), []string{"a.b.c"}, true},
|
||||
{"missing grant", grant("a.b.d"), []string{"a.b.c"}, false},
|
||||
{"grant wildcard covers code", grant("a.b.*"), []string{"a.b.c"}, true},
|
||||
{"grant wildcard other prefix", grant("a.x.*"), []string{"a.b.c"}, false},
|
||||
{"required wildcard met by any grant under the prefix", grant("a.b.access_genres"), []string{"a.b.*"}, true},
|
||||
{"required wildcard not met by a sibling plugin", grant("a.x.access_genres"), []string{"a.b.*"}, false},
|
||||
{"required wildcard with no grants", grant(), []string{"a.b.*"}, false},
|
||||
{"required wildcard met by a grant wildcard", grant("a.b.*"), []string{"a.b.*"}, true},
|
||||
{"required leading wildcard", grant("a.b.access_genres"), []string{"*.access_genres"}, true},
|
||||
{"required leading wildcard miss", grant("a.b.access_styles"), []string{"*.access_genres"}, false},
|
||||
{"several codes are any, first grants", grant("a.b.one"), []string{"a.b.one", "a.b.two"}, true},
|
||||
{"several codes are any, last grants", grant("a.b.two"), []string{"a.b.one", "a.b.two"}, true},
|
||||
{"several codes are any, none grants", grant("a.b.three"), []string{"a.b.one", "a.b.two"}, false},
|
||||
{"disabled grant is not a grant", &bouncer.Principal{PermissionGrants: map[string]bool{"a.b.c": false}}, []string{"a.b.c"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Allows(tt.principal, tt.required); got != tt.want {
|
||||
t.Fatalf("Allows(%v) = %v, want %v", tt.required, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user