Files
summercms/modules/cabana/settings_secret.go
Jakub Zych 1e841b4281 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
2026-10-13 11:36:12 +02:00

213 lines
6.7 KiB
Go

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}
}