feat(12.1-02): permissioneditor field in radio or checkbox mode

- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
  controller serves the options per request through
  cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
  mode's set and 403 for a changed locked code; stored codes that are not
  offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
  mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:44:50 +02:00
parent a1c6bb1ce6
commit f50d9b8f10
38 changed files with 1300 additions and 34 deletions

View File

@@ -22,6 +22,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
@@ -209,6 +210,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |

View File

@@ -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.
// @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).
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -105,6 +105,9 @@ type CompiledController struct {
// virtual is the set of form field names the controller lists through
// pact.FormVirtualFields: never bound, filled or projected.
virtual map[string]bool
// permissions are the form's `type: permissioneditor` fields: field name
// to mode (radio or checkbox).
permissions map[string]string
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -544,13 +544,17 @@ type actionConflict struct{}
func (actionConflict) Error() string { return "cabana: action does not apply" }
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels.
// values keyed by field name with their labels, and the stored permissions of
// every permissioneditor field as an object (D-16).
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
}
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
return RecordResult{}, err
}
return RecordResult{Data: data, Meta: meta}, nil
}
@@ -580,6 +584,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return RecordResult{}, err
}
permissions, err := liftPermissionValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withVirtualFields(withTx(ctx, tx), virtual)
@@ -633,6 +641,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return err
}
// D-16: permission values are checked against the controller's
// options and stored by the controller before the row write.
if err := applyPermissionValues(ctx, cc, target, permissions, update); err != nil {
return err
}
// D-18: submitted ids pass the same scoped query as the options
// endpoint; belongsTo keys land before the row write, pivot rows after.
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {

View File

@@ -93,7 +93,7 @@ func TestDatepickerSmokeCompile(t *testing.T) {
"minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"},
"min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"},
"key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
} {
t.Run(name, func(t *testing.T) {
err := activateFields(t, datepickerFields(tc.field))

View File

@@ -65,7 +65,7 @@ func TestDatepickerCompile(t *testing.T) {
"ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"},
"ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"},
"key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
"date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"},
"datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"},
"time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"},

View File

@@ -4,8 +4,10 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -17,6 +19,8 @@ type Member struct {
Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"`
Password string `gorm:"column:password" json:"-"`
// Permissions is a JSON object of permission code to value.
Permissions string `gorm:"column:permissions"`
}
func (Member) TableName() string { return "acme_roster_members" }
@@ -40,6 +44,8 @@ var (
_ pact.FormRules = MembersController{}
_ pact.FormBeforeCreate = MembersController{}
_ pact.FormBeforeUpdate = MembersController{}
_ cabana.PermissionEditorProvider = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
@@ -88,6 +94,40 @@ func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error
return nil
}
// AdminPermissionOptions lists the permissions the `type: permissioneditor`
// field offers, in display order. The principal on ctx decides what is locked.
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
principal, _ := bouncer.User(ctx)
mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
return []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
}, nil
}
// AdminPermissionValues reads the permissions stored on the record.
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
values := map[string]int{}
if raw := record.(*Member).Permissions; raw != "" {
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
}
return values, nil
}
// AdminSetPermissionValues stores the checked set on the model. The save
// writes the row afterwards, in the same transaction.
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
record.(*Member).Permissions = string(raw)
return nil
}
// hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
@@ -107,9 +147,23 @@ func Example_formSeams() {
_, inSave := cabana.VirtualFieldsFromContext(ctx)
err := ctl.FormBeforeCreate(ctx, member)
fmt.Println(inSave, err, member.Password == "")
// The permission editor: three options, the last one locked for an
// administrator without acme.roster.manage (here: nobody is signed in).
options, _ := ctl.AdminPermissionOptions(ctx, "permissions")
for _, option := range options {
fmt.Println(option.Code, option.Locked)
}
_ = 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))
// Output:
// [password password_confirmation notify]
// create: required|between:8,255|confirmed
// update: nullable|between:8,255|confirmed
// false <nil> true
// posts.edit false
// posts.publish false
// reports.export true
// {"posts.edit":1,"posts.publish":-1} 2
}

View File

