From 1e841b4281c0e4280ae5af2f5fed338242f0f7d6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 13 Oct 2026 11:36:12 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 24 ++ admin/src/api/schema.d.ts | 22 +- .../components/form/fields/PasswordField.vue | 124 ++++++--- admin/src/components/form/formContext.ts | 8 + admin/src/views/SettingsFormView.vue | 111 +++++++- admin/tests/form/PasswordField.test.ts | 93 ++++++- admin/tests/views/SettingsFormView.test.ts | 152 +++++++++++ docs/backend/forms.md | 3 +- docs/backend/settings.md | 38 +++ ...{editor-CkvLclbQ.js => editor-CP-kudaY.js} | 2 +- .../boardwalk/dist/assets/index-B8hF3DCG.js | 11 - .../boardwalk/dist/assets/index-Dioh6ysQ.js | 11 + modules/boardwalk/dist/index.html | 2 +- modules/cabana/README.md | 7 +- modules/cabana/admin_openapi.go | 2 + modules/cabana/companions_postgres_test.go | 12 +- modules/cabana/field_dependent.go | 20 +- modules/cabana/http.go | 13 +- modules/cabana/openapi_conformance_test.go | 22 +- modules/cabana/payments_smoke_test.go | 238 +++++++++++++++++- modules/cabana/richeditor_test.go | 13 +- modules/cabana/settings.go | 76 ++++-- modules/cabana/settings_secret.go | 212 ++++++++++++++++ modules/cabana/settings_secret_test.go | 99 ++++++++ .../payments/models/vault/fields.yaml | 12 + modules/phrasebook/backend/lang/en/lang.yaml | 8 + modules/phrasebook/backend/lang/pl/lang.yaml | 8 + 27 files changed, 1251 insertions(+), 92 deletions(-) create mode 100644 admin/tests/views/SettingsFormView.test.ts rename modules/boardwalk/dist/assets/{editor-CkvLclbQ.js => editor-CP-kudaY.js} (99%) delete mode 100644 modules/boardwalk/dist/assets/index-B8hF3DCG.js create mode 100644 modules/boardwalk/dist/assets/index-Dioh6ysQ.js create mode 100644 modules/cabana/settings_secret.go create mode 100644 modules/cabana/settings_secret_test.go create mode 100644 modules/cabana/testdata/payments/models/vault/fields.yaml diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 82a8f77..0f78345 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -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", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 402705e..7c8f8dc 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -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; diff --git a/admin/src/components/form/fields/PasswordField.vue b/admin/src/components/form/fields/PasswordField.vue index fcb14e8..847a76a 100644 --- a/admin/src/components/form/fields/PasswordField.vue +++ b/admin/src/components/form/fields/PasswordField.vue @@ -1,16 +1,25 @@ diff --git a/admin/src/components/form/formContext.ts b/admin/src/components/form/formContext.ts index c3e141e..2c84b32 100644 --- a/admin/src/components/form/formContext.ts +++ b/admin/src/components/form/formContext.ts @@ -82,6 +82,14 @@ export function createUploadGate(): { activeUploads: Ref; beginUpload: ( export const FORM_SESSION: InjectionKey = 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>>> = Symbol('summer.settings.secrets') + /** Event a widget element dispatches to run its action (bubbles, composed). */ export const WIDGET_EVENT = 'summer-action' diff --git a/admin/src/views/SettingsFormView.vue b/admin/src/views/SettingsFormView.vue index 859a2a4..5701986 100644 --- a/admin/src/views/SettingsFormView.vue +++ b/admin/src/views/SettingsFormView.vue @@ -1,13 +1,27 @@ + diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 342157a..1dc965f 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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. | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index cdee6a4..9baf6b4 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -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 diff --git a/modules/cabana/companions_postgres_test.go b/modules/cabana/companions_postgres_test.go index d18fc7a..d982166 100644 --- a/modules/cabana/companions_postgres_test.go +++ b/modules/cabana/companions_postgres_test.go @@ -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) + } } }) diff --git a/modules/cabana/field_dependent.go b/modules/cabana/field_dependent.go index 272a52f..d6b5a0d 100644 --- a/modules/cabana/field_dependent.go +++ b/modules/cabana/field_dependent.go @@ -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 != "" { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 84679f7..0f5cb4f 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -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 diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 087843c..dc1bce7 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -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 `), } } diff --git a/modules/cabana/payments_smoke_test.go b/modules/cabana/payments_smoke_test.go index 4ed7a9d..dafdbc3 100644 --- a/modules/cabana/payments_smoke_test.go +++ b/modules/cabana/payments_smoke_test.go @@ -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 := `

Bank

` + 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"] != "

Bank

" { + 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 != "

Bank

" { + t.Fatalf("stored notes = %q", stored.Notes) + } + + clean := "

IBAN PL00 & \"swift\"

\n
  • one
" + 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{"

x

"}}, 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()) + } +} diff --git a/modules/cabana/richeditor_test.go b/modules/cabana/richeditor_test.go index a17a044..d1c5e93 100644 --- a/modules/cabana/richeditor_test.go +++ b/modules/cabana/richeditor_test.go @@ -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{} }} diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index 653c55d..5fbf407 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -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."} } } diff --git a/modules/cabana/settings_secret.go b/modules/cabana/settings_secret.go new file mode 100644 index 0000000..b3ab665 --- /dev/null +++ b/modules/cabana/settings_secret.go @@ -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} +} diff --git a/modules/cabana/settings_secret_test.go b/modules/cabana/settings_secret_test.go new file mode 100644 index 0000000..a0faa4b --- /dev/null +++ b/modules/cabana/settings_secret_test.go @@ -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") + } +} diff --git a/modules/cabana/testdata/payments/models/vault/fields.yaml b/modules/cabana/testdata/payments/models/vault/fields.yaml new file mode 100644 index 0000000..78d6f5c --- /dev/null +++ b/modules/cabana/testdata/payments/models/vault/fields.yaml @@ -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 diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index ca0eaff..f471514 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -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. diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index c3136c1..84802d6 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -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.