fix(09): WR-01 match wildcard required permissions and treat several codes as any, like Winter

This commit is contained in:
Jakub Zych
2026-10-01 20:58:16 +02:00
parent 1a878b371e
commit b4b8b5df64
4 changed files with 84 additions and 10 deletions

View File

@@ -64,7 +64,7 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem {
}
```
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open. `cabana.Allows` is the check: superusers pass, a grant ending in `.*` matches every code with that prefix, and an empty requirement list allows any signed-in administrator. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open. `cabana.Allows` is the check and follows Winter's `hasAnyAccess`: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's.

View File

@@ -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.

View File

@@ -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 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
}
}

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