feat(19.6.1-02): per-record locked permission values

- cabana.PermissionRecordLock and the optional PermissionRecordLockProvider
- RecordMeta.PermissionLocks (meta.permissionLocks) on show and save responses, notes localized
- save rule: a record-locked code keeps its stored value, a differing present value is 403
- Roles lock code-declared grants (locked_by_code); Administrators lock every override on your own record (locked_self)
- SPA: FORM_PERMISSION_LOCKS, locked codes never sent, record-locked rows in the permission editor
- regenerated admin OpenAPI and schema.d.ts; docs and README
This commit is contained in:
Jakub Zych
2026-10-22 11:29:25 +02:00
parent e2525d4956
commit 705f94d9e5
22 changed files with 588 additions and 60 deletions

View File

@@ -38,7 +38,7 @@ Use cases:
- 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.
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. A translatable type's value is a locale map stored through the same lift, `cabana.TranslationWriter` and hydrate path as `mlmarkdown`, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through `cabana.FieldWritesFromContext`. A list column may name a plugin alias as its `type` and shows the type's `ListValue`. The cabana-owned upload route hands an element's file to the type's `Upload`. The form schema carries `element`, `translatable` and `typeOptions` for such a field. Settings and relation forms refuse plugin field types (supported in controller forms only). See [Plugin field types](../../docs/backend/field-types.md).
- 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.
- 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. A controller that also implements `cabana.PermissionRecordLockProvider` fixes some values of one stored record (a role's code-declared grants, an administrator's own overrides): the record response carries them as `meta.permissionLocks`, the admin shows them locked with their note and never sends them, and a save keeps their stored values. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
- Repeater: `type: repeater` edits a JSON list in one column (`lagoon.Jsonable`) with a nested `form.fields` of `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn` a field of the same row) and `richeditor` fields, plus `prompt`, `minItems`, `maxItems` and `titleFrom` (`cabana.RepeaterForm` in the schema; the span is always full). Other nested types, a nested repeater, nested `tab`, `context` or `preset`, and WinterCMS's `groups` and `style` fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under `field.index.nested`, the row bounds are 422s on the field (`repeater.max_items`, `repeater.min_items`), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
@@ -245,7 +245,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. |
| `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. |
| `cabana.AdminFieldTypes` | Swag annotation of the field types route. |
| `cabana.RecordMeta` | Meta of a record response: `labels` of relation fields, `actions` (show only) and `notices` (create and update only, omitted when empty). |
| `cabana.RecordMeta` | Meta of a record response: `labels` of relation fields, `actions` (show only), `notices` (create and update only, omitted when empty) and `PermissionLocks` (`permissionLocks`: per permissioneditor field, the permissions the record fixes, omitted when none applies). |
| `cabana.FieldNotice` | One entry of `meta.notices`: the `field`, the content `locale` of a translatable field type's value, and the localized `message` a field type's `Decode` reported. At most 10 per field and locale, each cut to 500 characters. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
@@ -307,6 +307,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.FieldUploadResponse` / `cabana.AdminFieldUpload` | Answer (`url`) and swag annotation of the field upload route. |
| `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.PermissionRecordLock` | One permission a stored record fixes: `Code`, the `Value` the editor shows (1, -1, or 0 for inherit) and a `Note` (phrase key or text). |
| `cabana.PermissionRecordLockProvider` | Optional controller capability next to `PermissionEditorProvider`: `AdminPermissionRecordLocks` returns a stored record's locked permissions; they travel as `meta.permissionLocks`, are never sent by the admin, and a save keeps their stored values (403 when a present value differs). |
| `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order (`json:"enabledLocales,omitempty"` so list schemas omit the key). |
| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through `TranslatedExact`. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key, including relation-child records served by `cabana.RelationService`; Show/save hydration uses `hydrateMLRecord`. |
| `cabana.TranslationWriter.TranslatedExact` | Returns the stored value for a field in a locale without D-11 fallback; `ok` is false when the non-default key is missing. The default locale still reads the host column. |

View File

@@ -842,7 +842,7 @@ func AdminPartial() {}
// AdminShow documents the record show route.
//
// @Summary Show an admin record
// @Description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.
// @Description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered. meta.permissionLocks maps a `type: permissioneditor` field to the permissions this record fixes (the controller's cabana.PermissionRecordLockProvider), each with code, value (1, -1, or 0 for inherit) and note (localized): the admin shows that value and the note on the row and never sends the code; it is absent when no record lock applies.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -860,7 +860,7 @@ func AdminShow() {}
// AdminUpdate documents the record update route.
//
// @Summary Update an admin record
// @Description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
// @Description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise. A record-locked permission (meta.permissionLocks) keeps its stored value when its code is absent from the body; a present value that differs from the stored one is a 403 on the field. The response carries meta.permissionLocks like the show route.
// @Tags admin
// @Accept json
// @Produce json

View File

@@ -18,6 +18,9 @@ const rolesLang = "backend::lang.user.role."
// rolesPermissionsField is the Roles form's permission editor field.
const rolesPermissionsField = "permissions"
// permissionLockedByCodeKey is the note of a role's code-declared grant.
const permissionLockedByCodeKey = "backend::lang.permissioneditor.locked_by_code"
// rolesAdmin is the Roles screen (summercms.cabana.roles), the WinterCMS
// Backend\Controllers\UserRoles controller over backend_user_roles. Every
// route needs backend.manage_roles.
@@ -26,14 +29,15 @@ type rolesAdmin struct {
}
var (
_ pact.AdminController = rolesAdmin{}
_ pact.AdminPermissioned = rolesAdmin{}
_ pact.AdminRecordSource = rolesAdmin{}
_ pact.FormRecordTitle = rolesAdmin{}
_ pact.FormBeforeCreate = rolesAdmin{}
_ pact.FormBeforeUpdate = rolesAdmin{}
_ pact.FormBeforeDelete = rolesAdmin{}
_ PermissionEditorProvider = rolesAdmin{}
_ pact.AdminController = rolesAdmin{}
_ pact.AdminPermissioned = rolesAdmin{}
_ pact.AdminRecordSource = rolesAdmin{}
_ pact.FormRecordTitle = rolesAdmin{}
_ pact.FormBeforeCreate = rolesAdmin{}
_ pact.FormBeforeUpdate = rolesAdmin{}
_ pact.FormBeforeDelete = rolesAdmin{}
_ PermissionEditorProvider = rolesAdmin{}
_ PermissionRecordLockProvider = rolesAdmin{}
)
func (rolesAdmin) ID() string { return RolesControllerID }
@@ -110,6 +114,32 @@ func (rolesAdmin) AdminPermissionValues(_ context.Context, field string, record
return decodePermissionValues(role.Permissions, false), nil
}
// AdminPermissionRecordLocks locks, on a role that code-declared grants
// (pact.Permission.Roles) attach to, every such grant, shown granted with
// the locked_by_code note (D-16): the grant comes from the plugin, so
// unticking it here would change nothing. Codes follow the catalog order.
func (c rolesAdmin) AdminPermissionRecordLocks(_ context.Context, field string, record any) ([]PermissionRecordLock, error) {
role, ok := record.(*BackendUserRole)
if field != rolesPermissionsField || !ok || role == nil {
return nil, fmt.Errorf("cabana: roles: cannot read permission field %q", field)
}
reg := c.registry()
if reg == nil {
return nil, nil
}
grants := reg.rolePermissions(role.Code)
if len(grants) == 0 {
return nil, nil
}
var out []PermissionRecordLock
for _, code := range reg.permissionOrder {
if grants[code] {
out = append(out, PermissionRecordLock{Code: code, Value: 1, Note: permissionLockedByCodeKey})
}
}
return out, nil
}
// AdminSetPermissionValues stores the grants on the role; the save writes
// the row.
func (rolesAdmin) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {

View File

@@ -25,29 +25,30 @@ type usersAdmin struct {
}
var (
_ pact.AdminController = usersAdmin{}
_ pact.AdminPermissioned = usersAdmin{}
_ pact.AdminRecordSource = usersAdmin{}
_ pact.FormRecordTitle = usersAdmin{}
_ FieldRelationProvider = usersAdmin{}
_ pact.ListExtendQuery = usersAdmin{}
_ pact.FormExtendQuery = usersAdmin{}
_ pact.FormVirtualFields = usersAdmin{}
_ pact.FormRules = usersAdmin{}
_ pact.FilterOptions = usersAdmin{}
_ pact.FormBeforeCreate = usersAdmin{}
_ pact.FormBeforeUpdate = usersAdmin{}
_ pact.FormAfterUpdate = usersAdmin{}
_ RelationLockProvider = usersAdmin{}
_ PermissionEditorProvider = usersAdmin{}
_ recordDataExtender = usersAdmin{}
_ schemaFieldHider = usersAdmin{}
_ pact.ListRowStates = usersAdmin{}
_ pact.FormBeforeDelete = usersAdmin{}
_ pact.FormAfterDelete = usersAdmin{}
_ pact.HasAdminRecordActions = usersAdmin{}
_ pact.HasAdminBulkActions = usersAdmin{}
_ pact.AdminPartialData = usersAdmin{}
_ pact.AdminController = usersAdmin{}
_ pact.AdminPermissioned = usersAdmin{}
_ pact.AdminRecordSource = usersAdmin{}
_ pact.FormRecordTitle = usersAdmin{}
_ FieldRelationProvider = usersAdmin{}
_ pact.ListExtendQuery = usersAdmin{}
_ pact.FormExtendQuery = usersAdmin{}
_ pact.FormVirtualFields = usersAdmin{}
_ pact.FormRules = usersAdmin{}
_ pact.FilterOptions = usersAdmin{}
_ pact.FormBeforeCreate = usersAdmin{}
_ pact.FormBeforeUpdate = usersAdmin{}
_ pact.FormAfterUpdate = usersAdmin{}
_ RelationLockProvider = usersAdmin{}
_ PermissionEditorProvider = usersAdmin{}
_ PermissionRecordLockProvider = usersAdmin{}
_ recordDataExtender = usersAdmin{}
_ schemaFieldHider = usersAdmin{}
_ pact.ListRowStates = usersAdmin{}
_ pact.FormBeforeDelete = usersAdmin{}
_ pact.FormAfterDelete = usersAdmin{}
_ pact.HasAdminRecordActions = usersAdmin{}
_ pact.HasAdminBulkActions = usersAdmin{}
_ pact.AdminPartialData = usersAdmin{}
)
func (usersAdmin) ID() string { return AdministratorsControllerID }
@@ -584,6 +585,32 @@ func (usersAdmin) AdminPermissionValues(_ context.Context, field string, record
return decodePermissionValues(user.Permissions, true), nil
}
// AdminPermissionRecordLocks locks every offered permission on the
// editor's own record, shown with its stored override (1, -1, or 0 for
// inherit) and the locked_self note (D-08); other records have none.
// AdminSetPermissionValues still refuses a change on the own record.
func (c usersAdmin) AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error) {
user, ok := record.(*BackendUser)
if field != usersPermissionsField || !ok || user == nil {
return nil, fmt.Errorf("cabana: administrators: cannot read permission field %q", field)
}
principal, _ := bouncer.User(ctx)
if principal == nil || user.ID == 0 || principal.ID != user.ID {
return nil, nil
}
stored := decodePermissionValues(user.Permissions, true)
options := catalogOptions(ctx, c.registry())
out := make([]PermissionRecordLock, 0, len(options))
for _, option := range options {
out = append(out, PermissionRecordLock{Code: option.Code, Value: stored[option.Code], Note: permissionLockedSelfKey})
}
return out, nil
}
// permissionLockedSelfKey is the note of every permission on the editor's
// own Administrators record.
const permissionLockedSelfKey = "backend::lang.permissioneditor.locked_self"
// AdminSetPermissionValues stores the override set on the record; the save
// writes the row. On the editor's own record any change is refused (D-08).
func (usersAdmin) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {

View File

@@ -541,6 +541,7 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
// RelationLockProvider may refuse the read with phrase keys.
return RecordResult{}, localizeControllerError(ctx, s.tr, err)
}
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}
@@ -667,6 +668,25 @@ func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController,
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
return RecordResult{}, err
}
// D-21: the permissions the record fixes, per permissioneditor field.
// Notes are phrase keys here; the show and save paths translate them.
for _, name := range permissionFieldNames(cc) {
provider, ok := cc.Controller.(PermissionRecordLockProvider)
if !ok || provider == nil {
break
}
locks, err := provider.AdminPermissionRecordLocks(withTx(ctx, tx), name, model)
if err != nil {
return RecordResult{}, lifecycleFailure(cc, err)
}
if len(locks) == 0 {
continue
}
if meta.PermissionLocks == nil {
meta.PermissionLocks = map[string][]PermissionRecordLock{}
}
meta.PermissionLocks[name] = append([]PermissionRecordLock(nil), locks...)
}
if cc != nil {
if ext, ok := cc.Controller.(recordDataExtender); ok && ext != nil {
ext.extendRecordData(ctx, model, data)
@@ -887,6 +907,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return RecordResult{}, err
}
result.Meta.Notices = localizeNotices(ctx, s.tr, notices)
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}

View File

@@ -0,0 +1,57 @@
package cabana_test
import (
"context"
"fmt"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// AccessRole is a role record whose permissions the acme.access roles form
// edits with a checkbox permission editor.
type AccessRole struct {
ID uint `gorm:"column:id;primaryKey"`
Code string `gorm:"column:code"`
}
// AccessRolesController owns the roles form. CodeGrants are the grants the
// application's plugins declare in code, per role code: they apply whatever
// the role's stored permissions say.
type AccessRolesController struct {
CodeGrants map[string][]string
}
var _ cabana.PermissionRecordLockProvider = AccessRolesController{}
// AdminPermissionRecordLocks shows the grants plugins declare for the role
// as granted and locked, with a note: unticking them on the form would
// change nothing.
func (c AccessRolesController) AdminPermissionRecordLocks(_ context.Context, field string, record any) ([]cabana.PermissionRecordLock, error) {
role := record.(*AccessRole)
var locks []cabana.PermissionRecordLock
for _, code := range c.CodeGrants[role.Code] {
locks = append(locks, cabana.PermissionRecordLock{
Code: code,
Value: 1,
Note: "acme.access::lang.roles.granted_by_plugin",
})
}
return locks, nil
}
// Example_permissionRecordLocks shows the locks of a role a plugin grants
// two permissions to, and of a role without code grants.
func Example_permissionRecordLocks() {
ctl := AccessRolesController{CodeGrants: map[string][]string{"editor": {"posts.edit", "posts.publish"}}}
ctx := context.Background()
locks, _ := ctl.AdminPermissionRecordLocks(ctx, "permissions", &AccessRole{ID: 1, Code: "editor"})
for _, lock := range locks {
fmt.Println(lock.Code, lock.Value)
}
locks, _ = ctl.AdminPermissionRecordLocks(ctx, "permissions", &AccessRole{ID: 2, Code: "guest"})
fmt.Println(len(locks))
// Output:
// posts.edit 1
// posts.publish 1
// 0
}

View File

@@ -64,6 +64,36 @@ type PermissionEditorProvider interface {
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.
@@ -252,8 +282,12 @@ func permissionAllowed(mode string, value int) bool {
// 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.
// 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
@@ -285,6 +319,7 @@ func applyPermissionValues(ctx context.Context, cc *CompiledController, record a
submitted[code] = n
}
stored := map[string]int{}
recordLocked := map[string]bool{}
if update {
current, err := provider.AdminPermissionValues(ctx, value.field, record)
if err != nil {
@@ -293,20 +328,37 @@ func applyPermissionValues(ctx context.Context, cc *CompiledController, record a
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 {
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 {
next[code] = n
if !recordLocked[code] {
next[code] = n
}
}
if err := provider.AdminSetPermissionValues(ctx, value.field, record, next); err != nil {
return lifecycleFailure(cc, err)
@@ -338,3 +390,38 @@ func projectPermissionFields(ctx context.Context, cc *CompiledController, record
}
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)
}
}
}

View File

@@ -0,0 +1,155 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
)
// lockRecord is the in-test record: its stored permissions.
type lockRecord struct {
ID uint
Permissions map[string]int
}
// lockEditor offers a.x and a.y; withLocks also record-locks a.x at 1.
type lockEditor struct {
withLocks bool
locked bool // a.x also request-Locked
stored map[string]int
}
func (lockEditor) ID() string { return "acme.locks.items" }
func (lockEditor) ModelName() string { return `Acme\Locks\Models\Item` }
func (lockEditor) ConfigDir() string { return "controllers/items" }
func (c *lockEditor) AdminPermissionOptions(context.Context, string) ([]PermissionOption, error) {
return []PermissionOption{{Code: "a.x", Label: "X", Locked: c.locked}, {Code: "a.y", Label: "Y"}}, nil
}
func (c *lockEditor) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
r := record.(*lockRecord)
out := map[string]int{}
for code, n := range r.Permissions {
out[code] = n
}
return out, nil
}
func (c *lockEditor) AdminSetPermissionValues(_ context.Context, _ string, record any, values map[string]int) error {
c.stored = values
record.(*lockRecord).Permissions = values
return nil
}
// lockingEditor adds the record-lock provider.
type lockingEditor struct{ *lockEditor }
func (lockingEditor) AdminPermissionRecordLocks(context.Context, string, any) ([]PermissionRecordLock, error) {
return []PermissionRecordLock{{Code: "a.x", Value: 1, Note: "backend::lang.permissioneditor.locked_by_code"}}, nil
}
func lockController(c *lockEditor, locks bool) *CompiledController {
cc := &CompiledController{
Form: &FormSchema{Fields: []FormField{{Name: "permissions", Type: permissionFieldType, Mode: "checkbox"}}},
permissions: map[string]string{"permissions": "checkbox"},
}
if locks {
cc.Controller = lockingEditor{c}
} else {
cc.Controller = c
}
return cc
}
func TestPermissionRecordLocksSmoke(t *testing.T) {
ctx := context.Background()
lift := func(values map[string]int) []permissionValue {
return []permissionValue{{field: "permissions", mode: "checkbox", values: values}}
}
t.Run("absent record-locked code keeps the stored value", func(t *testing.T) {
editor := &lockEditor{}
record := &lockRecord{ID: 1, Permissions: map[string]int{"a.y": 1}}
if err := applyPermissionValues(ctx, lockController(editor, true), record, lift(map[string]int{"a.y": 1}), true); err != nil {
t.Fatal(err)
}
if _, ok := editor.stored["a.x"]; ok || editor.stored["a.y"] != 1 || len(editor.stored) != 1 {
t.Fatalf("stored = %v, want only a.y: 1 (a.x unchanged, absent)", editor.stored)
}
})
t.Run("record-locked code that differs from the stored value is refused", func(t *testing.T) {
editor := &lockEditor{}
record := &lockRecord{ID: 1, Permissions: map[string]int{"a.y": 1}}
err := applyPermissionValues(ctx, lockController(editor, true), record, lift(map[string]int{"a.x": 1, "a.y": 1}), true)
var forbidden *ForbiddenError
if !errors.As(err, &forbidden) || forbidden.Details["permissions"] == nil {
t.Fatalf("err = %v, want ForbiddenError on permissions", err)
}
if editor.stored != nil {
t.Fatalf("stored = %v after a refusal", editor.stored)
}
})
t.Run("a body equal to the stored values passes", func(t *testing.T) {
editor := &lockEditor{}
record := &lockRecord{ID: 1, Permissions: map[string]int{"a.x": 1, "a.y": 1}}
if err := applyPermissionValues(ctx, lockController(editor, true), record, lift(map[string]int{"a.x": 1, "a.y": 1}), true); err != nil {
t.Fatal(err)
}
if editor.stored["a.x"] != 1 || editor.stored["a.y"] != 1 {
t.Fatalf("stored = %v", editor.stored)
}
})
t.Run("request-locked and record-locked code absent from the body is not a 403", func(t *testing.T) {
editor := &lockEditor{locked: true}
record := &lockRecord{ID: 1, Permissions: map[string]int{}}
if err := applyPermissionValues(ctx, lockController(editor, true), record, lift(map[string]int{"a.y": 1}), true); err != nil {
t.Fatalf("err = %v, want nil", err)
}
// Without the record lock the same body changes nothing on a.x
// either: a.x is stored absent and submitted absent.
if err := applyPermissionValues(ctx, lockController(&lockEditor{locked: true}, false), &lockRecord{ID: 1, Permissions: map[string]int{"a.x": 1}}, lift(map[string]int{"a.y": 1}), true); err == nil {
t.Fatal("request-locked a.x removed without a record lock: want 403")
}
})
t.Run("record-locked codes are not asked on create", func(t *testing.T) {
editor := &lockEditor{}
if err := applyPermissionValues(ctx, lockController(editor, true), &lockRecord{}, lift(map[string]int{"a.x": 1}), false); err != nil {
t.Fatal(err)
}
if editor.stored["a.x"] != 1 {
t.Fatalf("stored = %v, want a.x on create", editor.stored)
}
})
t.Run("record meta carries permissionLocks only with a provider", func(t *testing.T) {
record := &lockRecord{ID: 7, Permissions: map[string]int{"a.y": 1}}
result, err := projectFullRecord(ctx, nil, lockController(&lockEditor{}, true), record)
if err != nil {
t.Fatal(err)
}
locks := result.Meta.PermissionLocks["permissions"]
if len(locks) != 1 || locks[0].Code != "a.x" || locks[0].Value != 1 || locks[0].Note == "" {
t.Fatalf("permissionLocks = %+v", result.Meta.PermissionLocks)
}
raw, _ := json.Marshal(result.Meta)
if !strings.Contains(string(raw), `"permissionLocks":{"permissions":[{"code":"a.x","value":1,"note":`) {
t.Fatalf("meta JSON = %s", raw)
}
plain, err := projectFullRecord(ctx, nil, lockController(&lockEditor{}, false), record)
if err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(plain.Meta)
if strings.Contains(string(raw), "permissionLocks") {
t.Fatalf("meta JSON without a provider = %s", raw)
}
})
}

View File

@@ -222,6 +222,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err != nil {
return RecordResult{}, err
}
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}
@@ -307,6 +308,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
if err != nil {
return RecordResult{}, err
}
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}
@@ -371,6 +373,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
if err != nil {
return RecordResult{}, err
}
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}

View File

@@ -108,6 +108,12 @@ type RecordMeta struct {
Labels map[string][]RelationOption `json:"labels"`
Actions []RecordAction `json:"actions,omitempty"`
Notices []FieldNotice `json:"notices,omitempty"`
// PermissionLocks are, per permissioneditor field, the permissions this
// record fixes (the controller's PermissionRecordLockProvider), with the
// value to show and the note, translated in the request locale. The show
// and save responses carry it; it is omitted when no record lock applies
// and is never part of the form schema.
PermissionLocks map[string][]PermissionRecordLock `json:"permissionLocks,omitempty"`
}
// FieldNotice is one save notice: the field it is about, the content locale

View File

@@ -261,6 +261,8 @@ permissioneditor:
inherit: Inherit
deny: Deny
locked: You cannot change this permission.
locked_by_code: Granted to this role by a plugin. It cannot be removed here.
locked_self: You cannot change your own permissions.
empty: No permissions are defined yet.
other: Other
api_tokens:

View File

@@ -283,6 +283,8 @@ permissioneditor:
inherit: Dziedzicz
deny: Odmów
locked: Nie możesz zmienić tego uprawnienia.
locked_by_code: Nadane tej roli przez wtyczkę. Nie można go tu odebrać.
locked_self: Nie możesz zmieniać własnych uprawnień.
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
other: Inne
api_tokens: