From df5cace8525bc10fa40b25552137290793c08bb3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 10:58:38 +0200 Subject: [PATCH] feat(12.1-02): writable foreign keys, locked relation options, invisible columns - FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated --- admin/openapi/admin.json | 9 +- admin/src/api/schema.d.ts | 9 +- .../components/form/fields/RelationField.vue | 131 +++++++-- admin/src/components/list/DataTable.vue | 12 +- admin/tests/fixtures/roster.form-schema.json | 2 + admin/tests/fixtures/roster.list-schema.json | 3 +- admin/tests/fixtures/roster.list.json | 6 +- admin/tests/fixtures/roster.record.json | 4 +- admin/tests/smoke/preview.smoke.test.ts | 2 +- admin/tests/smoke/seams.smoke.test.ts | 175 ++++++++++- docs/backend/admin-controllers.md | 1 + docs/backend/lists-and-filters.md | 6 +- docs/backend/relation-manager.md | 57 ++++ .../boardwalk/dist/assets/index-CXbMmgdh.css | 1 - .../boardwalk/dist/assets/index-CbohsCp4.js | 9 + .../boardwalk/dist/assets/index-CqzF_Nki.css | 1 + .../boardwalk/dist/assets/index-CvMS0tdW.js | 9 - modules/boardwalk/dist/index.html | 4 +- modules/cabana/README.md | 7 +- modules/cabana/admin_openapi.go | 2 +- modules/cabana/crud.go | 5 + modules/cabana/example_form_seams_test.go | 79 ++++- modules/cabana/filter_schema.go | 24 +- modules/cabana/http.go | 4 + modules/cabana/list_schema.go | 2 + modules/cabana/phase121_fixture_test.go | 143 ++++++++- modules/cabana/phase121_form_test.go | 275 ++++++++++++++++++ modules/cabana/relation_field.go | 172 ++++++++++- modules/cabana/schema_types.go | 4 + .../controllers/people/config_filter.yaml | 6 + .../controllers/people/config_list.yaml | 1 + .../cabana/testdata/roster/lang/en/lang.yaml | 5 + .../cabana/testdata/roster/lang/pl/lang.yaml | 5 + .../roster/models/person/columns.yaml | 3 + .../testdata/roster/models/person/fields.yaml | 9 + modules/pact/README.md | 1 + modules/pact/capabilities.go | 7 +- modules/phrasebook/backend/lang/en/lang.yaml | 2 + modules/phrasebook/backend/lang/pl/lang.yaml | 2 + 39 files changed, 1115 insertions(+), 84 deletions(-) delete mode 100644 modules/boardwalk/dist/assets/index-CXbMmgdh.css create mode 100644 modules/boardwalk/dist/assets/index-CbohsCp4.js create mode 100644 modules/boardwalk/dist/assets/index-CqzF_Nki.css delete mode 100644 modules/boardwalk/dist/assets/index-CvMS0tdW.js create mode 100644 modules/cabana/testdata/roster/controllers/people/config_filter.yaml diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 5a4e71a..021911b 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1040,6 +1040,10 @@ }, "cabana.ListColumn": { "properties": { + "invisible": { + "description": "Invisible marks a column that is searched and sorted on the server but\nnot shown: the admin renders no header or cell for it and list rows do\nnot carry its value (columns.yaml invisible).", + "type": "boolean" + }, "key": { "type": "string" }, @@ -1701,6 +1705,9 @@ "label": { "type": "string" }, + "locked": { + "type": "boolean" + }, "value": { "type": "integer" } @@ -3345,7 +3352,7 @@ }, "/{vendor}/{plugin}/{controller}/schema/form": { "get": { - "description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).", + "description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.", "parameters": [ { "description": "Vendor", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index af3336f..c26846f 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1216,7 +1216,7 @@ export interface paths { }; /** * Admin form schema - * @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). + * @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. */ get: { parameters: { @@ -4540,6 +4540,12 @@ export interface components { [key: string]: components["schemas"]["cabana.MessageForms"]; }; "cabana.ListColumn": { + /** + * @description Invisible marks a column that is searched and sorted on the server but + * not shown: the admin renders no header or cell for it and list rows do + * not carry its value (columns.yaml invisible). + */ + invisible?: boolean; key: string; label: string; relation?: string; @@ -4723,6 +4729,7 @@ export interface components { }; "cabana.RelationOption": { label: string; + locked?: boolean; value: number; }; "cabana.RelationPanel": { diff --git a/admin/src/components/form/fields/RelationField.vue b/admin/src/components/form/fields/RelationField.vue index 9be7bf6..00efab0 100644 --- a/admin/src/components/form/fields/RelationField.vue +++ b/admin/src/components/form/fields/RelationField.vue @@ -1,6 +1,6 @@ - + +
diff --git a/modules/cabana/README.md b/modules/cabana/README.md index c2c61b1..da3f83c 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -13,11 +13,12 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages. - 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. 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 `$///...` 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. +- 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 `$///...` 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. 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. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - 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. - 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. +- 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. - Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`. - Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id. - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: ` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot. @@ -200,7 +201,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | | `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. | | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | -| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | +| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. | +| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. | +| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. | | `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. | | `cabana.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 3c1d616..1305c10 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -262,7 +262,7 @@ func AdminListSchema() {} // AdminFormSchema documents the form schema route. // // @Summary Admin form schema -// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). +// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. // @Tags admin // @Produce json // @Security BackendBearer diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 6179455..b891035 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -651,6 +651,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err := checkRelationScope(ctx, tx, cc, relations); err != nil { return err } + // D-07: a change to related ids the controller locks for this + // administrator is refused before any row is written. + if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil { + return err + } if err := assignBelongsTo(cc, target, relations); err != nil { return err } diff --git a/modules/cabana/example_form_seams_test.go b/modules/cabana/example_form_seams_test.go index f84b9e9..fc56c08 100644 --- a/modules/cabana/example_form_seams_test.go +++ b/modules/cabana/example_form_seams_test.go @@ -10,6 +10,7 @@ import ( "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" ) // Member is the model behind the acme.roster members controller. Password @@ -21,6 +22,26 @@ type Member struct { Password string `gorm:"column:password" json:"-"` // Permissions is a JSON object of permission code to value. Permissions string `gorm:"column:permissions"` + // OrganisationID is a protected column: the form writes it only through + // the team relation field. + OrganisationID *uint `gorm:"column:organisation_id"` +} + +// Team is what a member belongs to; Group is what a member is put into. +type Team struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` +} + +type Group struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Code string `gorm:"column:code"` +} + +type MemberGroup struct { + MemberID uint `gorm:"column:member_id;primaryKey"` + GroupID uint `gorm:"column:group_id;primaryKey"` } func (Member) TableName() string { return "acme_roster_members" } @@ -34,8 +55,11 @@ func (Member) Rules() map[string]string { } // MembersController is an admin controller whose form has fields that are -// not columns and rules of its own. -type MembersController struct{} +// not columns and rules of its own. DB is the application's database handle, +// for reads outside a save. +type MembersController struct { + DB *gorm.DB +} var ( _ pact.AdminController = MembersController{} @@ -46,6 +70,8 @@ var ( _ pact.FormBeforeUpdate = MembersController{} _ cabana.PermissionEditorProvider = MembersController{} + _ cabana.FieldRelationProvider = MembersController{} + _ cabana.RelationLockProvider = MembersController{} ) func (MembersController) ID() string { return "acme.roster.members" } @@ -128,6 +154,43 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin return nil } +// AdminFieldRelations binds the form's two relation fields. organisation_id +// is a protected column, so the team field would be read-only; the contract +// opts in to writing it through this field. +func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract { + return []cabana.FieldRelationContract{{ + Field: "team", + Kind: "belongsTo", + NewRelated: func() any { return &Team{} }, + ForeignKey: "organisation_id", + WritableForeignKey: true, + }, { + Field: "groups", + Kind: "belongsToMany", + NewRelated: func() any { return &Group{} }, + NewPivot: func() any { return &MemberGroup{} }, + ParentForeignKey: "member_id", + RelatedForeignKey: "group_id", + }} +} + +// AdminRelationLocks names the groups an administrator without +// acme.roster.manage may not put a member into or take a member out of. Inside +// a save the ids are read with the save's transaction. +func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) { + principal, _ := bouncer.User(ctx) + if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) { + return cabana.RelationLock{}, nil + } + db := c.DB + if tx, ok := cabana.TxFromContext(ctx); ok { + db = tx + } + lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"} + err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error + return lock, err +} + // hashPassword stands in for the application's password hasher. func hashPassword(plain string) string { sum := sha256.Sum256([]byte(plain)) @@ -157,6 +220,15 @@ func Example_formSeams() { _ = ctl.AdminSetPermissionValues(ctx, "permissions", member, map[string]int{"posts.edit": 1, "posts.publish": -1}) values, _ := ctl.AdminPermissionValues(ctx, "permissions", member) fmt.Println(member.Permissions, len(values)) + + // The relation fields: team writes a protected key, groups has locks. + for _, contract := range ctl.AdminFieldRelations() { + fmt.Println(contract.Field, contract.Kind, contract.WritableForeignKey) + } + // The team field has no locks; the groups field would read them from + // the database. + lock, err := ctl.AdminRelationLocks(ctx, "team") + fmt.Println(len(lock.IDs), err) // Output: // [password password_confirmation notify] // create: required|between:8,255|confirmed @@ -166,4 +238,7 @@ func Example_formSeams() { // posts.publish false // reports.export true // {"posts.edit":1,"posts.publish":-1} 2 + // team belongsTo true + // groups belongsToMany false + // 0 } diff --git a/modules/cabana/filter_schema.go b/modules/cabana/filter_schema.go index 92c3519..d43c82c 100644 --- a/modules/cabana/filter_schema.go +++ b/modules/cabana/filter_schema.go @@ -244,8 +244,8 @@ func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter if !registered { return fmt.Errorf("scope %s is not registered", filter.Scope) } - if _, ok := provider.(pact.FilterOptions); !ok { - return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name) + if filterOptionsProvider(ctl) == nil { + return fmt.Errorf("scope filter %s needs FilterOptions on the controller or the model to serve its choices (D-27)", filter.Name) } } return nil @@ -260,7 +260,8 @@ type FilterOption struct { // filterOptions serves GET /{vendor}/{plugin}/{controller}/filters/{scope}/options // for a declared scope filter of the controller's list. {scope} is the filter -// name (the filter[] key); the model receives the filter's scope method. +// name (the filter[] key); the controller, when it implements +// pact.FilterOptions, or else the model receives the filter's scope method. func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { name := r.PathValue("scope") @@ -277,8 +278,8 @@ func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) { writeNotFound(w, r) return } - provider, ok := filterProvider(cc.Controller).(pact.FilterOptions) - if !ok || provider == nil { + provider := filterOptionsProvider(cc.Controller) + if provider == nil { writeNotFound(w, r) return } @@ -294,6 +295,19 @@ func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) { }) } +// filterOptionsProvider resolves who serves a scope filter's choices: the +// controller when it implements pact.FilterOptions (it can read them from the +// database), else the model that owns the scope. +func filterOptionsProvider(ctl pact.AdminController) pact.FilterOptions { + if provider, ok := ctl.(pact.FilterOptions); ok && provider != nil { + return provider + } + if provider, ok := filterProvider(ctl).(pact.FilterOptions); ok && provider != nil { + return provider + } + return nil +} + func filterProvider(ctl pact.AdminController) pact.FilterScope { src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index fa9a3b1..b6fea84 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -1016,6 +1016,10 @@ func projectRow(row any, controller pact.AdminController, cols []ListColumn) map out["id"] = id.Interface() } for _, col := range cols { + // An invisible column is searched, never sent. + if col.Invisible { + continue + } if col.Relation != "" { if value, ok := relatedSelect(v, controller, col); ok { out[col.Key] = value diff --git a/modules/cabana/list_schema.go b/modules/cabana/list_schema.go index c348afd..ce158c3 100644 --- a/modules/cabana/list_schema.go +++ b/modules/cabana/list_schema.go @@ -67,6 +67,7 @@ type columnDocument struct { Type string `yaml:"type"` Relation string `yaml:"relation"` Select string `yaml:"select"` + Invisible bool `yaml:"invisible"` } // CompileListSchema compiles config_list.yaml and the columns.yaml it names. @@ -265,6 +266,7 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa Type: typ, Relation: spec.Relation, Select: spec.Select, + Invisible: spec.Invisible, }) } return compiled, nil diff --git a/modules/cabana/phase121_fixture_test.go b/modules/cabana/phase121_fixture_test.go index 4bade1c..76e4140 100644 --- a/modules/cabana/phase121_fixture_test.go +++ b/modules/cabana/phase121_fixture_test.go @@ -49,8 +49,11 @@ type rosterPerson struct { Slug string `gorm:"column:slug"` // Permissions is the permission editor's storage: a JSON object of code // to value, or NULL. - Permissions *string `gorm:"column:permissions"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + Permissions *string `gorm:"column:permissions"` + // OrganisationID is a protected fill key: only the team relation field, + // whose contract sets WritableForeignKey, writes it. + OrganisationID *uint `gorm:"column:organisation_id"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } func (rosterPerson) TableName() string { return "roster_people" } @@ -62,6 +65,42 @@ func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"} } +// FilterScopes and FilterScope: the tagged filter keeps the people who carry +// one tag. Its choices come from the controller, which can read the tags. +func (rosterPerson) FilterScopes() []string { return []string{"tagged"} } + +func (rosterPerson) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { + if db == nil || name != "tagged" { + return db + } + return db.Where("roster_people.id IN (SELECT person_id FROM roster_person_tags WHERE tag_id = ?)", value) +} + +// rosterTeam is the belongsTo target of the team field. +type rosterTeam struct { + ID uint `gorm:"column:id;primaryKey"` + Tenant string `gorm:"column:tenant"` + Name string `gorm:"column:name"` +} + +func (rosterTeam) TableName() string { return "roster_teams" } + +// rosterTag is the belongsToMany target of the tags field. The tag named +// staff is locked for an administrator without acme.roster.manage. +type rosterTag struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` +} + +func (rosterTag) TableName() string { return "roster_tags" } + +type rosterPersonTag struct { + PersonID uint `gorm:"column:person_id;primaryKey"` + TagID uint `gorm:"column:tag_id;primaryKey"` +} + +func (rosterPersonTag) TableName() string { return "roster_person_tags" } + // rosterHash is the fixture's stand-in for a password hash. func rosterHash(plain string) string { sum := sha256.Sum256([]byte(plain)) @@ -156,6 +195,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput { type rosterPlugin struct { spy *rosterSpy fsys fs.FS + // db is the handle the controller reads filter choices and locked tags + // with outside a transaction. + db *gorm.DB + // relations, when set, rewrites the controller's relation contracts + // (boot tests). + relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract } func (rosterPlugin) ID() string { return "acme.roster" } @@ -163,7 +208,7 @@ func (rosterPlugin) Requires() []string { return nil } func (rosterPlugin) Register(*backpack.App) error { return nil } func (rosterPlugin) Boot(*backpack.App) error { return nil } func (p rosterPlugin) AdminControllers() []pact.AdminController { - return []pact.AdminController{rosterController{spy: p.spy}} + return []pact.AdminController{rosterController{spy: p.spy, db: p.db, relations: p.relations}} } func (rosterPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}} @@ -188,7 +233,74 @@ func (rosterPlugin) LangFS() fs.FS { return out } -type rosterController struct{ spy *rosterSpy } +type rosterController struct { + spy *rosterSpy + db *gorm.DB + relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract +} + +// AdminFieldRelations: team writes the protected organisation_id through an +// explicit opt-in; tags is a plain belongsToMany. +func (c rosterController) AdminFieldRelations() []cabana.FieldRelationContract { + out := []cabana.FieldRelationContract{{ + Field: "team", Kind: "belongsTo", NewRelated: func() any { return &rosterTeam{} }, + ForeignKey: "organisation_id", WritableForeignKey: true, + }, { + Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &rosterTag{} }, + NewPivot: func() any { return &rosterPersonTag{} }, ParentForeignKey: "person_id", RelatedForeignKey: "tag_id", + }} + if c.relations != nil { + out = c.relations(out) + } + return out +} + +// RelationExtendOptionsQuery offers only the acme tenant's teams. +func (rosterController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB { + if field == "team" { + return db.Where("tenant = ?", "acme") + } + return db +} + +// handle is the save's transaction when there is one, else the plugin's +// database handle. +func (c rosterController) handle(ctx context.Context) *gorm.DB { + if tx, ok := cabana.TxFromContext(ctx); ok { + return tx + } + return c.db.WithContext(ctx) +} + +// AdminRelationLocks locks the staff tag for an administrator without +// acme.roster.manage. +func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) { + principal, _ := bouncer.User(ctx) + if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) { + return cabana.RelationLock{}, nil + } + var ids []uint + if err := c.handle(ctx).Model(&rosterTag{}).Where("name = ?", "staff").Pluck("id", &ids).Error; err != nil { + return cabana.RelationLock{}, err + } + return cabana.RelationLock{IDs: ids, Message: "acme.roster::lang.people.tag_locked"}, nil +} + +// FilterOptions serves the tagged filter's choices from the database. +func (c rosterController) FilterOptions(scope string) []pact.Option { + if scope != "tagged" || c.db == nil { + return nil + } + var tags []rosterTag + if err := c.db.Order("name").Find(&tags).Error; err != nil { + return nil + } + out := make([]pact.Option, len(tags)) + for i, tag := range tags { + out[i] = pact.Option{Value: fmt.Sprint(tag.ID), Label: tag.Name} + } + return out +} func (rosterController) ID() string { return "acme.roster.people" } func (rosterController) ModelName() string { return "Person" } @@ -496,9 +608,16 @@ type rosterEnv struct { } func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) { + t.Helper() + return newRosterEnvWith(t, nil) +} + +// newRosterEnvWith is newRosterEnv with the plugin adjusted by configure +// before it is assembled. +func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv, *gorm.DB) { t.Helper() gdb := adminGorm(t) - models := []any{&rosterPerson{}} + models := []any{&rosterPerson{}, &rosterTeam{}, &rosterTag{}, &rosterPersonTag{}} if err := gdb.Migrator().DropTable(models...); err != nil { t.Fatal(err) } @@ -537,7 +656,11 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) { t.Fatal(err) } spy := &rosterSpy{} - plugins := []party.Plugin{rosterPlugin{spy: spy}} + plugin := rosterPlugin{spy: spy, db: gdb} + if configure != nil { + configure(&plugin) + } + plugins := []party.Plugin{plugin} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } @@ -591,12 +714,18 @@ func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS { // rosterBoot activates the roster plugin over fsys and returns the boot error. func rosterBoot(t *testing.T, fsys fs.FS) error { + t.Helper() + return rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, fsys: fsys}) +} + +// rosterBootWith activates one roster plugin value and returns the boot error. +func rosterBootWith(t *testing.T, plugin rosterPlugin) error { t.Helper() cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) if err != nil { t.Fatal(err) } - _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}}) + _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{plugin}) return err } diff --git a/modules/cabana/phase121_form_test.go b/modules/cabana/phase121_form_test.go index eb38c34..b0061db 100644 --- a/modules/cabana/phase121_form_test.go +++ b/modules/cabana/phase121_form_test.go @@ -7,10 +7,13 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "testing" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" ) // rosterFormHead is the smallest config_form.yaml of the roster fixture. @@ -553,3 +556,275 @@ func TestPermissionEditorSmoke(t *testing.T) { } }) } + +// rosterPivot reads a person's tag ids in ascending order. +func rosterPivot(t *testing.T, gdb *gorm.DB, person uint) []uint { + t.Helper() + ids := []uint{} + if err := gdb.Model(&rosterPersonTag{}).Where("person_id = ?", person).Order("tag_id").Pluck("tag_id", &ids).Error; err != nil { + t.Fatal(err) + } + return ids +} + +// rosterSeed stores any fixture row. +func rosterSeed(t *testing.T, gdb *gorm.DB, row any) { + t.Helper() + if err := gdb.Create(row).Error; err != nil { + t.Fatal(err) + } +} + +// TestWritableForeignKeySmoke drives FieldRelationContract.WritableForeignKey +// (D-27 G3; T-12.1-11): a belongsTo field over a protected foreign key is +// writable only with the explicit opt-in, and submitted ids still pass the +// scoped options query. +func TestWritableForeignKeySmoke(t *testing.T) { + env, gdb := newRosterEnv(t) + home, away := rosterTeam{Tenant: "acme", Name: "Home"}, rosterTeam{Tenant: "other", Name: "Away"} + rosterSeed(t, gdb, &home) + rosterSeed(t, gdb, &away) + id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tess", Active: true}) + record := fmt.Sprintf("%s/%d", rosterPeople, id) + org := func(t *testing.T) uint { + t.Helper() + if person := rosterLoad(t, gdb, id); person.OrganisationID != nil { + return *person.OrganisationID + } + return 0 + } + + t.Run("the field is writable and offers the scoped options", func(t *testing.T) { + _, raw := rosterFormSchema(t, env, "bearer") + if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team"}`) { + t.Fatalf("team field is read-only or missing: %s", raw) + } + rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer") + if body := rec.Body.String(); !strings.Contains(body, `"label":"Home"`) || strings.Contains(body, "Away") { + t.Fatalf("options = %s", body) + } + }) + + t.Run("a save sets the protected key through the relation field only", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, home.ID), "bearer") + if org(t) != home.ID { + t.Fatalf("organisation_id = %d, want %d", org(t), home.ID) + } + body := rosterRecord(t, rec.Body.Bytes()) + if body.Data["team"] != float64(home.ID) || len(body.Meta.Labels["team"]) != 1 || body.Meta.Labels["team"][0].Label != "Home" { + t.Fatalf("update answered data=%v labels=%v", body.Data["team"], body.Meta.Labels["team"]) + } + if _, leaked := body.Data["organisation_id"]; leaked { + t.Fatalf("the response carries organisation_id: %v", body.Data) + } + // The scalar key stays protected: it is dropped from a body. + env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"organisation_id":%d}`, away.ID), "bearer") + if org(t) != home.ID { + t.Fatalf("a scalar organisation_id was written: %d", org(t)) + } + }) + + t.Run("an id outside the options scope is 422 and null clears the key", func(t *testing.T) { + rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, away.ID), "bearer") + rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "team", "The selected team is invalid.") + if org(t) != home.ID { + t.Fatalf("a refused save wrote organisation_id = %d", org(t)) + } + env.expect(t, http.StatusOK, http.MethodPut, record, `{"team":null}`, "bearer") + if org(t) != 0 { + t.Fatalf("null did not clear organisation_id: %d", org(t)) + } + }) + + t.Run("the same contract without the flag is read-only", func(t *testing.T) { + plain, plainDB := newRosterEnvWith(t, func(p *rosterPlugin) { + p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract { + in[0].WritableForeignKey = false + return in + } + }) + team := rosterTeam{Tenant: "acme", Name: "Home"} + rosterSeed(t, plainDB, &team) + person := rosterInsert(t, plainDB, rosterPerson{Tenant: "acme", Name: "Ro", Active: true}) + _, raw := rosterFormSchema(t, plain, "bearer") + if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team","readOnly":true}`) { + t.Fatalf("team field is not read-only: %s", raw) + } + plain.expect(t, http.StatusNotFound, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer") + plain.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, person), fmt.Sprintf(`{"team":%d}`, team.ID), "bearer") + if stored := rosterLoad(t, plainDB, person); stored.OrganisationID != nil { + t.Fatalf("a read-only relation field wrote organisation_id = %d", *stored.OrganisationID) + } + }) + + t.Run("the flag is refused on belongsToMany", func(t *testing.T) { + err := rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, relations: func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract { + in[1].WritableForeignKey = true + return in + }}) + const want = "field tags: WritableForeignKey is only valid on belongsTo" + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } + }) +} + +// TestRelationLockSmoke drives cabana.RelationLockProvider (D-27 G4, D-07; +// T-12.1-12): locked ids are flagged for the administrator they are locked +// for, and a create or update that changes the locked subset is 403 and +// writes nothing. +func TestRelationLockSmoke(t *testing.T) { + env, gdb := newRosterEnv(t) + staff, news, beta := rosterTag{Name: "staff"}, rosterTag{Name: "news"}, rosterTag{Name: "beta"} + for _, tag := range []*rosterTag{&staff, &news, &beta} { + rosterSeed(t, gdb, tag) + } + plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true}) + member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true}) + rosterSeed(t, gdb, &rosterPersonTag{PersonID: plain, TagID: news.ID}) + rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID}) + rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: news.ID}) + path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) } + tags := func(ids ...uint) string { + parts := make([]string, len(ids)) + for i, id := range ids { + parts[i] = fmt.Sprint(id) + } + return `"tags":[` + strings.Join(parts, ",") + `]` + } + same := func(t *testing.T, person uint, want ...uint) { + t.Helper() + sort.Slice(want, func(i, j int) bool { return want[i] < want[j] }) + if got := rosterPivot(t, gdb, person); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("pivot of %d = %v, want %v", person, got, want) + } + } + const message = "You need an additional permission to change the staff tag." + refused := func(t *testing.T, method, rel, body string) { + t.Helper() + rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited") + rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "tags", message) + var envelope cabana.ErrorEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || envelope.Error.Message != message { + t.Fatalf("message = %q err=%v", envelope.Error.Message, err) + } + } + + t.Run("options and labels carry locked for the limited admin only", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited") + if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 { + t.Fatalf("limited options = %s", body) + } + rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "bearer") + if strings.Contains(rec.Body.String(), `"locked"`) { + t.Fatalf("full admin options = %s", rec.Body.String()) + } + rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "limited") + if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 { + t.Fatalf("limited labels = %s", body) + } + rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "bearer") + if strings.Contains(rec.Body.String(), `"locked"`) { + t.Fatalf("full admin labels = %s", rec.Body.String()) + } + }) + + t.Run("adding or removing a locked id on update is 403 and the pivot is unchanged", func(t *testing.T) { + refused(t, http.MethodPut, path(plain), `{"name":"Sneaky",`+tags(news.ID, staff.ID)+`}`) + same(t, plain, news.ID) + if person := rosterLoad(t, gdb, plain); person.Name != "Plain" { + t.Fatalf("a refused save renamed the person: %q", person.Name) + } + refused(t, http.MethodPut, path(member), `{`+tags(news.ID)+`}`) + refused(t, http.MethodPut, path(member), `{`+tags()+`}`) + same(t, member, staff.ID, news.ID) + }) + + t.Run("creating a record with a locked id is 403 and no row is created", func(t *testing.T) { + refused(t, http.MethodPost, rosterPeople, `{"name":"Smuggled","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(staff.ID)+`}`) + var count int64 + if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Smuggled").Count(&count).Error; err != nil || count != 0 { + t.Fatalf("rows named Smuggled = %d err=%v", count, err) + } + }) + + t.Run("a change that leaves the locked subset alone passes", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags(staff.ID, beta.ID)+`}`, "limited") + same(t, member, staff.ID, beta.ID) + if !strings.Contains(rec.Body.String(), `"locked":true`) { + t.Fatalf("update response does not flag the locked label: %s", rec.Body.String()) + } + env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(beta.ID)+`}`, "limited") + same(t, plain, beta.ID) + // A save that does not send the field is not checked. + env.expect(t, http.StatusOK, http.MethodPut, path(member), `{"name":"Member B"}`, "limited") + same(t, member, staff.ID, beta.ID) + rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(news.ID)+`}`, "limited") + created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64) + same(t, uint(created), news.ID) + }) + + t.Run("the full admin may change the locked id", func(t *testing.T) { + env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(staff.ID)+`}`, "bearer") + same(t, plain, staff.ID) + env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags()+`}`, "bearer") + same(t, member) + }) +} + +// TestInvisibleColumnSmoke drives the columns.yaml invisible key (D-27 G6): +// the column is flagged in the schema, searched on the server and left out of +// the rows. +func TestInvisibleColumnSmoke(t *testing.T) { + env, gdb := newRosterEnv(t) + rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@hidden.example.test", Active: true}) + rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test", Active: true}) + + rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer") + if raw := rec.Body.String(); !strings.Contains(raw, `{"key":"email","label":"Email","searchable":true,"sortable":true,"invisible":true}`) || strings.Count(raw, `"invisible"`) != 1 { + t.Fatalf("list schema = %s", raw) + } + rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer") + if raw := rec.Body.String(); strings.Contains(raw, "email") || strings.Contains(raw, "example.test") || !strings.Contains(raw, `"name":"Ada"`) { + t.Fatalf("rows carry the invisible column: %s", raw) + } + rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=hidden.example", "", "bearer") + if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Ada"`) || strings.Contains(raw, `"name":"Bob"`) || strings.Contains(raw, "hidden.example") { + t.Fatalf("search by the invisible column = %s", raw) + } + // It still sorts on the server. + rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?sort=email&dir=desc", "", "bearer") + if raw := rec.Body.String(); strings.Index(raw, `"name":"Bob"`) > strings.Index(raw, `"name":"Ada"`) { + t.Fatalf("sort by the invisible column = %s", raw) + } +} + +// TestFilterOptionsController checks that a controller implementing +// pact.FilterOptions serves a scope filter's choices before the model, so the +// choices can come from the database. The model-only path is covered by +// TestPhase10FilterOptions. +func TestFilterOptionsController(t *testing.T) { + env, gdb := newRosterEnv(t) + news, beta := rosterTag{Name: "news"}, rosterTag{Name: "beta"} + rosterSeed(t, gdb, &news) + rosterSeed(t, gdb, &beta) + tagged := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tagged", Active: true}) + rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true}) + rosterSeed(t, gdb, &rosterPersonTag{PersonID: tagged, TagID: news.ID}) + + rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "bearer") + want := fmt.Sprintf(`"data":[{"value":"%d","label":"beta"},{"value":"%d","label":"news"}]`, beta.ID, news.ID) + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("options = %s, want %s", rec.Body.String(), want) + } + // The scope itself is still the model's. + rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s?filter[tagged]=%d", rosterPeople, news.ID), "", "bearer") + if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Tagged"`) || strings.Contains(raw, `"name":"Bare"`) { + t.Fatalf("filtered list = %s", raw) + } + // The model does not implement FilterOptions: without the controller's + // the filter would not compile. + if _, ok := any(rosterPerson{}).(pact.FilterOptions); ok { + t.Fatal("the fixture model serves filter options itself") + } +} diff --git a/modules/cabana/relation_field.go b/modules/cabana/relation_field.go index c138d29..95138f7 100644 --- a/modules/cabana/relation_field.go +++ b/modules/cabana/relation_field.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "math" "net/http" "reflect" @@ -39,6 +40,31 @@ type FieldRelationContract struct { // means the field's nameFrom, mapped through pact.ListRelationColumnMapper // when the controller implements it. LabelColumn string + // WritableForeignKey declares a belongsTo foreign key that is a protected + // fill key writable through this relation field. + WritableForeignKey bool +} + +// RelationLock names the related records of one relation field that the +// requesting administrator may not add or remove. IDs are related primary +// keys. Message is a phrase key or text for the 403 a refused save answers; +// it may be empty, and the admin then shows its own text. +type RelationLock struct { + IDs []uint + Message string +} + +// RelationLockProvider is implemented by an admin controller that locks some +// choices of its `type: relation` fields for some administrators (D-07). The +// framework asks it per request with the request context: the principal is +// read with bouncer.User, and inside a save the write transaction with +// TxFromContext. The lock is enforced on save: a create or update whose +// submitted value adds or removes a locked id, or replaces a belongsTo value +// that is locked or by one that is locked, is answered 403 and writes +// nothing. The `locked` flag on options and labels is a display aid only. A +// field with no locks returns the zero RelationLock. +type RelationLockProvider interface { + AdminRelationLocks(ctx context.Context, field string) (RelationLock, error) } // FieldRelationProvider is implemented by admin controllers whose form @@ -48,10 +74,13 @@ type FieldRelationProvider interface { } // RelationOption is one relation choice or label: the related primary key and -// its label column value. +// its label column value. Locked is true when the controller's +// RelationLockProvider names the record for the requesting administrator; the +// key is omitted otherwise. type RelationOption struct { - Value uint `json:"value"` - Label string `json:"label"` + Value uint `json:"value"` + Label string `json:"label"` + Locked bool `json:"locked,omitempty"` } // RecordMeta is the record envelope meta: display labels per relation field, @@ -84,8 +113,8 @@ type CompiledFieldRelation struct { // Multiple is true for belongsToMany. Multiple bool // ReadOnly is true for a belongsTo whose foreign key is a protected fill - // key (D-26): it is shown with its label but never written and has no - // options endpoint. + // key (D-26) and whose contract does not set WritableForeignKey: it is + // shown with its label but never written and has no options endpoint. ReadOnly bool // Nullable is true when a belongsTo foreign key accepts null. Nullable bool @@ -199,8 +228,11 @@ func compileFieldRelation(ctl pact.AdminController, field FormField, contract Fi return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey) } out.Nullable = fk.Type.Kind() == reflect.Pointer - out.ReadOnly = protectedFillKey(contract.ForeignKey) + out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey case relationKindBelongsToMany: + if contract.WritableForeignKey { + return nil, errors.New("WritableForeignKey is only valid on belongsTo") + } if contract.ForeignKey != "" { return nil, errors.New("belongsToMany cannot declare a ForeignKey") } @@ -337,6 +369,11 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController if err != nil { return nil, ListMeta{}, lifecycleFailure(cc, err) } + locked, _, err := relationLocks(ctx, cc, field) + if err != nil { + return nil, ListMeta{}, err + } + markLocked(rows, locked) last := 1 if total > 0 { last = int((total + int64(per) - 1) / int64(per)) @@ -344,6 +381,115 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil } +// relationLocks asks the controller's RelationLockProvider for the locked +// related ids of field, as a set, and the lock's message. A controller +// without the provider has no locks. A provider error is a lifecycle failure. +func relationLocks(ctx context.Context, cc *CompiledController, field string) (map[uint]bool, string, error) { + if cc == nil || cc.Controller == nil { + return nil, "", nil + } + provider, ok := cc.Controller.(RelationLockProvider) + if !ok || provider == nil { + return nil, "", nil + } + lock, err := provider.AdminRelationLocks(ctx, field) + if err != nil { + slog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err) + return nil, "", lifecycleFailure(cc, err) + } + if len(lock.IDs) == 0 { + return nil, lock.Message, nil + } + set := make(map[uint]bool, len(lock.IDs)) + for _, id := range lock.IDs { + set[id] = true + } + return set, lock.Message, nil +} + +// markLocked flags the options whose id is in locked. +func markLocked(options []RelationOption, locked map[uint]bool) { + if len(locked) == 0 { + return + } + for i := range options { + if locked[options[i].Value] { + options[i].Locked = true + } + } +} + +// checkRelationLocks refuses a save whose relation values change what the +// controller's RelationLockProvider locks for the requesting administrator +// (D-07; T-12.1-12). It runs inside the save's transaction after the scope +// check and before any row or pivot write, on create and on update. For a +// belongsToMany value the locked subset of the parent's current pivot rows +// (none on create) and of the submitted ids must be equal as sets. For a +// belongsTo value a change is refused when the current or the submitted id is +// locked. Only relation fields present in the body are checked: an absent +// field changes nothing. The refusal is a ForbiddenError (403) with the +// lock's message, also on the field. +func checkRelationLocks(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error { + for _, value := range values { + locked, message, err := relationLocks(ctx, cc, value.field) + if err != nil { + return err + } + if len(locked) == 0 { + continue + } + c := value.fr.Contract + changed := false + if value.fr.Multiple { + var current []uint + if parentPK := pkUint(model); parentPK != 0 { + err := tx.WithContext(ctx).Model(c.NewPivot()). + Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}). + Pluck(c.RelatedForeignKey, ¤t).Error + if err != nil { + return lifecycleFailure(cc, err) + } + } + before, after := map[uint]bool{}, map[uint]bool{} + for _, id := range current { + if locked[id] { + before[id] = true + } + } + for _, id := range value.ids { + if locked[id] { + after[id] = true + } + } + changed = len(before) != len(after) + for id := range before { + if !after[id] { + changed = true + } + } + } else { + v := reflect.ValueOf(model) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + current, _ := foreignKeyValue(v, c.ForeignKey) + var submitted uint + if !value.null && len(value.ids) > 0 { + submitted = value.ids[0] + } + changed = current != submitted && (locked[current] || locked[submitted]) + } + if changed { + detail := message + if detail == "" { + detail = "backend::lang.form.forbidden" + } + return &ForbiddenError{Message: message, Details: map[string]any{value.field: []string{detail}}} + } + } + return nil +} + // relationValue is one present, writable relation key lifted from a body. type relationValue struct { field string @@ -479,10 +625,13 @@ func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController } // assignBelongsTo writes validated belongsTo ids (or null) onto the parent's -// foreign key before the row write. Read-only keys never reach this point. +// foreign key before the row write. Read-only keys never reach this point: a +// protected foreign key is written only when its contract declares +// WritableForeignKey, the same rule compileFieldRelation applies. func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error { for _, value := range values { - if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) { + c := value.fr.Contract + if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) { continue } var id any @@ -577,6 +726,13 @@ func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledControl if err != nil { return RecordMeta{}, lifecycleFailure(cc, err) } + if len(labels) > 0 { + locked, _, err := relationLocks(withTx(ctx, tx), cc, name) + if err != nil { + return RecordMeta{}, err + } + markLocked(labels, locked) + } meta.Labels[name] = labels } return meta, nil diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index 86e80e6..164e401 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -22,6 +22,10 @@ type ListColumn struct { Type string `json:"type,omitempty"` Relation string `json:"relation,omitempty"` Select string `json:"select,omitempty"` + // Invisible marks a column that is searched and sorted on the server but + // not shown: the admin renders no header or cell for it and list rows do + // not carry its value (columns.yaml invisible). + Invisible bool `json:"invisible,omitempty"` } // ListFilter is one compiled config_filter scope. Values stay typed scalars. diff --git a/modules/cabana/testdata/roster/controllers/people/config_filter.yaml b/modules/cabana/testdata/roster/controllers/people/config_filter.yaml new file mode 100644 index 0000000..e1d3f28 --- /dev/null +++ b/modules/cabana/testdata/roster/controllers/people/config_filter.yaml @@ -0,0 +1,6 @@ +scopes: + tagged: + label: acme.roster::lang.people.tagged + modelClass: Tag + nameFrom: name + scope: tagged diff --git a/modules/cabana/testdata/roster/controllers/people/config_list.yaml b/modules/cabana/testdata/roster/controllers/people/config_list.yaml index 089d690..11fe232 100644 --- a/modules/cabana/testdata/roster/controllers/people/config_list.yaml +++ b/modules/cabana/testdata/roster/controllers/people/config_list.yaml @@ -2,6 +2,7 @@ list: ~/plugins/acme/roster/models/person/columns.yaml modelClass: Person title: acme.roster::lang.people.title recordUrl: acme/roster/people/preview/:id +filter: config_filter.yaml recordsPerPage: 20 showCheckboxes: true toolbar: diff --git a/modules/cabana/testdata/roster/lang/en/lang.yaml b/modules/cabana/testdata/roster/lang/en/lang.yaml index 10315eb..17fbfce 100644 --- a/modules/cabana/testdata/roster/lang/en/lang.yaml +++ b/modules/cabana/testdata/roster/lang/en/lang.yaml @@ -30,6 +30,11 @@ people: notify: Send a welcome message permissions: Permissions tab_permissions: Permissions + team: Team + no_team: No team + tags: Tags + tagged: Tag + tag_locked: You need an additional permission to change the staff tag. permissions: tab_content: Content tab_reports: Reports diff --git a/modules/cabana/testdata/roster/lang/pl/lang.yaml b/modules/cabana/testdata/roster/lang/pl/lang.yaml index 0e598cd..6456d29 100644 --- a/modules/cabana/testdata/roster/lang/pl/lang.yaml +++ b/modules/cabana/testdata/roster/lang/pl/lang.yaml @@ -30,6 +30,11 @@ people: notify: Wyślij wiadomość powitalną permissions: Uprawnienia tab_permissions: Uprawnienia + team: Zespół + no_team: Brak zespołu + tags: Tagi + tagged: Tag + tag_locked: Zmiana tagu staff wymaga dodatkowego uprawnienia. permissions: tab_content: Treści tab_reports: Raporty diff --git a/modules/cabana/testdata/roster/models/person/columns.yaml b/modules/cabana/testdata/roster/models/person/columns.yaml index 5397a41..2d69618 100644 --- a/modules/cabana/testdata/roster/models/person/columns.yaml +++ b/modules/cabana/testdata/roster/models/person/columns.yaml @@ -5,3 +5,6 @@ columns: email: label: acme.roster::lang.people.email searchable: true + invisible: true + slug: + label: acme.roster::lang.people.slug diff --git a/modules/cabana/testdata/roster/models/person/fields.yaml b/modules/cabana/testdata/roster/models/person/fields.yaml index d006fec..70a4ebc 100644 --- a/modules/cabana/testdata/roster/models/person/fields.yaml +++ b/modules/cabana/testdata/roster/models/person/fields.yaml @@ -7,6 +7,15 @@ fields: label: acme.roster::lang.people.email type: text span: right + team: + label: acme.roster::lang.people.team + type: relation + nameFrom: name + emptyOption: acme.roster::lang.people.no_team + tags: + label: acme.roster::lang.people.tags + type: relation + nameFrom: name slug: label: acme.roster::lang.people.slug type: text diff --git a/modules/pact/README.md b/modules/pact/README.md index b8d8001..8a5f257 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -123,6 +123,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.FormVirtualFields` | Optional controller list of form fields that are not model columns for the form (a password and its confirmation, for example): never bound, filled or returned; their submitted values reach the Form hooks through the admin framework's context accessor. | | `pact.FormRules` | Optional controller hook returning the validation rules of an admin save for `create` or `update`; the set replaces the model's `Rules()` for those saves and may name virtual fields. | | `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | +| `pact.FilterOptions` | Serves the choices of a scope filter. The admin controller may implement it and is asked first, so choices can be read from the database; otherwise the model is asked. | | `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. | | `pact.RelationBeforeCreate` | Optional controller hook run in the write transaction before a relation manager creates a related record. | | `pact.RelationAfterCreate` | Optional controller hook run after a relation manager creates a related record, before the commit. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 18e6fce..6ad8c4b 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -583,8 +583,11 @@ type FilterScope interface { } // FilterOptions serves the choices of a model-backed config_filter scope -// (D-27). It is implemented by the same model as FilterScope and receives the -// filter's scope method name; labels may be phrase keys. +// (D-27). It receives the filter's scope method name; labels may be phrase +// keys. The admin controller may implement it and is asked first, so choices +// that are read from the database can use the handle the controller holds; +// otherwise the model that implements FilterScope is asked. The scope itself +// is always the model's FilterScope. type FilterOptions interface { FilterOptions(scope string) []Option } diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 89d55f8..3c6c32f 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -76,6 +76,8 @@ form: deleting: Deleting… add: Add remove_item: "Remove: :name" + locked_item: "Locked: :name" + locked_note: Items marked with a lock need an additional permission to change. add_item: Add… select_placeholder: Select… loading_options: Loading… diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 75bd0c0..bb86927 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -82,6 +82,8 @@ form: deleting: Usuwanie… add: Dodaj remove_item: "Usuń: :name" + locked_item: "Zablokowane: :name" + locked_note: Zmiana pozycji oznaczonych kłódką wymaga dodatkowego uprawnienia. add_item: Dodaj… select_placeholder: Wybierz… loading_options: Wczytywanie…