fix(19.6.1-04): refuse code-declared role capture and wrap Administrators Runs

- Roles create and update refuse a submitted code that code-declared grants attach to
- compileActionExtensions wraps contributed Administrators Runs with guardOutranked
- Prove both holes on Postgres; keep the boot smoke callable with a superuser principal

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-22 13:29:38 +02:00
parent 422bd1e01f
commit 068adaabf9
5 changed files with 143 additions and 3 deletions

View File

@@ -1,9 +1,11 @@
package cabana
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
@@ -48,6 +50,9 @@ func compileActionExtensions(reg *Registry, plugins []party.Plugin) error {
if err := checkContributedAction(cc, pluginID, ext.Controller, action); err != nil {
return err
}
if ext.Controller == AdministratorsControllerID {
action.Run = wrapAdministratorsContributedRun(reg, action.Run)
}
// A new slice and map: the compiled backing array may be
// shared with another schema value.
names := make([]string, 0, len(cc.Form.recordActions)+1)
@@ -95,3 +100,21 @@ func checkContributedAction(cc *CompiledController, pluginID, controller string,
}
return nil
}
// wrapAdministratorsContributedRun runs guardOutranked on the locked
// Administrators record before the contributor's Run (D-19). Contributions
// to any other controller are stored unchanged. The wrap is not exported:
// cabana owns the privilege check.
func wrapAdministratorsContributedRun(reg *Registry, next func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error)) func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
principal, _ := bouncer.User(ctx)
user, ok := in.Record.(*BackendUser)
if !ok || user == nil {
return pact.AdminRecordActionResult{}, fmt.Errorf("cabana: contributed Administrators action: record is not a backend user")
}
if err := guardOutranked(ctx, reg, principal, user); err != nil {
return pact.AdminRecordActionResult{}, err
}
return next(ctx, in)
}
}

View File

@@ -61,3 +61,31 @@ func TestActionExtensionRun(t *testing.T) {
hidden := env.seedAdmin(t, adminSeed{Name: "ext-hidden", Superuser: true})
env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(hidden.ID, "actions", "acme_ping"), bothToken, map[string]any{}, "")
}
// TestActionExtensionOutranked (WR-03, D-19): a contributed record action
// on an outranking Administrators target is 403 user.outranked and Run is
// not called. Built-in deactivate stays 403 on that target. A covered
// target still runs.
func TestActionExtensionOutranked(t *testing.T) {
env := newScreensEnv(t)
delegate := env.seedAdmin(t, adminSeed{Name: "extout-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
delegateToken := env.login(t, delegate.Login)
target := env.seedAdmin(t, adminSeed{Name: "extout-target", RolePermissions: map[string]int{"acme.demo.access": 1}})
ping := userURL(target.ID, "actions", "acme_ping")
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, ping, delegateToken, map[string]any{}, enOutranked)
if runs, _ := env.spy.runs(); len(runs) != 0 {
t.Fatalf("outranked acme_ping ran: %v", runs)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, userURL(target.ID, "actions", "deactivate"), delegateToken, map[string]any{}, enOutranked)
covered := env.seedAdmin(t, adminSeed{Name: "extout-covered"})
result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "acme_ping"), delegateToken, map[string]any{}))
if result["message"] != "Pinged" {
t.Fatalf("covered result = %v", result)
}
runs, withTx := env.spy.runs()
if len(runs) != 1 || runs[0] != uint64(covered.ID) || !withTx[0] {
t.Fatalf("runs = %v tx = %v, want one run on %d inside the transaction", runs, withTx, covered.ID)
}
}

View File

@@ -7,6 +7,7 @@ import (
"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"
)
@@ -148,7 +149,8 @@ func TestActionExtensionBoot(t *testing.T) {
if !ok {
t.Fatal("acme_spy not resolvable")
}
if _, err := resolved.Run(context.Background(), pact.AdminRecordActionInput{RecordID: 7, Record: &BackendUser{ID: 7}}); err != nil || got.RecordID != 7 {
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true})
if _, err := resolved.Run(ctx, pact.AdminRecordActionInput{RecordID: 7, Record: &BackendUser{ID: 7}}); err != nil || got.RecordID != 7 {
t.Fatalf("run = %+v, %v", got, err)
}
})

View File

