Skip to content

Commit 15d2fe1

Browse files
committed
treat string partition ranges as sensitive data
1 parent ff4139e commit 15d2fe1

4 files changed

Lines changed: 285 additions & 8 deletions

File tree

flow/activities/flowable.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,10 @@ func (a *FlowableActivity) GetQRepPartitions(ctx context.Context,
596596
return nil, a.Alerter.LogFlowError(ctx, config.FlowJobName, shared.WrapError("failed to get partitions from source", err))
597597
}
598598
if len(partitions) > 0 {
599+
// run before monitoring.InitializeQRepRun to avoid logging sensitive fields to peerdb_stats.qrep_partitions as well
600+
if err := connmetadata.OffloadSensitivePartitionRanges(ctx, a.CatalogPool, config.ParentMirrorName, runUUID, partitions); err != nil {
601+
return nil, a.Alerter.LogFlowError(ctx, config.FlowJobName, shared.WrapError("failed to offload partition ranges", err))
602+
}
599603
if err := monitoring.InitializeQRepRun(
600604
ctx,
601605
logger,
@@ -663,6 +667,10 @@ func (a *FlowableActivity) ReplicateQRepPartitions(ctx context.Context,
663667
return a.Alerter.LogFlowError(ctx, config.FlowJobName, err)
664668
}
665669

670+
if err := connmetadata.RestoreSensitivePartitionRanges(ctx, a.CatalogPool, runUUID, partitions.Partitions); err != nil {
671+
return a.Alerter.LogFlowError(ctx, config.FlowJobName, fmt.Errorf("failed to rehydrate partition ranges: %w", err))
672+
}
673+
666674
for i, partition := range partitions.Partitions {
667675
partLogger := log.With(logger,
668676
slog.Int64("batchID", int64(partitions.BatchId)),
@@ -1619,6 +1627,10 @@ func (a *FlowableActivity) QRepHasNewRows(ctx context.Context,
16191627
if maxValue.(time.Time).After(x.TimestampRange.End.AsTime()) {
16201628
return true, nil
16211629
}
1630+
case *protos.PartitionRange_StringRange:
1631+
// checking for new rows is only possible for standalone QRepFlowWorkflow;
1632+
// this is a legacy feature and string partitioning is not supported
1633+
return false, errors.New("checking for new rows by a string partition range is not supported")
16221634
default:
16231635
return false, fmt.Errorf("unknown range type: %v", x)
16241636
}

flow/connectors/external_metadata/store.go

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/jackc/pgx/v5/pgtype"
1515
"go.temporal.io/sdk/log"
1616
"google.golang.org/protobuf/encoding/protojson"
17+
"google.golang.org/protobuf/proto"
1718

1819
"github.com/PeerDB-io/peerdb/flow/alerting"
1920
"github.com/PeerDB-io/peerdb/flow/connectors/utils/monitoring"
@@ -24,8 +25,9 @@ import (
2425
)
2526

2627
const (
27-
lastSyncStateTableName = "metadata_last_sync_state"
28-
qrepTableName = "metadata_qrep_partitions"
28+
lastSyncStateTableName = "metadata_last_sync_state"
29+
qrepTableName = "metadata_qrep_partitions"
30+
qrepPartitionRangesTableName = "metadata_qrep_partition_ranges"
2931
)
3032

3133
type PostgresMetadata struct {
@@ -286,6 +288,152 @@ func (p *PostgresMetadata) IsQRepPartitionSynced(ctx context.Context, req *proto
286288
return exists, nil
287289
}
288290

291+
// OffloadSensitivePartitionRanges encrypts and persists sensitive partition ranges
292+
// into metadata_qrep_partition_ranges and then strip the existing ranges and
293+
// replacing them with an empty range so the value never crosses a Temporal boundary.
294+
// RestoreSensitivePartitionRanges repopulates them from the catalog before replicating.
295+
func OffloadSensitivePartitionRanges(
296+
ctx context.Context,
297+
pool shared.CatalogPool,
298+
parentMirrorName string,
299+
runUUID string,
300+
partitions []*protos.QRepPartition,
301+
) error {
302+
if !partitionsContainSensitiveRanges(partitions) {
303+
return nil
304+
}
305+
306+
key, err := internal.PeerDBCurrentEncKey(ctx)
307+
if err != nil {
308+
return fmt.Errorf("failed to load current encryption key: %w", err)
309+
}
310+
311+
tx, err := pool.Begin(ctx)
312+
if err != nil {
313+
return fmt.Errorf("failed to begin transaction: %w", err)
314+
}
315+
defer shared.RollbackTx(tx, internal.LoggerFromCtx(ctx))
316+
317+
// delete any orphaned rows on retry
318+
if _, err := tx.Exec(ctx,
319+
`DELETE FROM `+qrepPartitionRangesTableName+` WHERE run_uuid=$1`, runUUID,
320+
); err != nil {
321+
return fmt.Errorf("failed to clear existing partition ranges: %w", err)
322+
}
323+
324+
for _, partition := range partitions {
325+
payload, err := proto.Marshal(partition.Range)
326+
if err != nil {
327+
return fmt.Errorf("failed to marshal partition range: %w", err)
328+
}
329+
ciphertext, err := key.Encrypt(payload)
330+
if err != nil {
331+
return fmt.Errorf("failed to encrypt partition range: %w", err)
332+
}
333+
334+
if _, err := tx.Exec(ctx,
335+
`INSERT INTO `+qrepPartitionRangesTableName+
336+
`(parent_mirror_name,run_uuid,partition_uuid,enc_key_id,range_payload) VALUES($1,$2,$3,$4,$5)`,
337+
parentMirrorName, runUUID, partition.PartitionId, key.ID, ciphertext,
338+
); err != nil {
339+
return fmt.Errorf("failed to persist encrypted partition range: %w", err)
340+
}
341+
342+
// strip the plaintext range, leaving an empty marker of the same type
343+
partition.Range = &protos.PartitionRange{
344+
Range: &protos.PartitionRange_StringRange{StringRange: &protos.StringPartitionRange{}},
345+
}
346+
}
347+
348+
if err := tx.Commit(ctx); err != nil {
349+
return fmt.Errorf("failed to commit partition ranges: %w", err)
350+
}
351+
return nil
352+
}
353+
354+
// RestoreSensitivePartitionRanges hydrates sensitive partitions in-place with the decrypted
355+
// values fetched from the catalog. Used in conjunction with OffloadSensitivePartitionRanges
356+
func RestoreSensitivePartitionRanges(
357+
ctx context.Context,
358+
pool shared.CatalogPool,
359+
runUUID string,
360+
partitions []*protos.QRepPartition,
361+
) error {
362+
if !partitionsContainSensitiveRanges(partitions) {
363+
return nil
364+
}
365+
366+
byID := make(map[string]*protos.QRepPartition, len(partitions))
367+
partitionIDs := make([]string, 0, len(partitions))
368+
for _, partition := range partitions {
369+
byID[partition.PartitionId] = partition
370+
partitionIDs = append(partitionIDs, partition.PartitionId)
371+
}
372+
373+
rows, err := pool.Query(ctx,
374+
`SELECT partition_uuid,enc_key_id,range_payload FROM `+qrepPartitionRangesTableName+` `+
375+
`WHERE run_uuid=$1 AND partition_uuid=ANY($2)`,
376+
runUUID, partitionIDs,
377+
)
378+
if err != nil {
379+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
380+
}
381+
defer rows.Close()
382+
383+
for rows.Next() {
384+
var partitionID, encKeyID string
385+
var ciphertext []byte
386+
if err := rows.Scan(&partitionID, &encKeyID, &ciphertext); err != nil {
387+
return fmt.Errorf("failed to scan encrypted partition range: %w", err)
388+
}
389+
390+
partition, ok := byID[partitionID]
391+
if !ok {
392+
continue
393+
}
394+
395+
payload, err := internal.Decrypt(ctx, encKeyID, ciphertext)
396+
if err != nil {
397+
return fmt.Errorf("failed to decrypt partition range for %s: %w", partitionID, err)
398+
}
399+
400+
var partitionRange protos.PartitionRange
401+
if err := proto.Unmarshal(payload, &partitionRange); err != nil {
402+
return fmt.Errorf("failed to unmarshal partition range for %s: %w", partitionID, err)
403+
}
404+
partition.Range = &partitionRange
405+
delete(byID, partitionID)
406+
}
407+
if err := rows.Err(); err != nil {
408+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
409+
}
410+
411+
if len(byID) > 0 {
412+
missing := make([]string, 0, len(byID))
413+
for id := range byID {
414+
missing = append(missing, id)
415+
}
416+
return fmt.Errorf("missing encrypted partition ranges for partitions: %v", missing)
417+
}
418+
419+
return nil
420+
}
421+
422+
// partitionsContainSensitiveRanges reports whether the partitions hold data that
423+
// must not cross a Temporal boundary in plaintext. String ranges may contain PII
424+
// data like email, so deem it as sensitive by default.
425+
func partitionsContainSensitiveRanges(partitions []*protos.QRepPartition) bool {
426+
for _, partition := range partitions {
427+
if partition == nil || partition.Range == nil {
428+
return false
429+
}
430+
if _, ok := partition.Range.Range.(*protos.PartitionRange_StringRange); ok {
431+
return true
432+
}
433+
}
434+
return false
435+
}
436+
289437
func (p *PostgresMetadata) SyncFlowCleanup(ctx context.Context, jobName string) error {
290438
tx, err := p.pool.Begin(ctx)
291439
if err != nil {
@@ -313,5 +461,9 @@ func SyncFlowCleanupInTx(ctx context.Context, tx pgx.Tx, jobName string) error {
313461
return err
314462
}
315463

464+
if _, err := tx.Exec(ctx, `DELETE FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name = $1`, jobName); err != nil {
465+
return err
466+
}
467+
316468
return nil
317469
}

flow/e2e/clickhouse_mysql_test.go

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,20 +1819,16 @@ func (s ClickHouseSuite) Test_MySQL_String_Partition_Key_UUID_Parallel_Snapshot(
18191819
EnvWaitForEqualTablesWithNames(env, s, "waiting on initial", srcTable, dstTable, "id,val")
18201820

18211821
partitionRows, err := s.catalog.Query(s.t.Context(),
1822-
`SELECT partition_start, partition_end, COALESCE(rows_in_partition, 0)
1823-
FROM peerdb_stats.qrep_partitions WHERE parent_mirror_name = $1`,
1822+
`SELECT COALESCE(rows_in_partition, 0) FROM peerdb_stats.qrep_partitions WHERE parent_mirror_name = $1`,
18241823
flowConnConfig.FlowJobName)
18251824
require.NoError(s.t, err)
18261825
defer partitionRows.Close()
18271826

18281827
var partitionCount int32
18291828
var totalRows int64
18301829
for partitionRows.Next() {
1831-
var partitionStart, partitionEnd *string
18321830
var rowsInPartition int64
1833-
require.NoError(s.t, partitionRows.Scan(&partitionStart, &partitionEnd, &rowsInPartition))
1834-
require.NotNil(s.t, partitionStart)
1835-
require.NotNil(s.t, partitionEnd)
1831+
require.NoError(s.t, partitionRows.Scan(&rowsInPartition))
18361832
totalRows += rowsInPartition
18371833
partitionCount++
18381834
}
@@ -1910,3 +1906,103 @@ func (s ClickHouseSuite) Test_MySQL_String_Partition_Key_Arbitrary_FullTable() {
19101906
env.Cancel(s.t.Context())
19111907
RequireEnvCanceled(s.t, env)
19121908
}
1909+
1910+
// Test_MySQL_String_Partition_Key_Sensitive_Data_Handling verifies that string partition ranges,
1911+
// which may carry sensitive watermark values, are never stored in plaintext in the catalog: they are
1912+
// stripped from peerdb_stats.qrep_partitions and persisted encrypted in metadata_qrep_partition_ranges.
1913+
func (s ClickHouseSuite) Test_MySQL_String_Partition_Key_Sensitive_Range_Handling() {
1914+
if _, ok := s.source.(*MySqlSource); !ok {
1915+
s.t.Skip("only applies to mysql")
1916+
}
1917+
1918+
srcTable := "test_string_pk_sensitive"
1919+
srcFullName := s.attachSchemaSuffix(srcTable)
1920+
dstTable := "test_string_pk_sensitive_dst"
1921+
1922+
const numRows = 8
1923+
const numPartitions = 4
1924+
require.NoError(s.t, s.source.Exec(s.t.Context(),
1925+
fmt.Sprintf("CREATE TABLE %s (id CHAR(36) PRIMARY KEY, val INT NOT NULL)", srcFullName)))
1926+
insertedUUIDs := make([]string, 0, numRows)
1927+
for i := 1; i <= numRows; i++ {
1928+
id := uuid.NewString()
1929+
insertedUUIDs = append(insertedUUIDs, id)
1930+
require.NoError(s.t, s.source.Exec(s.t.Context(),
1931+
fmt.Sprintf("INSERT INTO %s (id, val) VALUES ('%s', %d)", srcFullName, id, i)))
1932+
}
1933+
1934+
tableMappings := TableMappings(s, srcTable, dstTable)
1935+
// a String column can't be used directly as a Distributed sharding key (ClickHouse
1936+
// requires an integer-typed sharding expression), so hash it for the cluster suite
1937+
for _, tm := range tableMappings {
1938+
tm.ShardingKey = "cityHash64(id)"
1939+
}
1940+
connectionGen := FlowConnectionGenerationConfig{
1941+
FlowJobName: s.attachSuffix("string_pk_sensitive"),
1942+
TableMappings: tableMappings,
1943+
Destination: s.Peer().Name,
1944+
}
1945+
flowConnConfig := connectionGen.GenerateFlowConnectionConfigs(s)
1946+
flowConnConfig.DoInitialSnapshot = true
1947+
flowConnConfig.SnapshotMaxParallelWorkers = 4
1948+
flowConnConfig.SnapshotNumPartitionsOverride = numPartitions
1949+
1950+
tc := NewTemporalClient(s.t)
1951+
env := ExecutePeerflow(s.t, tc, flowConnConfig)
1952+
SetupCDCFlowStatusQuery(s.t, env, flowConnConfig)
1953+
1954+
EnvWaitForEqualTablesWithNames(env, s, "waiting on initial", srcTable, dstTable, "id,val")
1955+
1956+
partitionRows, err := s.catalog.Query(s.t.Context(),
1957+
`SELECT partition_start, partition_end FROM peerdb_stats.qrep_partitions WHERE parent_mirror_name = $1`,
1958+
flowConnConfig.FlowJobName)
1959+
require.NoError(s.t, err)
1960+
defer partitionRows.Close()
1961+
1962+
var partitionCount int32
1963+
for partitionRows.Next() {
1964+
var partitionStart, partitionEnd *string
1965+
var rowsInPartition int64
1966+
require.NoError(s.t, partitionRows.Scan(&partitionStart, &partitionEnd, &rowsInPartition))
1967+
require.Empty(s.t, partitionStart)
1968+
require.Empty(s.t, partitionEnd)
1969+
partitionCount++
1970+
}
1971+
require.NoError(s.t, partitionRows.Err())
1972+
require.EqualValues(s.t, numPartitions, partitionCount)
1973+
1974+
rangeRows, err := s.catalog.Query(s.t.Context(),
1975+
`SELECT enc_key_id, range_payload FROM metadata_qrep_partition_ranges WHERE parent_mirror_name = $1`,
1976+
flowConnConfig.FlowJobName)
1977+
require.NoError(s.t, err)
1978+
defer rangeRows.Close()
1979+
1980+
var encryptedCount int32
1981+
for rangeRows.Next() {
1982+
var encKeyID string
1983+
var rangePayload []byte
1984+
require.NoError(s.t, rangeRows.Scan(&encKeyID, &rangePayload))
1985+
require.NotEmpty(s.t, encKeyID)
1986+
require.NotEmpty(s.t, rangePayload)
1987+
// payload must be ciphertext: none of the plaintext watermark UUIDs should appear in it
1988+
for _, id := range insertedUUIDs {
1989+
require.NotContains(s.t, string(rangePayload), id)
1990+
}
1991+
encryptedCount++
1992+
}
1993+
require.NoError(s.t, rangeRows.Err())
1994+
require.EqualValues(s.t, numPartitions, encryptedCount)
1995+
1996+
env.Cancel(s.t.Context())
1997+
RequireEnvCanceled(s.t, env)
1998+
1999+
// dropping the mirror should delete metadata_qrep_partition_ranges entries from the catalog for this mirror.
2000+
dropEnv := ExecuteDropFlow(s.t.Context(), tc, flowConnConfig, 0)
2001+
EnvWaitForFinished(s.t, dropEnv, 3*time.Minute)
2002+
2003+
var remainingRanges int64
2004+
require.NoError(s.t, s.catalog.QueryRow(s.t.Context(),
2005+
`SELECT COUNT(*) FROM metadata_qrep_partition_ranges WHERE parent_mirror_name = $1`,
2006+
flowConnConfig.FlowJobName).Scan(&remainingRanges))
2007+
require.Zero(s.t, remainingRanges)
2008+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Stores QRep partition ranges offloaded to the catalog instead of being passed
2+
-- through Temporal. Today this is used only for string partition ranges, whose
3+
-- bounds carry user watermark values that could be sensitive, so they are encrypted
4+
-- and kept out of Temporal payloads.
5+
-- The table name and columns are deliberately generic so a future change can offload
6+
-- all partition ranges here to stay under Temporal's payload size limit, not just strings.
7+
CREATE TABLE IF NOT EXISTS metadata_qrep_partition_ranges (
8+
parent_mirror_name TEXT NOT NULL,
9+
run_uuid TEXT NOT NULL,
10+
partition_uuid TEXT NOT NULL,
11+
enc_key_id TEXT NOT NULL,
12+
range_payload BYTEA NOT NULL,
13+
PRIMARY KEY (run_uuid, partition_uuid)
14+
);
15+
16+
CREATE INDEX IF NOT EXISTS idx_metadata_qrep_partition_ranges_parent_mirror_name
17+
ON metadata_qrep_partition_ranges (parent_mirror_name);

0 commit comments

Comments
 (0)