feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)

- type: password on a settings form binds a lagoon.Encrypted column as a
  write-only secret (any other column fails boot naming the field): the
  GET omits it from data and reports stored, env or empty per field in
  meta.secrets; on PUT an absent key or "" keeps the value, null clears
  it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
  cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
  like a controller save and returns meta.notices; repeater and
  dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
  value, env, empty, pending clear with Undo), SettingsFormView tabs with
  error-tab activation and the secrets/notices meta, settings payload
  that never sends an untouched secret; regenerated OpenAPI and TS
  types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
  and TestSettingsRichEditorSmoke, conform settings fixture with a
  password field; settings.md sections, forms.md notes, cabana README
This commit is contained in:
Jakub Zych
2026-10-13 11:36:12 +02:00
parent 0c8605123e
commit 1e841b4281
27 changed files with 1251 additions and 92 deletions

View File

@@ -2611,6 +2611,25 @@
],
"type": "object"
},
"cabana.SettingsMeta": {
"properties": {
"notices": {
"description": "Notices are what the seaglass sanitizer removed from richeditor\nvalues on this save.",
"items": {
"$ref": "#/components/schemas/cabana.FieldNotice"
},
"type": "array"
},
"secrets": {
"additionalProperties": {
"type": "string"
},
"description": "Secrets maps each password field to stored, env or empty.",
"type": "object"
}
},
"type": "object"
},
"cabana.SettingsResult": {
"properties": {
"data": {
@@ -2619,6 +2638,9 @@
},
"exists": {
"type": "boolean"
},
"meta": {
"$ref": "#/components/schemas/cabana.SettingsMeta"
}
},
"required": [
@@ -3647,6 +3669,7 @@
},
"/settings/{code}": {
"get": {
"description": "The singleton's values keyed by field name. A write-only password field (a lagoon.Encrypted column) is never in data; meta.secrets reports its state instead: stored, env (empty column, the model's SecretFallbacks config key resolves) or empty. meta is omitted when the form has no password field.",
"parameters": [
{
"description": "Settings code",
@@ -3711,6 +3734,7 @@
]
},
"put": {
"description": "Saves the singleton. For a password field an absent key or an empty string keeps the stored value and JSON null clears it; any other non-string value is a 422 on the field. A richeditor value is sanitized like a controller save and what was removed comes back in meta.notices. The answer carries meta.secrets with the new states and never the values.",
"parameters": [
{
"description": "Settings code",

View File

@@ -1017,7 +1017,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Read admin settings */
/**
* Read admin settings
* @description The singleton's values keyed by field name. A write-only password field (a lagoon.Encrypted column) is never in data; meta.secrets reports its state instead: stored, env (empty column, the model's SecretFallbacks config key resolves) or empty. meta is omitted when the form has no password field.
*/
get: {
parameters: {
query?: never;
@@ -1068,7 +1071,10 @@ export interface paths {
};
};
};
/** Update admin settings */
/**
* Update admin settings
* @description Saves the singleton. For a password field an absent key or an empty string keeps the stored value and JSON null clears it; any other non-string value is a 422 on the field. A richeditor value is sanitized like a controller save and what was removed comes back in meta.notices. The answer carries meta.secrets with the new states and never the values.
*/
put: {
parameters: {
query?: never;
@@ -6077,11 +6083,23 @@ export interface components {
model: string;
order: number;
};
"cabana.SettingsMeta": {
/**
* @description Notices are what the seaglass sanitizer removed from richeditor
* values on this save.
*/
notices?: components["schemas"]["cabana.FieldNotice"][];
/** @description Secrets maps each password field to stored, env or empty. */
secrets?: {
[key: string]: string;
};
};
"cabana.SettingsResult": {
data: {
[key: string]: unknown;
};
exists: boolean;
meta?: components["schemas"]["cabana.SettingsMeta"];
};
"cabana.SuccessMeta": {
last_page?: number;

View File

@@ -1,16 +1,25 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { Eye, EyeOff } from '@lucide/vue'
import { CircleAlert, Eye, EyeOff, Info, Lock, Trash2, Undo2 } from '@lucide/vue'
import { t } from '../../../app/i18n'
import Button from '../../ui/Button.vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
import { FORM_SESSION } from '../formContext'
import { FORM_SESSION, SETTINGS_SECRETS } from '../formContext'
// Password control (UI-SPEC S7, D-19). The server never sends a value, so the
// field is empty on load; the value leaves only in the save body. The toggle
// switches the input between hidden and visible text. After a successful save
// the form clears the value and the control returns to hidden.
//
// On a settings page (UI-SPEC F2, D-13) the field is a write-only secret and
// carries a state from meta.secrets: a status line under the input says
// whether a value is stored, comes from the server configuration or is not
// set. "Remove stored value" makes the clear pending (the input empties and
// is disabled, Save sends null) until Undo; typing a value replaces the
// stored one on Save; an empty, untouched field is not sent at all. A record
// form provides no state, so this block never renders there.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const shown = ref(false)
const text = computed(() => (typeof props.modelValue === 'string' ? props.modelValue : ''))
@@ -22,35 +31,92 @@ if (session) {
shown.value = false
})
}
const secrets = inject(SETTINGS_SECRETS, null)
const state = computed(() => secrets?.value[props.field.name] ?? '')
const secret = computed(() => state.value !== '')
// A pending clear: the stored value is removed on Save (the model holds null).
const pendingClear = computed(() => state.value === 'stored' && props.modelValue === null)
const statusId = computed(() => `${props.controlId}-secret`)
const describedBy = computed(() => [props.describedBy ?? '', secret.value ? statusId.value : ''].filter(Boolean).join(' '))
const placeholder = computed(() => {
if (state.value === 'stored' && !pendingClear.value) {
return t('backend::lang.secret.placeholder_stored')
}
const own = attrs.value.placeholder
return typeof own === 'string' ? own : undefined
})
function clearStored(): void {
shown.value = false
emit('update:modelValue', null)
}
function undoClear(): void {
emit('update:modelValue', '')
}
</script>
<template>
<div class="relative">
<input
:id="controlId"
autocomplete="new-password"
spellcheck="false"
v-bind="attrs"
:name="field.name"
:type="shown ? 'text' : 'password'"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input pr-12"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
data-password-toggle
:aria-pressed="shown ? 'true' : 'false'"
:aria-label="t(shown ? 'backend::lang.form.hide_password' : 'backend::lang.form.show_password')"
class="absolute top-1/2 right-1.5 flex size-8 -translate-y-1/2 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="shown = !shown"
<div class="flex min-w-0 flex-col gap-1.5">
<div class="relative">
<input
:id="controlId"
autocomplete="new-password"
spellcheck="false"
v-bind="attrs"
:name="field.name"
:type="shown ? 'text' : 'password'"
:value="pendingClear ? '' : text"
:placeholder="placeholder"
:disabled="pendingClear || undefined"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input pr-12"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
data-password-toggle
:aria-pressed="shown ? 'true' : 'false'"
:aria-label="t(shown ? 'backend::lang.form.hide_password' : 'backend::lang.form.show_password')"
:disabled="pendingClear || undefined"
class="absolute top-1/2 right-1.5 flex size-8 -translate-y-1/2 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:opacity-60"
@click="shown = !shown"
>
<component :is="shown ? EyeOff : Eye" :size="16" aria-hidden="true" />
</button>
</div>
<p
v-if="secret"
:id="statusId"
:data-secret-state="pendingClear ? 'clearing' : state"
:role="pendingClear ? 'status' : undefined"
:class="pendingClear ? 'text-danger' : 'text-muted'"
class="flex flex-wrap items-center gap-2 text-[13px]"
>
<component :is="shown ? EyeOff : Eye" :size="16" aria-hidden="true" />
</button>
<template v-if="pendingClear">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.clear_pending') }}</span>
<Button variant="ghost" size="sm" :icon="Undo2" data-action="secret-undo" @click="undoClear">
{{ t('backend::lang.secret.undo') }}
</Button>
</template>
<template v-else-if="state === 'stored'">
<Lock :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.stored') }}</span>
<Button variant="ghost" size="sm" :icon="Trash2" data-action="secret-clear" @click="clearStored">
{{ t('backend::lang.secret.clear') }}
</Button>
</template>
<template v-else-if="state === 'env'">
<Info :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.secret.env') }}</span>
</template>
<span v-else>{{ t('backend::lang.secret.empty') }}</span>
</p>
</div>
</template>

View File

@@ -82,6 +82,14 @@ export function createUploadGate(): { activeUploads: Ref<number>; beginUpload: (
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')
/**
* The state of each write-only password field of a settings page (Phase 18
* D-13, UI-SPEC F2): field name to `stored`, `env` or `empty`, from the
* settings result's meta.secrets. SettingsFormView provides it; a record
* form provides nothing, so its password fields keep their plain behaviour.
*/
export const SETTINGS_SECRETS: InjectionKey<Readonly<Ref<Record<string, string>>>> = Symbol('summer.settings.secrets')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'

View File

@@ -1,13 +1,27 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, provide, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, FormView } from '../api/types'
import type { AdminRecord, FormField, FormView } from '../api/types'
import { t } from '../app/i18n'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import { contextAllows, editablePayload, fieldErrors, focusField } from '../components/form/formState'
import FormTabs from '../components/form/FormTabs.vue'
import { SETTINGS_SECRETS } from '../components/form/formContext'
import {
DEFAULT_TAB,
contextAllows,
editablePayload,
fieldErrors,
fieldHasErrors,
focusField,
noticeLines,
panelDomId,
tabDomId,
tabOf,
type TabItem,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { settingsEntry } from '../state/useSettings'
import { showToast } from '../state/useToasts'
@@ -15,6 +29,11 @@ import { showToast } from '../state/useToasts'
// One settings page (D-21) through the same FormGrid and field registry as
// records: GET the schema and the values, PUT the values keyed by field name,
// map a 422 the same way. Settings forms have no create step.
// Phase 18: fields with a `tab` render the FormTabs strip as on a record form
// (UI-SPEC F4); the write-only password fields carry their stored, env or
// empty state from meta.secrets (F2) and are sent only when the
// administrator typed a value (a string) or removed the stored one (null);
// richeditor save notices from meta.notices render under their field (F3).
const route = useRoute()
const code = String(route.params.code ?? '')
const ID_PREFIX = 'setting'
@@ -22,15 +41,53 @@ const ID_PREFIX = 'setting'
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const errors = ref<Record<string, string[]>>({})
const notices = ref<Record<string, string[]>>({})
const secrets = ref<Record<string, string>>({})
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
const activeTab = ref(DEFAULT_TAB)
provide(SETTINGS_SECRETS, computed(() => secrets.value))
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, 'update')))
const fieldNames = computed(() => fields.value.map((field) => field.name))
const title = computed(() => settingsEntry(code)?.label || schema.value?.name || code)
const description = computed(() => settingsEntry(code)?.description ?? '')
const tabs = computed<TabItem[]>(() => {
if (!fields.value.some((field) => field.tab)) {
return []
}
const out: TabItem[] = []
for (const field of fields.value) {
const key = tabOf(field)
let tab = out.find((item) => item.key === key)
if (!tab) {
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
if (fieldHasErrors(errors.value, field.name)) {
tab.errors += 1
}
}
return out
})
const activeIndex = computed(() => Math.max(tabs.value.findIndex((tab) => tab.key === activeTab.value), 0))
const panelFields = computed(() => {
if (tabs.value.length === 0) {
return fields.value
}
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
return fields.value.filter((field) => tabOf(field) === key)
})
async function load(): Promise<void> {
const params = { path: { code } }
const [schemaResult, valuesResult] = await Promise.all([
@@ -39,8 +96,10 @@ async function load(): Promise<void> {
])
schema.value = schemaResult.data?.data ?? null
values.value = { ...(valuesResult.data?.data.data ?? {}) }
secrets.value = { ...(valuesResult.data?.data.meta?.secrets ?? {}) }
failed.value = !schemaResult.data || !valuesResult.data
loading.value = false
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
}
function update(name: string, value: unknown): void {
@@ -52,6 +111,27 @@ function update(name: string, value: unknown): void {
}
}
/**
* The PUT body: every editable value, except a password field that was not
* typed into (undefined or an empty string keeps the stored value, so it is
* left out) while a pending clear sends null.
*/
function settingsPayload(editable: FormField[], current: AdminRecord): AdminRecord {
const out = editablePayload(editable, current)
for (const field of editable) {
if (field.type !== 'password') {
continue
}
const value = current[field.name]
if (value === null) {
out[field.name] = null
} else if (typeof value !== 'string' || value === '') {
delete out[field.name]
}
}
return out
}
async function onSave(): Promise<void> {
if (busy.value || !schema.value) {
return
@@ -60,17 +140,23 @@ async function onSave(): Promise<void> {
try {
const result = await api.PUT('/settings/{code}', {
params: { path: { code } },
body: editablePayload(fields.value, values.value),
body: settingsPayload(fields.value, values.value),
})
if (result.data) {
errors.value = {}
values.value = { ...result.data.data.data }
secrets.value = { ...(result.data.data.meta?.secrets ?? {}) }
notices.value = noticeLines(result.data.data.meta?.notices)
showToast(t('backend::lang.settings.saved'))
return
}
if (result.response.status === 422) {
errors.value = fieldErrors(result.error?.error.details)
const first = fields.value.find((field) => errors.value[field.name])
const invalid = fields.value.filter((field) => errors.value[field.name])
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (first && tabs.value.length > 0) {
activeTab.value = tabOf(first)
}
await nextTick()
if (first) {
focusField(ID_PREFIX, first.name)
@@ -100,10 +186,11 @@ void load()
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 flex-1 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<p v-if="description" class="text-muted">{{ description }}</p>
</div>
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
@@ -112,11 +199,19 @@ void load()
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<form class="rounded-card border border-border bg-surface p-7 shadow-card" novalidate @submit.prevent="onSave">
<form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="fields"
:fields="panelFields"
:values="values"
:errors="errors"
:notices="notices"
:source="null"
:id-prefix="ID_PREFIX"
@update="update"

View File

@@ -2,11 +2,11 @@
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { FORM_SESSION, SETTINGS_SECRETS, type FormSession } from '../../src/components/form/formContext'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
@@ -114,3 +114,92 @@ describe('password field component (UI-SPEC S7, D-19)', () => {
expect(input(plain.wrapper).attributes('required')).toBeUndefined()
})
})
describe('settings secret states (UI-SPEC F2, D-13)', () => {
const secretStrings = {
'backend::lang.secret.stored': { other: 'A value is stored. Leave the field empty to keep it.' },
'backend::lang.secret.env': { other: 'Nothing is stored here, so the value from the server configuration is used.' },
'backend::lang.secret.empty': { other: 'Not set.' },
'backend::lang.secret.clear': { other: 'Remove stored value' },
'backend::lang.secret.clear_pending': { other: 'The stored value will be removed when you save.' },
'backend::lang.secret.undo': { other: 'Keep stored value' },
'backend::lang.secret.placeholder_stored': { other: 'Enter a new value to replace it' },
}
function mountSecret(state: string, modelValue: unknown = '') {
const secrets = ref<Record<string, string>>({ api_key: state })
const wrapper = mount(PasswordField, {
props: { modelValue, controlId: 'f-api_key', field: { name: 'api_key', type: 'password', label: 'API key' } as FormField },
global: { provide: { [SETTINGS_SECRETS as symbol]: computed(() => secrets.value) } },
attachTo: document.body,
})
return { wrapper, secrets }
}
const line = (wrapper: VueWrapper) => wrapper.find('[data-secret-state]')
beforeEach(() => {
setBundle({ ...langFixture.data, ...strings, ...secretStrings }, 'en')
})
it('renders no status line on a record form (no state provided)', () => {
const { wrapper } = mountField()
expect(line(wrapper).exists()).toBe(false)
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
})
it('renders the stored, env and empty lines with the input always empty', () => {
const stored = mountSecret('stored')
expect(input(stored.wrapper).element.value).toBe('')
expect(line(stored.wrapper).attributes('data-secret-state')).toBe('stored')
expect(line(stored.wrapper).text()).toContain('A value is stored.')
expect(line(stored.wrapper).find('[data-action="secret-clear"]').text()).toBe('Remove stored value')
expect(input(stored.wrapper).attributes('placeholder')).toBe('Enter a new value to replace it')
expect(input(stored.wrapper).attributes('aria-describedby')).toContain('f-api_key-secret')
stored.wrapper.unmount()
const env = mountSecret('env')
expect(line(env.wrapper).attributes('data-secret-state')).toBe('env')
expect(line(env.wrapper).text()).toContain('server configuration')
expect(line(env.wrapper).find('[data-action="secret-clear"]').exists()).toBe(false)
env.wrapper.unmount()
const empty = mountSecret('empty')
expect(line(empty.wrapper).attributes('data-secret-state')).toBe('empty')
expect(line(empty.wrapper).text()).toBe('Not set.')
expect(line(empty.wrapper).find('button').exists()).toBe(false)
})
it('does not emit for an untouched field, emits null on Remove stored value and "" on Undo', async () => {
const { wrapper } = mountSecret('stored')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await line(wrapper).find('[data-action="secret-clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
await wrapper.setProps({ modelValue: null })
const pending = line(wrapper)
expect(pending.attributes('data-secret-state')).toBe('clearing')
expect(pending.attributes('role')).toBe('status')
expect(pending.classes()).toContain('text-danger')
expect(pending.text()).toContain('will be removed when you save')
expect(input(wrapper).attributes('disabled')).toBeDefined()
expect(input(wrapper).element.value).toBe('')
await pending.find('[data-action="secret-undo"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[null], ['']])
await wrapper.setProps({ modelValue: '' })
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
expect(input(wrapper).attributes('disabled')).toBeUndefined()
})
it('never shows the typed value after the save response replaces it', async () => {
const { wrapper, secrets } = mountSecret('empty')
await input(wrapper).setValue('sk_live_123')
expect(wrapper.emitted('update:modelValue')).toEqual([['sk_live_123']])
// The save answers: the value is gone from the model, the state is stored.
await wrapper.setProps({ modelValue: undefined })
secrets.value = { api_key: 'stored' }
await nextTick()
expect(input(wrapper).element.value).toBe('')
expect(wrapper.html()).not.toContain('sk_live_123')
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
})
})

View File

@@ -0,0 +1,152 @@
// The settings page with tabs and write-only secrets (Phase 18, UI-SPEC F2
// to F4). Fixtures are neutral acme.demo.* data; no application names
// appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { setSettings } from '../../src/state/useSettings'
import { clone, langFixture, settingsFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
const PAGE = `${API}/settings/search`
const strings = {
'backend::lang.form.tab_default': { other: 'General' },
'backend::lang.form.tab_errors': { one: ':count error', other: ':count errors' },
'backend::lang.secret.stored': { other: 'A value is stored.' },
'backend::lang.secret.env': { other: 'From the server configuration.' },
'backend::lang.secret.empty': { other: 'Not set.' },
'backend::lang.secret.clear': { other: 'Remove stored value' },
'backend::lang.secret.clear_pending': { other: 'Removed when you save.' },
'backend::lang.secret.undo': { other: 'Keep stored value' },
'backend::lang.settings.saved': { other: 'Settings saved' },
}
function tabbedSchema() {
const envelope = clone(settingsFixture.schema)
envelope.data.fields = [
{ name: 'use_engine', label: 'Use the search engine', type: 'switch' },
{ name: 'engine_host', label: 'Engine host', type: 'text', tab: 'Connection' },
{ name: 'api_key', label: 'API key', type: 'password', tab: 'Keys' },
{ name: 'test_api_key', label: 'Test API key', type: 'password', tab: 'Keys' },
] as typeof envelope.data.fields
return envelope
}
function valuesWith(secrets: Record<string, string>): Reply {
const envelope = clone(settingsFixture.values)
envelope.data.meta = { secrets }
return { body: envelope }
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${API}/settings`]: { body: settingsFixture.list },
[`GET ${PAGE}/schema`]: { body: tabbedSchema() },
[`GET ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }),
...overrides,
}
}
async function openPage(overrides: Record<string, Route> = {}) {
setSettings(settingsFixture.list.data)
return mountApp('/settings/search', routes(overrides))
}
const tabLabels = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').map((tab) => tab.text().replace(/\d+ errors?$/, '').trim())
const activeTab = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')
const fieldNames = (wrapper: VueWrapper) => wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('settings tabs (UI-SPEC F4)', () => {
it('renders no tab strip for an untabbed schema', async () => {
const { wrapper } = await openPage({ [`GET ${PAGE}/schema`]: { body: settingsFixture.schema } })
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
expect(wrapper.find('form').attributes('role')).toBeUndefined()
expect(fieldNames(wrapper)).toEqual(['use_engine', 'engine_host'])
})
it('renders the tabs with the default tab first and the card inside the panel', async () => {
const { wrapper } = await openPage()
expect(tabLabels(wrapper)).toEqual(['General', 'Connection', 'Keys'])
expect(activeTab(wrapper)!.text()).toBe('General')
expect(fieldNames(wrapper)).toEqual(['use_engine'])
expect(wrapper.find('form').attributes('role')).toBe('tabpanel')
expect(wrapper.find('form').attributes('aria-labelledby')).toBe(activeTab(wrapper)!.attributes('id'))
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
expect(fieldNames(wrapper)).toEqual(['api_key', 'test_api_key'])
})
it('activates the first tab with an error after a 422 and shows its error chip', async () => {
const { wrapper } = await openPage({
[`PUT ${PAGE}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { engine_host: ['Bad host'] } } } },
})
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(activeTab(wrapper)!.text()).toContain('Connection')
expect(activeTab(wrapper)!.find('[data-tab-errors]').text()).toBe('1')
expect(fieldNames(wrapper)).toEqual(['engine_host'])
expect(wrapper.find('[data-field="engine_host"]').text()).toContain('Bad host')
})
})
describe('settings secrets (UI-SPEC F2, D-13)', () => {
it('shows each secret state and sends neither an untouched secret nor its empty input', async () => {
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }) })
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty')
await wrapper.find('[data-field="test_api_key"] input').setValue('')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const sent = requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')
expect(sent).toHaveLength(1)
const body = (await sent[0]!.clone().json()) as Record<string, unknown>
expect(body).not.toHaveProperty('api_key')
expect(body).not.toHaveProperty('test_api_key')
expect(body).toHaveProperty('engine_host')
})
it('sends a typed secret once, then clears the input and shows the stored line from the response', async () => {
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'stored' }) })
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
await wrapper.find('[data-field="test_api_key"] input').setValue('sk_test_9')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record<string, unknown>
expect(body.test_api_key).toBe('sk_test_9')
expect(wrapper.find<HTMLInputElement>('[data-field="test_api_key"] input').element.value).toBe('')
expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
expect(wrapper.html()).not.toContain('sk_test_9')
expect(document.body.textContent).not.toContain('sk_test_9')
})
it('sends null for a removed stored value and restores the line on Undo', async () => {
const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'empty', test_api_key: 'empty' }) })
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click')
const line = wrapper.find('[data-field="api_key"] [data-secret-state]')
expect(line.attributes('data-secret-state')).toBe('clearing')
expect(wrapper.find('[data-field="api_key"] input').attributes('disabled')).toBeDefined()
await line.find('[data-action="secret-undo"]').trigger('click')
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored')
await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record<string, unknown>
expect(body.api_key).toBeNull()
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty')
})
})

View File

@@ -298,6 +298,7 @@ func (MembersController) FormVirtualFields() []string {
- Every listed name must be a field of the form's `fields.yaml` with the type `password`, `text`, `textarea`, `number`, `checkbox`, `switch` or `dropdown`. Anything else stops the start-up.
- `type: password` is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second `password` field, compared by the `confirmed` rule on the server.
- Form-only fields are not available on settings forms or relation forms.
- On a settings form `type: password` is not a form-only field but a write-only secret backed by a `lagoon.Encrypted` column; see [Write-only secrets](settings.md#write-only-secrets).
## Permission editor
@@ -449,7 +450,7 @@ The value travels as a string of HTML, or null. Anything else in a save body is
- HTML the policy keeps whole is stored byte for byte, so a value that only differs in escaping is never rewritten.
- Otherwise the clean HTML is stored and the save still succeeds. The create or update response then carries `meta.notices` for the field: how many elements and attributes were removed with their names, how many comments, or that some markup changed. The admin shows them under the field.
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up, so no save path stores an unsanitized value.
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms and on [settings forms](settings.md#rich-text-on-a-settings-page), whose save runs the same sanitizer; relation forms refuse it at start-up, so no save path stores an unsanitized value.
In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, underline, a level 3 heading, bullet and numbered lists, quotes, links, undo and redo. The editor code loads on first use, so forms without a rich text field never download it.

View File

@@ -95,6 +95,44 @@ The settings row is the one with ID 1. Before it exists, the page shows the fiel
The admin API serves the page at `<prefix>/api/v1/settings/{code}` (values) and `.../settings/{code}/schema` (the form), and `GET .../settings` lists the pages the administrator may open.
## Write-only secrets
A `type: password` field on a settings form is a write-only secret: an API key, a webhook signing secret. Its model column must be a `lagoon.Encrypted` (or a pointer to one), so the value is encrypted at rest under `app.key`; any other column type stops the start-up naming the field.
```yaml
fields:
secret_key:
label: acme.pay::lang.settings.secret_key
type: password
tab: acme.pay::lang.settings.tab_live
```
- `GET /settings/{code}` never includes the field in `data`. It reports the field's state in `meta.secrets` instead: `stored` when the column holds a value, `env` when the column is empty and a fallback applies (below), or `empty`. The `meta` key is omitted when the form has no password field.
- `PUT /settings/{code}` keeps the stored value when the key is absent or an empty string, clears it when the value is JSON `null`, and stores any other string through `lagoon.NewEncrypted`. A value that is neither a string nor `null` is a 422 on the field. The answer carries the new states in `meta.secrets` and never the values; cabana never logs a secret and never echoes it in an error.
- A `required` password field is satisfied by a stored value, so an absent key does not fail the required check.
The admin shows the field empty on every load with a status line under it: a stored value with a "Remove stored value" button (the clear is deferred until Save and can be undone), the configuration fallback, or "Not set". Typing a value replaces the stored one on Save; an untouched field is not sent.
A settings model may implement `cabana.SecretFallbacks` to name, per field, the configuration key the application falls back to when the column is empty, so the page can say that a value from the server configuration is in use:
```go src=modules/cabana/settings_secret_test.go#secretSettings.SecretFallbacks
// SecretFallbacks maps each secret field to the config key the application
// reads when the column is empty; SUMMER_* variables map onto those keys.
func (secretSettings) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.secret.api_key"}
}
```
cabana only checks whether the key resolves to a non-empty value (`env` state); reading and using the fallback value stays the plugin's job. The states are the constants `cabana.SecretStored`, `cabana.SecretEnv` and `cabana.SecretEmpty`, and the meta shape is `cabana.SettingsMeta`.
## Rich text on a settings page
`type: richeditor` is accepted on a settings form. The settings save runs every submitted value through the same seaglass sanitizer as a controller save (see [Rich text](forms.md#rich-text)): clean HTML is stored byte for byte, otherwise the clean value is stored and the answer lists what was removed in `meta.notices`, which the admin shows under the field. A value that is not a string or `null` is a 422 on the field. `repeater` and `dependsOn` are still refused on settings forms, as they are on relation forms.
## Tabs
Settings fields take `tab` like record form fields. When any field declares one, the admin renders the tab strip in the page header, with the fields that have no `tab` on the default tab first; the form card sits inside the active tab's panel. After a 422 the first tab with an error is activated and shows its error count. Without any `tab` the page is unchanged.
## Reading settings in plugin code
Settings are an ordinary table, so plugin code reads them with GORM:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-B8hF3DCG.js"></script>
<script type="module" crossorigin src="./assets/index-Dioh6ysQ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
</head>
<body>

View File

@@ -50,7 +50,7 @@ Use cases:
- 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.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- 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 for JWT sessions: 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). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
@@ -258,7 +258,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's money 422 messages. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's 422 messages and notices and its `Config` resolves the secret fallbacks. |
| `cabana.SettingsMeta` | Meta of a settings result: `secrets` (field to `stored`, `env` or `empty`) and the richeditor `notices`; omitted when empty. |
| `cabana.SecretFallbacks` | Implemented by a settings model whose password fields fall back to a config key when the column is empty: field name to compass key. |
| `cabana.SecretStored` / `cabana.SecretEnv` / `cabana.SecretEmpty` | The states of a write-only settings secret. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |

View File

@@ -363,6 +363,7 @@ func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /settings/{code}.
//
// @Summary Read admin settings
// @Description The singleton's values keyed by field name. A write-only password field (a lagoon.Encrypted column) is never in data; meta.secrets reports its state instead: stored, env (empty column, the model's SecretFallbacks config key resolves) or empty. meta is omitted when the form has no password field.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -377,6 +378,7 @@ func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /settings/{code}.
//
// @Summary Update admin settings
// @Description Saves the singleton. For a password field an absent key or an empty string keeps the stored value and JSON null clears it; any other non-string value is a 422 on the field. A richeditor value is sanitized like a controller save and what was removed comes back in meta.notices. The answer carries meta.secrets with the new states and never the values.
// @Tags admin
// @Accept json
// @Produce json

View File

@@ -344,7 +344,8 @@ func TestDependentRecheck(t *testing.T) {
// TestRichEditor pins D-10 through the routes: a save stores the sanitized
// value with notices in the request locale, clean HTML byte for byte with
// none, null stays null, a value that is not a string is a 422 that writes
// nothing; and settings and relation forms refuse the type at boot.
// nothing; relation forms refuse the type at boot and settings forms accept
// it (Phase 18 F3).
func TestRichEditor(t *testing.T) {
t.Run("boot refusals", func(t *testing.T) {
for name, field := range map[string]string{
@@ -360,9 +361,16 @@ func TestRichEditor(t *testing.T) {
Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile",
NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"},
}}})
if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") {
// Phase 18 F3: a settings form lets a richeditor through its
// gate (the fixture then fails on the missing column, not on
// the type); a repeater is still refused.
refused := err != nil && strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only")
if name == "repeater" && !refused {
t.Fatalf("settings form with %s: err = %v", name, err)
}
if name == "richeditor" && (refused || err == nil || !strings.Contains(err.Error(), "field body is not a model column")) {
t.Fatalf("settings form with richeditor should pass the gate: err = %v", err)
}
}
})

View File

@@ -116,11 +116,23 @@ func requireDependentProvider(ctl pact.AdminController, fields []FormField) erro
return nil
}
// controllerFormOnly refuses, on a settings form or a relation form, a field
// that only an admin controller's save path supports: its checks run in the
// controller save and nowhere else.
// controllerFormOnly refuses, on a relation form, a field that only an admin
// controller's save path supports: its checks run in the controller save and
// nowhere else. A settings form uses settingsFormOnly, which lets a
// richeditor through because the settings Put sanitizes it.
func controllerFormOnly(field FormField) error {
if field.Type == richEditorType || field.Type == repeaterType {
if field.Type == richEditorType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
return settingsFormOnly(field)
}
// settingsFormOnly refuses, on a settings form, a field only an admin
// controller's save path supports: a repeater and a dependent dropdown. A
// richeditor is let through, because the settings Put runs the same
// sanitizer gate as the controller save.
func settingsFormOnly(field FormField) error {
if field.Type == repeaterType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
if field.DependsOn != "" {

View File

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
@@ -527,7 +528,7 @@ func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db, Tr: s.translator()}).Get(r.Context(), setting)
result, err := (SettingsService{DB: db, Tr: s.translator(), Config: s.config()}).Get(r.Context(), setting)
if err != nil {
writeCRUDError(w, err)
return
@@ -548,7 +549,7 @@ func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := (SettingsService{DB: db, Tr: s.translator()}).Put(r.Context(), setting, body)
result, err := (SettingsService{DB: db, Tr: s.translator(), Config: s.config()}).Put(r.Context(), setting, body)
if err != nil {
writeCRUDError(w, err)
return
@@ -836,6 +837,14 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
})
}
// config is the app configuration, nil without an app.
func (s *service) config() *compass.Config {
if s == nil || s.app == nil {
return nil
}
return s.app.Config
}
func (s *service) translator() *phrasebook.Translator {
if s == nil || s.app == nil {
return nil

View File

@@ -353,7 +353,17 @@ func conformCases() []conformCase {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
rec := e.send(t, http.MethodGet, "/settings/conform", nil, true)
// Phase 18 D-13: the password field is absent from data and
// reported in meta.secrets.
var body cabana.Envelope[cabana.SettingsResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, leaked := body.Data.Data["api_key"]; leaked || body.Data.Meta == nil || body.Data.Meta.Secrets["api_key"] != cabana.SecretEmpty {
t.Fatalf("settings secrets = %s", rec.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
@@ -1060,12 +1070,13 @@ func (conformPart) AttachRelations() []attach.Relation {
}
type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
}
func (conformSettings) TableName() string { return "cabana_conform_settings" }
func (conformSettings) Fillable() []string { return []string{"enabled"} }
func (conformSettings) Fillable() []string { return []string{"enabled", "api_key"} }
func (conformSettings) Rules() map[string]string { return map[string]string{} }
type conformPlugin struct{ stamp string }
@@ -1438,6 +1449,9 @@ parts:
label: Enabled
type: switch
default: 0
api_key:
label: API key
type: password
`),
}
}

View File

@@ -1,12 +1,15 @@
package cabana_test
import (
"bytes"
"context"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -89,7 +92,7 @@ func (paymentsInvoice) Rules() map[string]string { return map[string]string{"num
// paymentsModels are the fixture tables, dropped and recreated per env.
func paymentsModels() []any {
return []any{&paymentsInvoice{}}
return []any{&paymentsInvoice{}, &paymentsVault{}}
}
// paymentsPlugin registers the invoices controller.
@@ -107,6 +110,38 @@ func (paymentsPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.payments.access", Roles: []string{"developer"}}}
}
// Settings registers the vault page: two write-only secrets on two tabs
// and a richeditor on the default tab (D-13, F2 to F4).
func (paymentsPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{Code: "vault", Label: "Vault", Category: "Acme", Icon: "settings", Model: "Vault",
Permissions: []string{"acme.payments.access"}, Form: "models/vault/fields.yaml",
NewModel: func() any { return &paymentsVault{} }}}
}
// paymentsVault is the settings model: two lagoon.Encrypted secrets, the
// first with a host-config fallback, and a richeditor notes column.
type paymentsVault struct {
ID uint `gorm:"column:id;primaryKey"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
TestAPIKey lagoon.Encrypted `gorm:"column:test_api_key;type:text"`
Notes string `gorm:"column:notes"`
}
func (paymentsVault) TableName() string { return "cabana_payments_vault" }
func (paymentsVault) Fillable() []string { return []string{"api_key", "test_api_key", "notes"} }
func (paymentsVault) Rules() map[string]string { return map[string]string{} }
// SecretFallbacks maps api_key to the acme.vault.api_key config key
// (SUMMER_ACME__VAULT__API_KEY).
func (paymentsVault) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.vault.api_key"}
}
var _ cabana.SecretFallbacks = paymentsVault{}
// paymentsAppKey is the SUMMER_APP__KEY of the fixture: 32 bytes, base64.
var paymentsAppKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("P"), 32))
// paymentsInvoices is the invoices controller.
type paymentsInvoices struct{}
@@ -132,13 +167,14 @@ var _ pact.HasAdminRecordActions = paymentsInvoices{}
// paymentsAssemble boots plugins on the test database with the fixture's
// config: the assembled admin handler, or the boot error.
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin, environ ...string) (http.Handler, error) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-payments\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
environ = append([]string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret, "SUMMER_APP__KEY=" + paymentsAppKey}, environ...)
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: environ})
if err != nil {
t.Fatal(err)
}
@@ -151,6 +187,15 @@ func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
// The column-encryption keys of the vault secrets (OpenFromApp does this
// at boot; the test publishes the shared test database by hand).
primary, previous, err := lagoon.LoadAppKey(app.Config)
if err != nil {
t.Fatal(err)
}
if err := lagoon.PublishEncryptionKeys(app, primary, previous); err != nil {
t.Fatal(err)
}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -579,3 +624,190 @@ func TestRecordActionDangerSmoke(t *testing.T) {
t.Fatalf("raw actions = %s %s (danger true on void, the key absent on resend)", out.Meta.Actions[0], out.Meta.Actions[1])
}
}
// vaultResult is a settings answer of the vault page.
type vaultResult struct {
Data struct {
Exists bool `json:"exists"`
Data map[string]any `json:"data"`
Meta *struct {
Secrets map[string]string `json:"secrets"`
Notices []cabana.FieldNotice `json:"notices"`
} `json:"meta"`
} `json:"data"`
}
func decodeVault(t *testing.T, raw []byte) vaultResult {
t.Helper()
var out vaultResult
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("vault: %v\n%s", err, raw)
}
return out
}
// TestSettingsSecretSmoke checks the D-13 write-only contract on Postgres:
// the GET never carries a secret and reports its state; a PUT with a value
// stores ciphertext; an empty string keeps the stored value; null clears it;
// a non-string is a 422; an empty column with the fallback key set in the
// environment reports env; and no body ever contains the plaintext.
func TestSettingsSecretSmoke(t *testing.T) {
e := newPaymentsEnv(t)
const secret = "sk_1"
noLeak := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if strings.Contains(rec.Body.String(), secret) || strings.Contains(rec.Body.String(), "sk_env") {
t.Fatalf("body leaks the secret: %s", rec.Body.String())
}
}
states := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]string {
t.Helper()
out := decodeVault(t, rec.Body.Bytes())
for _, name := range []string{"api_key", "test_api_key"} {
if _, leaked := out.Data.Data[name]; leaked {
t.Fatalf("%s is in data: %s", name, rec.Body.String())
}
}
if out.Data.Meta == nil {
t.Fatalf("no meta: %s", rec.Body.String())
}
return out.Data.Meta.Secrets
}
rec := e.send(t, http.MethodGet, "/settings/vault", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
}
if got := states(t, rec); got["api_key"] != cabana.SecretEmpty || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("initial states = %v", got)
}
if _, has := decodeVault(t, rec.Body.Bytes()).Data.Data["notes"]; !has {
t.Fatalf("notes missing from data: %s", rec.Body.String())
}
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": secret}, true)
if rec.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states after put = %v", got)
}
var column struct{ APIKey *string }
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey == nil || *column.APIKey == "" || *column.APIKey == secret {
t.Fatalf("stored api_key = %v, want non-empty ciphertext", column.APIKey)
}
ciphertext := *column.APIKey
rec = e.send(t, http.MethodGet, "/settings/vault", nil, true)
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored {
t.Fatalf("states after get = %v", got)
}
// An empty string keeps the stored value, byte for byte.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": "", "test_api_key": ""}, true)
if rec.Code != http.StatusOK {
t.Fatalf("keep status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretStored || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("states after keep = %v", got)
}
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey == nil || *column.APIKey != ciphertext {
t.Fatalf("an empty string rewrote the stored secret")
}
// A non-string is a 422 on the field, and nothing is written.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": 5}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"api_key"`) {
t.Fatalf("non-string status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
// null clears.
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"api_key": nil}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clear status=%d body=%s", rec.Code, rec.Body.String())
}
if got := states(t, rec); got["api_key"] != cabana.SecretEmpty {
t.Fatalf("states after clear = %v", got)
}
if err := e.db.Raw("SELECT api_key FROM cabana_payments_vault WHERE id = 1").Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.APIKey != nil && *column.APIKey != "" {
t.Fatalf("null did not clear the column: %q", *column.APIKey)
}
// With the fallback key set and the column empty, the state is env.
h, err := paymentsAssemble(t, e.db, []party.Plugin{paymentsPlugin{}}, "SUMMER_ACME__VAULT__API_KEY=sk_env")
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: e.db, login: e.login}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
rec = env.send(t, http.MethodGet, "/settings/vault", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("env get status=%d body=%s", rec.Code, rec.Body.String())
}
noLeak(t, rec)
if got := states(t, rec); got["api_key"] != cabana.SecretEnv || got["test_api_key"] != cabana.SecretEmpty {
t.Fatalf("env states = %v", got)
}
}
// TestSettingsRichEditorSmoke checks F3 on Postgres: a settings richeditor
// value is stored sanitized with notices, clean HTML byte for byte with no
// notice, and a value that is not a string is a 422 on the field.
func TestSettingsRichEditorSmoke(t *testing.T) {
e := newPaymentsEnv(t)
dirty := `<p onclick="steal()">Bank</p><script>alert(1)</script>`
rec := e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": dirty}, true)
if rec.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", rec.Code, rec.Body.String())
}
out := decodeVault(t, rec.Body.Bytes())
if out.Data.Data["notes"] != "<p>Bank</p>" {
t.Fatalf("notes = %#v", out.Data.Data["notes"])
}
if out.Data.Meta == nil || len(out.Data.Meta.Notices) != 2 || out.Data.Meta.Notices[0].Field != "notes" {
t.Fatalf("notices = %s", rec.Body.String())
}
var stored paymentsVault
if err := e.db.First(&stored, 1).Error; err != nil {
t.Fatal(err)
}
if stored.Notes != "<p>Bank</p>" {
t.Fatalf("stored notes = %q", stored.Notes)
}
clean := "<p>IBAN <strong>PL00</strong> &amp; \"swift\"</p>\n<ul><li>one</li></ul>"
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": clean}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clean status=%d body=%s", rec.Code, rec.Body.String())
}
if out := decodeVault(t, rec.Body.Bytes()); out.Data.Meta != nil && len(out.Data.Meta.Notices) != 0 {
t.Fatalf("clean HTML reported notices: %s", rec.Body.String())
}
var again paymentsVault
if err := e.db.First(&again, 1).Error; err != nil {
t.Fatal(err)
}
if again.Notes != clean {
t.Fatalf("clean notes not stored byte for byte: %q", again.Notes)
}
rec = e.send(t, http.MethodPut, "/settings/vault", map[string]any{"notes": []any{"<p>x</p>"}}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes"`) {
t.Fatalf("non-string notes status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -95,11 +95,18 @@ func TestRichEditorCompile(t *testing.T) {
}
})
t.Run("settings forms accept a richeditor (Phase 18 F3)", func(t *testing.T) {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n type: richeditor\n")}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}
if _, err := compileSetting("acme.unit", item, fsys); err != nil {
t.Fatalf("richeditor refused on a settings form: %v", err)
}
})
t.Run("settings forms refuse the controller-only features", func(t *testing.T) {
for name, field := range map[string]string{
"richeditor": " type: richeditor\n",
"repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": " type: dropdown\n dependsOn: enabled\n",
"repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": " type: dropdown\n dependsOn: enabled\n",
} {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}

View File

@@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
@@ -25,20 +26,29 @@ type CompiledSetting struct {
Item pact.SettingsItem
Form *FormSchema
Writable []WritableField
// Secrets are the names of the form's write-only password fields, in
// form order (Phase 18 D-13): lagoon.Encrypted columns whose value never
// leaves the server.
Secrets []string
}
// SettingsResult is the explicit missing/existing singleton response.
// SettingsResult is the explicit missing/existing singleton response. Data
// never holds a password field; Meta reports each one's state and the
// richeditor save notices, and is omitted when there is nothing to report.
type SettingsResult struct {
Exists bool `json:"exists"`
Data map[string]any `json:"data"`
Meta *SettingsMeta `json:"meta,omitempty"`
}
// SettingsService reads and transactionally updates compiled singleton rows.
// Tr localizes the 422 messages of the settings Put (a money value outside
// its field's scale or max); nil keeps the phrase keys.
// Tr localizes the 422 messages and notices of the settings Put; nil keeps
// the phrase keys. Config resolves the SecretFallbacks keys of a settings
// model for the env state of its secrets; nil reports no env state.
type SettingsService struct {
DB *gorm.DB
Tr *phrasebook.Translator
DB *gorm.DB
Tr *phrasebook.Translator
Config *compass.Config
}
// compileSettingLink registers a settings entry that opens an admin
@@ -89,13 +99,13 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
}
for _, field := range fields {
// A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == permissionFieldType {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
}
if field.Preset != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
}
if err := controllerFormOnly(field); err != nil {
if err := settingsFormOnly(field); err != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: %w", item.Code, field.Name, err)
}
}
@@ -106,8 +116,12 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
fillable[key] = struct{}{}
}
writable := make([]WritableField, 0, len(fields))
var secrets []string
for _, field := range fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
// A password field is a write-only secret on a settings form (D-13):
// bound like a scalar, checked against lagoon.Encrypted, never served.
secret := field.Type == passwordFieldType
if (!scalarFormField(field.Type) && !secret) || protectedFillKey(field.Name) {
continue
}
if _, ok := columns[field.Name]; !ok {
@@ -126,9 +140,15 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
}
}
if secret {
if err := checkSecretType(model, field); err != nil {
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
}
secrets = append(secrets, field.Name)
}
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
}
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable, Secrets: secrets}, nil
}
// Get returns default-valued data without creating a missing singleton.
@@ -144,13 +164,15 @@ func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (Set
return SettingsResult{}, err
}
err = s.DB.WithContext(ctx).Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: 1}).Take(model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsResult{Exists: false, Data: projectSetting(setting, model)}, nil
}
if err != nil {
exists := err == nil
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return SettingsResult{}, err
}
return SettingsResult{Exists: true, Data: projectSetting(setting, model)}, nil
return SettingsResult{
Exists: exists,
Data: withoutSecrets(setting, projectSetting(setting, model)),
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), nil),
}, nil
}
// Put validates and persists exactly one settings row. Identical replay is a
@@ -165,7 +187,19 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
if details := moneyDetails(ctx, s.Tr, setting.Form.Fields, body, nil); len(details) > 0 {
return SettingsResult{}, &ValidationError{Details: details}
}
var result SettingsResult
// D-13: an absent or empty secret keeps the stored value, null clears it.
if err := liftSecretValues(setting, body); err != nil {
return SettingsResult{}, err
}
// A richeditor value is a string or null; anything else is refused on
// the field instead of being dropped by the projection.
if err := checkSettingRichEditors(setting, body); err != nil {
return SettingsResult{}, err
}
var (
result SettingsResult
notices []pluginNotice
)
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
model, err := newSettingModel(setting)
if err != nil {
@@ -187,6 +221,9 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
}
before := projectSetting(setting, model)
projected := projectSettingBody(setting, body)
// Every richeditor value is sanitized before Fill sees it, as on a
// controller save.
notices = sanitizeSettingRichEditors(setting, projected)
allowed := make([]string, 0, len(setting.Writable))
for _, field := range setting.Writable {
allowed = append(allowed, field.FillKey)
@@ -217,7 +254,11 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
return err
}
}
result = SettingsResult{Exists: true, Data: after}
result = SettingsResult{
Exists: true,
Data: withoutSecrets(setting, after),
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), notices),
}
return nil
})
return result, err
@@ -309,7 +350,8 @@ func requireSettingInputs(setting *CompiledSetting, body map[string]any) error {
details[field.Name] = []string{"The " + field.Name + " field is invalid."}
continue
}
if field.Required && !present {
// An absent secret keeps its stored value, so required never applies.
if field.Required && !present && field.Type != passwordFieldType {
details[field.Name] = []string{"The " + field.Name + " field is required."}
}
}

