Files
kandev/apps/backend/internal/secrets/sqlite_store.go
T
2026-06-14 17:47:40 +01:00

213 lines
5.4 KiB
Go

package secrets
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
)
type sqliteStore struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
crypto *MasterKeyProvider
ownsDB bool
}
var _ SecretStore = (*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,
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)
_, err := s.db.Exec(schema)
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.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, encrypted_value, nonce, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
secret.ID, secret.Name, 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, created_at, updated_at
FROM secrets WHERE id = ?`), id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("secret not found: %s", 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 = ?`), id).
Scan(&ciphertext, &nonce)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("secret not found: %s", 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 = ?`),
existing.Name, ciphertext, nonce, now, id,
)
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 = ?`),
existing.Name, now, id,
)
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 = ?`), id)
if err != nil {
return fmt.Errorf("delete secret: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("secret not found: %s", id)
}
return nil
}
func (s *sqliteStore) List(ctx context.Context) ([]*SecretListItem, error) {
var rows []secretListRow
err := s.ro.SelectContext(ctx, &rows, `
SELECT id, name, 1 as has_value, created_at, updated_at
FROM secrets ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("list secrets: %w", err)
}
return toSecretListItems(rows), nil
}
// secretRow is the DB scan target for secret metadata queries.
type secretRow struct {
ID string `db:"id"`
Name string `db:"name"`
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,
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"`
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,
HasValue: r.HasValue,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
return items
}