GDPR: per-field encryption via struct tags + transparent store wrapper #35

Closed
opened 2026-02-20 00:33:15 +00:00 by ash · 0 comments
Owner

Design

Use struct tags to mark PII fields. Reflection-based, zero interfaces to implement.

type OrderCreated struct {
    OrderID string  `json:"order_id"`
    Name    string  `json:"name" pii:"customer"`
    Email   string  `json:"email" pii:"customer"`
    Total   float64 `json:"total"`
}

Tag format: pii:"<subject>" where subject is either:

  • A literal subject ID reference (field name that contains the subject ID)
  • Or resolved via a SubjectResolver function

Store Wrapper

store := gdpr.NewEncryptedStore(innerStore, gdpr.Config{
    Keys: keyStore,
    SubjectResolver: func(streamID string) string {
        return streamID // or extract from stream ID
    },
})

How it works

  • Append: reflect on event struct, find pii tags, encrypt those fields, store encrypted version alongside original event structure
  • Load: load event, find pii tags, decrypt fields from encrypted sidecar, populate struct
  • Forget: delete subject key → tagged fields return [FORGOTTEN], non-PII intact

Implementation

  • Use reflection to scan struct for pii tags (cache the scan per type)
  • Encrypted field data stored in event metadata (sidecar map)
  • Original tagged fields stored as empty/placeholder in the main payload
  • On read: merge decrypted values back into struct
  • Type cache with sync.Map for zero-alloc on hot path after first scan

Multi-subject

type Transfer struct {
    FromName string `json:"from_name" pii:"sender"`
    ToName   string `json:"to_name" pii:"receiver"`
    Amount   float64 `json:"amount"`
}

Different tags = different subjects = independent forget. Forget sender, receiver name still readable.

Tests

  • Struct tag scanning (cached)
  • Encrypt tagged fields only, non-tagged untouched
  • Round-trip: append → load → exact match
  • Forget one subject → only their fields [FORGOTTEN]
  • Multi-subject: forget one, other intact
  • No pii tags → passthrough, zero overhead
  • Nested structs with pii tags
  • Pointer fields with pii tags
  • Integration with CommandBus
  • Integration with all stores
  • Benchmark: overhead of reflection + encryption vs plain store

Drop

  • Remove PIICarrier interface
  • Remove PIIFields interface
  • Remove GetPII/SetPII pattern
  • Struct tags replace all of it
## Design Use struct tags to mark PII fields. Reflection-based, zero interfaces to implement. ```go type OrderCreated struct { OrderID string `json:"order_id"` Name string `json:"name" pii:"customer"` Email string `json:"email" pii:"customer"` Total float64 `json:"total"` } ``` Tag format: `pii:"<subject>"` where subject is either: - A literal subject ID reference (field name that contains the subject ID) - Or resolved via a SubjectResolver function ## Store Wrapper ```go store := gdpr.NewEncryptedStore(innerStore, gdpr.Config{ Keys: keyStore, SubjectResolver: func(streamID string) string { return streamID // or extract from stream ID }, }) ``` ## How it works - **Append**: reflect on event struct, find `pii` tags, encrypt those fields, store encrypted version alongside original event structure - **Load**: load event, find `pii` tags, decrypt fields from encrypted sidecar, populate struct - **Forget**: delete subject key → tagged fields return `[FORGOTTEN]`, non-PII intact ## Implementation - Use reflection to scan struct for `pii` tags (cache the scan per type) - Encrypted field data stored in event metadata (sidecar map) - Original tagged fields stored as empty/placeholder in the main payload - On read: merge decrypted values back into struct - Type cache with sync.Map for zero-alloc on hot path after first scan ## Multi-subject ```go type Transfer struct { FromName string `json:"from_name" pii:"sender"` ToName string `json:"to_name" pii:"receiver"` Amount float64 `json:"amount"` } ``` Different tags = different subjects = independent forget. Forget sender, receiver name still readable. ## Tests - Struct tag scanning (cached) - Encrypt tagged fields only, non-tagged untouched - Round-trip: append → load → exact match - Forget one subject → only their fields [FORGOTTEN] - Multi-subject: forget one, other intact - No pii tags → passthrough, zero overhead - Nested structs with pii tags - Pointer fields with pii tags - Integration with CommandBus - Integration with all stores - Benchmark: overhead of reflection + encryption vs plain store ## Drop - Remove PIICarrier interface - Remove PIIFields interface - Remove GetPII/SetPII pattern - Struct tags replace all of it
ash closed this issue 2026-02-20 00:50:50 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ash/eskit#35
No description provided.