@@ -70,8 +70,8 @@ func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormFi
return fmt.Errorf("%s is only valid on type: fileupload", key)
}
}
if _, ok := values["mode"]; ok && typ != "datepicker" {
return fmt.Errorf("mode is only valid on type: fileupload or datepicker")
if _, ok := values["mode"]; ok && typ != "datepicker" && typ != permissionFieldType {
return fmt.Errorf("mode is only valid on type: fileupload, datepicker or permissioneditor")
}
return nil
}

View File

@@ -0,0 +1,340 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
// permissionFieldType is the fields.yaml type of the permission editor (D-16).
const permissionFieldType = "permissioneditor"
// permissionRefusedKeys are generic field keys with no meaning on a
// permission editor: its choices come from the controller per request.
var permissionRefusedKeys = []string{"options", "default", "nameFrom", "emptyOption", "relation", "preset"}
// permissionLockedKey is the message a changed locked permission is refused
// with, on the field.
const permissionLockedKey = "backend::lang.permissioneditor.locked"
// PermissionOption is one permission a `type: permissioneditor` field offers.
// Code is the permission code stored with the record. Label, Tab and Comment
// are phrase keys or text, localized per request; options with the same Tab
// are shown as one section, and an option without a Tab goes to a last
// section. Locked marks a permission the requesting administrator may see but
// not change: the admin disables its control and a save that changes its
// value is answered 403.
type PermissionOption struct {
Code string `json:"code"`
Label string `json:"label"`
Tab string `json:"tab,omitempty"`
Comment string `json:"comment,omitempty"`
Locked bool `json:"locked,omitempty"`
}
// PermissionEditorProvider is implemented by an admin controller whose form
// has a `type: permissioneditor` field. The framework validates what an
// administrator submits against the offered options; reading and writing the
// record's stored permissions stays with the controller, so the storage shape
// is the plugin's decision.
//
// AdminPermissionOptions returns the permissions field offers the
// administrator on ctx (read with bouncer.User), in display order. It is
// called for the form schema and again inside every save.
//
// AdminPermissionValues returns the permissions stored on record for field as
// code to value. It is called to show a record and, inside a save, before the
// change is applied.
//
// AdminSetPermissionValues stores values on record for field. It runs inside
// the save's transaction (cabana.TxFromContext), before the record's row is
// written, so setting the model's column is enough. values holds the
// submitted codes with their checked values plus every stored code that is
// not offered, unchanged; an offered code that is absent has no value
// (inherit, or not allowed). An error fails the save like a Form hook's.
type PermissionEditorProvider interface {
AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error)
AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error)
AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error
}
// compilePermissionKeys checks the keys of a `type: permissioneditor` field:
// mode is required and must be radio (allow, inherit, deny) or checkbox
// (allow), and the keys that describe choices or a column value are refused.
// On every other type mode is checked by compileFileuploadKeys.
func compilePermissionKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != permissionFieldType {
return nil
}
for _, key := range permissionRefusedKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is not valid on type: permissioneditor", key)
}
}
mode, err := nodeString(values["mode"])
if err != nil || (mode != "radio" && mode != "checkbox") {
return fmt.Errorf("mode must be radio or checkbox on type: permissioneditor")
}
field.Mode = mode
return nil
}
// compilePermissionFields stops boot when a form has a permissioneditor field
// and its controller does not implement PermissionEditorProvider, and records
// the fields' modes for the save path.
func compilePermissionFields(pluginID string, cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
}
for _, field := range cc.Form.Fields {
if field.Type != permissionFieldType {
continue
}
if provider, ok := cc.Controller.(PermissionEditorProvider); !ok || provider == nil {
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath,
fmt.Errorf("field %s: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider", field.Name))
}
if cc.permissions == nil {
cc.permissions = map[string]string{}
}
cc.permissions[field.Name] = field.Mode
}
return nil
}
// permissionFieldNames are the controller's permissioneditor fields in a
// stable order.
func permissionFieldNames(cc *CompiledController) []string {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
names := make([]string, 0, len(cc.permissions))
for name := range cc.permissions {
names = append(names, name)
}
sort.Strings(names)
return names
}
// localizePermissionOptions fills the permission options of every
// permissioneditor field of a localized form view for one request. The
// options are asked from the controller with the request context, so they may
// depend on the administrator. fields is the view's own slice; the cached
// schema is not touched.
func localizePermissionOptions(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, fields []FormField) error {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return fmt.Errorf("cabana: controller %s: no PermissionEditorProvider", controllerID(cc))
}
if ctx == nil {
ctx = context.Background()
}
local := towel.WithLocale(ctx, schemaLocale(ctx, tr))
for i := range fields {
if fields[i].Type != permissionFieldType {
continue
}
options, err := provider.AdminPermissionOptions(ctx, fields[i].Name)
if err != nil {
return err
}
out := make([]PermissionOption, len(options))
for j, option := range options {
out[j] = PermissionOption{
Code: option.Code,
Label: translateKey(local, tr, option.Label),
Tab: translateKey(local, tr, option.Tab),
Comment: translateKey(local, tr, option.Comment),
Locked: option.Locked,
}
}
fields[i].PermissionOptions = out
}
return nil
}
// permissionValue is one permissioneditor value lifted from a save body.
type permissionValue struct {
field string
mode string
values map[string]int
}
// liftPermissionValues takes the permissioneditor values out of a save body
// before scalar projection, which drops every nested value. Only fields
// present in the body whose context allows op are lifted. A value must be a
// JSON object of permission code to integer; anything else is
// validation_failed on the field.
func liftPermissionValues(cc *CompiledController, body map[string]any, op string) ([]permissionValue, error) {
if cc == nil || len(cc.permissions) == 0 || body == nil {
return nil, nil
}
details := map[string]any{}
var out []permissionValue
for _, name := range permissionFieldNames(cc) {
if !contextAllows(cc, name, op) {
continue
}
raw, present := body[name]
if !present {
continue
}
object, ok := raw.(map[string]any)
values := make(map[string]int, len(object))
for code, item := range object {
n, isInt := permissionInt(item)
if !isInt {
ok = false
break
}
values[code] = n
}
if !ok {
details[name] = []string{"The " + name + " field must be an object of permission codes."}
continue
}
out = append(out, permissionValue{field: name, mode: cc.permissions[name], values: values})
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// permissionInt accepts a JSON integer only: no strings, booleans, fractions
// or nested values.
func permissionInt(value any) (int, bool) {
switch n := value.(type) {
case json.Number:
i, err := n.Int64()
if err != nil || i < math.MinInt32 || i > math.MaxInt32 {
return 0, false
}
return int(i), true
case float64:
if n != math.Trunc(n) || n < math.MinInt32 || n > math.MaxInt32 {
return 0, false
}
return int(n), true
case int:
return n, true
case int64:
if n < math.MinInt32 || n > math.MaxInt32 {
return 0, false
}
return int(n), true
default:
return 0, false
}
}
// permissionAllowed reports whether value is in mode's set: 1 or -1 for
// radio, 1 for checkbox.
func permissionAllowed(mode string, value int) bool {
if mode == "radio" {
return value == 1 || value == -1
}
return value == 1
}
// applyPermissionValues checks the lifted permission values against the
// controller's options and hands the next set to the controller, inside the
// save's transaction and before the row write (D-16; T-12.1-13). Every
// submitted code must be offered and every value in the mode's set (a 0 means
// "no value" and is dropped): otherwise 422 on the field. A locked option's
// stored and submitted value must be equal: otherwise a ForbiddenError (403)
// naming the field. The next set is the stored codes that are not offered,
// unchanged, plus the submitted codes.
func applyPermissionValues(ctx context.Context, cc *CompiledController, record any, lifted []permissionValue, update bool) error {
if len(lifted) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
for _, value := range lifted {
options, err := provider.AdminPermissionOptions(ctx, value.field)
if err != nil {
return lifecycleFailure(cc, err)
}
offered := make(map[string]PermissionOption, len(options))
for _, option := range options {
offered[option.Code] = option
}
submitted := make(map[string]int, len(value.values))
for code, n := range value.values {
if _, known := offered[code]; !known {
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an unknown permission."}}}
}
if n == 0 {
continue
}
if !permissionAllowed(value.mode, n) {
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an invalid value."}}}
}
submitted[code] = n
}
stored := map[string]int{}
if update {
current, err := provider.AdminPermissionValues(ctx, value.field, record)
if err != nil {
return lifecycleFailure(cc, err)
}
for code, n := range current {
stored[code] = n
}
}
next := make(map[string]int, len(stored)+len(submitted))
for code, n := range stored {
if _, known := offered[code]; !known {
next[code] = n
}
}
for _, option := range options {
if option.Locked && stored[option.Code] != submitted[option.Code] {
return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}}
}
}
for code, n := range submitted {
next[code] = n
}
if err := provider.AdminSetPermissionValues(ctx, value.field, record, next); err != nil {
return lifecycleFailure(cc, err)
}
}
return nil
}
// projectPermissionFields sets data[field] to the record's stored permissions
// for every permissioneditor field, as an object that is never null.
func projectPermissionFields(ctx context.Context, cc *CompiledController, record any, data map[string]any) error {
if cc == nil || len(cc.permissions) == 0 {
return nil
}
provider, ok := cc.Controller.(PermissionEditorProvider)
if !ok || provider == nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
for _, name := range permissionFieldNames(cc) {
values, err := provider.AdminPermissionValues(ctx, name, record)
if err != nil {
return lifecycleFailure(cc, err)
}
out := make(map[string]int, len(values))
for code, n := range values {
out[code] = n
}
data[name] = out
}
return nil
}

