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…