View File

@@ -0,0 +1,212 @@
package cabana
import (
"context"
"fmt"
"reflect"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/seaglass"
)
// passwordFieldType is the write-only secret type on a settings form.
const passwordFieldType = "password"
// SecretFallbacks is implemented by a settings model whose write-only
// password fields fall back to host configuration when the column is empty
// (Phase 18 D-13). The map is field name to compass config key; SUMMER_*
// environment variables map onto those keys as everywhere else. A field
// whose column is empty and whose key resolves to a non-empty value reports
// the env state; the value itself is never read by cabana beyond that check.
type SecretFallbacks interface {
SecretFallbacks() map[string]string
}
// The states of a write-only settings secret in SettingsMeta.Secrets.
const (
// SecretStored means the column holds a value.
SecretStored = "stored"
// SecretEnv means the column is empty and the model's SecretFallbacks
// config key resolves to a non-empty value.
SecretEnv = "env"
// SecretEmpty means the column is empty and no fallback applies.
SecretEmpty = "empty"
)
// SettingsMeta is the meta of a settings result: the state of every
// write-only password field (never its value) and the save notices of the
// richeditor fields. The key is omitted when there is nothing to report.
type SettingsMeta struct {
// Secrets maps each password field to stored, env or empty.
Secrets map[string]string `json:"secrets,omitempty"`
// Notices are what the seaglass sanitizer removed from richeditor
// values on this save.
Notices []FieldNotice `json:"notices,omitempty"`
}
var encryptedGoType = reflect.TypeOf(lagoon.Encrypted{})
// checkSecretType refuses a password field of a settings form whose model
// column is not a lagoon.Encrypted (or a pointer to one).
func checkSecretType(model any, field FormField) error {
sf, ok := structFieldByColumn(model, field.Name)
if !ok {
return fmt.Errorf("field %s is not a model column", field.Name)
}
t := sf.Type
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t != encryptedGoType {
return fmt.Errorf("field %s: password on a settings form needs a lagoon.Encrypted column, found %s", field.Name, sf.Type)
}
return nil
}
// secretStates reports the state of every password field of the setting on
// the loaded model: stored when the column holds a value, env when it is
// empty and the model's fallback key resolves in cfg, else empty. The
// result is nil when the form has no password field.
func secretStates(setting *CompiledSetting, model any, cfg *compass.Config) map[string]string {
if setting == nil || len(setting.Secrets) == 0 {
return nil
}
var fallbacks map[string]string
if provider, ok := model.(SecretFallbacks); ok && provider != nil {
fallbacks = provider.SecretFallbacks()
}
states := make(map[string]string, len(setting.Secrets))
for _, name := range setting.Secrets {
states[name] = SecretEmpty
if secretStored(model, name) {
states[name] = SecretStored
continue
}
if key := fallbacks[name]; key != "" && cfg != nil && cfg.String(key) != "" {
states[name] = SecretEnv
}
}
return states
}
// secretStored reports whether the model's Encrypted column holds a value.
func secretStored(model any, column string) bool {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return false
}
v = v.Elem()
}
f := fieldByColumn(v, column)
if !f.IsValid() || !f.CanInterface() {
return false
}
switch e := f.Interface().(type) {
case lagoon.Encrypted:
return e.Reveal() != ""
case *lagoon.Encrypted:
return e != nil && e.Reveal() != ""
}
return false
}
// liftSecretValues applies the write-only semantics to the settings body
// before projection (D-13): an absent key or an empty string keeps the
// stored value, so the key is dropped; JSON null clears it and stays; any
// other string replaces it; a value that is neither is a 422 on the field.
// The body is edited in place and the value is never copied anywhere else.
func liftSecretValues(setting *CompiledSetting, body map[string]any) error {
for _, name := range setting.Secrets {
value, present := body[name]
if !present {
continue
}
switch v := value.(type) {
case nil:
case string:
if v == "" {
delete(body, name)
}
default:
return &ValidationError{Details: fillTypeDetails(name)}
}
}
return nil
}
// withoutSecrets is the settings data projection without the password
// fields: a secret never leaves the server, in a GET or a PUT answer.
func withoutSecrets(setting *CompiledSetting, data map[string]any) map[string]any {
if setting == nil || len(setting.Secrets) == 0 {
return data
}
out := make(map[string]any, len(data))
for key, value := range data {
out[key] = value
}
for _, name := range setting.Secrets {
delete(out, name)
}
return out
}
// checkSettingRichEditors refuses a submitted richeditor value of a settings
// form that is neither a string nor null, before anything is written.
func checkSettingRichEditors(setting *CompiledSetting, body map[string]any) error {
for _, field := range setting.Form.Fields {
if field.Type != richEditorType {
continue
}
value, present := body[field.Name]
if !present {
continue
}
switch value.(type) {
case nil, string:
default:
return &ValidationError{Details: fillTypeDetails(field.Name)}
}
}
return nil
}
// sanitizeSettingRichEditors runs every projected richeditor value of a
// settings form through seaglass, the gate the controller save uses, and
// writes the clean value back; what the policy removed comes back as
// notices on the field.
func sanitizeSettingRichEditors(setting *CompiledSetting, projected map[string]any) []pluginNotice {
var notices []pluginNotice
for _, field := range setting.Form.Fields {
if field.Type != richEditorType {
continue
}
key := field.Name
for _, w := range setting.Writable {
if w.Name == field.Name {
key = w.FillKey
}
}
text, ok := projected[key].(string)
if !ok {
continue
}
clean, report := seaglass.SanitizeReport(text)
projected[key] = clean
for _, notice := range richEditorNotices(report) {
notices = append(notices, pluginNotice{field: field.Name, notice: notice})
}
}
return notices
}
// settingsMeta builds the result meta, nil when there is nothing to report.
func settingsMeta(ctx context.Context, tr *phrasebook.Translator, secrets map[string]string, notices []pluginNotice) *SettingsMeta {
localized := localizeNotices(ctx, tr, notices)
if len(secrets) == 0 && len(localized) == 0 {
return nil
}
return &SettingsMeta{Secrets: secrets, Notices: localized}
}

