392 lines
12 KiB
Go
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
|
|
}
|