Security architecture upgrade + bug reporting system

Database & Security:
- Dual password storage: bcrypt (auth) + pgcrypto AES-256 (recovery)
- SUPER_ADMIN master key recovery system (master_keys table)
- Row Level Security on customers, leads, opportunities, communications, tasks
  - SALES_USER: own records only
  - ADMIN: all records
  - SUPER_ADMIN: all records (bypasses RLS)
- Immutable audit logs (DELETE/UPDATE blocked by triggers)
- New audit event types: BUG_CREATED, BUG_UPDATED, BUG_ASSIGNED,
  BUG_RESOLVED, LOGIN, LOGOUT
- Database export logging (database_export_logs table)
- Backup logging with pg_dump script (scripts/backup.ps1)
- Fixed audit constraint to allow new action types

Authentication:
- Random JWT secret generated on every dev server start
  (invalidates all prior sessions after restart)
- Session cookie is now session-only (no maxAge)
- setSessionContext() for RLS integration

Bug Reporting System:
- bug_reports table with RLS (insert by all, select/update by admin only)
- POST /api/bug-reports (any authenticated user)
- GET /api/bug-reports (admin/super_admin only)
- PATCH /api/bug-reports/:id (admin/super_admin only)
- POST /api/auth/recover (super_admin password recovery)
- Audit logging for all bug report actions

Other:
- Added 'dev' to UserRole type
- Bug report modal UI with severity selector
- Added bug report button to topbar
This commit is contained in:
2026-06-26 11:13:28 +02:00
parent 9bbaf70145
commit 20a1744e7f
13 changed files with 1365 additions and 5 deletions
@@ -0,0 +1,604 @@
-- ============================================================================
-- 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;
-- ============================================================================
-- 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.
-- ============================================================================
+145
View File
@@ -0,0 +1,145 @@
-- ============================================================================
-- Bug Reporting System
-- ============================================================================
-- Access rules:
-- ALL authenticated users can INSERT (submit bug reports)
-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage)
-- SALES_USER and DEVELOPER cannot view bug_reports after submission
-- ============================================================================
CREATE TABLE IF NOT EXISTS bug_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reported_by UUID NOT NULL REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
page_url TEXT,
screenshot_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
assigned_to UUID REFERENCES users(id),
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by);
CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status);
CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity);
CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to);
CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC);
-- RLS: Allow INSERT for all authenticated users
-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN
ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS bug_reports_insert ON bug_reports;
CREATE POLICY bug_reports_insert ON bug_reports
FOR INSERT
WITH CHECK (
current_user_id() IS NOT NULL
AND reported_by = current_user_id()
);
DROP POLICY IF EXISTS bug_reports_select ON bug_reports;
CREATE POLICY bug_reports_select ON bug_reports
FOR SELECT
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
DROP POLICY IF EXISTS bug_reports_update ON bug_reports;
CREATE POLICY bug_reports_update ON bug_reports
FOR UPDATE
USING (
current_user_hierarchy_level() IS NOT NULL
AND current_user_hierarchy_level() <= 2
);
-- Audit trigger for bug report actions
CREATE OR REPLACE FUNCTION audit_bug_report_action()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_CREATED',
jsonb_build_object(
'title', NEW.title,
'severity', NEW.severity,
'page_url', NEW.page_url,
'status', NEW.status
),
NEW.reported_by
);
ELSIF TG_OP = 'UPDATE' THEN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
CASE NEW.status
WHEN 'resolved' THEN 'BUG_RESOLVED'
ELSE 'BUG_UPDATED'
END,
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status,
'assigned_to', NEW.assigned_to,
'resolution_notes', NEW.resolution_notes
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
VALUES (
'bug_reports',
NEW.id,
'BUG_ASSIGNED',
jsonb_build_object(
'assigned_to', NEW.assigned_to,
'previous_assignee', OLD.assigned_to,
'status', NEW.status
),
NULLIF(current_setting('app.current_user_id', true), '')
);
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports;
CREATE TRIGGER trg_audit_bug_report
AFTER INSERT OR UPDATE ON bug_reports
FOR EACH ROW
EXECUTE FUNCTION audit_bug_report_action();
-- Widen audit action constraint to include bug report events
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action;
ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action
CHECK (action::text = ANY (ARRAY[
'CREATE', 'UPDATE', 'DELETE',
'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED',
'LOGIN', 'LOGOUT'
]::text[]));
-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly
-- via RLS bypass attempts (additional safety via trigger)
CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass()
RETURNS TRIGGER AS $$
DECLARE
user_level INT;
BEGIN
user_level := current_user_hierarchy_level();
IF user_level IS NULL OR user_level > 2 THEN
RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
+6
View File
@@ -34,5 +34,11 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
\i 013_security_upgrade.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Migration Complete ==='
COMMIT;