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

@@ -371,6 +371,33 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
- A save that does not send the field leaves the stored permissions alone, and the field's `context` applies as for any field.
- The keys `options`, `default`, `nameFrom`, `emptyOption`, `relation` and `preset` are refused on the type. A field without `mode`, or on a controller that does not implement the provider, stops the start-up. The type is not available on settings forms or relation forms.
### Permissions the record fixes
Some values cannot change on one particular record: grants a plugin declares in code for a role apply whatever the role stores, and an administrator may not change their own overrides. The controller says so by also implementing `cabana.PermissionRecordLockProvider`. Its `AdminPermissionRecordLocks` returns, for a stored record, a list of `cabana.PermissionRecordLock`: the `Code`, the `Value` the editor shows (1, -1, or 0 for inherit) and a `Note` (a translation key or text). It is asked with the request's administrator, for a stored record only, never on a create. A roles controller locks the grants its plugins declare:
```go src=modules/cabana/example_permission_record_locks_test.go#AccessRolesController.AdminPermissionRecordLocks
// 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
}
```
- The record response (show, create and update) carries the locks as `meta.permissionLocks`, keyed by field name: `{"permissions": [{"code": "posts.edit", "value": 1, "note": "Granted to this role by a plugin. It cannot be removed here."}]}`, with the note translated in the request locale. The key is absent when no lock applies, and the form schema never carries it.
- The admin shows a record-locked row with the lock's value, a disabled control, the lock icon and the note instead of the option's comment. A record lock wins over `Locked`. On a read-only screen (the preview) only the value is shown.
- The admin never sends a record-locked code. On an update the server keeps the stored value of such a code when it is absent from the body; a present value must equal the stored one, otherwise the save is answered 403 `forbidden` on the field. The stored value is what `AdminPermissionValues` returns, so a role whose code grant is not in its stored permissions still saves, and the code is left out of the `Locked` comparison.
- cabana's own Roles screen locks a role's code-declared grants with `backend::lang.permissioneditor.locked_by_code`, and the Administrators screen locks every override on the editor's own record with `backend::lang.permissioneditor.locked_self`.
## Markdown and multilingual fields
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor with a Preview toggle. When Preview opens, and again shortly after the source changes while it is open, the SPA posts the field's source (the active locale's text for `mlmarkdown`) to the preview route below and renders only the server's answer; when the output is refused it shows the server's message as text. The server renders through `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST <prefix>/api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused.