feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

View File

@@ -2662,7 +2662,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {
@@ -2767,7 +2767,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {
@@ -2892,7 +2892,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {
@@ -3820,7 +3820,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {
@@ -4032,7 +4032,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {
@@ -4156,7 +4156,7 @@
} }
} }
}, },
"description": "Forbidden" "description": "also returned when controller code refuses the write; details may name fields"
}, },
"404": { "404": {
"content": { "content": {

View File

@@ -737,7 +737,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -821,7 +821,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -919,7 +919,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -1709,7 +1709,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -1776,7 +1776,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -1866,7 +1866,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"]; "application/json": components["schemas"]["cabana.ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description also returned when controller code refuses the write; details may name fields */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;

View File

@@ -5,8 +5,13 @@ import { t, tc } from '../../app/i18n'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…". // 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server // Messages of keys that are not form fields are listed here, so no server
// message is lost. // message is lost. With `forbidden` (UI-SPEC S6: a save the server refused
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>() // with 403) the same geometry shows that text instead; it stays until the
// next save attempt, and field messages still render on their fields.
const props = withDefaults(
defineProps<{ errors: Record<string, string[]>; fieldNames: string[]; forbidden?: string | null }>(),
{ forbidden: null },
)
const count = computed(() => Object.keys(props.errors).length) const count = computed(() => Object.keys(props.errors).length)
const orphans = computed(() => const orphans = computed(() =>
@@ -18,7 +23,19 @@ const orphans = computed(() =>
<template> <template>
<div <div
v-if="count > 0" v-if="forbidden"
role="alert"
data-forbidden-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
<div class="flex min-w-0 flex-col gap-1">
<p class="[overflow-wrap:anywhere]">{{ forbidden }}</p>
<p v-for="(text, index) in orphans" :key="index" class="text-[13px] [overflow-wrap:anywhere]">{{ text }}</p>
</div>
</div>
<div
v-else-if="count > 0"
role="alert" role="alert"
data-error-banner data-error-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger" class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"

View File

@@ -58,6 +58,9 @@ const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({}) const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({}) const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({}) const errors = ref<Record<string, string[]>>({})
// The text of a save the server refused with 403 (UI-SPEC S6). It is a
// banner, not a toast: it stays readable until the next save attempt.
const forbidden = ref<string | null>(null)
const loading = ref(true) const loading = ref(true)
const failed = ref(false) const failed = ref(false)
const busy = ref(false) const busy = ref(false)
@@ -245,6 +248,7 @@ async function save(): Promise<RecordEnvelope | null> {
return null return null
} }
busy.value = true busy.value = true
forbidden.value = null
try { try {
const body = editablePayload(fields.value, values.value) const body = editablePayload(fields.value, values.value)
const result = const result =
@@ -265,6 +269,11 @@ async function save(): Promise<RecordEnvelope | null> {
} }
if (result.response.status === 422) { if (result.response.status === 422) {
await showErrors(result.error?.error) await showErrors(result.error?.error)
} else if (result.response.status === 403) {
// Nothing was saved: every entered value and the dirty state stay.
// Fields the refusal names are marked and the first is focused.
forbidden.value = result.error?.error.message || t('backend::lang.form.forbidden')
await showErrors(result.error?.error)
} else { } else {
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger') showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
} }
@@ -324,7 +333,8 @@ async function onDelete(): Promise<void> {
await go(listPath) await go(listPath)
return return
} }
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger') const fallback = result.response.status === 403 ? 'backend::lang.list.action_forbidden' : 'backend::lang.form.error_generic'
showToast(result.error?.error.message || t(fallback), 'danger')
} catch { } catch {
showToast(t('backend::lang.form.error_generic'), 'danger') showToast(t('backend::lang.form.error_generic'), 'danger')
} finally { } finally {
@@ -403,7 +413,7 @@ void load()
</p> </p>
<template v-else-if="schema"> <template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" /> <FormErrorBanner :errors="errors" :field-names="fieldNames" :forbidden="forbidden" />
<form <form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined" :id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined" :role="tabs.length > 0 ? 'tabpanel' : undefined"

View File

@@ -1,6 +1,7 @@
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list // Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the // (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
// row state badges (UI-SPEC S4, D-12). // row state badges (UI-SPEC S4, D-12) and the forbidden save banner (UI-SPEC
// S6, D-27).
// Fixtures are neutral acme.roster.* data; no application // Fixtures are neutral acme.roster.* data; no application
// names appear in framework tests. // names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -9,7 +10,15 @@ import { setBundle } from '../../src/app/i18n'
import RecordActions from '../../src/components/form/RecordActions.vue' import RecordActions from '../../src/components/form/RecordActions.vue'
import RowStateBadges from '../../src/components/list/RowStateBadges.vue' import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
import ToastHost from '../../src/components/ui/Toast.vue' import ToastHost from '../../src/components/ui/Toast.vue'
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed' import {
clone,
formSchemaFixture,
langFixture,
recordFixture,
rosterListFixture,
rosterListSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const LIST = `${API}/acme/roster/people` const LIST = `${API}/acme/roster/people`
@@ -32,6 +41,7 @@ const strings = {
'backend::lang.form.action_stale': { 'backend::lang.form.action_stale': {
other: 'This action no longer applies to this record. The page has been refreshed.', other: 'This action no longer applies to this record. The page has been refreshed.',
}, },
'backend::lang.form.forbidden': { other: 'You do not have permission to make this change. Nothing was saved.' },
} }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> { function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -545,3 +555,131 @@ describe('row state (UI-SPEC S4, D-12)', () => {
expect(none.text()).toBe('') expect(none.text()).toBe('')
}) })
}) })
describe('forbidden save (UI-SPEC S6, D-27)', () => {
const WIDGETS = `${API}/acme/demo/widgets`
const RECORD = `${WIDGETS}/1`
function formRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${WIDGETS}/schema/form`]: { body: formSchemaFixture },
[`GET ${RECORD}`]: { body: recordFixture },
...overrides,
}
}
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
status: 403,
body: { error: { code: 'forbidden', message, details } },
})
const banner = (wrapper: VueWrapper) => wrapper.find('[data-forbidden-banner]')
const nameInput = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('#field-name')
it('shows a persistent alert banner with the server message, keeps the values and marks the named field', async () => {
const long = 'You may not rename this widget because it belongs to a maker you cannot manage. '.repeat(3).trim()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({ [`PUT ${RECORD}`]: refused(long, { name: ['This name is reserved.'] }) }),
{ attach: true },
)
await nameInput(wrapper).setValue('Reserved')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(banner(wrapper).attributes('role')).toBe('alert')
expect(banner(wrapper).text()).toBe(long)
expect(banner(wrapper).find('svg').exists()).toBe(true)
// A long message wraps; it is never truncated.
expect(banner(wrapper).html()).not.toContain('truncate')
// It is a banner, not a toast, and the 422 banner does not show.
expect(wrapper.find('[data-tone]').exists()).toBe(false)
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
// Nothing was saved: the typed value and the dirty state stay.
expect(nameInput(wrapper).element.value).toBe('Reserved')
expect(wrapper.text()).toContain('This name is reserved.')
expect(nameInput(wrapper).attributes('aria-invalid')).toBe('true')
expect(document.activeElement).toBe(nameInput(wrapper).element)
// Leaving still asks: the form is dirty.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(dialog()).not.toBeNull()
await press('cancel')
expect(banner(wrapper).exists()).toBe(true)
})
it('falls back to the framework text when the server sends no message, without field marks', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes({ [`PUT ${RECORD}`]: refused('') }), {
attach: true,
})
await nameInput(wrapper).setValue('Other')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).text()).toBe('You do not have permission to make this change. Nothing was saved.')
expect(nameInput(wrapper).attributes('aria-invalid')).not.toBe('true')
expect(nameInput(wrapper).element.value).toBe('Other')
})
it('refuses a create the same way', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
formRoutes({ [`POST ${WIDGETS}`]: refused('You may not add widgets here.') }),
{ attach: true },
)
await nameInput(wrapper).setValue('New widget')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).text()).toBe('You may not add widgets here.')
expect(nameInput(wrapper).element.value).toBe('New widget')
expect(router.currentRoute.value.name).toBe('create')
})
it('clears the banner when the next save attempt starts', async () => {
let attempt = 0
let release: ((reply: Reply) => void) | undefined
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({
[`PUT ${RECORD}`]: () => {
attempt += 1
if (attempt === 1) {
return refused('You may not rename this widget.')
}
return new Promise<Reply>((resolve) => {
release = resolve
})
},
}),
{ attach: true },
)
await nameInput(wrapper).setValue('Reserved')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(banner(wrapper).exists()).toBe(true)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
// The second request is still running; the banner is already gone.
expect(release).toBeDefined()
expect(banner(wrapper).exists()).toBe(false)
release?.({ body: recordFixture })
await flushPromises()
expect(banner(wrapper).exists()).toBe(false)
})
it('toasts a refused delete with the server message or the fallback', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/1',
formRoutes({ [`DELETE ${RECORD}`]: refused('') }),
{ attach: true },
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
expect(banner(wrapper).exists()).toBe(false)
expect(router.currentRoute.value.name).toBe('record')
})
})

View File

@@ -192,7 +192,9 @@ describe('edit a record', () => {
it('shows a danger toast with the envelope message for other errors', async () => { it('shows a danger toast with the envelope message for other errors', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', { const { wrapper } = await mountApp('/acme/demo/widgets/1', {
...formRoutes, ...formRoutes,
[`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } }, // Not a 422 and not a 403: a refused save (403) is the forbidden banner
// (Phase 12.1, UI-SPEC S6), covered in actions.smoke.test.ts.
[`PUT ${RECORD}`]: { status: 409, body: { error: { code: 'conflict', message: 'The record changed.', details: {} } } },
}) })
await wrapper.find('[data-action="save"]').trigger('click') await wrapper.find('[data-action="save"]').trigger('click')
@@ -200,8 +202,9 @@ describe('edit a record', () => {
const toast = wrapper.find('[data-tone="danger"]') const toast = wrapper.find('[data-tone="danger"]')
expect(toast.attributes('role')).toBe('alert') expect(toast.attributes('role')).toBe('alert')
expect(toast.text()).toContain('Forbidden.') expect(toast.text()).toContain('The record changed.')
expect(wrapper.find('[data-error-banner]').exists()).toBe(false) expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false)
}) })
it('renders an unknown field type as the unsupported box instead of breaking the form', async () => { it('renders an unknown field type as the unsupported box instead of breaking the form', async () => {

View File

@@ -221,6 +221,20 @@ A hook or scope that has to read the database during a write should use the tran
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes. Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
## Refusing a write
A hook or an action stops a write by returning an error. Which error decides what the administrator sees:
| Error | Answer |
|-------|--------|
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field. |
| `cabana.ForbiddenError` | 403 `forbidden`, with its `Message` and its `Details`. |
| any other error | The opaque 500. The error is logged on the server and its text never reaches the client. |
Return a `cabana.ForbiddenError` when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. `Message` is a translation key or text; cabana translates it, and every `Details` message, in the request locale. `Details` maps a field name to a list of messages, as a `cabana.ValidationError` does, and may be left out. With an empty `Message` the admin shows its own text.
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A `cabana.ForbiddenError` works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.
## Toolbar actions ## Toolbar actions
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points. `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.

View File

@@ -68,7 +68,9 @@ A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before a
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`. An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's. Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminBulkActions`) and record actions (`pact.HasAdminRecordActions`) may each name extra permissions, checked on top of the controller's. An administrator who lacks them does not get the action in the list schema or in a record's `meta.actions`, and posting it answers 403 and is written to the authentication log. A bulk or record action that ran is logged with the controller, the action, the administrator and the affected count or record id, without record contents.
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
## Managing administrators ## Managing administrators

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BgVbexs3.js"></script> <script type="module" crossorigin src="./assets/index-8CEYdgqp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css"> <link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css">
</head> </head>
<body> <body>

View File

@@ -23,6 +23,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
@@ -192,6 +193,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. | | `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | | `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. |
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | | `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. |
| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. | | `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. |

View File

@@ -255,8 +255,8 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
} }
// runAction calls the plugin's Run and writes the D-10 envelope. A // runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422; any other error is logged and answered with the // *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// generic 500 body, never the error text. // logged and answered with the generic 500 body, never the error text.
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) { func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
tr := s.translator() tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr)) ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
@@ -267,6 +267,12 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
// A refusal (D-27) is a 403 with the action's localized message.
var refused *ForbiddenError
if errors.As(err, &refused) {
writeCRUDError(w, localizeForbidden(ctx, tr, err))
return
}
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err) slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError) WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return

