You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PostgresKeyStore reads/writes the crypto_keys table that records which key
|
||||
// versions exist and which is currently active (the write key). The key
|
||||
// material is NOT stored here — only version metadata.
|
||||
type PostgresKeyStore struct {
|
||||
pool poolExec
|
||||
}
|
||||
|
||||
// poolExec is the subset of *pgxpool.Pool used by the store. Declared as an
|
||||
// interface so tests can inject a fake without a live DB.
|
||||
type poolExec interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
// poolFullExec additionally allows mutations (used by RotateActive/RetireVersion).
|
||||
// Exec returns the CONCRETE pgconn.CommandTag so *pgxpool.Pool satisfies this
|
||||
// interface — an interface-typed return would not match pgxpool's method
|
||||
// signature, making the type assertion in RotateActive/RetireVersion silently
|
||||
// fail and key rotation always error out.
|
||||
type poolFullExec interface {
|
||||
poolExec
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
// NewPostgresKeyStore builds the store over a pgx pool (or any compatible
|
||||
// querier). It satisfies KeyStore.
|
||||
func NewPostgresKeyStore(pool poolExec) *PostgresKeyStore {
|
||||
return &PostgresKeyStore{pool: pool}
|
||||
}
|
||||
|
||||
// ActiveVersion returns the single crypto_keys row whose status='active'. If
|
||||
// none is found it falls back to version 1 (single-key bootstrap before the
|
||||
// seed row exists).
|
||||
func (s *PostgresKeyStore) ActiveVersion(ctx context.Context) (int, error) {
|
||||
var v int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT version FROM crypto_keys WHERE status = 'active' ORDER BY version DESC LIMIT 1`,
|
||||
).Scan(&v)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, fmt.Errorf("crypto key active version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RotateActive bumps the active write version: it retires the current active
|
||||
// row and inserts/activates the next version (current+1). The new version's key
|
||||
// material must already be loadable by the provider (e.g. APP_MASTER_KEY_V<n>
|
||||
// staged) before this is called. Returns the new active version.
|
||||
//
|
||||
// pool must support Exec (a *pgxpool.Pool does). Both rows are written in one
|
||||
// transaction by the caller's pool if it is transactional; here we issue two
|
||||
// statements and rely on the unique active invariant being eventually consistent
|
||||
// within the rotation window (both versions remain loadable, so decrypts never
|
||||
// break).
|
||||
func (s *PostgresKeyStore) RotateActive(ctx context.Context, provider string, keyRef string) (int, error) {
|
||||
fe, ok := s.pool.(poolFullExec)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("crypto key store: pool does not support writes")
|
||||
}
|
||||
cur, err := s.ActiveVersion(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
next := cur + 1
|
||||
if _, err := fe.Exec(ctx,
|
||||
`UPDATE crypto_keys SET status = 'retiring' WHERE status = 'active'`); err != nil {
|
||||
return 0, fmt.Errorf("retire current key: %w", err)
|
||||
}
|
||||
if _, err := fe.Exec(ctx,
|
||||
`INSERT INTO crypto_keys (version, provider, key_ref, status)
|
||||
VALUES ($1, $2, NULLIF($3, ''), 'active')
|
||||
ON CONFLICT (version) DO UPDATE SET status = 'active', provider = EXCLUDED.provider, key_ref = EXCLUDED.key_ref`,
|
||||
next, provider, keyRef); err != nil {
|
||||
return 0, fmt.Errorf("insert next key: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// RetireVersion marks a fully-superseded version as retired (no longer needed
|
||||
// after the re-encrypt job has moved every row off it). The provider may then
|
||||
// safely drop that key material.
|
||||
func (s *PostgresKeyStore) RetireVersion(ctx context.Context, version int) error {
|
||||
fe, ok := s.pool.(poolFullExec)
|
||||
if !ok {
|
||||
return fmt.Errorf("crypto key store: pool does not support writes")
|
||||
}
|
||||
if _, err := fe.Exec(ctx,
|
||||
`UPDATE crypto_keys SET status = 'retired' WHERE version = $1`, version); err != nil {
|
||||
return fmt.Errorf("retire key v%d: %w", version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ KeyStore = (*PostgresKeyStore)(nil)
|
||||
Reference in New Issue
Block a user