// Package credentialstore provides a durable, dialect-abstracted repository // for principal and token records. It selects a SQL driver from the database // URL (PostgreSQL for postgres:// or postgresql://, SQLite for file paths), // runs idempotent schema migrations, and exposes an opaque Close that the // Control Plane wires to its server lifetime. // // The store is optional: callers pass an empty URL and receive a nil Store // without an error, preserving the legacy startup path. Any non-empty URL // must resolve to a working connection; otherwise the caller returns a fatal // error before serving. package credentialstore import ( "context" "database/sql" "fmt" "strings" _ "github.com/jackc/pgx/v5/stdlib" _ "modernc.org/sqlite" ) const ( dialectPostgres = "pgx" dialectSQLite = "sqlite" ) // dialect selects the driver name from a database URL. // // Dialects: // postgres:// or postgresql:// -> "pgx" (registered by jackc/pgx/v5/stdlib) // file path or "file:..." -> "sqlite" (registered by modernc.org/sqlite) // anything else -> "" (unknown; caller rejects) func dialectFromURL(rawURL string) (string, error) { trimmed := strings.TrimSpace(rawURL) if trimmed == "" { return "", fmt.Errorf("credentialstore: empty database url") } lower := strings.ToLower(trimmed) switch { case strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://"): return dialectPostgres, nil case strings.HasPrefix(lower, "file:") || !strings.Contains(lower, "://"): // Treat anything without a scheme as a file path for SQLite. return dialectSQLite, nil default: return "", fmt.Errorf("credentialstore: unsupported database url scheme: %s", schemeOf(trimmed)) } } func schemeOf(rawURL string) string { if i := strings.Index(rawURL, "://"); i >= 0 { return rawURL[:i] } return "" } // Store holds the underlying sql.DB handle and selected dialect. It is safe // for concurrent use through the standard database/sql pool. type Store struct { db *sql.DB dialect string keyRegistry EnvelopeKeyRegistry } // Option configures optional Store behaviors. type Option func(*Store) // WithEnvelopeKeyRegistry configures the store's envelope key validator. func WithEnvelopeKeyRegistry(registry EnvelopeKeyRegistry) Option { return func(s *Store) { if registry != nil { s.keyRegistry = registry } } } // KeyRegistry returns the configured EnvelopeKeyRegistry or an unavailable default. func (s *Store) KeyRegistry() EnvelopeKeyRegistry { if s == nil || s.keyRegistry == nil { return unavailableKeyRegistry{} } return s.keyRegistry } // Open connects to the database named by databaseURL, runs the idempotent // schema migration, and verifies reachability with a ping. When // databaseURL is empty, Open returns (nil, nil) so the caller can preserve // the legacy startup path without a configured credential plane. // // Open never silently falls back between dialects: a configured URL must // resolve to exactly one driver and succeed. func Open(ctx context.Context, databaseURL string, opts ...Option) (*Store, error) { if strings.TrimSpace(databaseURL) == "" { return nil, nil } dia, err := dialectFromURL(databaseURL) if err != nil { return nil, err } dataSource := databaseURL if dia == dialectSQLite { dataSource = sqliteDataSourceWithBusyTimeout(databaseURL) } db, err := sql.Open(dia, dataSource) if err != nil { return nil, fmt.Errorf("credentialstore: open %s: %w", dia, err) } db.SetMaxOpenConns(10) db.SetMaxIdleConns(2) if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, fmt.Errorf("credentialstore: ping %s: %w", dia, err) } if err := migrate(ctx, db, dia); err != nil { _ = db.Close() return nil, fmt.Errorf("credentialstore: migrate: %w", err) } s := &Store{ db: db, dialect: dia, keyRegistry: unavailableKeyRegistry{}, } for _, opt := range opts { opt(s) } return s, nil } // sqliteDataSourceWithBusyTimeout appends the modernc SQLite busy-timeout // pragma as the final query value. Keeping it in the DSN makes the timeout // apply to every connection the database/sql pool opens, including separate // Store handles used by concurrent bootstrap commands. func sqliteDataSourceWithBusyTimeout(dataSource string) string { const busyTimeoutPragma = "_pragma=busy_timeout%3d5000" base, fragment, hasFragment := strings.Cut(dataSource, "#") separator := "?" if strings.Contains(base, "?") { separator = "&" } if strings.HasSuffix(base, "?") || strings.HasSuffix(base, "&") { separator = "" } result := base + separator + busyTimeoutPragma if hasFragment { result += "#" + fragment } return result } // bind converts portable question-mark placeholders to the ordinal syntax // required by PostgreSQL. SQLite accepts question marks unchanged. func (s *Store) bind(query string) string { return bindQuery(s.dialect, query) } func bindQuery(dialect, query string) string { if dialect != dialectPostgres { return query } var bound strings.Builder bound.Grow(len(query) + 8) ordinal := 1 for _, r := range query { if r == '?' { fmt.Fprintf(&bound, "$%d", ordinal) ordinal++ continue } bound.WriteRune(r) } return bound.String() } // Close releases the underlying database connection. It is safe to call on a // nil receiver. func (s *Store) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } // RunInTransaction executes fn inside a single transaction. On success the // transaction is committed and fn's return value is returned. On any error // the transaction is rolled back and the error is returned unchanged. func (s *Store) RunInTransaction(ctx context.Context, fn func(ctx context.Context, tx *sql.Tx) error) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } if err := fn(ctx, tx); err != nil { _ = tx.Rollback() return err } return tx.Commit() } // DB exposes the underlying *sql.DB for callers that need direct access // (e.g. tests inspecting row counts). Production code should prefer the // typed methods on Store. func (s *Store) DB() *sql.DB { return s.db } // IsOpen reports whether the store holds an active connection. func (s *Store) IsOpen() bool { if s == nil || s.db == nil { return false } return s.db.Ping() == nil }