test(12.1-05): threat test for the Phase 12.1 framework contracts and the first gate stages

- TestPhase121Threats: one subtest per mitigated threat T-12.1-01 to T-12.1-15
- roster fixture: sentinel names and knobs for failing hooks and providers
- scripts/check-phase12.1.sh: fail-closed go test detector, --self-test and --security
This commit is contained in:
Jakub Zych
2026-10-05 14:30:31 +02:00
parent 52f864ebfc
commit c076b4c059
3 changed files with 827 additions and 11 deletions

View File

@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
@@ -190,11 +191,50 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
return out
}
// rosterKnobs switch on failures of the controller's providers, which get no
// record to carry a sentinel name. A nil pointer switches nothing on.
type rosterKnobs struct {
// permissionOptions makes AdminPermissionOptions fail.
permissionOptions atomic.Bool
// permissionValues makes AdminPermissionValues fail.
permissionValues atomic.Bool
// relationLocks makes AdminRelationLocks fail.
relationLocks atomic.Bool
// slowArchive, when set, runs inside the archive bulk action after the
// rows were locked (concurrency tests).
slowArchive atomic.Pointer[func()]
}
// The sentinel names below make one hook of the roster controller misbehave
// for the person who carries the name.
const (
// rosterKeep: FormBeforeDelete refuses with a ForbiddenError.
rosterKeep = "Keep"
// rosterKeepAfter: FormAfterUpdate and FormAfterDelete refuse after the
// row was written or removed.
rosterKeepAfter = "KeepAfter"
// rosterShort: ListRowStates answers one entry too few.
rosterShort = "Short"
// rosterStateErr: ListRowStates fails with a plain error.
rosterStateErr = "StateErr"
// rosterAppliesErr: the activate record action's Applies fails.
rosterAppliesErr = "AppliesErr"
// rosterCrash: the archive bulk action fails with a plain error.
rosterCrash = "Crash"
// rosterRunErr: the reinstate record action fails with a plain error
// after its write.
rosterRunErr = "RunErr"
// rosterDenyCreate and rosterDenyAfterCreate: the create hooks refuse.
rosterDenyCreate = "DenyCreate"
rosterDenyAfterCreate = "DenyAfterCreate"
)
// rosterPlugin is the acme.roster fixture plugin. fsys, when set, replaces
// the fixture tree (boot-error tests).
type rosterPlugin struct {
spy *rosterSpy
fsys fs.FS
spy *rosterSpy
knobs *rosterKnobs
fsys fs.FS
// db is the handle the controller reads filter choices and locked tags
// with outside a transaction.
db *gorm.DB
@@ -208,7 +248,7 @@ func (rosterPlugin) Requires() []string { return nil }
func (rosterPlugin) Register(*backpack.App) error { return nil }
func (rosterPlugin) Boot(*backpack.App) error { return nil }
func (p rosterPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rosterController{spy: p.spy, db: p.db, relations: p.relations}}
return []pact.AdminController{rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations}}
}
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
@@ -235,6 +275,7 @@ func (rosterPlugin) LangFS() fs.FS {
type rosterController struct {
spy *rosterSpy
knobs *rosterKnobs
db *gorm.DB
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
@@ -275,6 +316,9 @@ func (c rosterController) handle(ctx context.Context) *gorm.DB {
// AdminRelationLocks locks the staff tag for an administrator without
// acme.roster.manage.
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if c.knobs != nil && c.knobs.relationLocks.Load() {
return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2")
}
principal, _ := bouncer.User(ctx)
if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
@@ -339,8 +383,13 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
if person.DeletedAt.Valid {
out[i] = append(out[i], pact.RowStateDeleted)
}
if person.Name == "Odd" {
switch person.Name {
case "Odd":
out[i] = append(out[i], pact.RowState("starred"))
case rosterShort:
return out[:len(out)-1], nil
case rosterStateErr:
return nil, fmt.Errorf("the state table said hunter2")
}
}
return out, nil
@@ -387,7 +436,10 @@ var rosterPermissionCodes = []cabana.PermissionOption{
// AdminPermissionOptions serves the permission editor's options per
// administrator.
func (rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
func (c rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if c.knobs != nil && c.knobs.permissionOptions.Load() {
return nil, fmt.Errorf("the permission table said hunter2")
}
if field != "permissions" {
return nil, fmt.Errorf("unknown permission field %s", field)
}
@@ -402,7 +454,10 @@ func (rosterController) AdminPermissionOptions(ctx context.Context, field string
}
// AdminPermissionValues reads the stored JSON object.
func (rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
func (c rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
if c.knobs != nil && c.knobs.permissionValues.Load() {
return nil, fmt.Errorf("the permission column said hunter2")
}
person := record.(*rosterPerson)
out := map[string]int{}
if person.Permissions == nil || *person.Permissions == "" {
@@ -466,11 +521,33 @@ func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error
model.(*rosterPerson).Tenant = "acme"
storePassword(model.(*rosterPerson), values)
delete(values, "notify")
if model.(*rosterPerson).Name == rosterDenyCreate {
return rosterRefused
}
return nil
}
func (c rosterController) FormAfterCreate(ctx context.Context, _ any) error {
func (c rosterController) FormAfterCreate(ctx context.Context, model any) error {
c.spy.recordVirtual("after-create", ctx)
if model.(*rosterPerson).Name == rosterDenyAfterCreate {
return rosterRefused
}
return nil
}
// FormAfterUpdate refuses the name KeepAfter after the row was written.
func (rosterController) FormAfterUpdate(_ context.Context, model any) error {
if model.(*rosterPerson).Name == rosterKeepAfter {
return rosterRefused
}
return nil
}
// FormBeforeDelete refuses the person named Keep.
func (rosterController) FormBeforeDelete(_ context.Context, model any) error {
if model.(*rosterPerson).Name == rosterKeep {
return rosterRefused
}
return nil
}
@@ -501,7 +578,14 @@ func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
if !ok {
return fmt.Errorf("no transaction on the context")
}
return tx.Unscoped().Delete(model).Error
if err := tx.Unscoped().Delete(model).Error; err != nil {
return err
}
// Refused after the row was removed: the transaction must bring it back.
if model.(*rosterPerson).Name == rosterKeepAfter {
return rosterRefused
}
return nil
}
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
@@ -540,12 +624,20 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
if !ok {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
if c.knobs != nil {
if wait := c.knobs.slowArchive.Load(); wait != nil {
(*wait)()
}
}
for _, record := range in.Records {
// A refusal after earlier rows were written: the whole
// selection must roll back.
if record.(*rosterPerson).Name == rosterLocked {
return pact.AdminBulkActionResult{}, rosterRefused
}
if record.(*rosterPerson).Name == rosterCrash {
return pact.AdminBulkActionResult{}, fmt.Errorf("the archive said hunter2")
}
if err := tx.Delete(record).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
@@ -563,6 +655,9 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
Name: "activate", Label: "acme.roster::lang.people.activate",
Permissions: []string{"acme.roster.manage"},
Applies: func(_ context.Context, record any) (bool, error) {
if record.(*rosterPerson).Name == rosterAppliesErr {
return false, fmt.Errorf("the applies check said hunter2")
}
return !record.(*rosterPerson).Active, nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
@@ -594,6 +689,9 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if in.Record.(*rosterPerson).Name == rosterLocked {
return pact.AdminRecordActionResult{}, rosterRefused
}
if in.Record.(*rosterPerson).Name == rosterRunErr {
return pact.AdminRecordActionResult{}, fmt.Errorf("the reinstate said hunter2")
}
return pact.AdminRecordActionResult{}, nil
},
}}
@@ -604,7 +702,8 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
// token), limited (acme.roster.access only), cookie and cookie-only.
type rosterEnv struct {
*actEnv
spy *rosterSpy
spy *rosterSpy
knobs *rosterKnobs
}
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
@@ -656,7 +755,8 @@ func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv,
t.Fatal(err)
}
spy := &rosterSpy{}
plugin := rosterPlugin{spy: spy, db: gdb}
knobs := &rosterKnobs{}
plugin := rosterPlugin{spy: spy, knobs: knobs, db: gdb}
if configure != nil {
configure(&plugin)
}
@@ -668,7 +768,7 @@ func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv,
if err != nil {
t.Fatal(err)
}
env := &rosterEnv{actEnv: &actEnv{h: h}, spy: spy}
env := &rosterEnv{actEnv: &actEnv{h: h}, spy: spy, knobs: knobs}
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())