feat(cabana): log successful toolbar and widget action runs with the admin id (quick-261028-rqf)
- runAction writes "cabana: admin action run" with controller, action, admin_id and kind (toolbar or field; field name and record id for a widget), never values, payload, answer or record contents - the failure and data-rejected lines carry the same keys (admin_id, kind) - actionAdminID helper shared with the bulk and record lines (unchanged) - TestAdminActionRunLog pins the line on the acme.demo fixture (Postgres) - README and the permissions docs describe the run log
This commit is contained in:
@@ -72,7 +72,7 @@ The admin registers two permissions of its own on the System tab of the permissi
|
||||
|
||||
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
|
||||
|
||||
Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk or record action that ran is logged with the controller, the action, the administrator and the affected count or record id, without record contents.
|
||||
Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk, record, toolbar or widget action that ran is logged with the controller, the action and the administrator, plus the affected count for a bulk action, the record id for a record action (and for a widget action posted from a saved record), and the kind `toolbar` or `field` for the other two, without record contents, posted values or payload.
|
||||
|
||||
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ Use cases:
|
||||
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
|
||||
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies; only a `type: repeater` field's list of rows is exempt from the nested-value drop. Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published `cabana.TranslationWriter` inside the same permissioned save transaction. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
|
||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. `ClearOnChange` on a belongsTo `cabana.FieldRelationContract` or a hasMany `cabana.RelationContract` names nullable columns (of the record, or of the related child) that are set to NULL whenever the relation changes the foreign key, so a value that only makes sense for one parent (a role in an organisation) never moves to another or outlives the membership; a form save that writes such a column through its own field keeps the submitted value. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
|
||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. Each successful run is logged with the controller, the action, the administrator, the kind `field`, the field name and the record id when one was posted, without the values, the payload, the answer or record contents; a failed run's log line carries the same fields.
|
||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. Each successful run is logged with the controller, the action, the administrator and the kind `toolbar`.
|
||||
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. A detached action (`pact.AdminBulkAction.Detached`) opens no transaction: its records are loaded through the same scope and rules without row locks, `TxFromContext` reports none, and `Run` opens its own short transactions, so no transaction or lock is held across an external call.
|
||||
- Invisible columns and filter choices: `invisible: true` in `columns.yaml` keeps a column searchable and sortable on the server while the schema flags it, the SPA does not render it and list rows do not carry it. The choices of a scope filter are served by the controller when it implements `pact.FilterOptions`, else by the model.
|
||||
- Computed columns: a controller implementing `pact.ListExtendRecords` is called once per list page, after the records are loaded and before their columns are read, so a column of a field gorm ignores (a `-` tag with a column name) shows the value the hook set. Such a column compiles unsortable; `sortable: true` or `searchable: true` on it fails the boot. A hook error fails the list with the generic 500.
|
||||
@@ -398,7 +398,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md).
|
||||
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture.
|
||||
|
||||
## Testing
|
||||
@@ -415,6 +415,8 @@ The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Do
|
||||
|
||||
`ClearOnChange` is covered by `clear_on_change_field_test.go` (TestClearOnChangeFieldBoot, Docker-free, and TestClearOnChangeBelongsTo on Postgres: a changed, emptied, kept or co-submitted belongsTo key) and `clear_on_change_test.go` (TestClearOnChangeRelationBoot and, on Postgres, TestClearOnChangeHasMany: link, unlink and a deferred link committed by the parent's first save).
|
||||
|
||||
The toolbar and widget action run log is covered by the Postgres test TestAdminActionRunLog in `action_run_log_test.go`: who ran the action, its kind, the field and record id of a widget run, no posted or returned content, and no line for refused, invalid or failed runs.
|
||||
|
||||
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
|
||||
|
||||
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).
|
||||
|
||||
119
modules/cabana/action_run_log_test.go
Normal file
119
modules/cabana/action_run_log_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminActionRunLog pins the action run log of the admin-action-audit-log
|
||||
// todo (AR-18.1-03): who ran which toolbar or widget action. A successful run
|
||||
// writes one "cabana: admin action run" line with the controller, action,
|
||||
// administrator id and kind (plus field and record id for a widget) and never
|
||||
// the posted values, payload, answer or record contents; a refused, invalid
|
||||
// or failed run writes no run line, and the failure lines carry the same
|
||||
// administrator id and kind. Runs on the acme.demo.gadgets fixture
|
||||
// (PostgreSQL).
|
||||
func TestAdminActionRunLog(t *testing.T) {
|
||||
env, gdb := newActEnv(t)
|
||||
const widget = "/acme/demo/gadgets/widgets/lookup"
|
||||
const toolbar = "/acme/demo/gadgets/toolbar/recount"
|
||||
const runPrefix = "cabana: admin action run"
|
||||
secret := actInsert(t, gdb, "Secretname", "acme")
|
||||
|
||||
me := profileOf(t, env.expect(t, http.StatusOK, http.MethodGet, "/auth/me", "", "bearer").Body.Bytes())
|
||||
if me.ID == 0 {
|
||||
t.Fatal("the signed-in administrator has id 0")
|
||||
}
|
||||
adminToken := fmt.Sprintf("admin_id=%d", me.ID)
|
||||
logs := captureLog(t)
|
||||
|
||||
// A toolbar run: kind toolbar, no field and no record id.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, toolbar, `{}`, "bearer")
|
||||
env.spy.take()
|
||||
runs := logs.matching(runPrefix)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("toolbar run lines = %q", runs)
|
||||
}
|
||||
line := runs[0]
|
||||
for _, want := range []string{"controller=acme.demo.gadgets", "action=recount", "kind=toolbar", adminToken} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("toolbar run line lacks %s: %q", want, line)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"field=", "record_id="} {
|
||||
if strings.Contains(line, banned) {
|
||||
t.Fatalf("toolbar run line carries %s: %q", banned, line)
|
||||
}
|
||||
}
|
||||
|
||||
// A widget run on a saved record: kind field, the field name and the
|
||||
// record id, but not the record, the posted value or the answer.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, widget,
|
||||
fmt.Sprintf(`{"record_id":%d,"values":{"name":"typedsecret"}}`, secret), "bearer")
|
||||
env.spy.take()
|
||||
runs = logs.matching(runPrefix)
|
||||
if len(runs) != 2 {
|
||||
t.Fatalf("widget run lines = %q", runs)
|
||||
}
|
||||
line = runs[1]
|
||||
for _, want := range []string{"controller=acme.demo.gadgets", "action=lookup", "kind=field", "field=lookup", fmt.Sprintf("record_id=%d", secret), adminToken} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("widget run line lacks %s: %q", want, line)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"Secretname", "typedsecret", "looked-up", "filled in"} {
|
||||
if strings.Contains(line, banned) {
|
||||
t.Fatalf("widget run line carries %s: %q", banned, line)
|
||||
}
|
||||
}
|
||||
|
||||
// A widget run on the create form: no record id and no payload.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, widget, `{"payload":{"note":"payloadsecret"}}`, "bearer")
|
||||
env.spy.take()
|
||||
runs = logs.matching(runPrefix)
|
||||
if len(runs) != 3 {
|
||||
t.Fatalf("create-form run lines = %q", runs)
|
||||
}
|
||||
line = runs[2]
|
||||
for _, want := range []string{"kind=field", "field=lookup", adminToken} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("create-form run line lacks %s: %q", want, line)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"record_id=", "payloadsecret"} {
|
||||
if strings.Contains(line, banned) {
|
||||
t.Fatalf("create-form run line carries %s: %q", banned, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused, invalid and failed runs write no run line.
|
||||
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, widget, `{"values":{"name":"invalid"}}`, "bearer")
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, widget, `{"values":{"name":"refused"}}`, "bearer")
|
||||
env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"values":{"name":"boom"}}`, "bearer")
|
||||
env.expect(t, http.StatusInternalServerError, http.MethodPost, widget, `{"payload":"big"}`, "bearer")
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, toolbar, `{}`, "limited")
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, widget, `{}`, "limited")
|
||||
env.spy.take()
|
||||
if runs = logs.matching(runPrefix); len(runs) != 3 {
|
||||
t.Fatalf("a refused, invalid or failed run was logged as run: %q", runs)
|
||||
}
|
||||
|
||||
// The failure lines name the administrator and the kind.
|
||||
failed := logs.matching("cabana: admin action failed")
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("failure lines = %q", failed)
|
||||
}
|
||||
rejected := logs.matching("cabana: admin action data rejected")
|
||||
if len(rejected) != 1 {
|
||||
t.Fatalf("data rejected lines = %q", rejected)
|
||||
}
|
||||
for _, line := range []string{failed[0], rejected[0]} {
|
||||
for _, want := range []string{"action=lookup", "kind=field", "field=lookup", adminToken} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("failure line lacks %s: %q", want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -26,6 +27,21 @@ const maxActionPayloadBytes = 64 << 10
|
||||
// value is logged and answered with the opaque 500 body.
|
||||
const maxActionDataBytes = 256 << 10
|
||||
|
||||
// The kind key of the action run log: a toolbar action or a form field's
|
||||
// widget action.
|
||||
const (
|
||||
actionKindToolbar = "toolbar"
|
||||
actionKindField = "field"
|
||||
)
|
||||
|
||||
// actionAdminID returns the signed-in administrator's id, or 0 without one.
|
||||
func actionAdminID(ctx context.Context) uint {
|
||||
if principal, _ := bouncer.User(ctx); principal != nil {
|
||||
return principal.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the
|
||||
// SPA posts on behalf of a `type: widget` field, cabana checks the controller
|
||||
// and action permissions, loads record_id through the controller's form scope
|
||||
@@ -93,7 +109,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
||||
input.RecordID = &id
|
||||
input.Record = record
|
||||
}
|
||||
s.runAction(w, r, cc, action, input, field.Fill)
|
||||
s.runAction(w, r, cc, action, input, field.Fill, actionKindField)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +136,7 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id, values or payload."}}})
|
||||
return
|
||||
}
|
||||
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil)
|
||||
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil, actionKindToolbar)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,11 +190,7 @@ func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
var adminID uint
|
||||
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||
adminID = principal.ID
|
||||
}
|
||||
moduleLog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected)
|
||||
moduleLog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", actionAdminID(r.Context()), "affected", result.Affected)
|
||||
WriteData(w, http.StatusOK, result, nil)
|
||||
})
|
||||
}
|
||||
@@ -245,11 +257,7 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
var adminID uint
|
||||
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||
adminID = principal.ID
|
||||
}
|
||||
moduleLog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id)
|
||||
moduleLog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", actionAdminID(r.Context()), "record_id", id)
|
||||
WriteData(w, http.StatusOK, result, nil)
|
||||
})
|
||||
}
|
||||
@@ -330,8 +338,19 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
|
||||
// logged and answered with the generic 500 body, never the error text. The
|
||||
// result's Fill passes the fill filter; its Data bypasses that filter but not
|
||||
// the size cap: it is encoded once and embedded as-is, and a value above
|
||||
// maxActionDataBytes or one that cannot be encoded is an opaque 500.
|
||||
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
|
||||
// maxActionDataBytes or one that cannot be encoded is an opaque 500. A
|
||||
// successful run is logged with the controller, action, administrator id and
|
||||
// kind (field name and record id for a widget), never the values, payload,
|
||||
// answer or record contents; the failure lines carry the same keys.
|
||||
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string, kind string) {
|
||||
logArgs := []any{"controller", controllerID(cc), "action", action.Name, "admin_id", actionAdminID(r.Context()), "kind", kind}
|
||||
if kind == actionKindField {
|
||||
logArgs = append(logArgs, "field", input.Field)
|
||||
}
|
||||
if input.RecordID != nil {
|
||||
logArgs = append(logArgs, "record_id", *input.RecordID)
|
||||
}
|
||||
logArgs = slices.Clip(logArgs)
|
||||
tr := s.translator()
|
||||
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
|
||||
result, err := action.Run(ctx, input)
|
||||
@@ -344,7 +363,7 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
|
||||
writeCRUDError(w, localizeControllerError(ctx, tr, err))
|
||||
return
|
||||
}
|
||||
moduleLog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
|
||||
moduleLog.Error("cabana: admin action failed", append(logArgs, "error", err)...)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
@@ -355,12 +374,13 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
|
||||
if result.Data != nil {
|
||||
raw, err := json.Marshal(result.Data)
|
||||
if err != nil || len(raw) > maxActionDataBytes {
|
||||
moduleLog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err)
|
||||
moduleLog.Error("cabana: admin action data rejected", append(logArgs, "bytes", len(raw), "error", err)...)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
out.Data = json.RawMessage(raw)
|
||||
}
|
||||
moduleLog.Info("cabana: admin action run", logArgs...)
|
||||
WriteData(w, http.StatusOK, out, nil)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user