Files
kandev/apps/backend/internal/secrets/sqlite_store.go
T

392 lines
12 KiB
Go

package secrets
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
)
type sqliteStore struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
crypto *MasterKeyProvider
ownsDB bool
}
var _ ScopedSecretStore = (*sqliteStore)(nil)
// Provide creates the SQLite secret store using separate writer and reader pools.
func Provide(writer, reader *sqlx.DB, crypto *MasterKeyProvider) (*sqliteStore, func() error, error) {
store := &sqliteStore{db: writer, ro: reader, crypto: crypto}
if err := store.initSchema(); err != nil {
return nil, nil, fmt.Errorf("secrets schema init: %w", err)
}
return store, store.Close, nil
}
func (s *sqliteStore) initSchema() error {
binaryType := dialect.BlobType(s.db.DriverName())
schema := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS secrets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'global',
workspace_id TEXT NOT NULL DEFAULT '',
encrypted_value %s NOT NULL,
nonce %s NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`, binaryType, binaryType)
if _, err := s.db.Exec(schema); err != nil {
return err
}
// Existing databases: CREATE TABLE IF NOT EXISTS is a no-op, so the
// column must also be added via an idempotent migration (ADR 0027).
migrate := db.NewMigrateLogger(s.db, nil)
migrate.Apply("secrets.user_id", "ALTER TABLE secrets ADD COLUMN user_id TEXT NOT NULL DEFAULT ''")
migrate.Apply("secrets.scope", "ALTER TABLE secrets ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'")
migrate.Apply("secrets.workspace_id", "ALTER TABLE secrets ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''")
return nil
}
// scopeOwner returns the per-user scoping ID for a request context.
// Empty = unscoped (internal caller or auth disabled), matching the
// task-service scoping semantics. Rows with user_id=” (created pre-auth or
// by internal flows) stay visible to every caller until the setup wizard
// claims them for the admin.
func scopeOwner(ctx context.Context) string {
identity, ok := authn.IdentityFromContext(ctx)
if !ok || identity.Synthetic {
return ""
}
return identity.UserID
}
// ClaimUnowned assigns every unowned secret to ownerID. Called by the auth
// setup wizard; idempotent.
func (s *sqliteStore) ClaimUnowned(ctx context.Context, ownerID string) error {
_, err := s.db.ExecContext(ctx, s.db.Rebind(`
UPDATE secrets SET user_id = ? WHERE user_id = ''`), ownerID)
return err
}
func (s *sqliteStore) Close() error {
if !s.ownsDB {
return nil
}
return s.db.Close()
}
func (s *sqliteStore) Create(ctx context.Context, secret *SecretWithValue) error {
if secret == nil {
return fmt.Errorf("secret is required")
}
if secret.Scope == "" {
secret.Scope = ScopeGlobal
}
if err := validateSecretScope(secret.Scope, secret.WorkspaceID); err != nil {
return err
}
if secret.ID == "" {
secret.ID = uuid.New().String()
}
now := time.Now().UTC()
secret.CreatedAt = now
secret.UpdatedAt = now
ciphertext, nonce, err := Encrypt([]byte(secret.Value), s.crypto.Key())
if err != nil {
return fmt.Errorf("encrypt secret: %w", err)
}
_, err = s.db.ExecContext(ctx, s.db.Rebind(`
INSERT INTO secrets (id, name, user_id, scope, workspace_id, encrypted_value, nonce, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
secret.ID, secret.Name, scopeOwner(ctx), secret.Scope, secret.WorkspaceID, ciphertext, nonce, now, now,
)
if err != nil {
return fmt.Errorf("insert secret: %w", err)
}
return nil
}
func (s *sqliteStore) Get(ctx context.Context, id string) (*Secret, error) {
var row secretRow
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`
SELECT id, name, scope, workspace_id, created_at, updated_at
FROM secrets WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
id, scopeOwner(ctx), scopeOwner(ctx))
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
}
return nil, fmt.Errorf("get secret: %w", err)
}
return row.toSecret(), nil
}
func (s *sqliteStore) Reveal(ctx context.Context, id string) (string, error) {
var ciphertext, nonce []byte
err := s.ro.QueryRowContext(ctx, s.ro.Rebind(`
SELECT encrypted_value, nonce FROM secrets
WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
id, scopeOwner(ctx), scopeOwner(ctx)).
Scan(&ciphertext, &nonce)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("%w: %s", ErrNotFound, id)
}
return "", fmt.Errorf("reveal secret: %w", err)
}
plaintext, err := Decrypt(ciphertext, nonce, s.crypto.Key())
if err != nil {
return "", fmt.Errorf("decrypt secret: %w", err)
}
return string(plaintext), nil
}
func (s *sqliteStore) Update(ctx context.Context, id string, req *UpdateSecretRequest) error {
existing, err := s.Get(ctx, id)
if err != nil {
return err
}
now := time.Now().UTC()
if req.Name != nil {
existing.Name = *req.Name
}
if req.Value != nil {
ciphertext, nonce, err := Encrypt([]byte(*req.Value), s.crypto.Key())
if err != nil {
return fmt.Errorf("encrypt secret: %w", err)
}
_, err = s.db.ExecContext(ctx, s.db.Rebind(`
UPDATE secrets SET name = ?, encrypted_value = ?, nonce = ?, updated_at = ?
WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
existing.Name, ciphertext, nonce, now, id, scopeOwner(ctx), scopeOwner(ctx),
)
if err != nil {
return fmt.Errorf("update secret: %w", err)
}
} else {
_, err = s.db.ExecContext(ctx, s.db.Rebind(`
UPDATE secrets SET name = ?, updated_at = ?
WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
existing.Name, now, id, scopeOwner(ctx), scopeOwner(ctx),
)
if err != nil {
return fmt.Errorf("update secret: %w", err)
}
}
return nil
}
func (s *sqliteStore) Delete(ctx context.Context, id string) error {
result, err := s.db.ExecContext(ctx, s.db.Rebind(`
DELETE FROM secrets WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
id, scopeOwner(ctx), scopeOwner(ctx))
if err != nil {
return fmt.Errorf("delete secret: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w: %s", ErrNotFound, id)
}
return nil
}
func (s *sqliteStore) List(ctx context.Context) ([]*SecretListItem, error) {
return s.ListScoped(ctx, SecretListOptions{Scope: ScopeGlobal})
}
func (s *sqliteStore) ListScoped(ctx context.Context, opts SecretListOptions) ([]*SecretListItem, error) {
if err := validateListOptions(opts); err != nil {
return nil, err
}
var rows []secretListRow
query := `
SELECT id, name, scope, workspace_id, 1 as has_value, created_at, updated_at
FROM secrets WHERE (user_id = '' OR ? = '' OR user_id = ?)`
args := []any{scopeOwner(ctx), scopeOwner(ctx)}
switch opts.Scope {
case ScopeGlobal:
query += " AND scope = ?"
args = append(args, ScopeGlobal)
case ScopeWorkspace:
query += " AND ((scope = ? AND workspace_id = ?)"
args = append(args, ScopeWorkspace, opts.WorkspaceID)
if opts.IncludeGlobal {
query += " OR scope = ?"
args = append(args, ScopeGlobal)
}
query += ")"
}
query += " ORDER BY created_at DESC"
err := s.ro.SelectContext(ctx, &rows, s.ro.Rebind(query), args...)
if err != nil {
return nil, fmt.Errorf("list secrets: %w", err)
}
return toSecretListItems(rows), nil
}
func (s *sqliteStore) GetForWorkspace(ctx context.Context, id, workspaceID string) (*Secret, error) {
if strings.TrimSpace(workspaceID) == "" {
return nil, fmt.Errorf("workspace id is required")
}
secret, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if secret.Scope != ScopeGlobal && (secret.Scope != ScopeWorkspace || secret.WorkspaceID != workspaceID) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
}
return secret, nil
}
func (s *sqliteStore) RevealGlobal(ctx context.Context, id string) (string, error) {
secret, err := s.Get(ctx, id)
if err != nil {
return "", err
}
if secret.Scope != ScopeGlobal {
return "", fmt.Errorf("%w: %s", ErrNotFound, id)
}
return s.Reveal(ctx, id)
}
func (s *sqliteStore) RevealForWorkspace(ctx context.Context, id, workspaceID string) (string, error) {
if _, err := s.GetForWorkspace(ctx, id, workspaceID); err != nil {
return "", err
}
return s.Reveal(ctx, id)
}
func (s *sqliteStore) DeleteWorkspaceSecrets(ctx context.Context, workspaceID string) error {
if strings.TrimSpace(workspaceID) == "" {
return fmt.Errorf("workspace id is required")
}
return s.deleteWorkspaceSecrets(ctx, s.db, workspaceID)
}
// DeleteWorkspaceSecretsTx removes workspace-owned secrets on the supplied
// transaction so workspace and secret deletion can commit or roll back as one.
func (s *sqliteStore) DeleteWorkspaceSecretsTx(ctx context.Context, tx *sqlx.Tx, workspaceID string) error {
if tx == nil {
return fmt.Errorf("transaction is required")
}
return s.deleteWorkspaceSecrets(ctx, tx, workspaceID)
}
func (s *sqliteStore) deleteWorkspaceSecrets(ctx context.Context, exec sqlx.ExtContext, workspaceID string) error {
if strings.TrimSpace(workspaceID) == "" {
return fmt.Errorf("workspace id is required")
}
_, err := exec.ExecContext(ctx, s.db.Rebind(`
DELETE FROM secrets WHERE scope = ? AND workspace_id = ?`), ScopeWorkspace, workspaceID)
if err != nil {
return fmt.Errorf("delete workspace secrets: %w", err)
}
return nil
}
func validateSecretScope(scope SecretScope, workspaceID string) error {
switch scope {
case ScopeGlobal:
if strings.TrimSpace(workspaceID) != "" {
return fmt.Errorf("global secrets cannot have a workspace")
}
case ScopeWorkspace:
if strings.TrimSpace(workspaceID) == "" {
return fmt.Errorf("workspace secrets require a workspace")
}
default:
return fmt.Errorf("invalid secret scope")
}
return nil
}
func validateListOptions(opts SecretListOptions) error {
switch opts.Scope {
case ScopeGlobal:
if strings.TrimSpace(opts.WorkspaceID) != "" {
return fmt.Errorf("global secret listing cannot have a workspace")
}
case ScopeWorkspace:
if strings.TrimSpace(opts.WorkspaceID) == "" {
return fmt.Errorf("workspace secret listing requires a workspace")
}
default:
return fmt.Errorf("invalid secret scope")
}
return nil
}
// secretRow is the DB scan target for secret metadata queries.
type secretRow struct {
ID string `db:"id"`
Name string `db:"name"`
Scope SecretScope `db:"scope"`
WorkspaceID string `db:"workspace_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (r *secretRow) toSecret() *Secret {
return &Secret{
ID: r.ID,
Name: r.Name,
Scope: normalizeStoredScope(r.Scope),
WorkspaceID: r.WorkspaceID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// secretListRow is the DB scan target for list queries.
type secretListRow struct {
ID string `db:"id"`
Name string `db:"name"`
Scope SecretScope `db:"scope"`
WorkspaceID string `db:"workspace_id"`
HasValue bool `db:"has_value"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func toSecretListItems(rows []secretListRow) []*SecretListItem {
items := make([]*SecretListItem, len(rows))
for i, r := range rows {
items[i] = &SecretListItem{
ID: r.ID,
Name: r.Name,
Scope: normalizeStoredScope(r.Scope),
WorkspaceID: r.WorkspaceID,
HasValue: r.HasValue,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
return items
}
func normalizeStoredScope(scope SecretScope) SecretScope {
if scope == "" {
return ScopeGlobal
}
return scope
}