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:
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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ć.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user