@@ -155,13 +155,31 @@ func (rolesAdmin) AdminSetPermissionValues(_ context.Context, field string, reco
return nil
}
// FormBeforeCreate refuses a code another role uses. A role created here is
// never a system role: is_system is not fillable.
// refuseCodeDeclaredCode refuses a submitted code that code-declared grants
// (pact.Permission.Roles) attach to when it is not the stored code (empty
// on create). A role never becomes a code-declared system role through this
// screen, including when the editor is a superuser (D-16).
func (c rolesAdmin) refuseCodeDeclaredCode(submitted, stored string) error {
if submitted == stored {
return nil
}
if len(c.registry().rolePermissions(submitted)) > 0 {
return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_locked"}}}
}
return nil
}
// FormBeforeCreate refuses a code another role uses and a code that
// code-declared grants attach to. A role created here is never a system
// role: is_system is not fillable, and a code-declared code is refused.
func (c rolesAdmin) FormBeforeCreate(ctx context.Context, model any) error {
role, err := asRole(model)
if err != nil {
return err
}
if err := c.refuseCodeDeclaredCode(role.Code, ""); err != nil {
return err
}
return c.checkCode(ctx, role)
}
@@ -184,6 +202,9 @@ func (c rolesAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
if c.systemRole(stored.IsSystem, stored.Code) && role.Code != stored.Code {
return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_locked"}}}
}
if err := c.refuseCodeDeclaredCode(role.Code, stored.Code); err != nil {
return err
}
return c.checkCode(ctx, role)
}

View File

@@ -261,6 +261,72 @@ func TestRolesCodeDeclaredLocks(t *testing.T) {
}
}
// TestRolesCodeDeclaredCodeRefused (CR-01, SC-2, D-07, D-16): a role never
// becomes a code-declared system role through the Roles screen. Create and
// rename onto a code that pact.Permission.Roles attaches grants to answer
// 422 code_locked, including when the editor is a superuser. A unique
// non-declared code still creates and a same-code save of a system role
// still succeeds.
func TestRolesCodeDeclaredCodeRefused(t *testing.T) {
declared := fmt.Sprintf("plugcap-%d", time.Now().UnixNano())
env := newScreensEnv(t, grantPlugin{id: "acme.capturer", grants: []pact.Permission{{Code: "acme.capturer.secret", Roles: []string{declared}}}})
editor := env.seedAdmin(t, adminSeed{Name: "codecap-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1}})
editorToken := env.login(t, editor.Login)
mutable := env.seedRole(t, roleSeed{Name: "codecap-mutable"})
before := env.roleRow(t, mutable.ID)
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(mutable.ID), editorToken,
roleBody(mutable, map[string]any{"code": declared}), "code", enCodeLocked, false)
if env.roleRow(t, mutable.ID) != before {
t.Fatal("a refused rename onto a code-declared code touched the role")
}
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, editorToken,
map[string]any{"name": "Capture " + env.stamp, "code": declared}, "code", enCodeLocked, false)
if env.roleCountByCode(t, declared) != 0 {
t.Fatal("a refused create inserted a role with the code-declared code")
}
super := env.seedAdmin(t, adminSeed{Name: "codecap-super", Superuser: true})
superToken := env.login(t, super.Login)
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(mutable.ID), superToken,
roleBody(mutable, map[string]any{"code": declared}), "code", enCodeLocked, false)
if env.roleRow(t, mutable.ID) != before {
t.Fatal("a superuser rename onto a code-declared code touched the role")
}
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, superToken,
map[string]any{"name": "Super capture " + env.stamp, "code": declared}, "code", enCodeLocked, false)
if env.roleCountByCode(t, declared) != 0 {
t.Fatal("a superuser create inserted a role with the code-declared code")
}
plain := "plain-" + env.stamp
created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, editorToken,
map[string]any{"name": "Plain " + env.stamp, "code": plain, "description": "Not declared"}))
createdID := uint(created["id"].(float64))
t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) })
var developer cabana.BackendUserRole
if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil {
t.Fatal(err)
}
env.expect(t, http.StatusOK, http.MethodPut, roleURL(developer.ID), superToken,
roleBody(developer, map[string]any{"description": "Same code " + env.stamp}))
t.Cleanup(func() {
env.gdb.Exec(`UPDATE backend_user_roles SET description = ? WHERE id = ?`, developer.Description, developer.ID)
})
}
// roleCountByCode counts backend_user_roles rows with code.
func (e *screensEnv) roleCountByCode(t *testing.T, code string) int64 {
t.Helper()
var n int64
if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE code = ?`, code).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// reloadRoleByCode reads the role with code.
func (e *screensEnv) reloadRoleByCode(t *testing.T, code string) cabana.BackendUserRole {
t.Helper()