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:
@@ -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",
|
||||
|
||||
22
admin/src/api/schema.d.ts
vendored
22
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
152
admin/tests/views/SettingsFormView.test.ts
Normal file
152
admin/tests/views/SettingsFormView.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
11
modules/boardwalk/dist/assets/index-B8hF3DCG.js
vendored
11
modules/boardwalk/dist/assets/index-B8hF3DCG.js
vendored
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-Dioh6ysQ.js
vendored
Normal file
11
modules/boardwalk/dist/assets/index-Dioh6ysQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> & \"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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{} }}
|
||||
|
||||
@@ -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."}
|
||||
}
|
||||
}
|
||||
|
||||
212
modules/cabana/settings_secret.go
Normal file
212
modules/cabana/settings_secret.go
Normal 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}
|
||||
}
|
||||
99
modules/cabana/settings_secret_test.go
Normal file
99
modules/cabana/settings_secret_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
12
modules/cabana/testdata/payments/models/vault/fields.yaml
vendored
Normal file
12
modules/cabana/testdata/payments/models/vault/fields.yaml
vendored
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user