View File

@@ -0,0 +1,99 @@
package cabana
import (
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
)
// secretSettings is a settings model with one lagoon.Encrypted column and
// one plain string column, for the write-only password compile gate.
type secretSettings struct {
ID uint `gorm:"column:id;primaryKey"`
APIKey lagoon.Encrypted `gorm:"column:api_key;type:text"`
Token string `gorm:"column:token"`
Notes string `gorm:"column:notes"`
}
func (secretSettings) TableName() string { return "cabana_secret_settings" }
func (secretSettings) Fillable() []string { return []string{"api_key", "token", "notes"} }
func (secretSettings) Rules() map[string]string { return map[string]string{} }
// SecretFallbacks maps each secret field to the config key the application
// reads when the column is empty; SUMMER_* variables map onto those keys.
func (secretSettings) SecretFallbacks() map[string]string {
return map[string]string{"api_key": "acme.secret.api_key"}
}
func secretSettingsItem(fields string) (pact.SettingsItem, fstest.MapFS) {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte(fields)}}
item := pact.SettingsItem{Code: "secret", Label: "Secret", Form: "models/settings/fields.yaml", Model: "SecretSettings", NewModel: func() any { return &secretSettings{} }}
return item, fsys
}
// TestSettingsSecretCompile checks the D-13 boot gate without a database: a
// password field on a settings form binds a lagoon.Encrypted column as a
// write-only secret, keeps its tab, and is refused on any other column type
// naming the field.
func TestSettingsSecretCompile(t *testing.T) {
item, fsys := secretSettingsItem("fields:\n api_key:\n label: API key\n type: password\n tab: Keys\n notes:\n type: richeditor\n")
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
if len(setting.Secrets) != 1 || setting.Secrets[0] != "api_key" {
t.Fatalf("secrets = %v", setting.Secrets)
}
names := map[string]bool{}
for _, w := range setting.Writable {
names[w.Name] = true
}
if !names["api_key"] || !names["notes"] {
t.Fatalf("writable = %+v", setting.Writable)
}
if setting.Form.Fields[0].Tab != "Keys" {
t.Fatalf("tab dropped: %+v", setting.Form.Fields[0])
}
data := withoutSecrets(setting, map[string]any{"api_key": lagoon.NewEncrypted("x"), "notes": "n"})
if _, leaked := data["api_key"]; leaked || data["notes"] != "n" {
t.Fatalf("projection = %#v", data)
}
if (secretSettings{}).SecretFallbacks()["api_key"] != "acme.secret.api_key" {
t.Fatal("fallback key")
}
states := secretStates(setting, &secretSettings{}, nil)
if states["api_key"] != SecretEmpty {
t.Fatalf("states = %v", states)
}
states = secretStates(setting, &secretSettings{APIKey: lagoon.NewEncrypted("sk")}, nil)
if states["api_key"] != SecretStored {
t.Fatalf("states = %v", states)
}
item, fsys = secretSettingsItem("fields:\n token:\n type: password\n")
_, err = compileSetting("acme.unit", item, fsys)
if err == nil || !strings.Contains(err.Error(), "field token: password on a settings form needs a lagoon.Encrypted column") {
t.Fatalf("password on a string column: %v", err)
}
body := map[string]any{"api_key": "", "notes": "n"}
if err := liftSecretValues(setting, body); err != nil {
t.Fatal(err)
}
if _, kept := body["api_key"]; kept {
t.Fatalf("empty string did not keep the stored value: %#v", body)
}
body = map[string]any{"api_key": nil}
if err := liftSecretValues(setting, body); err != nil {
t.Fatal(err)
}
if value, present := body["api_key"]; !present || value != nil {
t.Fatalf("null did not stay as the clear: %#v", body)
}
if err := liftSecretValues(setting, map[string]any{"api_key": 5}); err == nil {
t.Fatal("a non-string secret was accepted")
}
}

