Skip to content

Commit f6df0b1

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

4 files changed

Lines changed: 284 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: 153 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,151 @@ 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 from previously failed attempt
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+
partition.Range = &protos.PartitionRange{
343+
Range: &protos.PartitionRange_StringRange{StringRange: &protos.StringPartitionRange{}},
344+
}
345+
}
346+
347+
if err := tx.Commit(ctx); err != nil {
348+
return fmt.Errorf("failed to commit partition ranges: %w", err)
349+
}
350+
return nil
351+
}
352+
353+
// RestoreSensitivePartitionRanges hydrates sensitive partitions in-place with the decrypted
354+
// values fetched from the catalog. Used in conjunction with OffloadSensitivePartitionRanges
355+
func RestoreSensitivePartitionRanges(
356+
ctx context.Context,
357+
pool shared.CatalogPool,
358+
runUUID string,
359+
partitions []*protos.QRepPartition,
360+
) error {
361+
if !partitionsContainSensitiveRanges(partitions) {
362+
return nil
363+
}
364+
365+
byID := make(map[string]*protos.QRepPartition, len(partitions))
366+
partitionIDs := make([]string, 0, len(partitions))
367+
for _, partition := range partitions {
368+
byID[partition.PartitionId] = partition
369+
partitionIDs = append(partitionIDs, partition.PartitionId)
370+
}
371+
372+
rows, err := pool.Query(ctx,
373+
`SELECT partition_uuid,enc_key_id,range_payload FROM `+qrepPartitionRangesTableName+` `+
374+
`WHERE run_uuid=$1 AND partition_uuid=ANY($2)`,
375+
runUUID, partitionIDs,
376+
)
377+
if err != nil {
378+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
379+
}
380+
defer rows.Close()
381+
382+
for rows.Next() {
383+
var partitionID, encKeyID string
384+
var ciphertext []byte
385+
if err := rows.Scan(&partitionID, &encKeyID, &ciphertext); err != nil {
386+
return fmt.Errorf("failed to scan encrypted partition range: %w", err)
387+
}
388+
389+
partition, ok := byID[partitionID]
390+
if !ok {
391+
continue
392+
}
393+
394+
payload, err := internal.Decrypt(ctx, encKeyID, ciphertext)
395+
if err != nil {
396+
return fmt.Errorf("failed to decrypt partition range for %s: %w", partitionID, err)
397+
}
398+
399+
var partitionRange protos.PartitionRange
400+
if err := proto.Unmarshal(payload, &partitionRange); err != nil {
401+
return fmt.Errorf("failed to unmarshal partition range for %s: %w", partitionID, err)
402+
}
403+
partition.Range = &partitionRange
404+
delete(byID, partitionID)
405+
}
406+
if err := rows.Err(); err != nil {
407+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
408+
}
409+
410+
if len(byID) > 0 {
411+
missing := make([]string, 0, len(byID))
412+
for id := range byID {
413+
missing = append(missing, id)
414+
}
415+
return fmt.Errorf("missing encrypted partition ranges for partitions: %v", missing)
416+
}
417+
418+
return nil
419+
}
420+
421+
// partitionsContainSensitiveRanges reports whether the partitions hold data that
422+
// must not cross a Temporal boundary in plaintext. String ranges may contain PII
423+
// data like email, so deem it as sensitive by default.
424+
func partitionsContainSensitiveRanges(partitions []*protos.QRepPartition) bool {
425+
for _, partition := range partitions {
426+
if partition == nil || partition.Range == nil {
427+
return false
428+
}
429+
if _, ok := partition.Range.Range.(*protos.PartitionRange_StringRange); ok {
430+
return true
431+
}
432+
}
433+
return false
434+
}
435+
289436
func (p *PostgresMetadata) SyncFlowCleanup(ctx context.Context, jobName string) error {
290437
tx, err := p.pool.Begin(ctx)
291438
if err != nil {
@@ -313,5 +460,9 @@ func SyncFlowCleanupInTx(ctx context.Context, tx pgx.Tx, jobName string) error {
313460
return err
314461
}
315462

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

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)