Files
João Salavisa bb5a81a396 refactor(secrets): detect absent secret via ErrNotFound sentinel (#1770)
* refactor(secrets): detect absent secret via ErrNotFound sentinel

Replace the "secret not found:" string-prefix matching with an exported
secrets.ErrNotFound sentinel, matched via errors.Is. The store(s) now
return fmt.Errorf("%w: %s", ErrNotFound, id); the rendered message is
byte-identical so logs are unchanged.

Consumers migrated off the prefix:
- internal/plugins/config.go isSecretNotFound (behind host GetSecret/
  DeleteSecret and service.go vault rollback)
- internal/integrations/secretadapter Exists (jira/linear/sentry inherit
  the change through the shared adapter)

Adds a non-Postgres-gated secrets test asserting errors.Is(err,
ErrNotFound) plus message-unchanged coverage; updates the affected test
fakes to wrap the sentinel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(secrets): address review — eager t.Cleanup, faithful fake Delete

- sqlite_store_test: register sqlxDB close via t.Cleanup immediately
  after creation, before the NewMasterKeyProvider/Provide fatal paths
  (per the t.Cleanup-immediately convention).
- secretadapter_test: fakeStore.Delete now returns wrapped
  secrets.ErrNotFound for an absent id, mirroring the real store and
  the fakeSecretRevealer.Delete fake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 01:08:29 +01:00

342 lines
11 KiB
Go

// config.go implements the operator-facing plugin settings mechanism: the
// manifest's config_schema (a JSON-Schema-like object the plugin author
// declares) drives a settings form in Settings > Plugins > <plugin>, and the
// helpers here mask/merge/validate the values flowing through it.
//
// Secret fields (a schema property with `secret: true` or
// `format: "password"`, e.g. a GitHub PAT) never leave the backend in
// cleartext on the operator API: GetMaskedConfig replaces stored values with
// configSecretMask, and mergeMaskedSecrets treats an incoming masked value as
// "keep what is stored" so re-submitting the form unchanged never clobbers a
// secret. The plugin process itself reads the REAL values via the Host
// GetConfig RPC (host.go) — that is how the configured secret reaches the
// plugin.
package plugins
import (
"errors"
"fmt"
"reflect"
"regexp"
"strings"
"github.com/kandev/kandev/internal/secrets"
)
// configSecretMask is the placeholder returned in place of a secret config
// value on the operator API, and recognized on write as "leave the stored
// value unchanged". Deliberately implausible as a real credential.
const configSecretMask = "********"
// isSecretNotFound reports whether err is the secrets layer's "absent entry"
// signal (secrets.ErrNotFound) rather than a genuine backend failure. Kept as
// one helper so the several call sites that must tell "absent" apart from a
// real backend error share one definition (host.go's GetSecret/DeleteSecret,
// service.go's vault rollback).
func isSecretNotFound(err error) bool {
return errors.Is(err, secrets.ErrNotFound)
}
// pluginVaultIDPrefix is the reserved id namespace every plugin-owned vault
// entry lives under: "plugin:<plugin_id>:...". Uninstall deletes the whole
// prefix.
const pluginVaultIDPrefix = "plugin:"
// pluginSecretKeyPattern bounds the keys plugins may use with the
// GetSecret/SetSecret/DeleteSecret Host RPCs: a single sane identifier, so
// a key can never smuggle separators into the vault-id namespace.
var pluginSecretKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
// pluginVaultPrefix returns the vault-id prefix owned by pluginID.
func pluginVaultPrefix(pluginID string) string {
return pluginVaultIDPrefix + pluginID + ":"
}
// pluginSecretID is the vault id for a plugin-owned secret stored via the
// SetSecret Host RPC.
func pluginSecretID(pluginID, key string) string {
return pluginVaultPrefix(pluginID) + "secret:" + key
}
// pluginConfigSecretID is the vault id backing a secret config_schema field
// saved from the plugin settings page. Distinct sub-namespace from
// pluginSecretID so a plugin's own SetSecret keys can never collide with
// config-managed entries.
func pluginConfigSecretID(pluginID, field string) string {
return pluginVaultPrefix(pluginID) + "config:" + field
}
// configVaultRef is the value persisted in <id>.config.yml in place of a
// secret config field's cleartext: "vault:" + the exact vault id holding
// the value. isConfigVaultRef only treats a value as a reference when it
// equals the ref derived for that specific plugin+field, so a cleartext
// secret that merely starts with "vault:" can never be misread.
func configVaultRef(pluginID, field string) string {
return "vault:" + pluginConfigSecretID(pluginID, field)
}
// isConfigVaultRef reports whether value is the vault reference marker for
// pluginID's config field.
func isConfigVaultRef(pluginID, field string, value any) bool {
s, ok := value.(string)
return ok && s == configVaultRef(pluginID, field)
}
// hasPluginVaultPrefix reports whether vault id belongs to pluginID's
// namespace.
func hasPluginVaultPrefix(id, pluginID string) bool {
return strings.HasPrefix(id, pluginVaultPrefix(pluginID))
}
// ErrConfigInvalid marks a config rejected by validateConfigSchema so the
// HTTP layer can map it to 400 instead of 500.
var ErrConfigInvalid = errors.New("plugin config invalid")
// schemaProperties extracts the "properties" object from a config_schema.
// Returns nil (no declared properties, everything permissive) when the
// schema is absent or not shaped like a JSON-Schema object.
func schemaProperties(schema map[string]any) map[string]any {
props, _ := schema["properties"].(map[string]any)
return props
}
// secretPropertyKeys returns the set of property names the schema marks as
// secret: `secret: true` or `format: "password"`.
func secretPropertyKeys(schema map[string]any) map[string]bool {
keys := map[string]bool{}
for name, raw := range schemaProperties(schema) {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
if prop["secret"] == true || prop["format"] == "password" {
keys[name] = true
}
}
return keys
}
// maskSecrets returns a copy of config with every set secret value replaced
// by configSecretMask — regardless of runtime type, since masking is the
// primary cleartext boundary on the operator API and a schema may declare a
// non-string property secret (or a stored value may be malformed). Only
// zero values (nil, "", false, 0) pass through, so the UI can distinguish
// "not set" from "set".
func maskSecrets(config map[string]any, schema map[string]any) map[string]any {
secrets := secretPropertyKeys(schema)
out := make(map[string]any, len(config))
for k, v := range config {
if secrets[k] && !isZeroConfigValue(v) {
out[k] = configSecretMask
continue
}
out[k] = v
}
return out
}
// isZeroConfigValue reports whether v is an unset/zero config value that
// maskSecrets leaves unmasked: nil, empty string, false, or numeric zero.
func isZeroConfigValue(v any) bool {
switch value := v.(type) {
case nil:
return true
case string:
return value == ""
case bool:
return !value
default:
if f, ok := numericValue(v); ok {
return f == 0
}
return false
}
}
// mergeMaskedSecrets resolves an incoming config write against the stored
// one: a secret field submitted as the mask placeholder keeps its stored
// value (or is dropped if nothing is stored). Everything else is taken from
// incoming verbatim — the write is a full replace, not a patch.
func mergeMaskedSecrets(incoming, existing map[string]any, schema map[string]any) map[string]any {
secrets := secretPropertyKeys(schema)
out := make(map[string]any, len(incoming))
for k, v := range incoming {
out[k] = v
}
for k := range secrets {
if out[k] != configSecretMask {
continue
}
if stored, ok := existing[k]; ok {
out[k] = stored
} else {
delete(out, k)
}
}
return out
}
// validateConfigSchema checks config against the author-declared
// config_schema for pluginID. Deliberately a small JSON-Schema subset —
// required fields, primitive types (string/boolean/number/integer), and enum
// membership on declared properties. Undeclared keys and richer schema
// constructs are permitted: the schema is advisory authoring metadata, not a
// hard sandbox.
//
// A secret field that was left unchanged carries its own vault reference (an
// internal storage marker) rather than a user value — the cleartext was
// already validated when it was first set. Such a value is skipped, so a
// field declaring both `secret: true` and e.g. `enum: [...]` does not 400 on
// every save that keeps the mask (the ref is not one of the enum values).
func validateConfigSchema(pluginID string, config map[string]any, schema map[string]any) error {
if err := checkRequiredKeys(config, schema); err != nil {
return err
}
if err := checkSecretFieldsAreStrings(config, schema); err != nil {
return err
}
props := schemaProperties(schema)
for name, raw := range props {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
value, present := config[name]
if !present {
continue
}
if isConfigVaultRef(pluginID, name, value) {
continue
}
if err := checkPropertyValue(name, value, prop); err != nil {
return err
}
}
return nil
}
// checkSecretFieldsAreStrings rejects a non-string value on any declared
// secret field. Secrets are vault-backed and the vault stores strings; a
// numeric/boolean secret would otherwise skip vault storage in
// storeConfigSecrets and persist in cleartext — the exact invariant this
// feature exists to uphold. Credentials are strings in practice, so this is
// a contract, not a limitation: authors wanting a secret value declare it
// `type: string`. nil (unset) is allowed.
func checkSecretFieldsAreStrings(config map[string]any, schema map[string]any) error {
for field := range secretPropertyKeys(schema) {
value, present := config[field]
if !present || value == nil {
continue
}
if _, ok := value.(string); !ok {
return fmt.Errorf("%w: secret field %q must be a string", ErrConfigInvalid, field)
}
}
return nil
}
// checkRequiredKeys enforces the schema's "required" list.
func checkRequiredKeys(config map[string]any, schema map[string]any) error {
required, _ := schema["required"].([]any)
for _, raw := range required {
name, ok := raw.(string)
if !ok {
continue
}
if _, present := config[name]; !present {
return fmt.Errorf("%w: missing required field %q", ErrConfigInvalid, name)
}
}
return nil
}
// checkPropertyValue validates one present value against its declared
// property: primitive type match plus enum membership.
func checkPropertyValue(name string, value any, prop map[string]any) error {
if typeName, ok := prop["type"].(string); ok {
if !valueMatchesType(value, typeName) {
return fmt.Errorf("%w: field %q must be a %s", ErrConfigInvalid, name, typeName)
}
}
if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
for _, allowed := range enum {
if enumValueMatches(value, allowed) {
return nil
}
}
return fmt.Errorf("%w: field %q must be one of the declared enum values", ErrConfigInvalid, name)
}
return nil
}
// enumValueMatches compares a submitted value against a declared enum
// entry. Numeric values are normalized first: the manifest YAML decodes an
// enum entry like 5 as int, while the same value submitted over HTTP JSON
// arrives as float64 — reflect.DeepEqual would wrongly reject that valid
// selection. Everything else falls back to DeepEqual.
func enumValueMatches(value, allowed any) bool {
vf, vOK := numericValue(value)
af, aOK := numericValue(allowed)
if vOK && aOK {
return vf == af
}
return reflect.DeepEqual(value, allowed)
}
// numericValue converts the numeric types a config value can realistically
// carry (JSON float64, YAML int/int64/uint64) to float64 for comparison.
func numericValue(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
case uint64:
return float64(n), true
default:
return 0, false
}
}
// valueMatchesType reports whether value satisfies the JSON-Schema primitive
// typeName. Numeric values may arrive as float64 (JSON decoding) or int
// (YAML round-trip of a stored config); "integer" additionally requires an
// integral float. Unknown type names (object/array/…) are not checked.
func valueMatchesType(value any, typeName string) bool {
switch typeName {
case "string":
_, ok := value.(string)
return ok
case "boolean":
_, ok := value.(bool)
return ok
case "number":
return isNumeric(value)
case "integer":
if f, ok := value.(float64); ok {
return f == float64(int64(f))
}
return isInt(value)
default:
return true
}
}
func isNumeric(value any) bool {
if _, ok := value.(float64); ok {
return true
}
return isInt(value)
}
func isInt(value any) bool {
switch value.(type) {
case int, int64, uint64:
return true
default:
return false
}
}