736 lines
28 KiB
Go
736 lines
28 KiB
Go
package secrets
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"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
|
|
|
|
// failAfterInsert is a test-only hook. When set, it is invoked after the
|
|
// transfer inserts the target row and before the Move source delete runs,
|
|
// proving Move is transactional (the inserted copy must roll back).
|
|
failAfterInsert func() error
|
|
|
|
// afterSourceLock is a test-only hook invoked right after the source row
|
|
// is selected with FOR UPDATE (Move on PostgreSQL), giving tests a
|
|
// deterministic point where the source row lock is known to be held.
|
|
afterSourceLock func() error
|
|
|
|
// beforeTransferInsert is a test-only hook invoked after the conflict
|
|
// check and before the conditional insert, letting tests commit a
|
|
// competing row at the exact point where the atomic NOT EXISTS guard must
|
|
// observe it (PostgreSQL: ordinary creates do not take the transfer lock).
|
|
beforeTransferInsert func() error
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// initSchema creates the secrets table if needed and applies idempotent column migrations.
|
|
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.NewRequiredMigrateLogger(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 ''")
|
|
if err := migrate.Err(); err != nil {
|
|
return fmt.Errorf("required secrets migration: %w", err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Close closes the underlying writer connection when the store owns it.
|
|
func (s *sqliteStore) Close() error {
|
|
if !s.ownsDB {
|
|
return nil
|
|
}
|
|
return s.db.Close()
|
|
}
|
|
|
|
// Create validates the scope, encrypts the value, and inserts a new secret.
|
|
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
|
|
}
|
|
|
|
// Get returns a secret's metadata by id, scoped to the caller; ErrNotFound if absent.
|
|
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
|
|
}
|
|
|
|
// Reveal returns the decrypted plaintext value of the secret with the given id.
|
|
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
|
|
}
|
|
|
|
// Update applies the requested name and/or value changes to the secret with the given id.
|
|
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
|
|
}
|
|
|
|
// Delete removes the secret with the given id, scoped to the caller; ErrNotFound if absent.
|
|
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
|
|
}
|
|
|
|
// List returns all global secrets visible to the caller.
|
|
func (s *sqliteStore) List(ctx context.Context) ([]*SecretListItem, error) {
|
|
return s.ListScoped(ctx, SecretListOptions{Scope: ScopeGlobal})
|
|
}
|
|
|
|
// ListScoped returns secrets matching the given scope options, newest first.
|
|
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
|
|
}
|
|
|
|
// GetForWorkspace returns the secret with id if it is global or belongs to the given workspace.
|
|
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
|
|
}
|
|
|
|
// RevealGlobal returns the decrypted value of the global secret with the given id.
|
|
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)
|
|
}
|
|
|
|
// RevealForWorkspace returns the decrypted value of a secret visible in the given workspace.
|
|
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)
|
|
}
|
|
|
|
// DeleteWorkspaceSecrets removes all workspace-owned secrets for the given workspace in a transaction.
|
|
func (s *sqliteStore) DeleteWorkspaceSecrets(ctx context.Context, workspaceID string) error {
|
|
if strings.TrimSpace(workspaceID) == "" {
|
|
return fmt.Errorf("workspace id is required")
|
|
}
|
|
// Run under a transaction so the PostgreSQL advisory lock is held to
|
|
// commit, keeping concurrent secret transfers from inserting into a
|
|
// workspace that is being deleted.
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin workspace secret cleanup: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := s.DeleteWorkspaceSecretsTx(ctx, tx, workspaceID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteWorkspaceSecretsTx removes workspace-owned secrets on the supplied
|
|
// transaction so workspace and secret deletion can commit or roll back as one.
|
|
// On PostgreSQL it first acquires the workspace's advisory lock (shared with
|
|
// secret transfers), guaranteeing a concurrent transfer cannot insert after
|
|
// this cleanup has run.
|
|
func (s *sqliteStore) DeleteWorkspaceSecretsTx(ctx context.Context, tx *sqlx.Tx, workspaceID string) error {
|
|
if tx == nil {
|
|
return fmt.Errorf("transaction is required")
|
|
}
|
|
if dialect.IsPostgres(s.db.DriverName()) {
|
|
if _, err := tx.ExecContext(ctx, s.db.Rebind("SELECT pg_advisory_xact_lock(?)"), WorkspaceLockKey(workspaceID)); err != nil {
|
|
return fmt.Errorf("acquire workspace lock: %w", err)
|
|
}
|
|
}
|
|
return s.deleteWorkspaceSecrets(ctx, tx, workspaceID)
|
|
}
|
|
|
|
// deleteWorkspaceSecrets runs the workspace-owned secret deletion on the supplied executor.
|
|
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
|
|
}
|
|
|
|
// validateSecretScope rejects scope/workspace combinations that are not allowed.
|
|
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
|
|
}
|
|
|
|
// validateListOptions rejects list options with an invalid scope or mismatched workspace.
|
|
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"`
|
|
}
|
|
|
|
// toSecret converts the scanned row into a Secret with its stored scope normalized.
|
|
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"`
|
|
}
|
|
|
|
// toSecretListItems converts scanned list rows into SecretListItems with normalized scopes.
|
|
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
|
|
}
|
|
|
|
// normalizeStoredScope maps a legacy empty stored scope to Global.
|
|
func normalizeStoredScope(scope SecretScope) SecretScope {
|
|
if scope == "" {
|
|
return ScopeGlobal
|
|
}
|
|
return scope
|
|
}
|
|
|
|
// transferExec is the minimal transaction-bound executor used by
|
|
// transferBody. Both *sql.Conn (SQLite BEGIN IMMEDIATE path) and *sqlx.Tx
|
|
// (PostgreSQL advisory-lock path) satisfy it, so every transfer statement
|
|
// runs on the same transaction and never on the store's reader handle.
|
|
type transferExec interface {
|
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
}
|
|
|
|
// CopyScoped copies a secret into the target scope under a new name and returns the copy.
|
|
func (s *sqliteStore) CopyScoped(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error) (*Secret, error) {
|
|
return s.transferScoped(ctx, sourceID, sourceWorkspaceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, false)
|
|
}
|
|
|
|
// MoveScoped moves a secret into the target scope under a new name, deleting the source.
|
|
func (s *sqliteStore) MoveScoped(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error) (*Secret, error) {
|
|
return s.transferScoped(ctx, sourceID, sourceWorkspaceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, true)
|
|
}
|
|
|
|
// transferScoped runs the atomic copy/move transfer: conflict check, insert, and optional source delete inside one transaction.
|
|
func (s *sqliteStore) transferScoped(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error, deleteSource bool) (*Secret, error) {
|
|
if err := validateSecretScope(targetScope, targetWorkspaceID); err != nil {
|
|
return nil, fmt.Errorf("scope validation: %w", err)
|
|
}
|
|
if requestedName != nil && strings.TrimSpace(*requestedName) == "" {
|
|
return nil, fmt.Errorf("target name is required")
|
|
}
|
|
if dialect.IsPostgres(s.db.DriverName()) {
|
|
return s.transferPostgres(ctx, sourceID, sourceWorkspaceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, deleteSource)
|
|
}
|
|
return s.transferSQLite(ctx, sourceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, deleteSource)
|
|
}
|
|
|
|
// transferSQLite runs the transfer on the single writer connection with
|
|
// BEGIN IMMEDIATE, taking the SQLite write lock up front so competing
|
|
// transfers serialize before any read.
|
|
func (s *sqliteStore) transferSQLite(ctx context.Context, sourceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error, deleteSource bool) (*Secret, error) {
|
|
conn, err := s.db.Conn(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acquire writer connection: %w", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
|
return nil, fmt.Errorf("begin immediate: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
// The rollback must reach SQLite even when the caller canceled ctx:
|
|
// a canceled ROLLBACK would leave the BEGIN IMMEDIATE transaction
|
|
// and its write lock open on the pooled connection.
|
|
_, _ = conn.ExecContext(context.WithoutCancel(ctx), "ROLLBACK")
|
|
}
|
|
}()
|
|
|
|
// SQLite's single writer serializes all writers (BEGIN IMMEDIATE), so the
|
|
// source row cannot change under us; no row lock is needed.
|
|
secret, err := s.transferBody(ctx, conn, func(q string) string { return q }, sourceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, deleteSource, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
|
|
return nil, fmt.Errorf("commit transfer: %w", err)
|
|
}
|
|
committed = true
|
|
return secret, nil
|
|
}
|
|
|
|
// transferPostgres runs the transfer in a transaction that acquires the
|
|
// destination's advisory lock (plus the source workspace's lock for Move) in
|
|
// sorted order before any read, serializing competing transfers and
|
|
// coordinating with workspace deletion under READ COMMITTED.
|
|
func (s *sqliteStore) transferPostgres(ctx context.Context, sourceID, sourceWorkspaceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error, deleteSource bool) (*Secret, error) {
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transfer: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
keys := []int64{transferTargetLockKey(targetScope, targetWorkspaceID)}
|
|
if deleteSource && strings.TrimSpace(sourceWorkspaceID) != "" {
|
|
keys = append(keys, WorkspaceLockKey(sourceWorkspaceID))
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
|
var prev int64
|
|
first := true
|
|
for _, key := range keys {
|
|
if !first && key == prev {
|
|
continue
|
|
}
|
|
if _, err := tx.ExecContext(ctx, s.db.Rebind("SELECT pg_advisory_xact_lock(?)"), key); err != nil {
|
|
return nil, fmt.Errorf("acquire transfer lock: %w", err)
|
|
}
|
|
prev = key
|
|
first = false
|
|
}
|
|
|
|
// A Move destroys the source, so the source row is locked with FOR UPDATE
|
|
// (deleteSource == true): a concurrent Update cannot commit a newer value
|
|
// between our read and delete, which would otherwise lose that value.
|
|
secret, err := s.transferBody(ctx, tx, s.db.Rebind, sourceID, targetScope, targetWorkspaceID, requestedName, verifyDestination, deleteSource, deleteSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit transfer: %w", err)
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
// transferTargetLockKey returns the advisory-lock key for a transfer target:
|
|
// the workspace key for Workspace targets, the Global constant otherwise.
|
|
func transferTargetLockKey(targetScope SecretScope, targetWorkspaceID string) int64 {
|
|
if normalizeStoredScope(targetScope) != ScopeWorkspace {
|
|
return GlobalSecretTransferLockKey
|
|
}
|
|
return WorkspaceLockKey(targetWorkspaceID)
|
|
}
|
|
|
|
// transferBody executes the shared transfer steps on the transaction-bound
|
|
// executor: destination verification (while the lock is held), source select,
|
|
// normalized-scope conflict check, insert of the copy (reusing the source's
|
|
// encrypted value), optional source delete, and the returned-row read. Every
|
|
// statement uses the same executor and the per-user visibility predicate.
|
|
func (s *sqliteStore) transferBody(ctx context.Context, exec transferExec, rebind func(string) string, sourceID string, targetScope SecretScope, targetWorkspaceID string, requestedName *string, verifyDestination func(context.Context) error, deleteSource, lockSource bool) (*Secret, error) {
|
|
owner := scopeOwner(ctx)
|
|
|
|
if verifyDestination != nil {
|
|
if err := verifyDestination(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
src, err := s.transferSource(ctx, exec, rebind, sourceID, owner, lockSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// An omitted name resolves from the transactional source row: a concurrent
|
|
// rename can never split the copied name from its value (or, for Move,
|
|
// delete the renamed row and recreate it under a stale name).
|
|
targetName := ""
|
|
if requestedName == nil {
|
|
targetName = src.Name
|
|
} else {
|
|
targetName = *requestedName
|
|
}
|
|
|
|
if err := s.transferConflictCheck(ctx, exec, rebind, targetScope, targetWorkspaceID, targetName, owner); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
storedScope := normalizeStoredScope(targetScope)
|
|
copyID := uuid.New().String()
|
|
if s.beforeTransferInsert != nil {
|
|
if err := s.beforeTransferInsert(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// The insert is guarded by the same conflict predicate atomically: on
|
|
// PostgreSQL, an ordinary create or rename does not take the transfer's
|
|
// advisory lock, so the eager check alone could race a late commit of the
|
|
// same name. NOT EXISTS turns that race into a clean conflict instead of a
|
|
// duplicate row.
|
|
predicate, predicateArgs := transferConflictPredicate(targetScope, targetWorkspaceID, targetName, owner)
|
|
result, err := exec.ExecContext(ctx, rebind(`
|
|
INSERT INTO secrets (id, name, user_id, scope, workspace_id, encrypted_value, nonce, created_at, updated_at)
|
|
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
WHERE NOT EXISTS (SELECT 1 FROM secrets WHERE `+predicate+`)`),
|
|
append([]any{copyID, targetName, owner, storedScope, targetWorkspaceID, src.Ciphertext, src.Nonce, now, now}, predicateArgs...)...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert transferred secret: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return nil, ErrSecretNameConflict
|
|
}
|
|
|
|
if deleteSource {
|
|
if err := s.deleteTransferredSource(ctx, exec, rebind, sourceID, owner); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var meta secretRow
|
|
if err := exec.QueryRowContext(ctx, rebind(`
|
|
SELECT id, name, scope, workspace_id, created_at, updated_at FROM secrets WHERE id = ?`),
|
|
copyID).Scan(&meta.ID, &meta.Name, &meta.Scope, &meta.WorkspaceID, &meta.CreatedAt, &meta.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("read transferred secret: %w", err)
|
|
}
|
|
return meta.toSecret(), nil
|
|
}
|
|
|
|
// deleteTransferredSource removes the source row using the same per-user
|
|
// visibility predicate as the source select. A missing row means the source
|
|
// vanished concurrently and the whole transfer rolls back.
|
|
func (s *sqliteStore) deleteTransferredSource(ctx context.Context, exec transferExec, rebind func(string) string, sourceID, owner string) error {
|
|
if s.failAfterInsert != nil {
|
|
if err := s.failAfterInsert(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result, err := exec.ExecContext(ctx, rebind(`
|
|
DELETE FROM secrets WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`),
|
|
sourceID, owner, owner)
|
|
if err != nil {
|
|
return fmt.Errorf("delete transferred source: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("%w: %s", ErrNotFound, sourceID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// transferSourceRow is the decrypted-source scan target for transferBody.
|
|
type transferSourceRow struct {
|
|
ID string
|
|
Name string
|
|
Scope SecretScope
|
|
WorkspaceID string
|
|
Ciphertext []byte
|
|
Nonce []byte
|
|
}
|
|
|
|
// transferSource selects the source row with the per-user visibility
|
|
// predicate. On PostgreSQL Moves (lockSource) it appends FOR UPDATE so a
|
|
// concurrent Update cannot interleave between the read and the delete; the
|
|
// afterSourceLock test hook fires once the row lock is known to be held.
|
|
func (s *sqliteStore) transferSource(ctx context.Context, exec transferExec, rebind func(string) string, sourceID, owner string, lockSource bool) (*transferSourceRow, error) {
|
|
query := `
|
|
SELECT id, name, scope, workspace_id, encrypted_value, nonce
|
|
FROM secrets WHERE id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`
|
|
if lockSource {
|
|
query += " FOR UPDATE"
|
|
}
|
|
var src transferSourceRow
|
|
err := exec.QueryRowContext(ctx, rebind(query), sourceID, owner, owner).
|
|
Scan(&src.ID, &src.Name, &src.Scope, &src.WorkspaceID, &src.Ciphertext, &src.Nonce)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", ErrNotFound, sourceID)
|
|
}
|
|
return nil, fmt.Errorf("transfer source lookup: %w", err)
|
|
}
|
|
if lockSource && s.afterSourceLock != nil {
|
|
if err := s.afterSourceLock(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &src, nil
|
|
}
|
|
|
|
// transferConflictPredicate returns the WHERE predicate and arguments that
|
|
// identify an existing secret with the given name in the destination scope,
|
|
// comparing the NORMALIZED scope so a legacy empty stored scope (which reads
|
|
// as Global) can never be bypassed. Shared by the eager conflict check and the
|
|
// atomic NOT EXISTS guard on the transfer insert.
|
|
func transferConflictPredicate(targetScope SecretScope, targetWorkspaceID, targetName, owner string) (string, []any) {
|
|
if normalizeStoredScope(targetScope) != ScopeWorkspace {
|
|
return `name = ? AND (scope = ? OR scope = '') AND (user_id = '' OR ? = '' OR user_id = ?)`,
|
|
[]any{targetName, ScopeGlobal, owner, owner}
|
|
}
|
|
return `name = ? AND scope = ? AND workspace_id = ? AND (user_id = '' OR ? = '' OR user_id = ?)`,
|
|
[]any{targetName, ScopeWorkspace, targetWorkspaceID, owner, owner}
|
|
}
|
|
|
|
// transferConflictCheck rejects a target name that already exists in the
|
|
// destination scope. It is the eager, pre-insert check; the insert itself is
|
|
// guarded by the same predicate atomically (see transferBody), so a competing
|
|
// create or rename that commits after this check can still never produce a
|
|
// duplicate name from the transfer.
|
|
func (s *sqliteStore) transferConflictCheck(ctx context.Context, exec transferExec, rebind func(string) string, targetScope SecretScope, targetWorkspaceID, targetName, owner string) error {
|
|
predicate, args := transferConflictPredicate(targetScope, targetWorkspaceID, targetName, owner)
|
|
query := `SELECT 1 FROM secrets WHERE ` + predicate + ` LIMIT 1`
|
|
var found int
|
|
if err := exec.QueryRowContext(ctx, rebind(query), args...).Scan(&found); err == nil {
|
|
return ErrSecretNameConflict
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("transfer conflict check: %w", err)
|
|
}
|
|
return nil
|
|
}
|