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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
57
modules/cabana/example_permission_record_locks_test.go
Normal file
57
modules/cabana/example_permission_record_locks_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
modules/cabana/record_locks_smoke_test.go
Normal file
155
modules/cabana/record_locks_smoke_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user