Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions examples/c1-golden-postgres.yml
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:

Copy link
Copy Markdown
Contributor

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. Consider sslmode: require (or stronger) as the reference default so downstream agent-authored configs don't inherit an insecure transport. (confidence: medium)

# require: default for C1 SaaS / public-internet customer DBs (TLS).
# disable: only for non-TLS local lab DBs — not the golden path.
sslmode: require

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (low confidence): sslmode=require encrypts the connection but does not verify the server certificate, so it does not protect against MITM on the path to a public-internet customer DB. Since this is a golden template customers will copy, consider verify-full (or verify-ca) for the SaaS default and reserving require for local labs. Non-blocking — this may be an intentional tradeoff if customers can't reliably provide root certs.


resource_types:
user:
name: User
description: A user row in the database
list:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 sslmode: disable turns off TLS entirely — DB credentials and row data would transit in plaintext. Since golden examples tend to get copied verbatim, consider defaulting to sslmode: require (or verify-full) so the canonical path is TLS-on. (confidence: medium)

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The query selects a stable u.id but the resource id maps to .username (a mutable field). If a user is renamed, C1 will see it as a delete + re-create, breaking grant continuity. Since this is the canonical golden fixture, consider mapping id: ".id" (keep display_name/login on .username) so the example demonstrates stable-ID practice. (confidence: medium)

traits:
user:
status: ".status"
login: ".username"
emails:
- ".email"
static_entitlements:
- id: member
display_name: "'Member'"
description: "'Application member'"
purpose: assignment
grantable_to:
- user
121 changes: 121 additions & 0 deletions pkg/bsql/offline_validate.go
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
}
166 changes: 166 additions & 0 deletions pkg/bsql/offline_validate_test.go
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")
}
Loading
Loading