Skip to content

Commit 5b2f94d

Browse files
committed
treat string partition ranges as sensitive data
1 parent 29306ca commit 5b2f94d

5 files changed

Lines changed: 347 additions & 10 deletions

File tree

flow/activities/flowable.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,12 @@ 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(
601+
ctx, a.CatalogPool, config.ParentMirrorName, runUUID, partitions,
602+
); err != nil {
603+
return nil, fmt.Errorf("failed to offload partition ranges: %w", err)
604+
}
599605
if err := monitoring.InitializeQRepRun(
600606
ctx,
601607
logger,
@@ -663,6 +669,10 @@ func (a *FlowableActivity) ReplicateQRepPartitions(ctx context.Context,
663669
return a.Alerter.LogFlowError(ctx, config.FlowJobName, err)
664670
}
665671

672+
if err := connmetadata.RestoreSensitivePartitionRanges(ctx, a.CatalogPool, runUUID, partitions.Partitions); err != nil {
673+
return fmt.Errorf("failed to rehydrate partition ranges: %w", err)
674+
}
675+
666676
for i, partition := range partitions.Partitions {
667677
partLogger := log.With(logger,
668678
slog.Int64("batchID", int64(partitions.BatchId)),
@@ -1619,6 +1629,10 @@ func (a *FlowableActivity) QRepHasNewRows(ctx context.Context,
16191629
if maxValue.(time.Time).After(x.TimestampRange.End.AsTime()) {
16201630
return true, nil
16211631
}
1632+
case *protos.PartitionRange_StringRange:
1633+
// checking for new rows is only possible for standalone QRepFlowWorkflow;
1634+
// this is a legacy feature and string partitioning is not supported
1635+
return false, errors.New("checking for new rows by a string partition range is not supported")
16221636
default:
16231637
return false, fmt.Errorf("unknown range type: %v", x)
16241638
}

flow/connectors/external_metadata/store.go

Lines changed: 148 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,146 @@ func (p *PostgresMetadata) IsQRepPartitionSynced(ctx context.Context, req *proto
286288
return exists, nil
287289
}
288290

291+
// OffloadSensitivePartitionRanges encrypts sensitive partition ranges, persists them to
292+
// the catalog, and replaces each partition's range in-place with an empty range.
293+
// Used in conjunction with RestoreSensitivePartitionRanges.
294+
func OffloadSensitivePartitionRanges(
295+
ctx context.Context,
296+
pool shared.CatalogPool,
297+
parentMirrorName string,
298+
runUUID string,
299+
partitions []*protos.QRepPartition,
300+
) error {
301+
if !partitionsContainSensitiveRanges(partitions) {
302+
return nil
303+
}
304+
305+
key, err := internal.PeerDBCurrentEncKey(ctx)
306+
if err != nil {
307+
return fmt.Errorf("failed to load current encryption key: %w", err)
308+
}
309+
310+
tx, err := pool.Begin(ctx)
311+
if err != nil {
312+
return fmt.Errorf("failed to begin transaction: %w", err)
313+
}
314+
defer shared.RollbackTx(tx, internal.LoggerFromCtx(ctx))
315+
316+
// delete any orphaned rows from previously failed attempt
317+
if _, err := tx.Exec(ctx,
318+
`DELETE FROM `+qrepPartitionRangesTableName+` WHERE run_uuid=$1`, runUUID,
319+
); err != nil {
320+
return fmt.Errorf("failed to clear existing partition ranges: %w", err)
321+
}
322+
323+
for _, partition := range partitions {
324+
payload, err := proto.Marshal(partition.Range)
325+
if err != nil {
326+
return fmt.Errorf("failed to marshal partition range: %w", err)
327+
}
328+
encrypted, err := key.Encrypt(payload)
329+
if err != nil {
330+
return fmt.Errorf("failed to encrypt partition range: %w", err)
331+
}
332+
333+
if _, err := tx.Exec(ctx,
334+
`INSERT INTO `+qrepPartitionRangesTableName+
335+
`(parent_mirror_name,run_uuid,partition_uuid,enc_key_id,range_payload) VALUES($1,$2,$3,$4,$5)`,
336+
parentMirrorName, runUUID, partition.PartitionId, key.ID, encrypted,
337+
); err != nil {
338+
return fmt.Errorf("failed to persist encrypted partition range: %w", err)
339+
}
340+
341+
partition.Range = &protos.PartitionRange{
342+
Range: &protos.PartitionRange_StringRange{StringRange: &protos.StringPartitionRange{}},
343+
}
344+
}
345+
346+
if err := tx.Commit(ctx); err != nil {
347+
return fmt.Errorf("failed to commit partition ranges: %w", err)
348+
}
349+
return nil
350+
}
351+
352+
// RestoreSensitivePartitionRanges hydrates sensitive partitions in-place with the decrypted
353+
// values fetched from the catalog. Used in conjunction with OffloadSensitivePartitionRanges
354+
func RestoreSensitivePartitionRanges(
355+
ctx context.Context,
356+
pool shared.CatalogPool,
357+
runUUID string,
358+
partitions []*protos.QRepPartition,
359+
) error {
360+
if !partitionsContainSensitiveRanges(partitions) {
361+
return nil
362+
}
363+
364+
partitionIDs := make([]string, 0, len(partitions))
365+
partitionByIDs := make(map[string]*protos.QRepPartition, len(partitions))
366+
for _, partition := range partitions {
367+
partitionIDs = append(partitionIDs, partition.PartitionId)
368+
partitionByIDs[partition.PartitionId] = partition
369+
}
370+
371+
rows, err := pool.Query(ctx,
372+
`SELECT partition_uuid,enc_key_id,range_payload FROM `+qrepPartitionRangesTableName+` `+
373+
`WHERE run_uuid=$1 AND partition_uuid=ANY($2)`,
374+
runUUID, partitionIDs,
375+
)
376+
if err != nil {
377+
return fmt.Errorf("failed to query partition ranges: %w", err)
378+
}
379+
results, err := pgx.CollectRows(rows, pgx.RowToStructByPos[struct {
380+
PartitionID string
381+
EncKeyID string
382+
Payload []byte
383+
}])
384+
if err != nil {
385+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
386+
}
387+
388+
for _, res := range results {
389+
partition, ok := partitionByIDs[res.PartitionID]
390+
if !ok {
391+
return fmt.Errorf("received unexpected partition range for partition %s", res.PartitionID)
392+
}
393+
394+
payload, err := internal.Decrypt(ctx, res.EncKeyID, res.Payload)
395+
if err != nil {
396+
return fmt.Errorf("failed to decrypt partition range for %s: %w", res.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", res.PartitionID, err)
402+
}
403+
partition.Range = &partitionRange
404+
delete(partitionByIDs, res.PartitionID)
405+
}
406+
407+
if len(partitionByIDs) > 0 {
408+
missing := make([]string, 0, len(partitionByIDs))
409+
for id := range partitionByIDs {
410+
missing = append(missing, id)
411+
}
412+
return fmt.Errorf("missing encrypted partition ranges for partitions: %v", missing)
413+
}
414+
415+
return nil
416+
}
417+
418+
func partitionsContainSensitiveRanges(partitions []*protos.QRepPartition) bool {
419+
for _, partition := range partitions {
420+
if partition == nil || partition.Range == nil {
421+
return false
422+
}
423+
// String ranges may contain PII data like email, so deem it as sensitive.
424+
if _, ok := partition.Range.Range.(*protos.PartitionRange_StringRange); ok {
425+
return true
426+
}
427+
}
428+
return false
429+
}
430+
289431
func (p *PostgresMetadata) SyncFlowCleanup(ctx context.Context, jobName string) error {
290432
tx, err := p.pool.Begin(ctx)
291433
if err != nil {
@@ -313,5 +455,9 @@ func SyncFlowCleanupInTx(ctx context.Context, tx pgx.Tx, jobName string) error {
313455
return err
314456
}
315457

458+
if _, err := tx.Exec(ctx, `DELETE FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name = $1`, jobName); err != nil {
459+
return err
460+
}
461+
316462
return nil
317463
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package connmetadata
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/jackc/pgx/v5"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
11+
"github.com/PeerDB-io/peerdb/flow/internal"
12+
)
13+
14+
func TestOffloadRestoreSensitivePartitionRanges(t *testing.T) {
15+
const encKeyID = "test_enc_key"
16+
t.Setenv("PEERDB_CURRENT_ENC_KEY_ID", encKeyID)
17+
t.Setenv("PEERDB_ENC_KEYS", `[{"id":"`+encKeyID+`","value":"cGVlcmRiX2NpX3Rlc3RfZW5jX2tleV8zMl9ieXRlcyE="}]`)
18+
19+
ctx := t.Context()
20+
pool, err := internal.GetCatalogConnectionPoolFromEnv(ctx)
21+
require.NoError(t, err)
22+
23+
parentMirrorName := "test_offload_restore_sensitive_partition_ranges"
24+
runUUID := uuid.NewString()
25+
t.Cleanup(func() {
26+
_, _ = pool.Exec(ctx, `DELETE FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name=$1`, parentMirrorName)
27+
})
28+
29+
p1Start, p1End, p2Start, p2End := "bar", "foo", "hello", "world"
30+
partitions := []*protos.QRepPartition{
31+
{
32+
PartitionId: uuid.NewString(),
33+
Range: &protos.PartitionRange{Range: &protos.PartitionRange_StringRange{
34+
StringRange: &protos.StringPartitionRange{Start: p1Start, End: p1End},
35+
}},
36+
},
37+
{
38+
PartitionId: uuid.NewString(),
39+
Range: &protos.PartitionRange{Range: &protos.PartitionRange_StringRange{
40+
StringRange: &protos.StringPartitionRange{Start: p2Start, End: p2End, EndInclusive: true},
41+
}},
42+
},
43+
}
44+
45+
require.NoError(t, OffloadSensitivePartitionRanges(ctx, pool, parentMirrorName, runUUID, partitions))
46+
for _, p := range partitions {
47+
require.Empty(t, p.Range.GetStringRange().Start)
48+
require.Empty(t, p.Range.GetStringRange().End)
49+
}
50+
51+
rows, err := pool.Query(ctx,
52+
`SELECT enc_key_id, range_payload FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name=$1`,
53+
parentMirrorName)
54+
require.NoError(t, err)
55+
results, err := pgx.CollectRows(rows, pgx.RowToStructByPos[struct {
56+
KeyID string
57+
Payload []byte
58+
}])
59+
require.NoError(t, err)
60+
require.Len(t, results, len(partitions))
61+
for _, res := range results {
62+
require.Equal(t, encKeyID, res.KeyID)
63+
for _, plaintext := range []string{p1Start, p1End, p2Start, p2End} {
64+
require.NotContains(t, string(res.Payload), plaintext)
65+
}
66+
}
67+
68+
require.NoError(t, RestoreSensitivePartitionRanges(ctx, pool, runUUID, partitions))
69+
require.Equal(t, p1Start, partitions[0].Range.GetStringRange().Start)
70+
require.Equal(t, p1End, partitions[0].Range.GetStringRange().End)
71+
require.False(t, partitions[0].Range.GetStringRange().EndInclusive)
72+
require.Equal(t, p2Start, partitions[1].Range.GetStringRange().Start)
73+
require.Equal(t, p2End, partitions[1].Range.GetStringRange().End)
74+
require.True(t, partitions[1].Range.GetStringRange().EndInclusive)
75+
}

0 commit comments

Comments
 (0)