Skip to content

Commit 4ee56fc

Browse files
authored
treat string partition ranges as sensitive data (#4501)
Before introducing parallel snapshotting for arbitrary string watermark column, in order to avoid sensitive data from being accidentally exposed in db/Temporal as plaintext, this PR will offload partition ranges to db (encrypted) before return the `GetQRepPartitions` Activity result, and then restored in the `ReplicateQRepPartitions` Activity, to avoid crossing Temporal boundary. Long term, offloading all partition ranges to DB is also ideal, in order to not having to worry about Temporal payload limit. This PR introduces a new table `metadata_qrep_partition_ranges` for storing partition ranges. I also evaluated building on top of existing `peerdb_stats.qrep_partitions` but that table is intended for monitoring so adding operational logic to it is not ideal. This PR also makes sure that sensitive ranges are removed before writing to `peerdb_stats.qrep_partitions`, which are not being used afaik. Open to feedback on what we should deem as sensitive that should be encrypted (e.g. all string vs. non-UUIDs vs. only encrypt email-shaped string). Currently all string ranges are encrypted. Testing: add integration test coverage for offload/restore correctness logic; as well as e2e test for the workflow, and that table is cleanup when mirror is dropped. Fixes: DBI-869
1 parent c12762f commit 4ee56fc

9 files changed

Lines changed: 421 additions & 22 deletions

File tree

flow/activities/flowable.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,17 @@ 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+
shouldOffload, err := internal.PeerDBOffloadPartitionRanges(ctx, config.Env)
600+
if err != nil {
601+
return nil, fmt.Errorf("failed to read offload partition ranges setting: %w", err)
602+
}
603+
if shouldOffload && config.InitialCopyOnly {
604+
if err := connmetadata.OffloadPartitionRanges(
605+
ctx, a.CatalogPool, config.ParentMirrorName, runUUID, partitions,
606+
); err != nil {
607+
return nil, fmt.Errorf("failed to offload partition ranges: %w", err)
608+
}
609+
}
599610
if err := monitoring.InitializeQRepRun(
600611
ctx,
601612
logger,
@@ -663,6 +674,10 @@ func (a *FlowableActivity) ReplicateQRepPartitions(ctx context.Context,
663674
return a.Alerter.LogFlowError(ctx, config.FlowJobName, err)
664675
}
665676

677+
if err := connmetadata.RestoreOffloadedPartitionRanges(ctx, a.CatalogPool, runUUID, partitions.Partitions); err != nil {
678+
return fmt.Errorf("failed to rehydrate partition ranges: %w", err)
679+
}
680+
666681
for i, partition := range partitions.Partitions {
667682
partLogger := log.With(logger,
668683
slog.Int64("batchID", int64(partitions.BatchId)),
@@ -1623,6 +1638,10 @@ func (a *FlowableActivity) QRepHasNewRows(ctx context.Context,
16231638
if maxValue.(time.Time).After(x.TimestampRange.End.AsTime()) {
16241639
return true, nil
16251640
}
1641+
case *protos.PartitionRange_StringRange:
1642+
// checking for new rows is only possible for standalone QRepFlowWorkflow;
1643+
// this is a legacy feature and string partitioning is not supported
1644+
return false, errors.New("checking for new rows by a string partition range is not supported")
16261645
default:
16271646
return false, fmt.Errorf("unknown range type: %v", x)
16281647
}

flow/connectors/external_metadata/store.go

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"errors"
77
"fmt"
88
"log/slog"
9+
"maps"
10+
"slices"
911
"strconv"
1012
"time"
1113

@@ -14,6 +16,7 @@ import (
1416
"github.com/jackc/pgx/v5/pgtype"
1517
"go.temporal.io/sdk/log"
1618
"google.golang.org/protobuf/encoding/protojson"
19+
"google.golang.org/protobuf/proto"
1720

1821
"github.com/PeerDB-io/peerdb/flow/alerting"
1922
"github.com/PeerDB-io/peerdb/flow/connectors/utils/monitoring"
@@ -24,8 +27,9 @@ import (
2427
)
2528

2629
const (
27-
lastSyncStateTableName = "metadata_last_sync_state"
28-
qrepTableName = "metadata_qrep_partitions"
30+
lastSyncStateTableName = "metadata_last_sync_state"
31+
qrepTableName = "metadata_qrep_partitions"
32+
qrepPartitionRangesTableName = "metadata_qrep_offloaded_partition_ranges"
2933
)
3034

3135
type PostgresMetadata struct {
@@ -260,6 +264,15 @@ func (p *PostgresMetadata) FinishQRepPartition(
260264
jobName string,
261265
startTime time.Time,
262266
) error {
267+
if partition.RangeOffloaded {
268+
// The range was offloaded but has since been rehydrated, so drop it before persisting
269+
// to metadata_qrep_partitions.
270+
// Clone rather than mutate in place because this partition is shared with the pull
271+
// goroutine, cloning keeps us from relying on implicit ordering to avoid a write race.
272+
// TODO: drop the sync_partition column entirely; nothing reads it back.
273+
partition = proto.CloneOf(partition)
274+
partition.Range = nil
275+
}
263276
pbytes, err := protojson.Marshal(partition)
264277
if err != nil {
265278
return fmt.Errorf("failed to marshal partition to json: %w", err)
@@ -286,6 +299,133 @@ func (p *PostgresMetadata) IsQRepPartitionSynced(ctx context.Context, req *proto
286299
return exists, nil
287300
}
288301

302+
// OffloadPartitionRanges encrypts partition ranges and persists them to the catalog,
303+
// then clears each partition's range in-place and sets RangeOffloaded so the range can
304+
// be rehydrated later. Used in conjunction with RestoreOffloadedPartitionRanges.
305+
func OffloadPartitionRanges(
306+
ctx context.Context,
307+
pool shared.CatalogPool,
308+
parentMirrorName string,
309+
runUUID string,
310+
partitions []*protos.QRepPartition,
311+
) error {
312+
key, err := internal.PeerDBCurrentEncKey(ctx)
313+
if err != nil {
314+
return fmt.Errorf("failed to load current encryption key: %w", err)
315+
}
316+
317+
tx, err := pool.Begin(ctx)
318+
if err != nil {
319+
return fmt.Errorf("failed to begin transaction: %w", err)
320+
}
321+
defer shared.RollbackTx(tx, internal.LoggerFromCtx(ctx))
322+
323+
// delete any orphaned rows from previously failed attempt
324+
if _, err := tx.Exec(ctx,
325+
`DELETE FROM `+qrepPartitionRangesTableName+` WHERE run_uuid=$1`, runUUID,
326+
); err != nil {
327+
return fmt.Errorf("failed to clear existing partition ranges: %w", err)
328+
}
329+
330+
insertRows := make([][]any, 0, len(partitions))
331+
for _, partition := range partitions {
332+
if partition.Range == nil {
333+
continue
334+
}
335+
payload, err := proto.Marshal(partition.Range)
336+
if err != nil {
337+
return fmt.Errorf("failed to marshal partition range: %w", err)
338+
}
339+
encrypted, err := key.Encrypt(payload)
340+
if err != nil {
341+
return fmt.Errorf("failed to encrypt partition range: %w", err)
342+
}
343+
insertRows = append(insertRows, []any{parentMirrorName, runUUID, partition.PartitionId, key.ID, encrypted})
344+
345+
partition.Range = nil
346+
partition.RangeOffloaded = true
347+
}
348+
349+
if _, err := tx.CopyFrom(ctx,
350+
pgx.Identifier{qrepPartitionRangesTableName},
351+
[]string{"parent_mirror_name", "run_uuid", "partition_uuid", "enc_key_id", "range_payload"},
352+
pgx.CopyFromRows(insertRows),
353+
); err != nil {
354+
return fmt.Errorf("failed to persist encrypted partition ranges: %w", err)
355+
}
356+
357+
if err := tx.Commit(ctx); err != nil {
358+
return fmt.Errorf("failed to commit partition ranges: %w", err)
359+
}
360+
return nil
361+
}
362+
363+
// RestoreOffloadedPartitionRanges hydrates offloaded partition ranges in-place with the decrypted
364+
// values fetched from the catalog. Used in conjunction with OffloadPartitionRanges.
365+
func RestoreOffloadedPartitionRanges(
366+
ctx context.Context,
367+
pool shared.CatalogPool,
368+
runUUID string,
369+
partitions []*protos.QRepPartition,
370+
) error {
371+
partitionByIDs := make(map[string]*protos.QRepPartition, len(partitions))
372+
for _, partition := range partitions {
373+
if !partition.RangeOffloaded {
374+
continue
375+
}
376+
partitionByIDs[partition.PartitionId] = partition
377+
}
378+
if len(partitionByIDs) == 0 {
379+
return nil
380+
}
381+
382+
rows, err := pool.Query(ctx,
383+
`SELECT partition_uuid,enc_key_id,range_payload FROM `+qrepPartitionRangesTableName+` `+
384+
`WHERE run_uuid=$1 AND partition_uuid=ANY($2)`,
385+
runUUID, slices.Collect(maps.Keys(partitionByIDs)),
386+
)
387+
if err != nil {
388+
return fmt.Errorf("failed to query partition ranges: %w", err)
389+
}
390+
results, err := pgx.CollectRows(rows, pgx.RowToStructByPos[struct {
391+
PartitionID string
392+
EncKeyID string
393+
Payload []byte
394+
}])
395+
if err != nil {
396+
return fmt.Errorf("failed to read encrypted partition ranges: %w", err)
397+
}
398+
399+
for _, res := range results {
400+
partition, ok := partitionByIDs[res.PartitionID]
401+
if !ok {
402+
return fmt.Errorf("received unexpected partition range for partition %s", res.PartitionID)
403+
}
404+
405+
payload, err := internal.Decrypt(ctx, res.EncKeyID, res.Payload)
406+
if err != nil {
407+
return fmt.Errorf("failed to decrypt partition range for %s: %w", res.PartitionID, err)
408+
}
409+
410+
var partitionRange protos.PartitionRange
411+
if err := proto.Unmarshal(payload, &partitionRange); err != nil {
412+
return fmt.Errorf("failed to unmarshal partition range for %s: %w", res.PartitionID, err)
413+
}
414+
partition.Range = &partitionRange
415+
delete(partitionByIDs, res.PartitionID)
416+
}
417+
418+
if len(partitionByIDs) > 0 {
419+
missing := make([]string, 0, len(partitionByIDs))
420+
for id := range partitionByIDs {
421+
missing = append(missing, id)
422+
}
423+
return fmt.Errorf("missing encrypted partition ranges for partitions: %v", missing)
424+
}
425+
426+
return nil
427+
}
428+
289429
func (p *PostgresMetadata) SyncFlowCleanup(ctx context.Context, jobName string) error {
290430
tx, err := p.pool.Begin(ctx)
291431
if err != nil {
@@ -313,5 +453,9 @@ func SyncFlowCleanupInTx(ctx context.Context, tx pgx.Tx, jobName string) error {
313453
return err
314454
}
315455

456+
if _, err := tx.Exec(ctx, `DELETE FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name = $1`, jobName); err != nil {
457+
return err
458+
}
459+
316460
return nil
317461
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package connmetadata
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/google/uuid"
8+
"github.com/jackc/pgx/v5"
9+
"github.com/stretchr/testify/require"
10+
"google.golang.org/protobuf/proto"
11+
"google.golang.org/protobuf/types/known/timestamppb"
12+
13+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
14+
"github.com/PeerDB-io/peerdb/flow/internal"
15+
)
16+
17+
func TestOffloadRestoreSensitivePartitionRanges(t *testing.T) {
18+
const encKeyID = "test_enc_key"
19+
t.Setenv("PEERDB_CURRENT_ENC_KEY_ID", encKeyID)
20+
t.Setenv("PEERDB_ENC_KEYS", `[{"id":"`+encKeyID+`","value":"cGVlcmRiX2NpX3Rlc3RfZW5jX2tleV8zMl9ieXRlcyE="}]`)
21+
22+
ctx := t.Context()
23+
pool, err := internal.GetCatalogConnectionPoolFromEnv(ctx)
24+
require.NoError(t, err)
25+
26+
for _, tc := range []struct {
27+
name string
28+
ranges []*protos.PartitionRange
29+
}{
30+
{
31+
name: "int",
32+
ranges: []*protos.PartitionRange{
33+
{Range: &protos.PartitionRange_IntRange{IntRange: &protos.IntPartitionRange{Start: 1, End: 100}}},
34+
{Range: &protos.PartitionRange_IntRange{IntRange: &protos.IntPartitionRange{Start: 101, End: 200}}},
35+
},
36+
},
37+
{
38+
name: "timestamp",
39+
ranges: []*protos.PartitionRange{
40+
{Range: &protos.PartitionRange_TimestampRange{TimestampRange: &protos.TimestampPartitionRange{
41+
Start: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)),
42+
End: timestamppb.New(time.Date(2026, 6, 30, 23, 59, 59, 0, time.UTC)),
43+
}}},
44+
{Range: &protos.PartitionRange_TimestampRange{TimestampRange: &protos.TimestampPartitionRange{
45+
Start: timestamppb.New(time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)),
46+
End: timestamppb.New(time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)),
47+
}}},
48+
},
49+
},
50+
{
51+
name: "string_uuid",
52+
ranges: []*protos.PartitionRange{
53+
{Range: &protos.PartitionRange_StringRange{
54+
StringRange: &protos.StringPartitionRange{Start: uuid.NewString(), End: uuid.NewString()},
55+
}},
56+
{Range: &protos.PartitionRange_StringRange{
57+
StringRange: &protos.StringPartitionRange{Start: uuid.NewString(), End: uuid.NewString(), EndInclusive: true},
58+
}},
59+
},
60+
},
61+
} {
62+
t.Run(tc.name, func(t *testing.T) {
63+
parentMirrorName := "test_offload_restore_partition_ranges_" + tc.name
64+
runUUID := uuid.NewString()
65+
t.Cleanup(func() {
66+
_, _ = pool.Exec(ctx, `DELETE FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name=$1`, parentMirrorName)
67+
})
68+
69+
partitions := make([]*protos.QRepPartition, len(tc.ranges))
70+
expectedRanges := make([]*protos.PartitionRange, len(tc.ranges))
71+
for i, r := range tc.ranges {
72+
partitions[i] = &protos.QRepPartition{PartitionId: uuid.NewString(), Range: r}
73+
expectedRanges[i] = proto.Clone(r).(*protos.PartitionRange)
74+
}
75+
76+
require.NoError(t, OffloadPartitionRanges(ctx, pool, parentMirrorName, runUUID, partitions))
77+
for _, p := range partitions {
78+
require.Nil(t, p.Range)
79+
require.True(t, p.RangeOffloaded)
80+
}
81+
82+
rows, err := pool.Query(ctx,
83+
`SELECT enc_key_id, range_payload FROM `+qrepPartitionRangesTableName+` WHERE parent_mirror_name=$1`,
84+
parentMirrorName)
85+
require.NoError(t, err)
86+
results, err := pgx.CollectRows(rows, pgx.RowToStructByPos[struct {
87+
KeyID string
88+
Payload []byte
89+
}])
90+
require.NoError(t, err)
91+
require.Len(t, results, len(partitions))
92+
for _, res := range results {
93+
require.Equal(t, encKeyID, res.KeyID)
94+
require.NotEmpty(t, res.Payload)
95+
for _, r := range expectedRanges {
96+
unencrypted, err := proto.Marshal(r)
97+
require.NoError(t, err)
98+
require.NotContains(t, string(res.Payload), string(unencrypted))
99+
}
100+
}
101+
102+
require.NoError(t, RestoreOffloadedPartitionRanges(ctx, pool, runUUID, partitions))
103+
for i, p := range partitions {
104+
require.Truef(t, proto.Equal(expectedRanges[i], p.Range),
105+
"partition %d range mismatch after restore: want %v, got %v", i, expectedRanges[i], p.Range)
106+
}
107+
})
108+
}
109+
}

flow/connectors/utils/monitoring/monitoring.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,11 @@ func addPartitionToQRepRun(ctx context.Context, tx pgx.Tx, flowJobName string,
332332
}
333333

334334
var rangeStart, rangeEnd *string
335-
if partition.Range != nil {
335+
if partition.RangeOffloaded {
336+
internal.LoggerFromCtx(ctx).Info(
337+
"omitting partition_start/partition_end from qrep_partitions because it's offloaded",
338+
slog.String("partitionId", partition.PartitionId))
339+
} else if partition.Range != nil {
336340
switch x := partition.Range.Range.(type) {
337341
case *protos.PartitionRange_IntRange:
338342
rangeStart, rangeEnd = new(strconv.FormatInt(x.IntRange.Start, 10)), new(strconv.FormatInt(x.IntRange.End, 10))
@@ -369,7 +373,7 @@ func addPartitionToQRepRun(ctx context.Context, tx pgx.Tx, flowJobName string,
369373
default:
370374
return fmt.Errorf("unknown range type: %v", x)
371375
}
372-
} else if !partition.FullTablePartition {
376+
} else if !partition.FullTablePartition && len(partition.ChildTableRanges) == 0 {
373377
internal.LoggerFromCtx(ctx).Warn("[monitoring]: partition "+partition.PartitionId+" has nil range",
374378
slog.String(string(shared.FlowNameKey), parentMirrorName))
375379
}

0 commit comments

Comments
 (0)