feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BgVbexs3.js"></script>
<script type="module" crossorigin src="./assets/index-8CEYdgqp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css">
</head>
<body>

View File

@@ -23,6 +23,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.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- 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`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `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` (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.
@@ -192,6 +193,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. |
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. |
| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. |

View File

@@ -255,8 +255,8 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
}
// runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422; any other error is logged and answered with the
// generic 500 body, never the error text.
// *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// logged and answered with the generic 500 body, never the error text.
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
@@ -267,6 +267,12 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
writeCRUDError(w, err)
return
}
// A refusal (D-27) is a 403 with the action's localized message.
var refused *ForbiddenError
if errors.As(err, &refused) {
writeCRUDError(w, localizeForbidden(ctx, tr, err))
return
}
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return

View File

@@ -371,7 +371,7 @@ func AdminList() {}
// @Param X-Session-Key header string false "Form session key: the save attaches the files uploaded under it"
// @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller} [post]
@@ -390,7 +390,7 @@ func AdminCreate() {}
// @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
@@ -412,7 +412,7 @@ func AdminBulkDelete() {}
// @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkActionResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
@@ -435,7 +435,7 @@ func AdminBulkAction() {}
// @Param body body AdminActionRequest true "Empty object"
// @Success 200 {object} Envelope[AdminActionResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
@@ -553,7 +553,7 @@ func AdminShow() {}
// @Param X-Session-Key header string false "Form session key: the save applies the file uploads and removals held against it"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
@@ -571,7 +571,7 @@ func AdminUpdate() {}
// @Param id path integer true "Record id"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]

View File

@@ -65,6 +65,72 @@ type ValidationError struct {
func (e *ValidationError) Error() string { return "validation_failed" }
// ForbiddenError is a write that controller code refuses (D-27): a lifecycle
// hook, a bulk action, a record action, a toolbar action or a widget action
// returns it, and the admin API answers 403 with code forbidden. Message is a
// phrase key or text shown to the administrator; it may be empty, and the
// admin then shows its own text. Details maps a field name to a list of
// messages (phrase keys or text) shown on that field. Both are localized in
// the request locale before the response is written. The surrounding
// transaction is rolled back, so a refused write changes nothing.
type ForbiddenError struct {
Message string
Details map[string]any
}
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
}
}
}
return out
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError that comes out of it, so a refusal leaves the
// service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
type CapabilityError struct {
ControllerID string
@@ -172,7 +238,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
return BulkResult{}, err
}
var result BulkResult
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -220,7 +286,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
return BulkResult{}, err
}
var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
@@ -285,7 +351,7 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result BulkActionResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
@@ -430,7 +496,7 @@ func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, i
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
result := AdminActionResult{Fill: map[string]any{}}
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -503,7 +569,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return RecordResult{}, err
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -608,6 +674,13 @@ func writeCRUDError(w http.ResponseWriter, err error) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
return
}
// Controller code refused the write (D-27): only the plugin-authored
// message and details are written, never another error's text.
var refused *ForbiddenError
if errors.As(err, &refused) && refused != nil {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", refused.Message, refused.Details)
return
}
var missing recordNotFound
if errors.As(err, &missing) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
@@ -700,6 +773,11 @@ func lifecycleFailure(cc *CompiledController, err error) error {
if errors.As(err, &invalid) {
return err
}
// Without this a hook's refusal would become the opaque lifecycle error.
var refused *ForbiddenError
if errors.As(err, &refused) {
return err
}
var closed *CapabilityError
if errors.As(err, &closed) {
return err

View File

@@ -140,6 +140,11 @@ func (c actController) AdminActions() []pact.AdminAction {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}}
case "boom":
return pact.AdminActionResult{}, errors.New("upstream said hunter2")
case "refused":
return pact.AdminActionResult{}, &cabana.ForbiddenError{
Message: "acme.demo::lang.gadgets.looked_up",
Details: map[string]any{"name": []string{"acme.demo::lang.gadgets.name"}},
}
case "nested":
return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil
case "encoded":

View File

@@ -653,3 +653,119 @@ func TestSoftDeletedRecordSmoke(t *testing.T) {
}
})
}
// rosterError decodes a D-10 error envelope.
func rosterError(t *testing.T, rec *httptest.ResponseRecorder) cabana.ErrorBody {
t.Helper()
var body cabana.ErrorEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error body %s: %v", rec.Body.String(), err)
}
return body.Error
}
// TestForbiddenSmoke checks cabana.ForbiddenError through the assembled
// router on PostgreSQL (D-27; T-12.1-05, T-12.1-06): a hook, a bulk action, a
// record action and a widget action that refuse a write are answered 403
// with the localized message and details and change nothing, and every other
// error stays the opaque 500.
func TestForbiddenSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
t.Run("a hook refuses an update", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Reserved","email":"new@example.test"}`, "bearer")
got := rosterError(t, rec)
if got.Code != "forbidden" || got.Message != "You may not rename this person." {
t.Fatalf("error = %+v", got)
}
if !reflect.DeepEqual(got.Details, map[string]any{"name": []any{"This name is reserved."}}) {
t.Fatalf("details = %#v", got.Details)
}
if person := rosterLoad(t, gdb, id); person.Name != "Ada" || person.Email != "ada@example.test" {
t.Fatalf("a refused update changed the row: %+v", person)
}
})
t.Run("an empty message stays empty and details stay an object", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Silent"}`, "bearer")
if !strings.Contains(rec.Body.String(), `"message":""`) || !strings.Contains(rec.Body.String(), `"details":{}`) {
t.Fatalf("body = %s", rec.Body.String())
}
if rosterLoad(t, gdb, id).Name != "Bea" {
t.Fatal("a refused update changed the row")
}
})
t.Run("a bulk action refuses and rolls every row back", func(t *testing.T) {
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/archive", fmt.Sprintf(`{"ids":[%d,%d]}`, first, locked), "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." || len(got.Details) != 0 {
t.Fatalf("error = %+v", got)
}
// The first row was already soft-deleted inside the transaction.
if rosterLoad(t, gdb, first).DeletedAt.Valid || rosterLoad(t, gdb, locked).DeletedAt.Valid {
t.Fatal("a refused bulk action changed a selected row")
}
if rosterRefused.Message != "acme.roster::lang.people.locked" {
t.Fatalf("the plugin's error value was modified: %+v", rosterRefused)
}
})
t.Run("a record action refuses and rolls its write back", func(t *testing.T) {
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, path(locked)+"/actions/reinstate", `{}`, "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." {
t.Fatalf("error = %+v", got)
}
if !rosterLoad(t, gdb, locked).Banned {
t.Fatal("a refused record action changed the row")
}
})
t.Run("the message follows the request locale", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
req := httptest.NewRequest(http.MethodPut, adminAPI(path(id)), strings.NewReader(`{"name":"Reserved"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "pl")
req.Header.Set("Authorization", "Bearer "+env.token)
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Message != "Nie możesz zmienić nazwy tej osoby." ||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Ta nazwa jest zastrzeżona."}}) {
t.Fatalf("status=%d error=%+v", rec.Code, got)
}
})
t.Run("a widget action refuses", func(t *testing.T) {
demo, _ := newActEnv(t)
rec := demo.expect(t, http.StatusForbidden, http.MethodPost, "/acme/demo/gadgets/widgets/lookup", `{"values":{"name":"refused"}}`, "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "Name and Active were filled in." ||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Name"}}) {
t.Fatalf("error = %+v", got)
}
})
t.Run("a plain hook error is the opaque 500", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Dee"})
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, path(id), `{"name":"Boom"}`, "bearer")
got := rosterError(t, rec)
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "roster database") {
t.Fatalf("500 body = %s", rec.Body.String())
}
if rosterLoad(t, gdb, id).Name != "Dee" {
t.Fatal("a failed update changed the row")
}
})
t.Run("a permission denial keeps the framework text", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Eve"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "limited")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message == "" {
t.Fatalf("error = %+v", got)
}
})
}

View File

@@ -178,6 +178,30 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"
// rosterRefused is a shared refusal value: the framework must localize a
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
// the field, and fails with a plain error for the name Boom.
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
switch model.(*rosterPerson).Name {
case "Reserved":
return &cabana.ForbiddenError{
Message: "acme.roster::lang.people.refused",
Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}},
}
case "Silent":
return &cabana.ForbiddenError{}
case "Boom":
return fmt.Errorf("the roster database said hunter2")
}
return nil
}
// FormAfterDelete removes the person for good inside the delete's
// transaction: the list keeps soft-deleted people, so deleting one there is
// permanent.
@@ -226,6 +250,11 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
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 err := tx.Delete(record).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
@@ -270,6 +299,10 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
// Refused after the write: the transaction must roll it back.
if in.Record.(*rosterPerson).Name == rosterLocked {
return pact.AdminRecordActionResult{}, rosterRefused
}
return pact.AdminRecordActionResult{}, nil
},
}}

View File

@@ -858,6 +858,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
return out
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError a relation hook returned (D-27).
func (s RelationService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// Link links eligible related records to the parent and never restamps
// existing links. On a belongsToMany it writes pivot rows (with the pivot
// form's values when the body carries a pivot object for one id); on a
@@ -877,7 +883,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -1194,7 +1200,7 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
return RelationMutationResult{}, err
}
var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {

View File

@@ -150,7 +150,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -269,7 +269,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
return RecordResult{}, recordNotFound{}
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -304,7 +304,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -361,7 +361,7 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
return BulkResult{}, err
}
var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -453,7 +453,7 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
return nil, recordNotFound{}
}
var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {
@@ -500,7 +500,7 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
return nil, recordNotFound{}
}
var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil {

View File

@@ -12,3 +12,6 @@ people:
reinstate: Reinstate
reinstate_confirm: Lift the ban on this person?
state_inactive: Not active
refused: You may not rename this person.
refused_name: This name is reserved.
locked: This person is locked and cannot be changed.

View File

@@ -12,3 +12,6 @@ people:
reinstate: Przywróć
reinstate_confirm: Zdjąć blokadę z tej osoby?
state_inactive: Nieaktywna
refused: Nie możesz zmienić nazwy tej osoby.
refused_name: Ta nazwa jest zastrzeżona.
locked: Ta osoba jest zablokowana i nie można jej zmienić.

View File

@@ -94,6 +94,7 @@ form:
action_confirm: "Run “:action” on this record?"
action_done: Action completed.
action_stale: This action no longer applies to this record. The page has been refreshed.
forbidden: You do not have permission to make this change. Nothing was saved.
relation:
add: Add
link: Link

View File

@@ -104,6 +104,7 @@ form:
action_confirm: "Wykonać „:action” na tym rekordzie?"
action_done: Akcja została wykonana.
action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona.
forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane.
relation:
add: Dodaj
link: Dołącz