View File

@@ -25,7 +25,7 @@ var (
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {},
"password": {}, "permissioneditor": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -548,6 +548,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compilePartialPath(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compilePermissionKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileFileuploadKeys(typ, values, &field); err != nil {
return FormField{}, err
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"reflect"
"slices"
@@ -699,6 +700,13 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
}
kept = append(kept, field)
}
// A permission editor's options are the controller's answer for this
// administrator (D-16); kept is this request's own slice.
if err := localizePermissionOptions(r.Context(), s.translator(), cc, kept); err != nil {
slog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
view.Fields = kept
view.Assets = s.controllerAssets(cc)
meta := map[string]any{}

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"net/http"
@@ -15,6 +16,7 @@ import (
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
@@ -43,9 +45,12 @@ type rosterPerson struct {
JoinedIP *string `gorm:"column:joined_ip"`
// Password is a stored hash. The form's password field is virtual: the
// controller's hooks derive this column from the submitted value.
Password string `gorm:"column:password" json:"-"`
Slug string `gorm:"column:slug"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
Password string `gorm:"column:password" json:"-"`
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"`
}
func (rosterPerson) TableName() string { return "roster_people" }
@@ -258,6 +263,60 @@ func (rosterController) PartialData(_ context.Context, name string, record any)
return rosterStatus{}, nil
}
// rosterPermissionCodes are the permissions the people form offers: two tabs
// and one permission without a tab. reports.export is locked for an
// administrator without acme.roster.manage.
var rosterPermissionCodes = []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content", Comment: "acme.roster::lang.permissions.posts_edit_comment"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports"},
{Code: "misc.beta", Label: "acme.roster::lang.permissions.misc_beta"},
}
// AdminPermissionOptions serves the permission editor's options per
// administrator.
func (rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if field != "permissions" {
return nil, fmt.Errorf("unknown permission field %s", field)
}
principal, _ := bouncer.User(ctx)
out := append([]cabana.PermissionOption(nil), rosterPermissionCodes...)
for i := range out {
if out[i].Code == "reports.export" {
out[i].Locked = !cabana.Allows(principal, []string{"acme.roster.manage"})
}
}
return out, nil
}
// AdminPermissionValues reads the stored JSON object.
func (rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
person := record.(*rosterPerson)
out := map[string]int{}
if person.Permissions == nil || *person.Permissions == "" {
return out, nil
}
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
return nil, err
}
return out, nil
}
// AdminSetPermissionValues writes the JSON object onto the model; the save
// writes the row.
func (rosterController) AdminSetPermissionValues(ctx context.Context, _ string, record any, values map[string]int) error {
if _, ok := cabana.TxFromContext(ctx); !ok {
return fmt.Errorf("no transaction on the context")
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
text := string(raw)
record.(*rosterPerson).Permissions = &text
return nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"

View File

@@ -386,3 +386,170 @@ func TestPresetSchema(t *testing.T) {
})
}
}
// TestPermissionEditorSmoke drives `type: permissioneditor` through the
// assembled router on PostgreSQL (D-16; T-12.1-13): options per administrator,
// the code and value checks, the locked guard, kept unknown codes and the
// boot rules.
func TestPermissionEditorSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
legacy := `{"legacy.code":1,"reports.export":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &legacy})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
stored := func(t *testing.T) map[string]int {
t.Helper()
person := rosterLoad(t, gdb, id)
out := map[string]int{}
if person.Permissions != nil {
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
t.Fatalf("stored permissions %q: %v", *person.Permissions, err)
}
}
return out
}
same := func(t *testing.T, got, want map[string]int) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("permissions = %v, want %v", got, want)
}
}
const unknown = "The permissions field contains an unknown permission."
const invalid = "The permissions field contains an invalid value."
const shape = "The permissions field must be an object of permission codes."
t.Run("the schema carries localized options, locked only for the limited admin", func(t *testing.T) {
view, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"permissions","type":"permissioneditor","label":"Permissions","tab":"Permissions","context":"update","mode":"radio","permissionOptions":[{"code":"posts.edit","label":"Edit posts","tab":"Content","comment":"Change the text of any post."},{"code":"posts.publish","label":"Publish posts","tab":"Content"},{"code":"reports.export","label":"Export reports","tab":"Reports"},{"code":"misc.beta","label":"Try beta features"}]`) {
t.Fatalf("permission field is not in the schema: %s", raw)
}
if strings.Contains(raw, `"locked"`) {
t.Fatalf("an option is locked for the full admin: %s", raw)
}
_ = view
limited, raw := rosterFormSchema(t, env, "limited")
if !strings.Contains(raw, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) || strings.Count(raw, `"locked":true`) != 1 {
t.Fatalf("limited admin's options: %s", raw)
}
// The cached schema was not mutated by either request.
for _, field := range limited.Fields {
if field.Type == "permissioneditor" && len(field.PermissionOptions) != 4 {
t.Fatalf("options = %+v", field.PermissionOptions)
}
}
})
t.Run("show returns the stored codes as an object", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"reports.export":1}`) {
t.Fatalf("show: %s", rec.Body.String())
}
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, blank), "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
t.Fatalf("show without stored permissions: %s", rec.Body.String())
}
})
t.Run("an update stores offered codes, drops inherit and keeps a stored code that is not offered", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0,"reports.export":1}}`, "bearer")
want := map[string]int{"legacy.code": 1, "posts.edit": 1, "posts.publish": -1, "reports.export": 1}
same(t, stored(t), want)
got, _ := rosterRecord(t, rec.Body.Bytes()).Data["permissions"].(map[string]any)
if len(got) != 4 || got["posts.publish"] != float64(-1) || got["legacy.code"] != float64(1) {
t.Fatalf("update answered %v", got)
}
// An offered code that is left out goes back to inherit.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"reports.export":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
// A save without the field leaves the column alone.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Perm B"}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
})
t.Run("an unknown code, a value outside the set and a non-object are 422", func(t *testing.T) {
before := stored(t)
for body, message := range map[string]string{
`{"permissions":{"posts.edit":1,"admin.root":1}}`: unknown,
// A stored code that is not offered cannot be submitted either.
`{"permissions":{"legacy.code":1}}`: unknown,
`{"permissions":{"posts.edit":2}}`: invalid,
`{"permissions":{"posts.edit":-2}}`: invalid,
`{"permissions":{"posts.edit":"1"}}`: shape,
`{"permissions":{"posts.edit":1.5}}`: shape,
`{"permissions":{"posts.edit":true}}`: shape,
`{"permissions":{"posts.edit":{"a":1}}}`: shape,
`{"permissions":["posts.edit"]}`: shape,
`{"permissions":"posts.edit"}`: shape,
`{"permissions":null}`: shape,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, body, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
}
same(t, stored(t), before)
})
t.Run("a changed locked code is 403 for the limited admin and nothing is written", func(t *testing.T) {
before := stored(t)
const locked = "You cannot change this permission."
// Removing it (leaving it out), denying it and renaming at the same time.
for _, body := range []string{
`{"name":"Sneaky","permissions":{"posts.edit":1}}`,
`{"name":"Sneaky","permissions":{"posts.edit":1,"reports.export":-1}}`,
`{"name":"Sneaky","permissions":{"reports.export":0}}`,
} {
rec := env.expect(t, http.StatusForbidden, http.MethodPut, record, body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
}
same(t, stored(t), before)
if person := rosterLoad(t, gdb, id); person.Name != "Perm B" {
t.Fatalf("a refused save renamed the person: %q", person.Name)
}
// Granting it where it is not stored is refused too.
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, bare), `{"permissions":{"reports.export":1}}`, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
if person := rosterLoad(t, gdb, bare); person.Permissions != nil {
t.Fatalf("a refused save wrote %q", *person.Permissions)
}
// The limited admin may change the other codes while the locked one
// keeps its stored value.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1,"reports.export":1}}`, "limited")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1, "reports.export": 1})
// The full admin may change it.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1})
})
t.Run("a field hidden on create is not written by a create", func(t *testing.T) {
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1","permissions":{"posts.edit":1}}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes())
newID, _ := created.Data["id"].(float64)
if person := rosterLoad(t, gdb, uint(newID)); person.Permissions != nil {
t.Fatalf("create wrote permissions %q", *person.Permissions)
}
if got, ok := created.Data["permissions"].(map[string]any); !ok || len(got) != 0 {
t.Fatalf("create answered permissions %v", created.Data["permissions"])
}
})
t.Run("boot rules", func(t *testing.T) {
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
"field permissions: mode must be radio or checkbox on type: permissioneditor", rosterFieldsFile)
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
"mode must be radio or checkbox on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
"default is not valid on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
"mode is only valid on type: fileupload, datepicker or permissioneditor")
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
t.Fatalf("mode: checkbox did not boot: %v", err)
}
// A controller without the provider cannot have the field.
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
})
}

