This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+100
View File
@@ -0,0 +1,100 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
// ReencryptStore abstracts the DB scan + per-row re-seal so the handler is
// testable without a live Postgres. ReencryptTable processes one batch of rows
// for one logical table that are still below activeVersion, re-sealing each
// blob with enc.EncryptVersion(active) inside a per-row transaction, and returns
// how many rows it advanced. It returns 0 when no rows remain on an old version
// (the table is done). Because each row's UPDATE sets both blob+key_version in
// one tx, the job is idempotent and resumable: re-running after a crash simply
// finds the rows that did not yet advance.
type ReencryptStore interface {
// Tables returns the logical table names this store can re-encrypt.
Tables() []string
// ReencryptTable advances up to batch rows of table below activeVersion onto
// the active version, returning the number of rows updated this call.
ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, table string, activeVersion, batch int) (int, error)
// StaleCount reports how many rows of table remain below activeVersion (for
// the reencrypt-status endpoint and the handler's completion check).
StaleCount(ctx context.Context, table string, activeVersion int) (int, error)
}
// SecretReencryptPayload is the job payload. ActiveVersion is the target write
// version (the version crypto_keys is now active on); the handler re-encrypts
// every stale row onto it. BatchSize bounds per-iteration work; 0 → default.
type SecretReencryptPayload struct {
ActiveVersion int `json:"active_version"`
BatchSize int `json:"batch_size"`
}
// SecretReencryptHandler implements jobs.Handler for TypeSecretReencrypt.
type SecretReencryptHandler struct {
store ReencryptStore
enc *crypto.KeyedEncryptor
log *slog.Logger
}
// NewSecretReencryptHandler builds the handler.
func NewSecretReencryptHandler(store ReencryptStore, enc *crypto.KeyedEncryptor, log *slog.Logger) *SecretReencryptHandler {
if log == nil {
log = slog.Default()
}
return &SecretReencryptHandler{store: store, enc: enc, log: log}
}
// Type reports the job type this handler serves.
func (h *SecretReencryptHandler) Type() jobs.JobType { return jobs.TypeSecretReencrypt }
const defaultReencryptBatch = 100
// Handle scans every encrypted-column table and re-seals rows still on an old
// key version onto the active version, batch by batch, until none remain. It is
// idempotent (already-advanced rows are skipped by the key_version<active
// predicate) and resumable (a crash mid-run leaves a partial set; re-running
// completes the rest). A malformed payload is a permanent error (no retry).
func (h *SecretReencryptHandler) Handle(ctx context.Context, job jobs.Job) error {
var p SecretReencryptPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return jobs.Permanent(fmt.Errorf("secret_reencrypt: bad payload: %w", err))
}
active := p.ActiveVersion
if active <= 0 {
v, err := h.enc.ActiveVersion(ctx)
if err != nil {
return fmt.Errorf("secret_reencrypt: resolve active version: %w", err)
}
active = v
}
batch := p.BatchSize
if batch <= 0 {
batch = defaultReencryptBatch
}
for _, table := range h.store.Tables() {
for {
if err := ctx.Err(); err != nil {
return err // canceled/timeout → retry later, resumable
}
n, err := h.store.ReencryptTable(ctx, h.enc, table, active, batch)
if err != nil {
return fmt.Errorf("secret_reencrypt: table %s: %w", table, err)
}
if n == 0 {
break // table drained
}
h.log.Info("secret_reencrypt.batch", "table", table, "rows", n, "active_version", active)
}
}
h.log.Info("secret_reencrypt.done", "active_version", active)
return nil
}
@@ -0,0 +1,195 @@
package handlers
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
)
// PostgresReencryptStore re-encrypts the platform's encrypted-column tables
// using one transaction per row so the job is idempotent and resumable. Each
// table's blob column(s) are decrypted under the row's current key_version then
// re-sealed under the active version, and the row's key_version is advanced in
// the same tx. Rows with NULL blobs are skipped by the WHERE predicate.
type PostgresReencryptStore struct {
pool *pgxpool.Pool
}
// NewPostgresReencryptStore builds the store over a pgx pool.
func NewPostgresReencryptStore(pool *pgxpool.Pool) *PostgresReencryptStore {
return &PostgresReencryptStore{pool: pool}
}
var _ ReencryptStore = (*PostgresReencryptStore)(nil)
// tableSpec describes how to scan + update one encrypted-column table.
type tableSpec struct {
name string
blobs []string // encrypted column names (1 or 2)
notNul string // SQL predicate for "row carries ciphertext"
}
// reencryptSpecs is the canonical list of encrypted-column owners. Order is
// stable so the status endpoint and the handler iterate deterministically.
var reencryptSpecs = []tableSpec{
{name: "acp_agent_kinds", blobs: []string{"encrypted_env", "encrypted_mcp_servers"},
notNul: "(encrypted_env IS NOT NULL OR encrypted_mcp_servers IS NOT NULL)"},
{name: "acp_agent_kind_config_files", blobs: []string{"encrypted_content"},
notNul: "encrypted_content IS NOT NULL"},
{name: "workspace_run_profiles", blobs: []string{"encrypted_env"},
notNul: "encrypted_env IS NOT NULL"},
{name: "git_credentials", blobs: []string{"encrypted_secret"},
notNul: "encrypted_secret IS NOT NULL"},
{name: "llm_endpoints", blobs: []string{"api_key_encrypted"},
notNul: "api_key_encrypted IS NOT NULL"},
}
func specByName(name string) (tableSpec, bool) {
for _, s := range reencryptSpecs {
if s.name == name {
return s, true
}
}
return tableSpec{}, false
}
// Tables lists the re-encryptable table names.
func (s *PostgresReencryptStore) Tables() []string {
out := make([]string, 0, len(reencryptSpecs))
for _, sp := range reencryptSpecs {
out = append(out, sp.name)
}
return out
}
// StaleCount reports how many rows of table remain below activeVersion.
func (s *PostgresReencryptStore) StaleCount(ctx context.Context, table string, active int) (int, error) {
spec, ok := specByName(table)
if !ok {
return 0, fmt.Errorf("unknown reencrypt table %q", table)
}
q := fmt.Sprintf(
`SELECT count(*) FROM %s WHERE key_version < $1 AND %s`, spec.name, spec.notNul)
var n int
if err := s.pool.QueryRow(ctx, q, active).Scan(&n); err != nil {
return 0, fmt.Errorf("stale count %s: %w", table, err)
}
return n, nil
}
// ReencryptTable advances up to batch rows below active onto the active version.
// It selects a batch of ids FOR UPDATE SKIP LOCKED, then re-seals each row in its
// own transaction so a crash leaves a clean partial state.
func (s *PostgresReencryptStore) ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, table string, active, batch int) (int, error) {
spec, ok := specByName(table)
if !ok {
return 0, fmt.Errorf("unknown reencrypt table %q", table)
}
// Pull a batch of candidate ids (cheap, uses the partial key_version index).
idQ := fmt.Sprintf(
`SELECT id FROM %s WHERE key_version < $1 AND %s ORDER BY id LIMIT $2`, spec.name, spec.notNul)
rows, err := s.pool.Query(ctx, idQ, active, batch)
if err != nil {
return 0, fmt.Errorf("select ids %s: %w", table, err)
}
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, fmt.Errorf("scan id %s: %w", table, err)
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
done := 0
for _, id := range ids {
if err := s.reencryptRow(ctx, enc, spec, active, id); err != nil {
return done, err
}
done++
}
return done, nil
}
// reencryptRow re-seals one row's blob(s) in a single transaction.
func (s *PostgresReencryptStore) reencryptRow(ctx context.Context, enc *crypto.KeyedEncryptor, spec tableSpec, active int, id uuid.UUID) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
// Lock the row and read its current version + blobs.
cols := ""
for _, c := range spec.blobs {
cols += c + ", "
}
selQ := fmt.Sprintf(`SELECT %skey_version FROM %s WHERE id = $1 FOR UPDATE`, cols, spec.name)
dest := make([]any, 0, len(spec.blobs)+1)
blobVals := make([]*[]byte, len(spec.blobs))
for i := range spec.blobs {
blobVals[i] = new([]byte)
dest = append(dest, blobVals[i])
}
var curVer int
dest = append(dest, &curVer)
if err := tx.QueryRow(ctx, selQ, id).Scan(dest...); err != nil {
if err == pgx.ErrNoRows {
return nil // row vanished; nothing to do
}
return fmt.Errorf("lock row %s/%s: %w", spec.name, id, err)
}
if curVer >= active {
return tx.Commit(ctx) // already advanced by a concurrent run; idempotent
}
// Re-seal each non-nil blob: decrypt under curVer, encrypt under active.
setExpr := ""
args := []any{id}
argN := 2
for i, c := range spec.blobs {
b := *blobVals[i]
if len(b) == 0 {
continue // NULL/empty blob: leave as-is
}
plain, derr := enc.DecryptVersion(ctx, b, curVer)
if derr != nil {
return fmt.Errorf("decrypt %s/%s.%s v%d: %w", spec.name, id, c, curVer, derr)
}
sealed, err := enc.SealWithVersion(ctx, active, plain)
if err != nil {
return fmt.Errorf("reseal %s/%s.%s: %w", spec.name, id, c, err)
}
if setExpr != "" {
setExpr += ", "
}
setExpr += fmt.Sprintf("%s = $%d", c, argN)
args = append(args, sealed)
argN++
}
// Always advance key_version (covers the all-NULL-blob defensive case too).
if setExpr != "" {
setExpr += ", "
}
setExpr += fmt.Sprintf("key_version = $%d", argN)
args = append(args, active)
updQ := fmt.Sprintf(`UPDATE %s SET %s WHERE id = $1`, spec.name, setExpr)
if _, err := tx.Exec(ctx, updQ, args...); err != nil {
return fmt.Errorf("update %s/%s: %w", spec.name, id, err)
}
return tx.Commit(ctx)
}
@@ -0,0 +1,173 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
// memRow models one encrypted-column row across versions.
type memRow struct {
blob []byte
keyVersion int
plaintext []byte // ground truth for assertions
null bool
}
// memStore is an in-memory ReencryptStore over a single logical table, with a
// failNext hook to model a crash mid-run for the resumability test.
type memStore struct {
rows []*memRow
failNext bool
}
func (m *memStore) Tables() []string { return []string{"t"} }
func (m *memStore) StaleCount(_ context.Context, _ string, active int) (int, error) {
n := 0
for _, r := range m.rows {
if !r.null && r.keyVersion < active {
n++
}
}
return n, nil
}
func (m *memStore) ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, _ string, active, batch int) (int, error) {
done := 0
for _, r := range m.rows {
if done >= batch {
break
}
if r.null || r.keyVersion >= active {
continue
}
if m.failNext {
m.failNext = false
return done, context.Canceled // simulate a crash after some rows
}
plain, err := enc.DecryptVersion(ctx, r.blob, r.keyVersion)
if err != nil {
return done, err
}
sealed, err := enc.SealWithVersion(ctx, active, plain)
if err != nil {
return done, err
}
r.blob = sealed
r.keyVersion = active
done++
}
return done, nil
}
func key(t *testing.T) []byte {
t.Helper()
k := make([]byte, 32)
_, err := rand.Read(k)
require.NoError(t, err)
return k
}
// twoVersionEnc returns a KeyedEncryptor with v1+v2 keys and a store reporting
// active=2, plus a helper to seal under v1.
func twoVersionEnc(t *testing.T) *crypto.KeyedEncryptor {
prov := crypto.NewEnvProvider(nil)
prov.SetKey(1, key(t))
prov.SetKey(2, key(t))
return crypto.NewKeyedEncryptor(prov, crypto.NewStaticKeyStore(2))
}
func TestSecretReencrypt_AdvancesAllRows(t *testing.T) {
ctx := context.Background()
enc := twoVersionEnc(t)
// Seed 3 rows encrypted under v1 + 1 NULL row.
store := &memStore{}
for i := 0; i < 3; i++ {
pt := []byte{byte(i), byte(i + 1)}
ct, err := enc.SealWithVersion(ctx, 1, pt)
require.NoError(t, err)
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
}
store.rows = append(store.rows, &memRow{null: true, keyVersion: 1})
h := NewSecretReencryptHandler(store, enc, nil)
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 2})
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
// All non-null rows now on v2 and decrypt to identical plaintext.
for _, r := range store.rows {
if r.null {
require.Equal(t, 1, r.keyVersion, "NULL rows are skipped, version unchanged")
continue
}
require.Equal(t, 2, r.keyVersion)
got, err := enc.DecryptVersion(ctx, r.blob, 2)
require.NoError(t, err)
require.Equal(t, r.plaintext, got)
}
left, _ := store.StaleCount(ctx, "t", 2)
require.Equal(t, 0, left)
}
func TestSecretReencrypt_IdempotentRerun(t *testing.T) {
ctx := context.Background()
enc := twoVersionEnc(t)
store := &memStore{}
pt := []byte("hello")
ct, err := enc.SealWithVersion(ctx, 1, pt)
require.NoError(t, err)
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
h := NewSecretReencryptHandler(store, enc, nil)
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2})
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
blobAfterFirst := store.rows[0].blob
// Second run is a no-op: row already on active version, blob unchanged.
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
require.Equal(t, blobAfterFirst, store.rows[0].blob)
}
func TestSecretReencrypt_ResumableAfterCrash(t *testing.T) {
ctx := context.Background()
enc := twoVersionEnc(t)
store := &memStore{failNext: true}
for i := 0; i < 5; i++ {
pt := []byte{byte(i)}
ct, err := enc.SealWithVersion(ctx, 1, pt)
require.NoError(t, err)
store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt})
}
h := NewSecretReencryptHandler(store, enc, nil)
payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 10})
// First run crashes (ctx.Canceled bubbles up).
require.Error(t, h.Handle(ctx, jobs.Job{Payload: payload}))
left, _ := store.StaleCount(ctx, "t", 2)
require.Greater(t, left, 0, "crash left some rows on old version")
// Re-run completes the rest.
require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload}))
left, _ = store.StaleCount(ctx, "t", 2)
require.Equal(t, 0, left)
for _, r := range store.rows {
got, err := enc.DecryptVersion(ctx, r.blob, 2)
require.NoError(t, err)
require.Equal(t, r.plaintext, got)
}
}
func TestSecretReencrypt_BadPayloadIsPermanent(t *testing.T) {
h := NewSecretReencryptHandler(&memStore{}, twoVersionEnc(t), nil)
err := h.Handle(context.Background(), jobs.Job{Payload: []byte("not json")})
require.Error(t, err)
require.True(t, jobs.IsPermanent(err))
}