Files
summercms/modules/cabana/field_permission.go
Jakub Zych f50d9b8f10 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
2026-10-05 10:44:50 +02:00

341 lines
12 KiB
Go

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
}