PII Vault Access Patterns
Using the PII vault: decryption helpers, purpose policies in the dashboard, the decryption audit log, and the console-only migration in Settings.
Overview
This guide covers day-to-day use of the Purpose-Bound PII Vault: calling it from an edge function, keeping purpose policies in the dashboard, reading the decryption audit log, and running the migration. For the module reference (function signatures, the audit table's columns, the migration function's bodies and error codes) see Purpose-Bound PII Vault. For the regulatory mapping see Zero-PII Compliance Guide.
What a decryption actually does:
edge function
-> decryptPii(field, purpose)
-> decrypt the AES-256-GCM ciphertext (or read the legacy plaintext column)
-> insert a pii_decryption_log row (fire and forget, never awaited)
-> plaintext, live only inside this invocationThere is no purpose check and no rate check on that path. The audit row is the control; the policy records in the dashboard are the statement of intent it gets compared against.
| Task | Where it happens | Who can do it |
|---|---|---|
| Encrypt or decrypt a field | Edge function code, by importing the module | Platform engineers; there is no public route and no API key scope |
| Create or disable purpose policies | Dashboard, PII vault tab, Purpose policies | Tenant admin on the Enterprise plan |
| Read the decryption audit log | Dashboard, PII vault tab, Audit log | Tenant admin on the Enterprise plan |
| Inspect legacy coverage or preserve its encrypted counterpart | Dashboard, Settings, PII Migration Tooling | Platform admin who is also an admin of that tenant |
| Preserve account names and clear profile copies | Manual backfill-profile-names operator action | Actual configured service key; global scope and explicit count confirmation |
Purpose policies
A purpose policy is a row in shield_pii_policies stating what a tenant expects for one purpose code. The vault does not read the table, so a policy changes nothing at decryption time. It is worth keeping anyway: it is the documented expectation a reviewer measures the audit log against, and it is what an auditor asks to see beside the log.
| Field | Type | Default | Meaning |
|---|---|---|---|
purpose_code | text | none | The code this policy governs, unique per tenant |
description | text | null | Free text shown in the dashboard |
max_decryptions_per_hour | integer | 100 | The rate the tenant considers normal for this code |
ttl_seconds | integer | 300 | How long a decrypted value is expected to stay in use |
is_active | boolean | true | Whether the tenant currently stands behind this policy |
Create one.
- Open the dashboard and select the PII vault tab (Enterprise plan).
- Switch to the Purpose policies view.
- Expand Create purpose policy.
- Pick a purpose code from the list, or choose Custom and type one. The value is lowercased and spaces become underscores before it is stored.
- Set Maximum decryptions per hour (default 100, accepted range 1 to 10,000) and Session length in seconds (default 300, accepted range 10 to 86,400).
- Press Create policy.
A second policy for the same code is refused by the table's unique constraint, and the dashboard reports "A policy for this purpose code already exists." Each listed policy has a toggle that flips is_active without deleting it, and a delete button behind a confirmation dialog.
Read the empty state carefully. With no policies, the tab says "Every purpose code is accepted until you create a policy." Every purpose code is also accepted after you create one: the sentence describes the tab's own list, and the vault's behaviour is unchanged either way.
Decryption patterns
Pattern 1: one field, one purpose. The common case, and the shape send-marketing-email uses:
import { decryptPii } from "../_shared/pii-vault.ts";
const email = 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,
},
);
if (!email) return; // decryption failed; the caller decides what that meansUse the value inside the invocation and let it fall out of scope. Nothing persists it, and nothing writes it to a log line.
Pattern 2: several fields, one purpose. decryptPiiBatch resolves the fields in parallel and writes one audit row per field:
import { decryptPiiBatch } from "../_shared/pii-vault.ts";
const [email, displayName] = await decryptPiiBatch(
[
{ encryptedValue: subscriber.email_encrypted, fieldName: "newsletter_subscribers.email" },
{ encryptedValue: profile.display_name_encrypted, fieldName: "profiles.display_name" },
],
{
purpose: "billing_invoice_generation",
functionName: "billing-pipeline",
tenantId: tenant.id,
userHash: profile.user_hash,
},
);This is an illustrative vault call, not the account-display path. profiles.display_name_encrypted is retained legacy state. New provisioning labels use separate purpose-bound ciphertext per organization membership; the signed-in dashboard renders the auth account metadata.
Pattern 3: storing a new value. Write the ciphertext and the keyed digest together, so lookups never need the plaintext:
import { encryptAndHash } from "../_shared/pii-vault.ts";
import { IDENTIFIER_HASH_VERSION } from "../_shared/identifier-hash.ts";
const result = await encryptAndHash(address);
if (result) {
await supabase.from("newsletter_subscribers").update({
email_encrypted: result.encrypted,
email_hash: result.hash, // keyed HMAC-SHA-256, for equality lookups
hash_version: IDENTIFIER_HASH_VERSION,
}).eq("id", subscriber.id).eq("tenant_id", tenantId);
}The digest covers the trimmed, lowercased value, so any spelling of the same address matches. A null result means a key is missing: encryptAndHash fails closed rather than storing an unprotected value, and the row should be left untouched.
Failure handling. encryptPii, decryptPii and decryptPiiBatch never throw, so no call site needs a try or catch around them. They return null (or an array containing null) and log the reason to the function console. Treat null as "this field is unavailable in this invocation" and decide per call site whether to skip the record or fail the request.
Decryption audit log
The audit log is the record of every decryption that returned a value. Its columns and its write path are in Purpose-Bound PII Vault; this section is about reading it.
In the dashboard. PII vault tab, Audit log view:
- Purpose tiles across the top: up to eight codes, each with its count in the loaded set and a bar showing that count relative to the largest. Selecting a tile filters the table to that code, and selecting it again clears the filter.
- Full audit log, a collapsed section holding a search box and a purpose filter. Search matches the purpose, function name, field name and user hash.
- Table columns: Time, Purpose, Function, Field, User (the first 12 characters of the keyed digest) and Source.
- Refresh, top right, reloads the view.
The view fetches the 200 most recent events for the tenant when it opens. It is a snapshot rather than a live feed, so a decryption made while the tab is open appears after a refresh, and a busy tenant sees only the newest 200 events here.
Reading the Source column.
| Badge | Metadata value | What it means |
|---|---|---|
| Encrypted | source: "encrypted" | The ciphertext was read; this field is migrated for that row |
| Plaintext | source: "plaintext_fallback" | A legacy plaintext column was read; that row is not migrated yet |
Searching for one data subject. Paste the subject's keyed digest into the search box. The digest is what _shared/identifier-hash.ts produces for that identifier; the dashboard shows only its first 12 characters, and searching on those 12 works. For a complete per-subject record across every table, run a data subject access request instead, which reads the whole log from the database rather than the loaded page. See DSAR Workflows.
Migration and coverage
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.
A card reading Unknown means the count could not be read. That card offers no shred control, because shredding is irreversible and an unknown count is not a safe basis for starting it.
Reviewing access
The audit log supports review by a person. No shipped code turns it into an alert.
What the dashboard gives you. The purpose tiles show how often each code was used within the loaded 200 events, ordered by count, with a bar for relative volume. There is no time axis and no comparison against a previous period, so the tiles describe what a tenant has been decrypting lately and cannot say whether this hour is unusual. The filters and the hash search let you pull the accesses for one purpose, one function or one subject.
What to look for.
| Pattern | How it shows up | Sensible response |
|---|---|---|
| A code you did not expect | A purpose tile whose name matches no policy and no shipped function | Find the calling function in the Function column; only send-marketing-email should appear today |
| Volume above the policy | Tile count far above the policy's max_decryptions_per_hour for the period | Nothing was blocked, so check the calling function for a loop or an unintended bulk run |
| Plaintext fallbacks appearing | Source badges reading Plaintext | Rows are being written without ciphertext, or a column regressed; check Settings, PII Migration Tooling |
| One subject, many accesses | Repeated rows sharing a user hash | Confirm against the operation that caused them, and raise it with whoever ran it |
What does not exist. pii_decryption_log is read by the PII vault tab, shield-compliance-report and shield-dsar, and by nothing else. No playbook trigger, alert rule or webhook fires from it, and the insider-threat analytics read shield_audit_log rather than this table, so a vault decryption cannot raise their PII access spike anomaly. Build review on the compliance report, which counts decryptions by purpose and function over a period, and on periodic reads of the tab. See Compliance Report and Insider Threat Analytics.