-- ============================================================================ -- CRM Security Architecture Upgrade -- Internal Company CRM — Prioritizes control, recovery, and maintainability -- ============================================================================ -- Implements: -- • Dual password storage (bcrypt + pgcrypto AES reversible) -- • SUPER_ADMIN master key recovery system -- • Row Level Security (RLS) on CRM tables -- • Database export logging -- • Backup logging -- • Immutable admin action tracking -- • SQL injection defense (parameterized queries enforced app-side) -- ============================================================================ -- ============================================================================ -- 1. DUAL PASSWORD STORAGE -- ============================================================================ -- password_hash (bcrypt) — used for normal login authentication -- password_encrypted (AES) — reversible encryption for emergency credential recovery -- ============================================================================ ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT; -- ============================================================================ -- 2. SUPER_ADMIN MASTER KEY SYSTEM -- ============================================================================ -- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt. -- Only accessible by SUPER_ADMIN role via security definer functions. -- ============================================================================ CREATE TABLE IF NOT EXISTS master_keys ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), key_name VARCHAR(100) NOT NULL UNIQUE, key_value TEXT NOT NULL, description TEXT, created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE; -- ============================================================================ -- 3. DATABASE EXPORT LOGGING -- ============================================================================ -- Tracks all database exports and SQL dumps performed by SUPER_ADMIN. -- Immutable — never allow deletion or update. -- ============================================================================ CREATE TABLE IF NOT EXISTS database_export_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), exported_by UUID NOT NULL REFERENCES users(id), export_type VARCHAR(50) NOT NULL, file_name VARCHAR(500) NOT NULL, file_size_bytes BIGINT, record_count INT, ip_address INET, user_agent TEXT, notes TEXT, exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by); CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC); COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.'; COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export'; -- ============================================================================ -- 4. BACKUP LOGGING -- ============================================================================ -- Records every automated or manual database backup. -- Retention: minimum 30 days of backup history. -- ============================================================================ CREATE TABLE IF NOT EXISTS backup_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), backup_type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, file_name VARCHAR(500), file_size_bytes BIGINT, pg_dump_exit_code INT, error_message TEXT, checksum VARCHAR(64), verification_status VARCHAR(20), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, duration_seconds INT, retention_days INT NOT NULL DEFAULT 30, triggered_by UUID REFERENCES users(id), notes TEXT ); CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC); CREATE INDEX idx_backup_logs_status ON backup_logs(status); CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type); CREATE INDEX idx_backup_logs_completed ON backup_logs(status) WHERE status = 'completed'; -- ============================================================================ -- 5. AUDIT TRIGGER: Password changes -- ============================================================================ CREATE OR REPLACE FUNCTION audit_password_change() RETURNS TRIGGER AS $$ BEGIN IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN INSERT INTO audit_logs ( table_name, record_id, action, old_data, new_data, changed_by, ip_address ) VALUES ( 'users', NEW.id, 'UPDATE', jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required), jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required), NULLIF(current_setting('app.current_user_id', true), ''), NULLIF(current_setting('app.current_ip', true), '') ); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; DROP TRIGGER IF EXISTS trg_audit_password_change ON users; CREATE TRIGGER trg_audit_password_change AFTER UPDATE OF password_hash ON users FOR EACH ROW EXECUTE FUNCTION audit_password_change(); -- ============================================================================ -- 6. AUDIT TRIGGER: User creation -- ============================================================================ CREATE OR REPLACE FUNCTION audit_user_creation() RETURNS TRIGGER AS $$ BEGIN INSERT INTO audit_logs ( table_name, record_id, action, new_data, changed_by ) VALUES ( 'users', NEW.id, 'CREATE', jsonb_build_object( 'username', NEW.username, 'email', NEW.email, 'first_name', NEW.first_name, 'last_name', NEW.last_name, 'is_active', NEW.is_active ), NEW.created_by ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; DROP TRIGGER IF EXISTS trg_audit_user_create ON users; CREATE TRIGGER trg_audit_user_create AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION audit_user_creation(); -- ============================================================================ -- 7. AUDIT TRIGGER: Database exports -- ============================================================================ CREATE OR REPLACE FUNCTION audit_database_export() RETURNS TRIGGER AS $$ BEGIN INSERT INTO audit_logs ( table_name, record_id, action, new_data, changed_by ) VALUES ( 'database_export_logs', NEW.id, 'CREATE', jsonb_build_object( 'export_type', NEW.export_type, 'file_name', NEW.file_name, 'file_size_bytes', NEW.file_size_bytes, 'record_count', NEW.record_count ), NEW.exported_by ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs; CREATE TRIGGER trg_audit_database_export AFTER INSERT ON database_export_logs FOR EACH ROW EXECUTE FUNCTION audit_database_export(); -- ============================================================================ -- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger -- (trg_audit_login in 001_schema.sql) -- This enhances it to also track session-level events. -- ============================================================================ DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts; CREATE TRIGGER trg_audit_login AFTER INSERT ON login_attempts FOR EACH ROW EXECUTE FUNCTION audit_login(); -- ============================================================================ -- 9. ROW LEVEL SECURITY -- ============================================================================ -- RLS policies enforce data visibility per role: -- SALES_USER → only sees records assigned to them -- ADMIN → sees operational records for investigation -- SUPER_ADMIN → sees everything -- ============================================================================ -- Helper function to get current user's role hierarchy level CREATE OR REPLACE FUNCTION current_user_hierarchy_level() RETURNS INT AS $$ DECLARE uid UUID; BEGIN BEGIN uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID; EXCEPTION WHEN OTHERS THEN RETURN NULL; END; IF uid IS NULL THEN RETURN NULL; END IF; RETURN ( SELECT MIN(r.hierarchy_level) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = uid ); END; $$ LANGUAGE plpgsql STABLE SECURITY DEFINER; -- Helper function to get current user's ID from session setting CREATE OR REPLACE FUNCTION current_user_id() RETURNS UUID AS $$ BEGIN BEGIN RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID; EXCEPTION WHEN OTHERS THEN RETURN NULL; END; END; $$ LANGUAGE plpgsql STABLE; -- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1 -- ── customers ── ALTER TABLE customers ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS rls_customers_select ON customers; CREATE POLICY rls_customers_select ON customers FOR SELECT USING ( current_user_hierarchy_level() IS NULL OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all OR owner_id = current_user_id() ); DROP POLICY IF EXISTS rls_customers_insert ON customers; CREATE POLICY rls_customers_insert ON customers FOR INSERT WITH CHECK ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner OR owner_id = current_user_id() -- SALES_USER can only assign to self ) ); DROP POLICY IF EXISTS rls_customers_update ON customers; CREATE POLICY rls_customers_update ON customers FOR UPDATE USING ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR owner_id = current_user_id() ) ) WITH CHECK ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR ( owner_id = current_user_id() AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership ) ) ); DROP POLICY IF EXISTS rls_customers_delete ON customers; CREATE POLICY rls_customers_delete ON customers FOR DELETE USING ( current_user_hierarchy_level() IS NOT NULL AND current_user_hierarchy_level() <= 2 ); -- ── leads ── ALTER TABLE leads ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS rls_leads_select ON leads; CREATE POLICY rls_leads_select ON leads FOR SELECT USING ( current_user_hierarchy_level() IS NULL OR current_user_hierarchy_level() <= 2 OR assigned_to = current_user_id() ); DROP POLICY IF EXISTS rls_leads_insert ON leads; CREATE POLICY rls_leads_insert ON leads FOR INSERT WITH CHECK ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR assigned_to = current_user_id() ) ); DROP POLICY IF EXISTS rls_leads_update ON leads; CREATE POLICY rls_leads_update ON leads FOR UPDATE USING ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR assigned_to = current_user_id() ) ); DROP POLICY IF EXISTS rls_leads_delete ON leads; CREATE POLICY rls_leads_delete ON leads FOR DELETE USING ( current_user_hierarchy_level() IS NOT NULL AND current_user_hierarchy_level() <= 2 ); -- ── opportunities ── ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS rls_opportunities_select ON opportunities; CREATE POLICY rls_opportunities_select ON opportunities FOR SELECT USING ( current_user_hierarchy_level() IS NULL OR current_user_hierarchy_level() <= 2 OR owner_id = current_user_id() ); DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities; CREATE POLICY rls_opportunities_insert ON opportunities FOR INSERT WITH CHECK ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR owner_id = current_user_id() ) ); DROP POLICY IF EXISTS rls_opportunities_update ON opportunities; CREATE POLICY rls_opportunities_update ON opportunities FOR UPDATE USING ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR owner_id = current_user_id() ) ); DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities; CREATE POLICY rls_opportunities_delete ON opportunities FOR DELETE USING ( current_user_hierarchy_level() IS NOT NULL AND current_user_hierarchy_level() <= 2 ); -- ── communications ── ALTER TABLE communications ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS rls_communications_select ON communications; CREATE POLICY rls_communications_select ON communications FOR SELECT USING ( current_user_hierarchy_level() IS NULL OR current_user_hierarchy_level() <= 2 OR created_by = current_user_id() ); DROP POLICY IF EXISTS rls_communications_insert ON communications; CREATE POLICY rls_communications_insert ON communications FOR INSERT WITH CHECK ( current_user_hierarchy_level() IS NOT NULL ); DROP POLICY IF EXISTS rls_communications_delete ON communications; CREATE POLICY rls_communications_delete ON communications FOR DELETE USING ( current_user_hierarchy_level() IS NOT NULL AND current_user_hierarchy_level() <= 2 ); -- ── tasks ── ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS rls_tasks_select ON tasks; CREATE POLICY rls_tasks_select ON tasks FOR SELECT USING ( current_user_hierarchy_level() IS NULL OR current_user_hierarchy_level() <= 2 OR assigned_to = current_user_id() OR assigned_by = current_user_id() ); DROP POLICY IF EXISTS rls_tasks_update ON tasks; CREATE POLICY rls_tasks_update ON tasks FOR UPDATE USING ( current_user_hierarchy_level() IS NOT NULL AND ( current_user_hierarchy_level() <= 2 OR assigned_to = current_user_id() OR assigned_by = current_user_id() ) ); -- ============================================================================ -- 10. IMMUTABLE AUDIT LOG PROTECTION -- ============================================================================ -- Prevent deletion or update of audit_logs at the database level. -- Even SUPER_ADMIN cannot delete historical audit records. -- ============================================================================ CREATE OR REPLACE FUNCTION prevent_audit_mutation() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.'; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs; CREATE TRIGGER trg_prevent_audit_delete BEFORE DELETE ON audit_logs FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation(); DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs; CREATE TRIGGER trg_prevent_audit_update BEFORE UPDATE ON audit_logs FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation(); -- Same protection for database_export_logs DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs; CREATE TRIGGER trg_prevent_export_delete BEFORE DELETE ON database_export_logs FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation(); DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs; CREATE TRIGGER trg_prevent_export_update BEFORE UPDATE ON database_export_logs FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation(); -- ============================================================================ -- 11. ENCRYPTION FUNCTIONS -- ============================================================================ -- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key. -- Master key is stored in master_keys table, fetched by security definer functions. -- ============================================================================ -- Get the active master decryption key -- Only callable by SUPER_ADMIN CREATE OR REPLACE FUNCTION get_master_decryption_key() RETURNS TEXT AS $$ DECLARE key_value TEXT; uid UUID; user_level INT; BEGIN uid := current_user_id(); IF uid IS NULL THEN RAISE EXCEPTION 'Not authenticated'; END IF; user_level := current_user_hierarchy_level(); IF user_level IS NULL OR user_level > 1 THEN RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key'; END IF; SELECT mk.key_value INTO key_value FROM master_keys mk WHERE mk.is_active = TRUE ORDER BY mk.created_at DESC LIMIT 1; RETURN key_value; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Encrypt a plaintext password using the active master key CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT) RETURNS TEXT AS $$ DECLARE master_key TEXT; BEGIN SELECT key_value INTO master_key FROM master_keys WHERE is_active = TRUE ORDER BY created_at DESC LIMIT 1; IF master_key IS NULL THEN RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.'; END IF; RETURN encode( pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'), 'escape' ); END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Decrypt a password that was encrypted with encrypt_password() CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT) RETURNS TEXT AS $$ DECLARE master_key TEXT; BEGIN master_key := get_master_decryption_key(); RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key); END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- ============================================================================ -- 12. RLS BYPASS FOR SUPER_ADMIN -- ============================================================================ -- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies -- to check if the current user should bypass restrictions. -- ============================================================================ CREATE OR REPLACE FUNCTION is_super_admin() RETURNS BOOLEAN AS $$ BEGIN RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1; END; $$ LANGUAGE plpgsql STABLE SECURITY DEFINER; -- ============================================================================ -- 13. SEED DATA: Master key (for development/testing) -- ============================================================================ -- In production, this key should be set via a secure deployment process. -- The key is stored in the database and encrypted at rest by PostgreSQL. -- ============================================================================ INSERT INTO master_keys (key_name, key_value, description, created_by) SELECT 'MASTER_DECRYPTION_KEY', encode(gen_random_bytes(32), 'hex'), 'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.', '00000000-0000-0000-0000-000000000001' WHERE NOT EXISTS ( SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY' ); -- ============================================================================ -- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts -- ============================================================================ UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026') WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL; UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026') WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL; UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026') WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL; UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; -- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL. -- They will set their own passwords on first login (password_change_required=TRUE). -- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint -- after users have set their chosen passwords. -- ============================================================================ -- FUTURE REQUIREMENT NOTE: -- If this system is ever exposed publicly, remove reversible password storage -- immediately. Keep bcrypt only. Delete the password_encrypted column and -- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally. -- ============================================================================