fix(09): WR-10 fail boot when another plugin already owns the backend guard

This commit is contained in:
Jakub Zych
2026-10-01 21:15:05 +02:00
parent 20a79c5df4
commit 3f476164f9
4 changed files with 50 additions and 6 deletions

View File

@@ -20,7 +20,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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 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.
- 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` (a guard another plugin already registered under that name fails `cabana.Activate`), 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.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).

View File

@@ -0,0 +1,42 @@
package cabana_test
import (
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/party"
)
// openGuard authenticates every request: the guard a plugin would have to
// register under "backend" to take over admin authentication.
type openGuard struct{}
func (openGuard) Authenticate(*http.Request) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}, nil
}
// TestActivateRefusesAForeignBackendGuard pins WR-10: a guard another plugin
// registered under "backend" fails boot; it is never silently reused for the
// admin API.
func TestActivateRefusesAForeignBackendGuard(t *testing.T) {
app := phase10App(t, "development", nil)
guards := bouncer.NewRegistry()
if err := guards.Register("evil.plugin", "backend", openGuard{}); err != nil {
t.Fatal(err)
}
if err := app.Publish(guards); err != nil {
t.Fatal(err)
}
_, err := cabana.Activate(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err == nil || !strings.Contains(err.Error(), "backend") || !strings.Contains(err.Error(), "evil.plugin") {
t.Fatalf("Activate with a foreign backend guard: err=%v, want a boot error naming the guard and its owner", err)
}
clean := phase10App(t, "development", nil)
if _, err := cabana.Activate(clean, []party.Plugin{demoPlugin{fsys: demoFS()}}); err != nil {
t.Fatalf("Activate without a conflicting guard: %v", err)
}
}

View File

@@ -113,10 +113,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
bl := adminBlacklist(app)
users := lazyBackendUsers{app: app, reg: reg}
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err
}
// The admin API is only as strong as this guard (audience, secret, user
// provider), so cabana always registers its own and never mounts the API
// behind a guard another plugin already put under the name "backend": a
// taken name fails boot instead of silently replacing admin authentication.
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, fmt.Errorf("cabana: backend guard: %w", err)
}
mw, err := guards.Middleware("backend")
if err != nil {