Purpose-Bound PII Vault
The shared PII vault: AES-256-GCM at rest, just-in-time decryption for a declared purpose, the decryption audit log, and the console-only migration tool.
Architecture
The Purpose-Bound PII Vault is a shared module, supabase/functions/_shared/pii-vault.ts, that other edge functions import. It has no route of its own. Signal identifiers use keyed HMAC-SHA-256 digests under a server-held pepper. The vault provides AES-256-GCM encryption and a purpose-coded decryption audit for operational values such as newsletter delivery addresses. Account authentication details and administrative content are separate data categories; see Data handling. A retained encrypted profile-name counterpart belongs to legacy migration state, while new SCIM labels use their own account-and-organization-bound encrypted envelope.
| Surface | What it is | Who reaches it |
|---|---|---|
_shared/pii-vault.ts | Shared module with four exported functions | Edge functions, by import |
pii_decryption_log | Append-only audit table | Tenant admins of an Enterprise organization read it, only the service role writes it |
shield_pii_policies | Purpose policy records | Tenant admins of an Enterprise organization, through the dashboard; its members can read them |
shield-pii-migrate | Console-only edge function | Platform-and-tenant admin for legacy coverage/encryption; configured service key only for account-name backfill |
| PII vault tab | Audit log, purpose policies and coverage views | Dashboard, Enterprise plan |
send-marketing-email resolves subscriber addresses through the vault under marketing_email_send. Other encrypted values, including SCIM display labels and provider credentials, use their own checked read paths; the vault audit is not a record of every decryption on the platform.
| Guarantee | What the code does |
|---|---|
| Never throws | encryptPii, decryptPii and decryptPiiBatch catch every error and return null, so a decryption failure cannot crash the calling function |
| Fails closed | encrypt() throws when SHIELD_ENCRYPTION_KEY is unset and hashIdentifier() throws when IDENTIFIER_HASH_PEPPER is unset, so encryptAndHash returns null rather than storing an unprotected value |
| Audited | Every decryption that returns a value inserts a pii_decryption_log row naming the purpose code, the calling function and the field |
| Encrypted at rest | AES-256-GCM under the platform master key, stored as base64 of a 12-byte IV followed by the ciphertext and its GCM tag |
| Dual read | The ciphertext is preferred, a legacy plaintext column is read only while that column is still being migrated, and the audit row records which was used |
A filed application covers purpose-bound decryption with auditable purpose codes.
For how this data model maps to GDPR, CCPA and CPRA, SEC Regulation S-ID and the SOC 2 readiness evidence, see the Zero-PII Compliance Guide.
Encryption and decryption
Four functions are exported, and none of them throws.
| Function | Argument | Returns |
|---|---|---|
encryptPii | plaintext: string | The AES-256-GCM ciphertext, or null when the master key is unset |
decryptPii | field: PiiField, purpose: DecryptionPurpose | The plaintext, or null when nothing could be resolved |
decryptPiiBatch | fields: PiiField[], purpose: DecryptionPurpose | One entry per field, each a string or null, resolved in parallel |
encryptAndHash | plaintext: string | { encrypted, hash }, or null when either key is unset |
The two argument shapes:
interface PiiField {
encryptedValue: string | null; // the ciphertext column
plaintextFallback?: string | null; // the legacy column, during migration only
fieldName: string; // recorded on the audit row
}
interface DecryptionPurpose {
purpose: string; // free-form purpose code, recorded verbatim
functionName: string; // the calling edge function
tenantId?: string; // audit row scope; omit for system-level work
userHash?: string; // keyed digest of the data subject, never a raw value
metadata?: Record<string, unknown>; // merged into the audit row's metadata
}The one shipped call site, in send-marketing-email:
import { decryptPii } from "../_shared/pii-vault.ts";
const recipient = await decryptPii(
{
encryptedValue: subscriber.email_encrypted,
plaintextFallback: null,
fieldName: "newsletter_subscribers.email",
},
{
purpose: "marketing_email_send",
functionName: "send-marketing-email",
tenantId: tenant.id,
userHash: subscriber.email_hash,
metadata: { subscriber_id: subscriber.id },
},
);Resolution order in `decryptPii`.
encryptedValueis present and decrypts: return it, audit rowsource: "encrypted".encryptedValueis present, decryption fails, andplaintextFallbackis set: return the fallback, audit rowsource: "plaintext_fallback".- Only
plaintextFallbackis set: return it, audit rowsource: "plaintext_fallback". - Neither resolves: return
nulland write no audit row.
Storing a new value. encryptAndHash produces both the ciphertext for the _encrypted column and the lookup digest for the _hash column:
const result = await encryptAndHash("user@example.com");
// result.encrypted -> AES-256-GCM ciphertext
// result.hash -> keyed HMAC-SHA-256 digest, 64 lowercase hex charactersThe digest is hashIdentifier("email", value): HMAC-SHA-256 over the trimmed, lowercased address under the server-held pepper. It is never a plain SHA-256. An unkeyed digest of an email address can be reversed by enumerating candidate addresses, so keying is the control that makes a leaked digest column disclose equality and nothing else. Write hash_version = 2 beside the digest, as shield-pii-migrate does, so rows written before keyed hashing (version 1, unkeyed) stay identifiable.
Purpose codes
Every decryption declares a purpose code. The vault accepts any string, records it verbatim on the audit row, and enforces nothing: decryptPii runs no allowlist check, no counter and no expiry check.
| Purpose code | Reserved for | Emitted by shipped code |
|---|---|---|
marketing_email_send | send-marketing-email broadcasts | Yes |
support_ticket_reply | Resolving an identity to answer a support ticket | No |
billing_invoice_generation | Rendering a name or address onto an invoice | No |
account_recovery_verification | Identity resolution during social recovery | No |
compliance_data_subject_request | Data subject request fulfilment | No |
pii_migration | Bulk encryption of legacy columns | No |
admin_user_lookup | Resolving a display name for the dashboard | No |
Those seven are the canonical conventions carried in the module header and offered in the dashboard's policy form. Only marketing_email_send reaches the log from shipped code, so an audit log holding nothing else is the expected state rather than a sign of missing instrumentation. shield-pii-migrate imports encryptAndHash alone and never decrypts, so it writes no pii_migration rows, and shield-dsar reads the log table instead of decrypting through the vault.
Purpose policies record intent. A tenant admin can store one policy per purpose code in shield_pii_policies, with max_decryptions_per_hour (default 100) and ttl_seconds (default 300). No edge function reads that table. A policy therefore neither blocks nor throttles a decryption; it states what the tenant expects, so a reviewer can compare it against the audit log. Treat the hourly limit and the session length as review criteria, and see PII Vault Access Patterns for the dashboard steps.
Decryption audit log
Every decryption that returns a value inserts one row into pii_decryption_log. The insert is fire and forget with the service role: the calling function does not await it, and an insert failure is written to the function console without failing the caller.
| Column | Type | What it holds |
|---|---|---|
id | uuid | Primary key |
tenant_id | uuid | Tenant scope, null for system-level work, cascades when the tenant is deleted |
user_hash | text | Keyed HMAC-SHA-256 digest of the data subject, or null when the caller passed none |
field_name | text | The field decrypted, as the caller named it, for example newsletter_subscribers.email |
purpose | text | The declared purpose code |
function_name | text | The edge function that performed the decryption |
decrypted_at | timestamptz | When it happened; this table has no created_at column |
metadata | jsonb | Always carries source, plus whatever the caller added |
source is encrypted when the ciphertext was read and plaintext_fallback when a legacy column was used.
Row-level security allows a tenant admin to SELECT the tenant's rows while the organization is on Enterprise: plan enterprise or enterprise_dedicated, with billing neither unpaid nor cancelled, the rule the plan-gated functions apply. The purpose policies carry the same condition. There is no client insert, update or delete path at all: only the service role writes, which is what makes the table usable as evidence.
Three shipped readers: the PII vault tab (most recent 200 rows for the tenant), shield-compliance-report (purpose, function name and timestamp over the reporting period), and shield-dsar, which exports the rows matching a user_hash on an access request and, on an erasure request, nulls user_hash and tombstones metadata while keeping the row so the access record survives the erasure.
Verify it worked. Dashboard, PII vault tab, Audit log view. A new decryption appears as a row with its Purpose, Function, Field, the first 12 characters of the user hash, and a Source badge reading Encrypted or Plaintext. The view loads the most recent 200 events when it opens and does not stream, so press Refresh after triggering a decryption.
Migration and shredding
shield-pii-migrate has two authorization lanes. The existing status and encrypt actions require a signed-in platform admin who is also an admin of the named tenant. They inspect or preserve the legacy encrypted profile counterpart. They do not preserve the account's authentication name or its organization-specific provisioning labels, and encryption coverage is not proof that account-name cleanup is complete.
Account-name cleanup uses `backfill-profile-names`. This is a manual, global operator action, never a dashboard button or a scheduled job. It accepts only the actual configured service key as its Bearer credential; an administrator session or a JWT claiming a service role is insufficient. The selected project's direct function endpoint is used, not api.passkeybridge.io. For staging, select the dashboard labeled PasskeyBridge-Staging; production must not be touched.
Preview is the default:
{"action":"backfill-profile-names","dry_run":true,"batch_size":50}It returns dry_run, the exact global remaining count, selected and batch_size. Profiles are account-scoped rather than tenant-scoped. No name or authentication metadata is returned.
After reviewing that count, a write request explicitly supplies it:
{"action":"backfill-profile-names","dry_run":false,"expected_count":12,"batch_size":50,"confirm":"BACKFILL_AUTH_AND_CLEAR_PROFILE_NAMES"}Replace 12 with the count just observed. Batches are 1 to 100 rows. For each row, one transaction locks the profile and auth account, checks the source timestamp, preserves unrelated authentication metadata, sets full_name only when it does not conflict, preserves encrypted labels for existing memberships without replacing newer IdP labels, then clears and reads back the profile name. Existing encrypted profile counterparts are retained.
A conflicting auth name, stale source or missing account stops the batch; earlier successful rows remain committed. The response carries counts, blocked_profile_id and the last successful next_cursor. Review the conflict securely rather than automatically overwriting it. Preview again before each write and use the current count. An optional after_id UUID advances past a reviewed row; skipped rows still count in remaining, and restarting without a cursor revisits them. Completion means an exact post-batch remaining of zero, not the length of a returned page.
shred on profiles now returns 409, so the older action cannot bypass these preservation steps. Settings, PII Migration Tooling shows Account backfill required instead of a profile shred control. A legacy card's encrypted count does not certify the auth backfill. No hosted cleanup is implied by this documentation.
The repository operator runbook is docs/runbooks/profile-name-backfill.md. Release the additive schema before the dependent handlers and confirm its ledger/readback; the apply and deploy jobs start independently. The SQL rollback restores only the previous signup hook and retains accepted ciphertext and auth metadata. Keep cipher-aware SCIM readers and auth-record rendering after names have migrated; reverting to older readers first would lose label readback.