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 } // PermissionRecordLock is one permission whose value a stored record fixes: // the permission editor shows Value and Note on that row and cannot change // it. Code is an offered permission code; Value is what the editor shows (1 // allow, -1 deny, 0 inherit or not granted); Note is a phrase key or text, // translated in the request locale before it reaches the admin. type PermissionRecordLock struct { Code string `json:"code"` Value int `json:"value"` Note string `json:"note"` } // PermissionRecordLockProvider is implemented, optionally, by an admin // controller that also implements PermissionEditorProvider, when some // permission values of a stored record cannot be changed on the form, for // example grants a plugin declares in code for a role, or an // administrator's own overrides. // // AdminPermissionRecordLocks returns the locked permissions of record for // field, for the administrator on ctx (read with bouncer.User). It is asked // for a stored record only, never on a create: to show the record (the // record response's meta.permissionLocks) and inside an update save, before // the change is applied. A record-locked code is never required in a save // body: when absent its stored value is kept, when present it must equal the // stored value, otherwise the save is answered 403 on the field. The stored // value is what AdminPermissionValues returns, which may differ from Value // (a code grant need not be stored on the role). type PermissionRecordLockProvider interface { AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, 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. On an update, a code the controller's // PermissionRecordLockProvider locks for the record keeps its stored value: // a submitted value must equal it (403 on the field otherwise), an absent // one is not a change, and the code is left out of the locked-option // comparison. The next set is the stored codes that are not offered or are // record-locked, unchanged, plus the other 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{} recordLocked := map[string]bool{} 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 } locks, err := permissionRecordLocks(ctx, cc, value.field, record) if err != nil { return err } for _, lock := range locks { recordLocked[lock.Code] = true } } next := make(map[string]int, len(stored)+len(submitted)) for code, n := range stored { if _, known := offered[code]; !known || recordLocked[code] { next[code] = n } } for code := range recordLocked { if n, present := value.values[code]; present && n != stored[code] { return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}} } } for _, option := range options { if recordLocked[option.Code] { continue } if option.Locked && stored[option.Code] != submitted[option.Code] { return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}} } } for code, n := range submitted { if !recordLocked[code] { 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 } // permissionRecordLocks asks the controller's PermissionRecordLockProvider // for the record locks of field on a stored record; nil when the controller // has none. func permissionRecordLocks(ctx context.Context, cc *CompiledController, field string, record any) ([]PermissionRecordLock, error) { if cc == nil { return nil, nil } provider, ok := cc.Controller.(PermissionRecordLockProvider) if !ok || provider == nil { return nil, nil } locks, err := provider.AdminPermissionRecordLocks(ctx, field, record) if err != nil { return nil, lifecycleFailure(cc, err) } return locks, nil } // localizePermissionLocks translates the notes of meta.permissionLocks in // the request locale. The map is the response's own. func localizePermissionLocks(ctx context.Context, tr *phrasebook.Translator, locks map[string][]PermissionRecordLock) { if len(locks) == 0 { return } if ctx == nil { ctx = context.Background() } ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr)) for _, entries := range locks { for i := range entries { entries[i].Note = translateKey(ctx, tr, entries[i].Note) } } }