cocottetech/@platform/infrastructure/sql/migrations/0004_credentials_vault.sql
2026-05-19 23:17:53 -07:00

117 lines
4.5 KiB
SQL

-- ============================================================================
-- 0004_credentials_vault.sql
-- ----------------------------------------------------------------------------
-- Per-user encrypted credential store for surface adapters.
--
-- Dual-mode auth: 'cookie' (paste-once, no captcha exposure) and 'credentials'
-- (full re-login with username/password/totp). Both paths converge on the
-- same Playwright runtime once authenticated.
--
-- Encryption: AES-256-GCM at-rest in *_enc columns. DEK is delivered per-
-- session by the custodian process on black via SSE; falls back to
-- CREDENTIALS_ENCRYPTION_KEY env in dev. Encrypted blob format:
-- "<iv_hex>:<authTag_hex>:<ciphertext_hex>"
--
-- Spec source: _engineering-credentials-vault.md §"Step 1 — schema port".
-- ============================================================================
CREATE TYPE credential_platform_type AS ENUM (
'escort',
'content',
'screening',
'reviews',
'socials',
'messaging',
'travel',
'other'
);
CREATE TYPE credential_auth_mode AS ENUM ('cookie', 'credentials');
CREATE TABLE credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE,
-- Identity
platform TEXT NOT NULL, -- canonical surface id (e.g. 'tryst')
platform_name TEXT, -- display name
platform_type credential_platform_type NOT NULL DEFAULT 'escort',
category TEXT, -- soft taxonomy bucket
url TEXT, -- login page or root url
-- Auth-mode discriminator
auth_mode credential_auth_mode NOT NULL DEFAULT 'credentials',
-- credentials-mode fields (NULL when auth_mode='cookie')
username TEXT,
password_enc TEXT,
totp_secret_enc TEXT,
-- cookie-mode fields (NULL when auth_mode='credentials')
cookie_blob_enc TEXT,
-- shared
note TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- mode-switch keeps prior auth for revert
last_verified_at TIMESTAMPTZ NULL, -- last time adapter confirmed the session works
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT credentials_auth_mode_shape CHECK (
(auth_mode = 'credentials'
AND username IS NOT NULL
AND password_enc IS NOT NULL
AND cookie_blob_enc IS NULL)
OR
(auth_mode = 'cookie'
AND cookie_blob_enc IS NOT NULL
AND password_enc IS NULL
AND totp_secret_enc IS NULL)
)
);
-- One active row per (user, platform, username). Archived rows ignored;
-- this lets mode-switch keep prior creds without unique conflicts.
CREATE UNIQUE INDEX idx_credentials_user_platform_username_active
ON credentials (user_id, lower(platform), lower(coalesce(username, '__cookie__')))
WHERE NOT is_archived;
CREATE INDEX idx_credentials_user_platform
ON credentials (user_id, platform)
WHERE NOT is_archived;
CREATE INDEX idx_credentials_org_platform
ON credentials (org_id, platform)
WHERE org_id IS NOT NULL AND NOT is_archived;
CREATE TRIGGER trg_credentials_updated_at
BEFORE UPDATE ON credentials
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- ----------------------------------------------------------------------------
-- RLS — tenant isolation
-- ----------------------------------------------------------------------------
ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_credentials ON credentials
USING (
user_id = current_setting('app.user_id', true)::uuid
OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid)
)
WITH CHECK (
user_id = current_setting('app.user_id', true)::uuid
OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid)
);
-- ----------------------------------------------------------------------------
-- Per-user attribution salt (referenced by _engineering-surface-metrics §9)
-- ----------------------------------------------------------------------------
ALTER TABLE users
ADD COLUMN IF NOT EXISTS attribution_salt BYTEA NULL;
COMMENT ON COLUMN users.attribution_salt IS
'Per-user 32-byte salt for identifier_hash in prospect_touchpoints. Generated lazily on first touchpoint write. NEVER exposed via API.';