- 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
341 lines
12 KiB
Go
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
|
|
}
|