View File

@@ -0,0 +1,12 @@
fields:
api_key:
label: API key
type: password
tab: Live keys
test_api_key:
label: Test API key
type: password
tab: Test keys
notes:
label: Notes
type: richeditor

View File

@@ -144,6 +144,14 @@ money:
too_precise: "Use at most :scale decimal places. Amounts are never rounded."
too_large: "This amount is larger than the field can store (at most :max)."
currency: "Currency: :code"
secret:
stored: "A value is stored. Leave the field empty to keep it."
env: "Nothing is stored here, so the value from the server configuration is used."
empty: "Not set."
clear: "Remove stored value"
clear_pending: "The stored value will be removed when you save."
undo: "Keep stored value"
placeholder_stored: "Enter a new value to replace it"
fileupload:
attachment: Attachment details
help: Add a title and description for this attachment.

View File

@@ -154,6 +154,14 @@ money:
too_precise: "Użyj najwyżej :scale miejsc po przecinku. Kwoty nie są zaokrąglane."
too_large: "Ta kwota jest większa, niż pole może zapisać (najwyżej :max)."
currency: "Waluta: :code"
secret:
stored: "Wartość jest zapisana. Pozostaw pole puste, aby jej nie zmieniać."
env: "Nic tu nie zapisano, więc używana jest wartość z konfiguracji serwera."
empty: "Nie ustawiono."
clear: "Usuń zapisaną wartość"
clear_pending: "Zapisana wartość zostanie usunięta po zapisaniu."
undo: "Zachowaj zapisaną wartość"
placeholder_stored: "Wpisz nową wartość, aby ją zastąpić"
fileupload:
attachment: Szczegóły załącznika
help: Dodaj tytuł oraz opis załącznika.