View File

@@ -371,7 +371,7 @@ func AdminList() {}
// @Param X-Session-Key header string false "Form session key: the save attaches the files uploaded under it" // @Param X-Session-Key header string false "Form session key: the save attaches the files uploaded under it"
// @Success 201 {object} RecordEnvelope // @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller} [post] // @Router /{vendor}/{plugin}/{controller} [post]
@@ -390,7 +390,7 @@ func AdminCreate() {}
// @Param body body AdminIDsRequest true "Record ids" // @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkResult] // @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope
@@ -412,7 +412,7 @@ func AdminBulkDelete() {}
// @Param body body AdminIDsRequest true "Record ids" // @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkActionResult] // @Success 200 {object} Envelope[BulkActionResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
@@ -435,7 +435,7 @@ func AdminBulkAction() {}
// @Param body body AdminActionRequest true "Empty object" // @Param body body AdminActionRequest true "Empty object"
// @Success 200 {object} Envelope[AdminActionResult] // @Success 200 {object} Envelope[AdminActionResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
@@ -553,7 +553,7 @@ func AdminShow() {}
// @Param X-Session-Key header string false "Form session key: the save applies the file uploads and removals held against it" // @Param X-Session-Key header string false "Form session key: the save applies the file uploads and removals held against it"
// @Success 200 {object} RecordEnvelope // @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [put] // @Router /{vendor}/{plugin}/{controller}/{id} [put]
@@ -571,7 +571,7 @@ func AdminUpdate() {}
// @Param id path integer true "Record id" // @Param id path integer true "Record id"
// @Success 200 {object} Envelope[BulkResult] // @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope "also returned when controller code refuses the write; details may name fields"
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id} [delete] // @Router /{vendor}/{plugin}/{controller}/{id} [delete]

