diff --git a/module/pipeline_step_db_dynamic.go b/module/pipeline_step_db_dynamic.go new file mode 100644 index 00000000..0175022c --- /dev/null +++ b/module/pipeline_step_db_dynamic.go @@ -0,0 +1,76 @@ +package module + +import ( + "fmt" + "strings" +) + +// validateSQLIdentifier checks that s is safe to interpolate directly into SQL as an +// identifier (e.g. a table name). Only ASCII letters (A-Z, a-z), ASCII digits (0-9), +// underscores (_) and hyphens (-) are permitted. This strict allowlist prevents SQL +// injection when dynamic values are embedded in queries via allow_dynamic_sql. +func validateSQLIdentifier(s string) error { + if s == "" { + return fmt.Errorf("dynamic SQL identifier must not be empty") + } + for _, c := range s { + if (c < 'a' || c > 'z') && + (c < 'A' || c > 'Z') && + (c < '0' || c > '9') && + c != '_' && c != '-' { + return fmt.Errorf("dynamic SQL identifier %q contains unsafe character %q (only ASCII letters, digits, underscores and hyphens are allowed)", s, string(c)) + } + } + return nil +} + +// resolveDynamicSQL resolves every {{ }} template expression found in query against +// pc and validates that each resolved value is a safe SQL identifier. The validated +// values are substituted back into the query in left-to-right order and the final +// SQL string is returned. +// +// Each occurrence of a template expression is resolved independently, so +// non-deterministic functions like {{uuid}} or {{now}} produce a distinct value +// per occurrence. +// +// This is only called when allow_dynamic_sql is true (explicit opt-in). Callers +// are responsible for ensuring that the query has already passed template parsing. +func resolveDynamicSQL(tmpl *TemplateEngine, query string, pc *PipelineContext) (string, error) { + if !strings.Contains(query, "{{") { + return query, nil + } + + // Process template expressions in left-to-right order. Each occurrence is + // resolved and validated independently to preserve correct semantics for + // non-deterministic template functions (e.g. {{uuid}}, {{now}}). + var result strings.Builder + rest := query + for { + openIdx := strings.Index(rest, "{{") + if openIdx < 0 { + result.WriteString(rest) + break + } + closeIdx := strings.Index(rest[openIdx:], "}}") + if closeIdx < 0 { + return "", fmt.Errorf("dynamic SQL: unclosed template action in query (missing closing '}}')") + } + closeIdx += openIdx + + // Write the literal SQL text before this expression. + result.WriteString(rest[:openIdx]) + + expr := rest[openIdx : closeIdx+2] + + resolved, err := tmpl.Resolve(expr, pc) + if err != nil { + return "", fmt.Errorf("dynamic SQL: failed to resolve %q: %w", expr, err) + } + if err := validateSQLIdentifier(resolved); err != nil { + return "", fmt.Errorf("dynamic SQL: %w", err) + } + result.WriteString(resolved) + rest = rest[closeIdx+2:] + } + return result.String(), nil +} diff --git a/module/pipeline_step_db_exec.go b/module/pipeline_step_db_exec.go index 588564a4..3b97f361 100644 --- a/module/pipeline_step_db_exec.go +++ b/module/pipeline_step_db_exec.go @@ -10,13 +10,14 @@ import ( // DBExecStep executes parameterized SQL INSERT/UPDATE/DELETE against a named database service. type DBExecStep struct { - name string - database string - query string - params []string - ignoreError bool - app modular.Application - tmpl *TemplateEngine + name string + database string + query string + params []string + ignoreError bool + allowDynamicSQL bool + app modular.Application + tmpl *TemplateEngine } // NewDBExecStepFactory returns a StepFactory that creates DBExecStep instances. @@ -32,8 +33,10 @@ func NewDBExecStepFactory() StepFactory { return nil, fmt.Errorf("db_exec step %q: 'query' is required", name) } - // Safety: reject template expressions in SQL to prevent injection - if strings.Contains(query, "{{") { + // Safety: reject template expressions in SQL to prevent injection, + // unless allow_dynamic_sql is explicitly enabled. + allowDynamicSQL, _ := config["allow_dynamic_sql"].(bool) + if !allowDynamicSQL && strings.Contains(query, "{{") { return nil, fmt.Errorf("db_exec step %q: query must not contain template expressions (use params instead)", name) } @@ -51,13 +54,14 @@ func NewDBExecStepFactory() StepFactory { ignoreError, _ := config["ignore_error"].(bool) return &DBExecStep{ - name: name, - database: database, - query: query, - params: params, - ignoreError: ignoreError, - app: app, - tmpl: NewTemplateEngine(), + name: name, + database: database, + query: query, + params: params, + ignoreError: ignoreError, + allowDynamicSQL: allowDynamicSQL, + app: app, + tmpl: NewTemplateEngine(), }, nil } } @@ -65,6 +69,18 @@ func NewDBExecStepFactory() StepFactory { func (s *DBExecStep) Name() string { return s.name } func (s *DBExecStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + // Resolve template expressions in the query early (before any DB access) when + // dynamic SQL is enabled. This validates resolved identifiers against an + // allowlist before any database interaction. + query := s.query + if s.allowDynamicSQL { + var err error + query, err = resolveDynamicSQL(s.tmpl, query, pc) + if err != nil { + return nil, fmt.Errorf("db_exec step %q: %w", s.name, err) + } + } + if s.app == nil { return nil, fmt.Errorf("db_exec step %q: no application context", s.name) } @@ -102,7 +118,7 @@ func (s *DBExecStep) Execute(_ context.Context, pc *PipelineContext) (*StepResul // Normalize SQL placeholders: users write $1,$2,$3 (PostgreSQL style), // engine converts to ? for SQLite automatically. - query := normalizePlaceholders(s.query, driver) + query = normalizePlaceholders(query, driver) // Execute statement result, err := db.Exec(query, resolvedParams...) diff --git a/module/pipeline_step_db_exec_test.go b/module/pipeline_step_db_exec_test.go index 1e5512ee..eb1ea246 100644 --- a/module/pipeline_step_db_exec_test.go +++ b/module/pipeline_step_db_exec_test.go @@ -3,6 +3,7 @@ package module import ( "context" "database/sql" + "strings" "testing" _ "modernc.org/sqlite" @@ -195,6 +196,68 @@ func TestDBExecStep_RejectsTemplateInQuery(t *testing.T) { } } +func TestDBExecStep_DynamicTableName(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + _, err = db.Exec(`CREATE TABLE items_alpha (id TEXT PRIMARY KEY, name TEXT NOT NULL)`) + if err != nil { + t.Fatalf("create table: %v", err) + } + + app := mockAppWithDB("test-db", db) + factory := NewDBExecStepFactory() + step, err := factory("dynamic-insert", map[string]any{ + "database": "test-db", + "query": `INSERT INTO items_{{.steps.auth.tenant}} (id, name) VALUES (?, ?)`, + "params": []any{"i1", "Widget"}, + "allow_dynamic_sql": true, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "alpha"}) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + affected, _ := result.Output["affected_rows"].(int64) + if affected != 1 { + t.Errorf("expected affected_rows=1, got %v", affected) + } +} + +func TestDBExecStep_DynamicSQL_RejectsInjection(t *testing.T) { + factory := NewDBExecStepFactory() + step, err := factory("injection-exec", map[string]any{ + "database": "test-db", + "query": `DELETE FROM items_{{.steps.auth.tenant}} WHERE id = ?`, + "params": []any{"i1"}, + "allow_dynamic_sql": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "alpha'; DROP TABLE items;--"}) + + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for unsafe SQL identifier") + } + if !strings.Contains(err.Error(), "unsafe character") { + t.Errorf("expected 'unsafe character' in error, got: %v", err) + } +} + func TestDBExecStep_MissingDatabase(t *testing.T) { factory := NewDBExecStepFactory() _, err := factory("no-db", map[string]any{ diff --git a/module/pipeline_step_db_query.go b/module/pipeline_step_db_query.go index 43ed813a..524e71b3 100644 --- a/module/pipeline_step_db_query.go +++ b/module/pipeline_step_db_query.go @@ -25,13 +25,14 @@ type DBDriverProvider interface { // DBQueryStep executes a parameterized SQL SELECT against a named database service. type DBQueryStep struct { - name string - database string - query string - params []string - mode string // "list" or "single" - app modular.Application - tmpl *TemplateEngine + name string + database string + query string + params []string + mode string // "list" or "single" + allowDynamicSQL bool + app modular.Application + tmpl *TemplateEngine } // NewDBQueryStepFactory returns a StepFactory that creates DBQueryStep instances. @@ -47,8 +48,10 @@ func NewDBQueryStepFactory() StepFactory { return nil, fmt.Errorf("db_query step %q: 'query' is required", name) } - // Safety: reject template expressions in SQL to prevent injection - if strings.Contains(query, "{{") { + // Safety: reject template expressions in SQL to prevent injection, + // unless allow_dynamic_sql is explicitly enabled. + allowDynamicSQL, _ := config["allow_dynamic_sql"].(bool) + if !allowDynamicSQL && strings.Contains(query, "{{") { return nil, fmt.Errorf("db_query step %q: query must not contain template expressions (use params instead)", name) } @@ -72,13 +75,14 @@ func NewDBQueryStepFactory() StepFactory { } return &DBQueryStep{ - name: name, - database: database, - query: query, - params: params, - mode: mode, - app: app, - tmpl: NewTemplateEngine(), + name: name, + database: database, + query: query, + params: params, + mode: mode, + allowDynamicSQL: allowDynamicSQL, + app: app, + tmpl: NewTemplateEngine(), }, nil } } @@ -86,6 +90,18 @@ func NewDBQueryStepFactory() StepFactory { func (s *DBQueryStep) Name() string { return s.name } func (s *DBQueryStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + // Resolve template expressions in the query early (before any DB access) when + // dynamic SQL is enabled. This validates resolved identifiers against an + // allowlist before any database interaction. + query := s.query + if s.allowDynamicSQL { + var err error + query, err = resolveDynamicSQL(s.tmpl, query, pc) + if err != nil { + return nil, fmt.Errorf("db_query step %q: %w", s.name, err) + } + } + // Resolve database service if s.app == nil { return nil, fmt.Errorf("db_query step %q: no application context", s.name) @@ -124,7 +140,7 @@ func (s *DBQueryStep) Execute(_ context.Context, pc *PipelineContext) (*StepResu // Normalize SQL placeholders: users write $1,$2,$3 (PostgreSQL style), // engine converts to ? for SQLite automatically. - query := normalizePlaceholders(s.query, driver) + query = normalizePlaceholders(query, driver) // Execute query rows, err := db.Query(query, resolvedParams...) diff --git a/module/pipeline_step_db_query_cached.go b/module/pipeline_step_db_query_cached.go index 2b40fe55..400f1dab 100644 --- a/module/pipeline_step_db_query_cached.go +++ b/module/pipeline_step_db_query_cached.go @@ -20,15 +20,16 @@ type dbQueryCacheEntry struct { // in an in-process, TTL-aware cache keyed by a template-resolved cache key. // Concurrent pipeline executions are safe: access is protected by a read-write mutex. type DBQueryCachedStep struct { - name string - database string - query string - params []string - cacheKey string - cacheTTL time.Duration - scanFields []string - app modular.Application - tmpl *TemplateEngine + name string + database string + query string + params []string + cacheKey string + cacheTTL time.Duration + scanFields []string + allowDynamicSQL bool + app modular.Application + tmpl *TemplateEngine mu sync.RWMutex cache map[string]dbQueryCacheEntry @@ -47,8 +48,10 @@ func NewDBQueryCachedStepFactory() StepFactory { return nil, fmt.Errorf("db_query_cached step %q: 'query' is required", name) } - // Safety: reject template expressions in SQL to prevent injection - if strings.Contains(query, "{{") { + // Safety: reject template expressions in SQL to prevent injection, + // unless allow_dynamic_sql is explicitly enabled. + allowDynamicSQL, _ := config["allow_dynamic_sql"].(bool) + if !allowDynamicSQL && strings.Contains(query, "{{") { return nil, fmt.Errorf("db_query_cached step %q: query must not contain template expressions (use params instead)", name) } @@ -92,16 +95,17 @@ func NewDBQueryCachedStepFactory() StepFactory { } return &DBQueryCachedStep{ - name: name, - database: database, - query: query, - params: params, - cacheKey: cacheKey, - cacheTTL: cacheTTL, - scanFields: scanFields, - app: app, - tmpl: NewTemplateEngine(), - cache: make(map[string]dbQueryCacheEntry), + name: name, + database: database, + query: query, + params: params, + cacheKey: cacheKey, + cacheTTL: cacheTTL, + scanFields: scanFields, + allowDynamicSQL: allowDynamicSQL, + app: app, + tmpl: NewTemplateEngine(), + cache: make(map[string]dbQueryCacheEntry), }, nil } } @@ -112,6 +116,18 @@ func (s *DBQueryCachedStep) Name() string { return s.name } // Execute checks the in-memory cache first; on a miss (or expiry) it queries // the database, stores the result, and returns it. func (s *DBQueryCachedStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + // Resolve template expressions in the query early (before any DB access) when + // dynamic SQL is enabled. This validates resolved identifiers against an + // allowlist before any database interaction. + query := s.query + if s.allowDynamicSQL { + var err error + query, err = resolveDynamicSQL(s.tmpl, query, pc) + if err != nil { + return nil, fmt.Errorf("db_query_cached step %q: %w", s.name, err) + } + } + if s.app == nil { return nil, fmt.Errorf("db_query_cached step %q: no application context", s.name) } @@ -151,7 +167,7 @@ func (s *DBQueryCachedStep) Execute(ctx context.Context, pc *PipelineContext) (* s.mu.Unlock() // Query the database - result, err := s.runQuery(ctx, pc) + result, err := s.runQuery(ctx, pc, query) if err != nil { return nil, err } @@ -169,7 +185,8 @@ func (s *DBQueryCachedStep) Execute(ctx context.Context, pc *PipelineContext) (* } // runQuery executes the SQL query and returns the result as a map. -func (s *DBQueryCachedStep) runQuery(ctx context.Context, pc *PipelineContext) (map[string]any, error) { +// query is the (already dynamic-SQL-resolved) query string to execute. +func (s *DBQueryCachedStep) runQuery(ctx context.Context, pc *PipelineContext, query string) (map[string]any, error) { svc, ok := s.app.SvcRegistry()[s.database] if !ok { return nil, fmt.Errorf("db_query_cached step %q: database service %q not found", s.name, s.database) @@ -200,7 +217,7 @@ func (s *DBQueryCachedStep) runQuery(ctx context.Context, pc *PipelineContext) ( resolvedParams[i] = resolved } - query := normalizePlaceholders(s.query, driver) + query = normalizePlaceholders(query, driver) rows, err := db.QueryContext(ctx, query, resolvedParams...) if err != nil { diff --git a/module/pipeline_step_db_query_cached_test.go b/module/pipeline_step_db_query_cached_test.go index ab21aebf..ab00f7a0 100644 --- a/module/pipeline_step_db_query_cached_test.go +++ b/module/pipeline_step_db_query_cached_test.go @@ -2,6 +2,7 @@ package module import ( "context" + "strings" "testing" "time" ) @@ -405,3 +406,68 @@ func TestDBQueryCachedStep_NegativeTTLRejected(t *testing.T) { t.Fatal("expected error for negative cache_ttl") } } + +func TestDBQueryCachedStep_DynamicTableName(t *testing.T) { + db := setupTestDB(t) + _, err := db.Exec(`CREATE TABLE companies_beta (id TEXT PRIMARY KEY, name TEXT NOT NULL)`) + if err != nil { + t.Fatalf("create table: %v", err) + } + _, err = db.Exec(`INSERT INTO companies_beta (id, name) VALUES ('b1', 'Beta LLC')`) + if err != nil { + t.Fatalf("insert: %v", err) + } + + app := mockAppWithDB("test-db", db) + factory := NewDBQueryCachedStepFactory() + step, err := factory("dynamic-cached", map[string]any{ + "database": "test-db", + "query": `SELECT id, name FROM companies_{{.steps.auth.tenant}} WHERE id = $1`, + "params": []any{"b1"}, + "cache_key": `tenant:{{.steps.auth.tenant}}:b1`, + "cache_ttl": "5m", + "allow_dynamic_sql": true, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "beta"}) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if result.Output["cache_hit"] != false { + t.Errorf("expected cache_hit=false on first call, got %v", result.Output["cache_hit"]) + } + if result.Output["name"] != "Beta LLC" { + t.Errorf("expected name='Beta LLC', got %v", result.Output["name"]) + } +} + +func TestDBQueryCachedStep_DynamicSQL_RejectsInjection(t *testing.T) { + factory := NewDBQueryCachedStepFactory() + step, err := factory("injection-cached", map[string]any{ + "database": "test-db", + "query": `SELECT * FROM companies_{{.steps.auth.tenant}}`, + "cache_key": "k", + "allow_dynamic_sql": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "alpha'; DROP TABLE companies;--"}) + + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for unsafe SQL identifier") + } + if !strings.Contains(err.Error(), "unsafe character") { + t.Errorf("expected 'unsafe character' in error, got: %v", err) + } +} diff --git a/module/pipeline_step_db_query_test.go b/module/pipeline_step_db_query_test.go index acef121d..a70638f8 100644 --- a/module/pipeline_step_db_query_test.go +++ b/module/pipeline_step_db_query_test.go @@ -3,6 +3,7 @@ package module import ( "context" "database/sql" + "strings" "testing" _ "modernc.org/sqlite" @@ -218,6 +219,93 @@ func TestDBQueryStep_RejectsTemplateInQuery(t *testing.T) { } } +func TestDBQueryStep_DynamicTableName(t *testing.T) { + db := setupTestDB(t) + // Create a second table whose name is derived from a "tenant" value. + _, err := db.Exec(`CREATE TABLE companies_alpha (id TEXT PRIMARY KEY, name TEXT NOT NULL)`) + if err != nil { + t.Fatalf("create tenant table: %v", err) + } + _, err = db.Exec(`INSERT INTO companies_alpha (id, name) VALUES ('a1', 'Alpha Corp')`) + if err != nil { + t.Fatalf("insert: %v", err) + } + + app := mockAppWithDB("test-db", db) + factory := NewDBQueryStepFactory() + step, err := factory("dynamic-table", map[string]any{ + "database": "test-db", + "query": `SELECT id, name FROM companies_{{.steps.auth.tenant}} WHERE id = ?`, + "params": []any{"a1"}, + "mode": "single", + "allow_dynamic_sql": true, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "alpha"}) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + found, _ := result.Output["found"].(bool) + if !found { + t.Error("expected found=true") + } + row, _ := result.Output["row"].(map[string]any) + if row["name"] != "Alpha Corp" { + t.Errorf("expected name='Alpha Corp', got %v", row["name"]) + } +} + +func TestDBQueryStep_DynamicSQL_RejectsInjection(t *testing.T) { + factory := NewDBQueryStepFactory() + step, err := factory("injection-attempt", map[string]any{ + "database": "test-db", + "query": `SELECT * FROM companies_{{.steps.auth.tenant}}`, + "mode": "list", + "allow_dynamic_sql": true, + }, nil) // nil app is fine – we expect an error before the DB is touched + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("auth", map[string]any{"tenant": "alpha; DROP TABLE companies;--"}) + + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for unsafe SQL identifier") + } + if !strings.Contains(err.Error(), "unsafe character") { + t.Errorf("expected 'unsafe character' in error, got: %v", err) + } +} + +func TestDBQueryStep_DynamicSQL_RejectsEmpty(t *testing.T) { + factory := NewDBQueryStepFactory() + step, err := factory("empty-ident", map[string]any{ + "database": "test-db", + "query": `SELECT * FROM companies_{{.steps.auth.tenant}}`, + "mode": "list", + "allow_dynamic_sql": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + // Tenant resolves to empty string (missing key → zero value) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for empty SQL identifier") + } +} + func TestDBQueryStep_MissingDatabase(t *testing.T) { factory := NewDBQueryStepFactory() _, err := factory("no-db", map[string]any{ @@ -228,6 +316,28 @@ func TestDBQueryStep_MissingDatabase(t *testing.T) { } } +func TestDBQueryStep_DynamicSQL_UnclosedAction(t *testing.T) { + factory := NewDBQueryStepFactory() + step, err := factory("unclosed", map[string]any{ + "database": "test-db", + "query": `SELECT * FROM companies_{{.steps.auth.tenant`, + "mode": "list", + "allow_dynamic_sql": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, nil) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for unclosed template action") + } + if !strings.Contains(err.Error(), "unclosed template action") { + t.Errorf("expected 'unclosed template action' in error, got: %v", err) + } +} + func TestDBQueryStep_EmptyResult(t *testing.T) { db := setupTestDB(t) app := mockAppWithDB("test-db", db) diff --git a/schema/module_schema.go b/schema/module_schema.go index 83c670e3..721f76f4 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -999,9 +999,10 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Query results as rows/count (list mode) or row/found (single mode)"}}, ConfigFields: []ConfigFieldDef{ {Key: "database", Label: "Database", Type: FieldTypeString, Required: true, Description: "Name of the database service (must implement DBProvider)", Placeholder: "admin-db", InheritFrom: "dependency.name"}, - {Key: "query", Label: "SQL Query", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL SELECT query (use ? for placeholders, no template expressions allowed)", Placeholder: "SELECT id, name FROM companies WHERE id = ?"}, + {Key: "query", Label: "SQL Query", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL SELECT query (use ? for placeholders). Template expressions are forbidden unless allow_dynamic_sql is true.", Placeholder: "SELECT id, name FROM companies WHERE id = ?"}, {Key: "params", Label: "Parameters", Type: FieldTypeArray, ArrayItemType: "string", Description: "Template-resolved parameter values for ? placeholders in query"}, {Key: "mode", Label: "Mode", Type: FieldTypeSelect, Options: []string{"list", "single"}, DefaultValue: "list", Description: "Result mode: 'list' returns rows/count, 'single' returns row/found"}, + {Key: "allow_dynamic_sql", Label: "Allow Dynamic SQL", Type: FieldTypeBool, DefaultValue: "false", Description: "When true, template expressions in 'query' are resolved at runtime. Each resolved value must contain only letters, digits, underscores and hyphens to prevent SQL injection."}, }, }) @@ -1014,11 +1015,12 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Query result fields as top-level keys plus cache_hit boolean"}}, ConfigFields: []ConfigFieldDef{ {Key: "database", Label: "Database", Type: FieldTypeString, Required: true, Description: "Name of the database service (must implement DBProvider)", Placeholder: "db", InheritFrom: "dependency.name"}, - {Key: "query", Label: "SQL Query", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL SELECT query using $N placeholders (e.g. $1, $2); automatically converted to ? for SQLite drivers. No template expressions allowed.", Placeholder: "SELECT backend_url, settings FROM routing_config WHERE tenant_id = $1 LIMIT 1"}, + {Key: "query", Label: "SQL Query", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL SELECT query using $N placeholders (e.g. $1, $2); automatically converted to ? for SQLite drivers. Template expressions are forbidden unless allow_dynamic_sql is true.", Placeholder: "SELECT backend_url, settings FROM routing_config WHERE tenant_id = $1 LIMIT 1"}, {Key: "params", Label: "Parameters", Type: FieldTypeArray, ArrayItemType: "string", Description: "Template-resolved parameter values for query placeholders"}, {Key: "cache_key", Label: "Cache Key", Type: FieldTypeString, Required: true, Description: "Template-resolved key used to store/retrieve the cached result", Placeholder: "tenant_config:{{.steps.parse.headers.X-Tenant-Id}}"}, {Key: "cache_ttl", Label: "Cache TTL", Type: FieldTypeString, DefaultValue: "5m", Description: "Duration string for how long to cache the result (e.g. '5m', '30s', '1h')", Placeholder: "5m"}, {Key: "scan_fields", Label: "Scan Fields", Type: FieldTypeArray, ArrayItemType: "string", Description: "Column names to include in the output map (omit to include all columns)"}, + {Key: "allow_dynamic_sql", Label: "Allow Dynamic SQL", Type: FieldTypeBool, DefaultValue: "false", Description: "When true, template expressions in 'query' are resolved at runtime. Each resolved value must contain only letters, digits, underscores and hyphens to prevent SQL injection."}, }, }) @@ -1031,8 +1033,9 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Execution result with affected_rows and last_id"}}, ConfigFields: []ConfigFieldDef{ {Key: "database", Label: "Database", Type: FieldTypeString, Required: true, Description: "Name of the database service (must implement DBProvider)", Placeholder: "admin-db", InheritFrom: "dependency.name"}, - {Key: "query", Label: "SQL Statement", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL INSERT/UPDATE/DELETE statement (use ? for placeholders)", Placeholder: "INSERT INTO companies (id, name) VALUES (?, ?)"}, + {Key: "query", Label: "SQL Statement", Type: FieldTypeSQL, Required: true, Description: "Parameterized SQL INSERT/UPDATE/DELETE statement (use ? for placeholders). Template expressions are forbidden unless allow_dynamic_sql is true.", Placeholder: "INSERT INTO companies (id, name) VALUES (?, ?)"}, {Key: "params", Label: "Parameters", Type: FieldTypeArray, ArrayItemType: "string", Description: "Template-resolved parameter values for ? placeholders"}, + {Key: "allow_dynamic_sql", Label: "Allow Dynamic SQL", Type: FieldTypeBool, DefaultValue: "false", Description: "When true, template expressions in 'query' are resolved at runtime. Each resolved value must contain only letters, digits, underscores and hyphens to prevent SQL injection."}, }, })