사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
211 lines
6.2 KiB
Go
211 lines
6.2 KiB
Go
// 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
|
|
}
|