- 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
213 lines
6.7 KiB
Go
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}
|
|
}
|