-
Notifications
You must be signed in to change notification settings - Fork 2
CE-1007: baton-sql library API (NewFromYAML + LookupFunc) #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| --- | ||
| # C1 agent-authored v1 golden fixture (Postgres, sync-only). | ||
| # Secrets must live in C1 map values, not in this YAML. | ||
| # | ||
| # Expected c1 sql_connector_map_values keys: | ||
| # DB_HOST, DB_PORT, DB_DATABASE, DB_USER, DB_PASSWORD | ||
| # | ||
| # SaaS network: customer DB must allowlist ConductorOne documented egress IPs. | ||
| app_name: Golden Postgres | ||
| app_description: Sync-only Postgres users for C1 SQL catalog golden path | ||
|
|
||
| connect: | ||
| scheme: postgres | ||
| host: ${DB_HOST} | ||
| port: ${DB_PORT} | ||
| database: ${DB_DATABASE} | ||
| user: ${DB_USER} | ||
| password: ${DB_PASSWORD} | ||
| params: | ||
| # require: default for C1 SaaS / public-internet customer DBs (TLS). | ||
| # disable: only for non-TLS local lab DBs — not the golden path. | ||
| sslmode: require | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion (low confidence): |
||
|
|
||
| resource_types: | ||
| user: | ||
| name: User | ||
| description: A user row in the database | ||
| list: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: This golden fixture is documented as connecting from ConductorOne SaaS across the internet to a customer DB (egress-IP allowlisting comment above), yet |
||
| query: | | ||
| SELECT | ||
| u.id, | ||
| u.username, | ||
| u.email, | ||
| u.status | ||
| FROM users u | ||
| pagination: | ||
| strategy: offset | ||
| primary_key: id | ||
| map: | ||
| # Resource id must be a stable immutable PK (not a renameable login). | ||
| id: ".id" | ||
| display_name: ".username" | ||
| description: ".email" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: The query selects a stable |
||
| traits: | ||
| user: | ||
| status: ".status" | ||
| login: ".username" | ||
| emails: | ||
| - ".email" | ||
| static_entitlements: | ||
| - id: member | ||
| display_name: "'Member'" | ||
| description: "'Application member'" | ||
| purpose: assignment | ||
| grantable_to: | ||
| - user | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "net/url" | ||
| "strings" | ||
| ) | ||
|
|
||
| // OfflineValidate performs YAML-level structural checks without opening a DB or | ||
| // requiring SQLSyncer. Suitable for editor/RPC offline validation. | ||
| func OfflineValidate(cfg *Config) error { | ||
| if cfg == nil { | ||
| return errors.New("config is nil") | ||
| } | ||
| if strings.TrimSpace(cfg.AppName) == "" { | ||
| return errors.New("app_name is required") | ||
| } | ||
| if err := validateConnectOffline(&cfg.Connect); err != nil { | ||
| return err | ||
| } | ||
| if len(cfg.ResourceTypes) == 0 { | ||
| return errors.New("resource_types is required") | ||
| } | ||
| for name, rt := range cfg.ResourceTypes { | ||
| if strings.TrimSpace(rt.Name) == "" { | ||
| return fmt.Errorf("resource_types.%s: name is required", name) | ||
| } | ||
| if rt.List == nil || strings.TrimSpace(rt.List.Query) == "" { | ||
| return fmt.Errorf("resource_types.%s: list.query is required", name) | ||
| } | ||
| if err := validateScope(rt.List.Scope); err != nil { | ||
| return fmt.Errorf("resource_types.%s.list: %w", name, err) | ||
| } | ||
| } | ||
| return RejectNonV1ProductFeatures(cfg) | ||
| } | ||
|
|
||
| // RejectNonV1ProductFeatures fails configs that are outside the C1 agent-authored | ||
| // v1 product surface (Postgres single-DB sync-only). | ||
| func RejectNonV1ProductFeatures(cfg *Config) error { | ||
| if cfg == nil { | ||
| return errors.New("config is nil") | ||
| } | ||
| if cfg.Connect.Databases != nil { | ||
| return errors.New("connect.databases is not supported in v1 (single database only)") | ||
| } | ||
| if len(cfg.Actions) > 0 { | ||
| return errors.New("actions are not supported in v1 (sync-only)") | ||
| } | ||
| for name, rt := range cfg.ResourceTypes { | ||
| if rt.AccountProvisioning != nil { | ||
| return fmt.Errorf("resource_types.%s: account_provisioning is not supported in v1", name) | ||
| } | ||
| if rt.CredentialRotation != nil { | ||
| return fmt.Errorf("resource_types.%s: credential_rotation is not supported in v1", name) | ||
| } | ||
| } | ||
| scheme, err := resolveConnectScheme(&cfg.Connect) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if scheme != "postgres" { | ||
| return fmt.Errorf("scheme %q is not supported in v1; use postgres:// only (not postgresql)", scheme) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // ValidateYAML parses YAML bytes and runs OfflineValidate. | ||
| func ValidateYAML(data []byte) error { | ||
| cfg, err := Parse(data) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid YAML: %w", err) | ||
| } | ||
| return OfflineValidate(cfg) | ||
| } | ||
|
|
||
| func validateConnectOffline(c *DatabaseConfig) error { | ||
| if c == nil { | ||
| return errors.New("connect is required") | ||
| } | ||
| hasDSN := strings.TrimSpace(c.DSN) != "" | ||
| hasScheme := strings.TrimSpace(c.Scheme) != "" | ||
| hasHost := strings.TrimSpace(c.Host) != "" | ||
| if !hasDSN && !hasScheme { | ||
| return errors.New("connect: dsn or scheme is required") | ||
| } | ||
| if !hasDSN && hasScheme && !hasHost { | ||
| return errors.New("connect: host is required when using structured fields without dsn") | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func resolveConnectScheme(c *DatabaseConfig) (string, error) { | ||
| if c == nil { | ||
| return "", errors.New("connect is required") | ||
| } | ||
| if s := strings.TrimSpace(c.Scheme); s != "" { | ||
| return strings.ToLower(s), nil | ||
| } | ||
| dsn := strings.TrimSpace(c.DSN) | ||
| if dsn == "" { | ||
| return "", errors.New("connect: scheme or dsn is required") | ||
| } | ||
| // Placeholders like postgres://${HOST}/db — peel scheme before parse when possible. | ||
| if idx := strings.Index(dsn, "://"); idx > 0 { | ||
| return strings.ToLower(dsn[:idx]), nil | ||
| } | ||
| // Entire DSN may be a single ${VAR}; cannot resolve scheme offline without lookup. | ||
| if strings.HasPrefix(dsn, "${") && strings.HasSuffix(dsn, "}") { | ||
| return "", errors.New("connect: scheme must be set explicitly when dsn is a single placeholder") | ||
| } | ||
| u, err := url.Parse(dsn) | ||
| if err != nil { | ||
| return "", fmt.Errorf("connect: invalid dsn: %w", err) | ||
| } | ||
| if u.Scheme == "" { | ||
| return "", errors.New("connect: scheme missing from dsn") | ||
| } | ||
| return strings.ToLower(u.Scheme), nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func minimalPostgresYAML() string { | ||
| return ` | ||
| app_name: Golden Postgres | ||
| app_description: v1 sync-only fixture | ||
| connect: | ||
| scheme: postgres | ||
| host: ${DB_HOST} | ||
| port: ${DB_PORT} | ||
| database: ${DB_DATABASE} | ||
| user: ${DB_USER} | ||
| password: ${DB_PASSWORD} | ||
| params: | ||
| sslmode: disable | ||
| resource_types: | ||
| user: | ||
| name: User | ||
| description: Database user | ||
| list: | ||
| query: | | ||
| SELECT id, username, email, status FROM users | ||
| pagination: | ||
| strategy: offset | ||
| primary_key: id | ||
| map: | ||
| id: ".username" | ||
| display_name: ".username" | ||
| description: ".email" | ||
| traits: | ||
| user: | ||
| status: ".status" | ||
| login: ".username" | ||
| emails: | ||
| - ".email" | ||
| static_entitlements: | ||
| - id: member | ||
| display_name: "'Member'" | ||
| description: "'Member of app'" | ||
| purpose: assignment | ||
| grantable_to: | ||
| - user | ||
| ` | ||
| } | ||
|
|
||
| func TestOfflineValidate_HappyMinimalPostgres(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| require.NoError(t, OfflineValidate(cfg)) | ||
| require.NoError(t, ValidateYAML([]byte(minimalPostgresYAML()))) | ||
| } | ||
|
|
||
| func TestOfflineValidate_NoNetwork(t *testing.T) { | ||
| // Ensure OfflineValidate does not attempt any DB connection for bad configs either. | ||
| err := ValidateYAML([]byte(` | ||
| app_name: x | ||
| connect: | ||
| scheme: postgres | ||
| host: 127.0.0.1 | ||
| resource_types: | ||
| user: | ||
| name: User | ||
| list: | ||
| query: SELECT 1 | ||
| `)) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
||
| func TestRejectNonV1_MultiDB(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| cfg.Connect.Databases = &DatabasesConfig{Static: []string{"a", "b"}} | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "databases") | ||
| } | ||
|
|
||
| func TestRejectNonV1_Actions(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| cfg.Actions = map[string]ActionConfig{ | ||
| "enable": {Name: "Enable", Query: "SELECT 1"}, | ||
| } | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "actions") | ||
| } | ||
|
|
||
| func TestRejectNonV1_AccountProvisioning(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| rt := cfg.ResourceTypes["user"] | ||
| rt.AccountProvisioning = &AccountProvisioning{} | ||
| cfg.ResourceTypes["user"] = rt | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "account_provisioning") | ||
| } | ||
|
|
||
| func TestRejectNonV1_CredentialRotation(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| rt := cfg.ResourceTypes["user"] | ||
| rt.CredentialRotation = &CredentialRotation{} | ||
| cfg.ResourceTypes["user"] = rt | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "credential_rotation") | ||
| } | ||
|
|
||
| func TestRejectNonV1_NonPostgresScheme(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| cfg.Connect.Scheme = "mysql" | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, strings.ToLower(err.Error()), "postgres") | ||
| } | ||
|
|
||
| func TestRejectNonV1_PostgresqlAliasRejected(t *testing.T) { | ||
| cfg, err := Parse([]byte(minimalPostgresYAML())) | ||
| require.NoError(t, err) | ||
| cfg.Connect.Scheme = "postgresql" | ||
| err = RejectNonV1ProductFeatures(cfg) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "postgresql") | ||
| } | ||
|
|
||
| func TestRejectNonV1_SchemeFromDSN(t *testing.T) { | ||
| cfg, err := Parse([]byte(` | ||
| app_name: DSN App | ||
| connect: | ||
| dsn: "postgres://${DB_HOST}:${DB_PORT}/${DB_DATABASE}?sslmode=disable" | ||
| user: "${DB_USER}" | ||
| password: "${DB_PASSWORD}" | ||
| resource_types: | ||
| user: | ||
| name: User | ||
| list: | ||
| query: SELECT 1 | ||
| `)) | ||
| require.NoError(t, err) | ||
| require.NoError(t, RejectNonV1ProductFeatures(cfg)) | ||
| } | ||
|
|
||
| func TestOfflineValidate_MissingAppName(t *testing.T) { | ||
| err := ValidateYAML([]byte(` | ||
| connect: | ||
| scheme: postgres | ||
| host: localhost | ||
| resource_types: | ||
| user: | ||
| name: User | ||
| list: | ||
| query: SELECT 1 | ||
| `)) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "app_name") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: This "golden path" fixture connects over the public internet (the header comment notes the customer DB must allowlist ConductorOne egress IPs) but sets
sslmode: disable, sending credentials and query data unencrypted. Considersslmode: require(or stronger) as the reference default so downstream agent-authored configs don't inherit an insecure transport. (confidence: medium)