117 lines
4.5 KiB
SQL
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.';
|