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
This commit is contained in:
@@ -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 `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
|
||||
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. 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: <name>` 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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <nil>
|
||||
}
|
||||
|
||||
@@ -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[<name>] key); the model receives the filter's scope method.
|
||||
// name (the filter[<name>] 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
6
modules/cabana/testdata/roster/controllers/people/config_filter.yaml
vendored
Normal file
6
modules/cabana/testdata/roster/controllers/people/config_filter.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
scopes:
|
||||
tagged:
|
||||
label: acme.roster::lang.people.tagged
|
||||
modelClass: Tag
|
||||
nameFrom: name
|
||||
scope: tagged
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,6 @@ columns:
|
||||
email:
|
||||
label: acme.roster::lang.people.email
|
||||
searchable: true
|
||||
invisible: true
|
||||
slug:
|
||||
label: acme.roster::lang.people.slug
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user