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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DEO3TzjB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B2epb8_Z.css">
<script type="module" crossorigin src="./assets/index-CvMS0tdW.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CXbMmgdh.css">
</head>
<body>
<div id="app"></div>

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

View File

@@ -220,3 +220,10 @@ messages:
update_submit: Save record
pivot_submit: Save link details
link_submit: Add link
permissioneditor:
allow: Allow
inherit: Inherit
deny: Deny
locked: You cannot change this permission.
empty: No permissions are defined yet.
other: Other

View File

@@ -242,3 +242,10 @@ messages:
update_submit: Zapisz rekord
pivot_submit: Zapisz szczegóły powiązania
link_submit: Dodaj powiązanie
permissioneditor:
allow: Zezwól
inherit: Dziedzicz
deny: Odmów
locked: Nie możesz zmienić tego uprawnienia.
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
other: Inne