View File

@@ -103,6 +103,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
return nil, err
}
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
return nil, err
}
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
return nil, err
}

View File

@@ -21,6 +21,8 @@ var relationFormRefusedTypes = map[string]bool{
"relation": true, "relation-manager": true, "widget": true, "partial": true,
// A password is a virtual field, and only an admin controller lists those.
"password": true,
// A permission editor's options and storage belong to an admin controller.
"permissioneditor": true,
}
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].

View File

@@ -257,8 +257,9 @@ type FormField struct {
// Path names the controller partial of a `type: partial` field: the
// template {ConfigDir}/_{path}.htm (D-09).
Path string `json:"path,omitempty"`
// Mode is the fileupload mode (image or file, default file) or the
// datepicker mode (date, datetime or time, default datetime).
// Mode is the fileupload mode (image or file, default file), the
// datepicker mode (date, datetime or time, default datetime) or the
// permissioneditor mode (radio or checkbox).
Mode string `json:"mode,omitempty"`
// Format is a datepicker's WinterCMS (PHP date) display format;
// DisplayFormat is the same format in the SPA's moment-style tokens.
@@ -304,6 +305,10 @@ type FormField struct {
// Preset makes a text field follow another field of the form while the
// administrator has not edited it, on create only (fields.yaml preset).
Preset *FieldPreset `json:"preset,omitempty"`
// PermissionOptions are the permissions a `type: permissioneditor` field
// offers the requesting administrator, in display order. They are filled
// per request by the form schema route.
PermissionOptions []PermissionOption `json:"permissionOptions,omitempty"`
optionsMethod string
}

View File

@@ -63,7 +63,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
}
for _, field := range fields {
// A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" {
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
}
if field.Preset != nil {

View File

@@ -28,3 +28,13 @@ people:
password: Password
password_confirmation: Repeat the password
notify: Send a welcome message
permissions: Permissions
tab_permissions: Permissions
permissions:
tab_content: Content
tab_reports: Reports
posts_edit: Edit posts
posts_edit_comment: Change the text of any post.
posts_publish: Publish posts
reports_export: Export reports
misc_beta: Try beta features

View File

@@ -28,3 +28,13 @@ people:
password: Hasło
password_confirmation: Powtórz hasło
notify: Wyślij wiadomość powitalną
permissions: Uprawnienia
tab_permissions: Uprawnienia
permissions:
tab_content: Treści
tab_reports: Raporty
posts_edit: Edycja wpisów
posts_edit_comment: Zmiana treści dowolnego wpisu.
posts_publish: Publikowanie wpisów
reports_export: Eksport raportów
misc_beta: Funkcje beta

View File

@@ -26,6 +26,12 @@ fields:
type: checkbox
default: true
context: create
permissions:
label: acme.roster::lang.people.permissions
type: permissioneditor
mode: radio
tab: acme.roster::lang.people.tab_permissions
context: update
joined_ip:
label: acme.roster::lang.people.joined_ip
type: text