# Row Level Security (RLS) Quick Reference Guide **Target Audience**: Developers integrating with Foxhunt database **Last Updated**: 2025-10-03 (Wave 79 Agent 8) --- ## TL;DR - What You Need to Know 1. **Set session variables** before querying audit tables 2. **Use prepared statements** to prevent SQL injection 3. **Don't bypass RLS** - it's there for compliance (SOX/MiFID II) --- ## Required Session Variables Before querying any table with RLS enabled, you MUST set these session variables: ```sql -- Set current user's UUID SET app.current_user_id = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; -- Set current user's role SET app.current_user_role = 'trader'; -- or 'admin', 'compliance_officer', 'risk_manager' ``` **Valid Roles**: - `admin` - Full access to everything - `compliance_officer` - Access to all audit trails - `risk_manager` - Access to risk-related audit trails - `trader` - Access to trading-related data - `system` - Insert-only access to audit tables --- ## Rust/SQLx Integration ### Connection Pool Setup ```rust use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; pub struct DatabaseConnection { pool: PgPool, user_id: Uuid, user_role: String, } impl DatabaseConnection { pub async fn new(database_url: &str, user_id: Uuid, user_role: String) -> Result { let pool = PgPool::connect(database_url).await?; Ok(Self { pool, user_id, user_role }) } /// Get a connection with RLS session variables set pub async fn get_connection(&self) -> Result> { let mut tx = self.pool.begin().await?; // Set session variables for RLS sqlx::query("SET app.current_user_id = $1") .bind(self.user_id) .execute(&mut *tx) .await?; sqlx::query("SET app.current_user_role = $1") .bind(&self.user_role) .execute(&mut *tx) .await?; Ok(tx) } } ``` ### Usage Example ```rust use foxhunt_db::DatabaseConnection; async fn query_audit_trail(conn: &DatabaseConnection, trade_id: Uuid) -> Result> { let mut tx = conn.get_connection().await?; // Query will automatically be filtered by RLS policies let events = sqlx::query_as::<_, AuditEvent>( "SELECT * FROM sox_trade_audit WHERE trade_id = $1" ) .bind(trade_id) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(events) } ``` --- ## Tables with RLS Enabled ### Audit Tables (Append-Only, No Updates/Deletes) 1. **`sox_trade_audit`** - Who can see: User's own trades, or admin/compliance/risk_manager - Permissions: SELECT, INSERT only 2. **`mifid_transaction_report`** - Who can see: admin, compliance_officer, trader - Permissions: SELECT, INSERT only 3. **`position_limits_audit`** - Who can see: User's own limits, or admin/risk_manager - Permissions: SELECT, INSERT only 4. **`kill_switch_audit`** - Who can see: admin, risk_manager ONLY - Permissions: SELECT, INSERT only 5. **`best_execution_analysis`** - Who can see: admin, compliance_officer, trader - Permissions: SELECT, INSERT only 6. **`transaction_audit_events`** - Who can see (SELECT): Own events, or admin/compliance/risk_manager - Who can insert (INSERT): admin, system ONLY - Permissions: SELECT, INSERT only (NO UPDATE/DELETE) --- ## Common Patterns ### Pattern 1: Query User's Own Audit Trail ```rust async fn get_my_trades(conn: &DatabaseConnection) -> Result> { let mut tx = conn.get_connection().await?; // RLS automatically filters to current user's trades let trades = sqlx::query_as::<_, Trade>( "SELECT * FROM sox_trade_audit ORDER BY order_timestamp DESC LIMIT 100" ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(trades) } ``` ### Pattern 2: Admin Queries All Audit Trails ```rust async fn admin_query_all_trades( conn: &DatabaseConnection, start_date: DateTime, ) -> Result> { // Ensure user has admin role assert_eq!(conn.user_role, "admin", "Admin role required"); let mut tx = conn.get_connection().await?; // Admin can see all trades let trades = sqlx::query_as::<_, Trade>( "SELECT * FROM sox_trade_audit WHERE order_timestamp >= $1" ) .bind(start_date) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(trades) } ``` ### Pattern 3: Insert Audit Event (System Role) ```rust async fn log_trade_activity( system_conn: &DatabaseConnection, // Must have 'system' role trade: &Trade, ) -> Result { assert_eq!(system_conn.user_role, "system", "System role required"); let mut tx = system_conn.get_connection().await?; let audit_id = sqlx::query_scalar::<_, Uuid>( "SELECT log_sox_trade_activity($1, $2, $3, $4, $5, $6, $7, $8, $9)" ) .bind(trade.trade_id) .bind(trade.user_id) .bind(&trade.symbol) .bind(&trade.side) .bind(trade.quantity) .bind(trade.price) .bind(&trade.order_type) .bind(trade.trade_value) .bind(trade.order_timestamp) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(audit_id) } ``` --- ## Error Handling ### RLS Policy Violation If a query violates an RLS policy, PostgreSQL will silently filter results (returns empty set), NOT throw an error. ```rust // Example: Trader tries to access kill switch audit let mut tx = trader_conn.get_connection().await?; // This will return 0 rows, not an error let result = sqlx::query_as::<_, KillSwitchEvent>( "SELECT * FROM kill_switch_audit" ) .fetch_all(&mut *tx) .await?; assert_eq!(result.len(), 0); // Empty result, no error ``` ### Session Variable Not Set If session variables are not set, RLS policies will fail: ```rust // WRONG: Forgot to set session variables let result = sqlx::query_as::<_, Trade>("SELECT * FROM sox_trade_audit") .fetch_all(&pool) .await; // May return error or empty result ``` --- ## Testing RLS Policies ### Test Helper Function ```rust #[cfg(test)] mod tests { use super::*; async fn setup_test_connection( role: &str, user_id: Uuid, ) -> DatabaseConnection { DatabaseConnection::new( &std::env::var("DATABASE_URL").unwrap(), user_id, role.to_string(), ) .await .unwrap() } #[tokio::test] async fn test_user_can_see_own_trades() { let user_id = Uuid::new_v4(); let conn = setup_test_connection("trader", user_id).await; // Insert test trade let trade_id = insert_test_trade(&conn, user_id).await.unwrap(); // Verify user can see their own trade let trades = get_my_trades(&conn).await.unwrap(); assert!(trades.iter().any(|t| t.trade_id == trade_id)); } #[tokio::test] async fn test_user_cannot_see_other_trades() { let user1_id = Uuid::new_v4(); let user2_id = Uuid::new_v4(); let conn1 = setup_test_connection("trader", user1_id).await; let conn2 = setup_test_connection("trader", user2_id).await; // User 1 inserts trade let trade_id = insert_test_trade(&conn1, user1_id).await.unwrap(); // User 2 tries to see User 1's trade let trades = get_my_trades(&conn2).await.unwrap(); assert!(!trades.iter().any(|t| t.trade_id == trade_id)); } #[tokio::test] async fn test_admin_can_see_all_trades() { let admin_id = Uuid::new_v4(); let user_id = Uuid::new_v4(); let admin_conn = setup_test_connection("admin", admin_id).await; let user_conn = setup_test_connection("trader", user_id).await; // User inserts trade let trade_id = insert_test_trade(&user_conn, user_id).await.unwrap(); // Admin can see all trades let trades = admin_query_all_trades(&admin_conn, Utc::now() - Duration::hours(1)) .await .unwrap(); assert!(trades.iter().any(|t| t.trade_id == trade_id)); } } ``` --- ## Performance Considerations ### RLS Policy Overhead RLS policies add a WHERE clause to every query. For example: ```sql -- Your query SELECT * FROM sox_trade_audit WHERE symbol = 'AAPL'; -- What PostgreSQL actually executes (if user_role = 'trader') SELECT * FROM sox_trade_audit WHERE symbol = 'AAPL' AND (user_id = current_user_id() OR has_role('admin') OR ...); ``` **Optimization Tips**: 1. Add indexes on `user_id` columns 2. Use connection pooling to avoid repeated session variable setup 3. Cache role membership checks if possible ### Recommended Indexes ```sql -- Index on user_id for user-specific queries CREATE INDEX idx_sox_trade_audit_user_id ON sox_trade_audit(user_id); CREATE INDEX idx_position_limits_audit_user_id ON position_limits_audit(user_id); -- Composite indexes for common queries CREATE INDEX idx_sox_trade_audit_user_symbol ON sox_trade_audit(user_id, symbol); CREATE INDEX idx_sox_trade_audit_timestamp ON sox_trade_audit(order_timestamp DESC); ``` --- ## Security Best Practices ### 1. Never Bypass RLS ```rust // WRONG: Don't try to bypass RLS by using superuser connections let superuser_pool = PgPool::connect("postgresql://postgres:...").await?; // RIGHT: Always use application user with RLS enabled let app_pool = PgPool::connect("postgresql://foxhunt_user:...").await?; ``` ### 2. Validate Roles Before Privileged Operations ```rust fn require_role(conn: &DatabaseConnection, required_role: &str) -> Result<()> { if conn.user_role != required_role { return Err(Error::Unauthorized( format!("Role '{}' required, got '{}'", required_role, conn.user_role) )); } Ok(()) } async fn activate_kill_switch(conn: &DatabaseConnection) -> Result<()> { // Only admin and risk_manager can activate kill switch if conn.user_role != "admin" && conn.user_role != "risk_manager" { return Err(Error::Unauthorized("Insufficient privileges")); } // ... proceed with kill switch activation } ``` ### 3. Use Prepared Statements ```rust // WRONG: String interpolation (SQL injection risk) let query = format!("SELECT * FROM sox_trade_audit WHERE symbol = '{}'", symbol); // RIGHT: Prepared statement with parameter binding let trades = sqlx::query_as::<_, Trade>( "SELECT * FROM sox_trade_audit WHERE symbol = $1" ) .bind(symbol) .fetch_all(&pool) .await?; ``` ### 4. Audit Log Immutability ```rust // WRONG: Trying to update audit trail (will fail) let result = sqlx::query("UPDATE sox_trade_audit SET quantity = $1 WHERE trade_id = $2") .bind(new_quantity) .bind(trade_id) .execute(&pool) .await; // Result: Permission denied (UPDATE revoked on audit tables) // RIGHT: Audit trails are append-only // If correction needed, insert a new correction record let correction_id = sqlx::query_scalar::<_, Uuid>( "INSERT INTO sox_trade_audit_corrections (original_trade_id, corrected_quantity, reason) VALUES ($1, $2, $3) RETURNING id" ) .bind(trade_id) .bind(new_quantity) .bind("User error - incorrect quantity entered") .fetch_one(&pool) .await?; ``` --- ## Troubleshooting ### Problem: Empty Result Set **Symptom**: Query returns 0 rows, but you know data exists. **Possible Causes**: 1. Session variables not set 2. User doesn't have required role 3. RLS policy filtering out results **Debug Steps**: ```sql -- Check session variables SHOW app.current_user_id; SHOW app.current_user_role; -- Check if user has required role SELECT has_role('admin'); -- Bypass RLS temporarily (TESTING ONLY - requires superuser) SET SESSION AUTHORIZATION foxhunt_test; SELECT * FROM sox_trade_audit; -- Should show all rows RESET SESSION AUTHORIZATION; ``` ### Problem: Permission Denied **Symptom**: `ERROR: permission denied for table sox_trade_audit` **Possible Causes**: 1. Using wrong database user (not `foxhunt_user` or member of `authenticated_users`) 2. Public access revoked and user not granted explicit permissions **Debug Steps**: ```sql -- Check current user SELECT current_user; -- Check user's roles SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, oid, 'member'); -- Check table permissions SELECT grantee, privilege_type FROM information_schema.table_privileges WHERE table_name = 'sox_trade_audit' AND grantee = current_user; ``` --- ## Migration History This RLS implementation was created as part of: - **Wave 79 Agent 8**: Production Database Configuration (2025-10-03) - **Migration File**: `999_production_roles_setup.sql` - **Documentation**: `WAVE79_AGENT8_DATABASE_PRODUCTION_SETUP.md` --- ## Further Reading - [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - [SQLx Documentation](https://docs.rs/sqlx/latest/sqlx/) - Foxhunt Internal: `/docs/WAVE79_AGENT8_DATABASE_PRODUCTION_SETUP.md` --- **Last Updated**: 2025-10-03 **Maintained By**: Foxhunt Database Team **Questions?**: Check `/docs/WAVE79_AGENT8_DATABASE_PRODUCTION_SETUP.md` for detailed information