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

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

View File

@@ -50,7 +50,7 @@ Use cases:
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).
@@ -258,7 +258,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's money 422 messages. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's 422 messages and notices and its `Config` resolves the secret fallbacks. |
| `cabana.SettingsMeta` | Meta of a settings result: `secrets` (field to `stored`, `env` or `empty`) and the richeditor `notices`; omitted when empty. |
| `cabana.SecretFallbacks` | Implemented by a settings model whose password fields fall back to a config key when the column is empty: field name to compass key. |
| `cabana.SecretStored` / `cabana.SecretEnv` / `cabana.SecretEmpty` | The states of a write-only settings secret. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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