Tenant Credentials Encryption β Migration Plan β
Status: π Ready to apply once the
backofficeschema exists and ahasura_userrole is provisioned. Scope: Concrete DDL for pgsodium-encrypted tenant API credentials + masked Hasura-facing view, implementing ADR-029 Layer B. Why this file exists: This plan was originally drafted as a Hasura migration (apps/hasura/migrations/default/20260420120100_tenant_credentials_encryption/). The migration was removed because the backoffice database is still empty. The encryption primitives and masking pattern are non-obvious enough that losing them to agit rmwould cost a non-trivial redesign later; this document preserves the concrete SQL.
Preconditions β
- Postgres 15+ with
shared_preload_librariesincludingpgsodium. On Ubicloudstandard-2this is pre-enabled (ADR-022 Β§Consequences). - A dedicated
hasura_userrole exists. Raw table access will beREVOKEd fromPUBLIC, and only the masked view will beGRANTed tohasura_user. - A dedicated
tenant_admin_rawrole (to be created in a follow-up migration; see ADR-029 Layer B) is the only role that maySELECTthe underlying ciphertext columns. It is used by a NestJS service account, never by a Hasura user role. backofficeschema exists.
Key Hierarchy (ADR-029 Layer B) β
- One KEK (key-encryption-key) per environment, stored in the
pgsodium.keytable. - The DEK (data-encryption-key) for each ciphertext column is derived from the KEK using the pgsodium deterministic context
busflow.tenant_credentials. - Determinism is scoped to
key_fingerprintonly.api_secretandwebhook_secretuse standard (randomized) AEAD and are not searchable. Legal sign-off on determinism is recorded against thekey_fingerprintcolumn in ADR-029 Β§Decision.
1. Extension β
CREATE EXTENSION IF NOT EXISTS pgsodium;2. Create the Per-Environment KEK β
DO $$
BEGIN
PERFORM pgsodium.create_key(
name => 'busflow_tenant_credentials_kek',
key_type => 'aead-det'
);
EXCEPTION WHEN duplicate_object THEN
-- idempotent β migration may have partially applied
NULL;
END $$;Important: create_key is environment-scoped. Do not drop this key in the down migration β other environments may still reference it. The down section below deliberately leaves the KEK alone.
3. Credentials Table β
All PII-bearing columns are TEXT ciphertext. The column comments below are load-bearing β they record the AEAD mode so future reviewers don't have to re-derive it from the masked view.
CREATE TABLE IF NOT EXISTS backoffice.tenant_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
provider TEXT NOT NULL,
label TEXT NOT NULL,
key_fingerprint TEXT NOT NULL, -- deterministic AEAD ciphertext of the credential's stable fingerprint
api_secret TEXT NOT NULL, -- standard AEAD ciphertext
webhook_secret TEXT, -- standard AEAD ciphertext (nullable)
last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT now() AT TIME ZONE 'UTC',
created_at TIMESTAMPTZ NOT NULL DEFAULT now() AT TIME ZONE 'UTC'
);
-- Unique fingerprint per tenant+provider (deterministic AEAD makes this safe).
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_creds_fp
ON backoffice.tenant_credentials (tenant_id, provider, key_fingerprint);
COMMENT ON TABLE backoffice.tenant_credentials IS
'Tenant API credentials. Columns are pgsodium AEAD ciphertext. Never expose via Hasura directly; use v_tenant_credentials_masked.';Why deterministic on key_fingerprint: the fingerprint is a stable identifier derived from the credential (e.g. the last 4 chars of the API key). The product requires equality lookups like "does this tenant already have a Mollie key with fingerprint X?" β that's impossible with randomized AEAD. The fingerprint deliberately leaks no plaintext information on its own; the ****$SUFFIX masking in Β§4 is what is shown to operators.
Why randomized on api_secret / webhook_secret: these must never be searchable. Randomized AEAD is the correct default; accept the loss of equality lookups.
4. Masked View (Hasura-Facing) β
Hasura must never see the ciphertext or the plaintext. It only ever sees ****XXXX β enough for an operator to disambiguate credentials in the UI, nothing more.
CREATE OR REPLACE VIEW backoffice.v_tenant_credentials_masked AS
SELECT
id,
tenant_id,
provider,
label,
-- ****$SUFFIX β last 4 characters of the base64-encoded ciphertext are
-- sufficient to disambiguate in the UI and reveal nothing about the plaintext.
'****' || right(key_fingerprint, 4) AS key_fingerprint_mask,
last_rotated_at,
created_at
FROM backoffice.tenant_credentials;5. Permissions Lockdown β
-- Strip all default public access to the raw table.
REVOKE ALL ON backoffice.tenant_credentials FROM PUBLIC;
-- Hasura reads ONLY the masked view.
GRANT SELECT ON backoffice.v_tenant_credentials_masked TO hasura_user;Operator rule (enforced by code review, not SQL): if you need to surface another field to Hasura, extend the masked view. Do not GRANT on backoffice.tenant_credentials. The single REVOKE ALL above is the only guard β once hasura_user gets a column-level grant on the raw table, the masking contract is permanently broken for that deployment.
Writes (credential creation / rotation) flow through a NestJS service using the tenant_admin_raw role β never through Hasura. The write path must call pgsodium's AEAD encrypt functions before insertion; the DDL above deliberately does not add triggers that would silently re-encrypt already-encrypted data.
6. pii_redacted_at Parity (Future Work) β
When the wider GDPR retention plan (gdpr-migration-plan.md) lands, tenant_credentials should be added to the scrub list with a distinct retention window β credentials deleted by the tenant manager should be purged within the 30-day GDPR window, not the 3-year passenger window. This is deferred because:
- There is no tenant UI yet to distinguish "deleted" from "active" credentials.
- Rotation (which is in scope today) is not a deletion β
last_rotated_atbumps but the row stays.
Track in a separate ADR once the credential lifecycle (draft β active β revoked β purged) is specified.
Down Migration β
DROP VIEW IF EXISTS backoffice.v_tenant_credentials_masked;
DROP INDEX IF EXISTS backoffice.idx_tenant_creds_fp;
DROP TABLE IF EXISTS backoffice.tenant_credentials;
-- Do NOT drop the KEK β other tenants/envs may still reference it.Warning: the down migration destroys tenant credentials. It exists for migration development only. Any real post-drop recovery must come from a PITR restore per postgres-cutover.md Β§PITR.
When to Land This Migration β
- After
backofficeschema exists andhasura_useris provisioned. - After the
tenant_admin_rawrole exists (add it in a preceding migration). - Before the first tenant wires up a real payment provider or WhatsApp channel β credentials must land into the encrypted table from day one; there is no supported "plaintext β encrypted" migration path.
Related β
- ADR-029 β decision record (Layer B covers this file).
secrets-rotation-runbook.mdβ quarterly and incident rotation procedures.- ADR-022 β confirms pgsodium availability on Ubicloud
standard-2.