gitmote / internal / meta / secrets.go · history · branches
3799 bytes · raw
package meta
import (
"context"
)
// CISecret is one repo's stored secret envelope. It never carries the plaintext
// value — only the sealed bytes and the key version that sealed them. Decryption
// happens in internal/secrets with the server-held master key.
type CISecret struct {
Name string
Version int
IV []byte
CT []byte
}
// SetSecret upserts a repo's secret envelope by (repo_id, name). Written only by
// the leader, like every other row.
func (m *Metadata) SetSecret(ctx context.Context, repoID int64, name string, version int, iv, ct []byte) error {
_, err := m.db.ExecContext(ctx,
`INSERT INTO ci_secrets (repo_id, name, v, iv, ct, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(repo_id, name) DO UPDATE SET
v = excluded.v, iv = excluded.iv, ct = excluded.ct, created_at = excluded.created_at`,
repoID, name, version, iv, ct, now())
return err
}
// ListSecretNames returns a repo's secret names (never values), ordered by name
// — the only view the UI ever gets.
func (m *Metadata) ListSecretNames(ctx context.Context, repoID int64) ([]string, error) {
rows, err := m.db.QueryContext(ctx,
`SELECT name FROM ci_secrets WHERE repo_id = ? ORDER BY name`, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
names = append(names, n)
}
return names, rows.Err()
}
// GetSecrets returns a repo's secret envelopes for decryption at trigger time.
func (m *Metadata) GetSecrets(ctx context.Context, repoID int64) ([]CISecret, error) {
rows, err := m.db.QueryContext(ctx,
`SELECT name, v, iv, ct FROM ci_secrets WHERE repo_id = ? ORDER BY name`, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
var secrets []CISecret
for rows.Next() {
var s CISecret
if err := rows.Scan(&s.Name, &s.Version, &s.IV, &s.CT); err != nil {
return nil, err
}
secrets = append(secrets, s)
}
return secrets, rows.Err()
}
// DeleteSecret removes a repo's secret by name. Deleting an absent secret is a
// no-op (idempotent), so it returns no ErrNotFound.
func (m *Metadata) DeleteSecret(ctx context.Context, repoID int64, name string) error {
_, err := m.db.ExecContext(ctx,
`DELETE FROM ci_secrets WHERE repo_id = ? AND name = ?`, repoID, name)
return err
}
// GetOrCreateSecret returns the persisted server secret named name, generating
// and persisting it with gen the first time it's requested. It backs the
// auto-provisioned session cookie key and CI worker secret (see cmd/gitmote): an
// absent value is created once — on the very first, uncontended cold start, which
// is necessarily the leader (a follower opens read-only, so its insert would fail
// loud, and a restart then reads the leader's value restored from the replica).
// Narrowly scoped to these named server secrets, not a general key–value store.
func (m *Metadata) GetOrCreateSecret(ctx context.Context, name string, gen func() ([]byte, error)) ([]byte, error) {
var val []byte
err := m.db.QueryRowContext(ctx,
`SELECT value FROM server_secrets WHERE name = ?`, name).Scan(&val)
if err == nil {
return val, nil
}
if !isNoRows(err) {
return nil, err
}
val, err = gen()
if err != nil {
return nil, err
}
// ON CONFLICT DO NOTHING keeps a concurrent creator's value; the re-read below
// then returns whichever insert won, so every caller agrees on one value.
if _, err := m.db.ExecContext(ctx,
`INSERT INTO server_secrets (name, value) VALUES (?, ?)
ON CONFLICT(name) DO NOTHING`, name, val); err != nil {
return nil, err
}
if err := m.db.QueryRowContext(ctx,
`SELECT value FROM server_secrets WHERE name = ?`, name).Scan(&val); err != nil {
return nil, err
}
return val, nil
}