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 }