View File

@@ -65,6 +65,72 @@ type ValidationError struct {
func (e *ValidationError) Error() string { return "validation_failed" } func (e *ValidationError) Error() string { return "validation_failed" }
// ForbiddenError is a write that controller code refuses (D-27): a lifecycle
// hook, a bulk action, a record action, a toolbar action or a widget action
// returns it, and the admin API answers 403 with code forbidden. Message is a
// phrase key or text shown to the administrator; it may be empty, and the
// admin then shows its own text. Details maps a field name to a list of
// messages (phrase keys or text) shown on that field. Both are localized in
// the request locale before the response is written. The surrounding
// transaction is rolled back, so a refused write changes nothing.
type ForbiddenError struct {
Message string
Details map[string]any
}
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
}
}
}
return out
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError that comes out of it, so a refusal leaves the
// service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context. // CapabilityError is a fail-closed Fill/Validate failure with controller context.
type CapabilityError struct { type CapabilityError struct {
ControllerID string ControllerID string
@@ -172,7 +238,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
return BulkResult{}, err return BulkResult{}, err
} }
var result BulkResult var result BulkResult
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
target, err := newWritableModel(cc) target, err := newWritableModel(cc)
if err != nil { if err != nil {
@@ -220,7 +286,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
return BulkResult{}, err return BulkResult{}, err
} }
var result BulkResult var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
@@ -285,7 +351,7 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
} }
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr)) ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result BulkActionResult var result BulkActionResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
@@ -430,7 +496,7 @@ func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, i
} }
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr)) ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
result := AdminActionResult{Fill: map[string]any{}} result := AdminActionResult{Fill: map[string]any{}}
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
target, err := newWritableModel(cc) target, err := newWritableModel(cc)
if err != nil { if err != nil {
@@ -503,7 +569,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return RecordResult{}, err return RecordResult{}, err
} }
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
target, err := newWritableModel(cc) target, err := newWritableModel(cc)
if err != nil { if err != nil {
@@ -608,6 +674,13 @@ func writeCRUDError(w http.ResponseWriter, err error) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details) WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
return return
} }
// Controller code refused the write (D-27): only the plugin-authored
// message and details are written, never another error's text.
var refused *ForbiddenError
if errors.As(err, &refused) && refused != nil {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", refused.Message, refused.Details)
return
}
var missing recordNotFound var missing recordNotFound
if errors.As(err, &missing) { if errors.As(err, &missing) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound) WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
@@ -700,6 +773,11 @@ func lifecycleFailure(cc *CompiledController, err error) error {
if errors.As(err, &invalid) { if errors.As(err, &invalid) {
return err return err
} }
// Without this a hook's refusal would become the opaque lifecycle error.
var refused *ForbiddenError
if errors.As(err, &refused) {
return err
}
var closed *CapabilityError var closed *CapabilityError
if errors.As(err, &closed) { if errors.As(err, &closed) {
return err return err

View File

@@ -140,6 +140,11 @@ func (c actController) AdminActions() []pact.AdminAction {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}} return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{"name": []string{"Name is taken."}}}
case "boom": case "boom":
return pact.AdminActionResult{}, errors.New("upstream said hunter2") return pact.AdminActionResult{}, errors.New("upstream said hunter2")
case "refused":
return pact.AdminActionResult{}, &cabana.ForbiddenError{
Message: "acme.demo::lang.gadgets.looked_up",
Details: map[string]any{"name": []string{"acme.demo::lang.gadgets.name"}},
}
case "nested": case "nested":
return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil return pact.AdminActionResult{Fill: map[string]any{"name": []string{"a"}, "active": false}}, nil
case "encoded": case "encoded":

View File

@@ -653,3 +653,119 @@ func TestSoftDeletedRecordSmoke(t *testing.T) {
} }
}) })
} }
// rosterError decodes a D-10 error envelope.
func rosterError(t *testing.T, rec *httptest.ResponseRecorder) cabana.ErrorBody {
t.Helper()
var body cabana.ErrorEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error body %s: %v", rec.Body.String(), err)
}
return body.Error
}
// TestForbiddenSmoke checks cabana.ForbiddenError through the assembled
// router on PostgreSQL (D-27; T-12.1-05, T-12.1-06): a hook, a bulk action, a
// record action and a widget action that refuse a write are answered 403
// with the localized message and details and change nothing, and every other
// error stays the opaque 500.
func TestForbiddenSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
t.Run("a hook refuses an update", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Reserved","email":"new@example.test"}`, "bearer")
got := rosterError(t, rec)
if got.Code != "forbidden" || got.Message != "You may not rename this person." {
t.Fatalf("error = %+v", got)
}
if !reflect.DeepEqual(got.Details, map[string]any{"name": []any{"This name is reserved."}}) {
t.Fatalf("details = %#v", got.Details)
}
if person := rosterLoad(t, gdb, id); person.Name != "Ada" || person.Email != "ada@example.test" {
t.Fatalf("a refused update changed the row: %+v", person)
}
})
t.Run("an empty message stays empty and details stay an object", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Silent"}`, "bearer")
if !strings.Contains(rec.Body.String(), `"message":""`) || !strings.Contains(rec.Body.String(), `"details":{}`) {
t.Fatalf("body = %s", rec.Body.String())
}
if rosterLoad(t, gdb, id).Name != "Bea" {
t.Fatal("a refused update changed the row")
}
})
t.Run("a bulk action refuses and rolls every row back", func(t *testing.T) {
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/archive", fmt.Sprintf(`{"ids":[%d,%d]}`, first, locked), "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." || len(got.Details) != 0 {
t.Fatalf("error = %+v", got)
}
// The first row was already soft-deleted inside the transaction.
if rosterLoad(t, gdb, first).DeletedAt.Valid || rosterLoad(t, gdb, locked).DeletedAt.Valid {
t.Fatal("a refused bulk action changed a selected row")
}
if rosterRefused.Message != "acme.roster::lang.people.locked" {
t.Fatalf("the plugin's error value was modified: %+v", rosterRefused)
}
})
t.Run("a record action refuses and rolls its write back", func(t *testing.T) {
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, path(locked)+"/actions/reinstate", `{}`, "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." {
t.Fatalf("error = %+v", got)
}
if !rosterLoad(t, gdb, locked).Banned {
t.Fatal("a refused record action changed the row")
}
})
t.Run("the message follows the request locale", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
req := httptest.NewRequest(http.MethodPut, adminAPI(path(id)), strings.NewReader(`{"name":"Reserved"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "pl")
req.Header.Set("Authorization", "Bearer "+env.token)
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Message != "Nie możesz zmienić nazwy tej osoby." ||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Ta nazwa jest zastrzeżona."}}) {
t.Fatalf("status=%d error=%+v", rec.Code, got)
}
})
t.Run("a widget action refuses", func(t *testing.T) {
demo, _ := newActEnv(t)
rec := demo.expect(t, http.StatusForbidden, http.MethodPost, "/acme/demo/gadgets/widgets/lookup", `{"values":{"name":"refused"}}`, "bearer")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "Name and Active were filled in." ||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Name"}}) {
t.Fatalf("error = %+v", got)
}
})
t.Run("a plain hook error is the opaque 500", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Dee"})
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, path(id), `{"name":"Boom"}`, "bearer")
got := rosterError(t, rec)
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "roster database") {
t.Fatalf("500 body = %s", rec.Body.String())
}
if rosterLoad(t, gdb, id).Name != "Dee" {
t.Fatal("a failed update changed the row")
}
})
t.Run("a permission denial keeps the framework text", func(t *testing.T) {
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Eve"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "limited")
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message == "" {
t.Fatalf("error = %+v", got)
}
})
}

View File

@@ -178,6 +178,30 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil return out, nil
} }
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"
// rosterRefused is a shared refusal value: the framework must localize a
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
// the field, and fails with a plain error for the name Boom.
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
switch model.(*rosterPerson).Name {
case "Reserved":
return &cabana.ForbiddenError{
Message: "acme.roster::lang.people.refused",
Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}},
}
case "Silent":
return &cabana.ForbiddenError{}
case "Boom":
return fmt.Errorf("the roster database said hunter2")
}
return nil
}
// FormAfterDelete removes the person for good inside the delete's // FormAfterDelete removes the person for good inside the delete's
// transaction: the list keeps soft-deleted people, so deleting one there is // transaction: the list keeps soft-deleted people, so deleting one there is
// permanent. // permanent.
@@ -226,6 +250,11 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context") return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
} }
for _, record := range in.Records { for _, record := range in.Records {
// A refusal after earlier rows were written: the whole
// selection must roll back.
if record.(*rosterPerson).Name == rosterLocked {
return pact.AdminBulkActionResult{}, rosterRefused
}
if err := tx.Delete(record).Error; err != nil { if err := tx.Delete(record).Error; err != nil {
return pact.AdminBulkActionResult{}, err return pact.AdminBulkActionResult{}, err
} }
@@ -270,6 +299,10 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil { if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err return pact.AdminRecordActionResult{}, err
} }
// Refused after the write: the transaction must roll it back.
if in.Record.(*rosterPerson).Name == rosterLocked {
return pact.AdminRecordActionResult{}, rosterRefused
}
return pact.AdminRecordActionResult{}, nil return pact.AdminRecordActionResult{}, nil
}, },
}} }}

View File

@@ -858,6 +858,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
return out return out
} }
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError a relation hook returned (D-27).
func (s RelationService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// Link links eligible related records to the parent and never restamps // Link links eligible related records to the parent and never restamps
// existing links. On a belongsToMany it writes pivot rows (with the pivot // existing links. On a belongsToMany it writes pivot rows (with the pivot
// form's values when the body carries a pivot object for one id); on a // form's values when the body carries a pivot object for one id); on a
@@ -877,7 +883,7 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
return RelationMutationResult{}, err return RelationMutationResult{}, err
} }
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -1194,7 +1200,7 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
return RelationMutationResult{}, err return RelationMutationResult{}, err
} }
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {

View File

@@ -150,7 +150,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)} return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
} }
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -269,7 +269,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
return RecordResult{}, recordNotFound{} return RecordResult{}, recordNotFound{}
} }
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -304,7 +304,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)} return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)}
} }
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -361,7 +361,7 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
return BulkResult{}, err return BulkResult{}, err
} }
var result BulkResult var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -453,7 +453,7 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
return nil, recordNotFound{} return nil, recordNotFound{}
} }
var data map[string]any var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
@@ -500,7 +500,7 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
return nil, recordNotFound{} return nil, recordNotFound{}
} }
var data map[string]any var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {

View File

@@ -12,3 +12,6 @@ people:
reinstate: Reinstate reinstate: Reinstate
reinstate_confirm: Lift the ban on this person? reinstate_confirm: Lift the ban on this person?
state_inactive: Not active state_inactive: Not active
refused: You may not rename this person.
refused_name: This name is reserved.
locked: This person is locked and cannot be changed.

View File

@@ -12,3 +12,6 @@ people:
reinstate: Przywróć reinstate: Przywróć
reinstate_confirm: Zdjąć blokadę z tej osoby? reinstate_confirm: Zdjąć blokadę z tej osoby?
state_inactive: Nieaktywna state_inactive: Nieaktywna
refused: Nie możesz zmienić nazwy tej osoby.
refused_name: Ta nazwa jest zastrzeżona.
locked: Ta osoba jest zablokowana i nie można jej zmienić.

View File

@@ -94,6 +94,7 @@ form:
action_confirm: "Run “:action” on this record?" action_confirm: "Run “:action” on this record?"
action_done: Action completed. action_done: Action completed.
action_stale: This action no longer applies to this record. The page has been refreshed. action_stale: This action no longer applies to this record. The page has been refreshed.
forbidden: You do not have permission to make this change. Nothing was saved.
relation: relation:
add: Add add: Add
link: Link link: Link

View File

@@ -104,6 +104,7 @@ form:
action_confirm: "Wykonać „:action” na tym rekordzie?" action_confirm: "Wykonać „:action” na tym rekordzie?"
action_done: Akcja została wykonana. action_done: Akcja została wykonana.
action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona. action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona.
forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane.
relation: relation:
add: Dodaj add: Dodaj
link: Dołącz link: Dołącz