Row-Level Security
Row-Level Security (RLS) ensures that each user sees only the data rows they are authorized to access. The Query Engine's RlsFilterService intercepts every SQL query, extracts table references, evaluates security policies for each table against the user's roles and attributes, and injects WHERE clause predicates that restrict row visibility. This process is transparent to the user -- the query they write is automatically augmented with security filters before execution.
RLS Architecture
The RLS system spans two services:
+------------------+ Policy Eval +-------------------+
| Query Engine | --------------------------> | Governance Service|
| RlsFilterService| | (OPA + Polaris) |
+--------+---------+ +-------------------+
|
SQL Transformation
|
+--------v---------+
| Modified SQL |
| with WHERE |
| predicates |
+------------------+| Component | Service | Responsibility |
|---|---|---|
RlsFilterService | Query Engine | SQL parsing, table extraction, filter injection |
RlsServiceClient | Query Engine | HTTP client to governance service |
| OPA Policy Engine | Governance Service | Policy evaluation, filter generation |
| Polaris Authorization | Governance Service | Table-level access control |
RLS Filter Context
Every RLS evaluation starts with a RlsFilterContext that captures the complete security context:
public static class RlsFilterContext {
private UUID tenantId; // Tenant owning the data
private UUID executionId; // Query execution identifier
private UUID userId; // User executing the query
private String userEmail; // User email for email-based policies
private List<String> userRoles; // User's RBAC roles
private Map<String, Object> userAttributes; // Custom attributes (department, region, etc.)
private String query; // Original SQL query
private String databaseName; // Database context
private String schemaName; // Schema context
private Map<String, Object> environmentAttributes; // Time, IP, location
private Boolean requestBypass; // Emergency bypass flag
private String bypassJustification; // Required justification for bypass
private String clientIp; // Client IP address
private String sessionId; // Session identifier
}The context is assembled by the QueryExecutionService from the JWT token claims, the request headers, and the query metadata.
Table Extraction
The RlsFilterService extracts table references from SQL queries using multiple regex patterns to cover different SQL constructs:
FROM Clause Extraction
private static final Pattern FROM_PATTERN = Pattern.compile(
"\\bFROM\\s+([\\w.\"]+(?:\\s+(?:AS\\s+)?\\w+)?(?:\\s*,\\s*[\\w.\"]+(?:\\s+(?:AS\\s+)?\\w+)?)*)",
Pattern.CASE_INSENSITIVE
);Handles:
FROM orders-- simple table referenceFROM orders o-- table with aliasFROM orders AS o-- table with explicit aliasFROM schema.orders-- schema-qualified tableFROM orders o, customers c-- comma-separated tables
JOIN Clause Extraction
private static final Pattern JOIN_PATTERN = Pattern.compile(
"\\b(INNER|LEFT|RIGHT|FULL|CROSS)?\\s*JOIN\\s+([\\w.\"]+)(?:\\s+(?:AS\\s+)?(\\w+))?",
Pattern.CASE_INSENSITIVE
);Handles all JOIN types: INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, CROSS JOIN.
UPDATE and DELETE Extraction
private static final Pattern UPDATE_PATTERN = Pattern.compile(
"\\bUPDATE\\s+([\\w.\"]+)(?:\\s+(?:AS\\s+)?(\\w+))?\\s+SET\\b",
Pattern.CASE_INSENSITIVE
);
private static final Pattern DELETE_PATTERN = Pattern.compile(
"\\bDELETE\\s+FROM\\s+([\\w.\"]+)(?:\\s+(?:AS\\s+)?(\\w+))?",
Pattern.CASE_INSENSITIVE
);RLS filters are applied to all SQL operation types, not just SELECT queries. UPDATE and DELETE operations receive the same WHERE clause restrictions, preventing users from modifying rows they cannot read.
Extracted Table Structure
Each extracted table produces a TableReference object:
public static class TableReference {
private String name; // Table name (e.g., "orders")
private String alias; // Alias if present (e.g., "o")
private String schema; // Schema if qualified (e.g., "public")
private String database; // Database if qualified
}Policy Evaluation
For each extracted table, the RlsFilterService sends an evaluation request to the governance service:
RlsEvaluationRequest request = RlsEvaluationRequest.builder()
.tenantId(context.getTenantId())
.executionId(context.getExecutionId())
.userId(context.getUserId())
.userEmail(context.getUserEmail())
.userRoles(context.getUserRoles())
.userAttributes(context.getUserAttributes())
.resourceFqn(resourceFqn) // e.g., "analytics.public.orders"
.resourceType("TABLE")
.operation(determineOperation(sql)) // SELECT, INSERT, UPDATE, DELETE
.environmentAttributes(context.getEnvironmentAttributes())
.build();
RlsEvaluationResult result = rlsServiceClient.evaluateRls(request);Evaluation Result
The governance service returns an RlsEvaluationResult:
public class RlsEvaluationResult {
private boolean requiresFiltering; // Whether RLS applies to this table
private String whereClause; // Generated WHERE predicate
private List<AppliedPolicyInfo> appliedPolicies; // Which policies matched
private boolean accessDenied; // Complete access denial
private String denialReason; // Reason for denial
}Policy Types
The governance service supports several policy types:
| Policy Type | Description | Example Filter |
|---|---|---|
| Attribute-based | Filter by user attribute match | region = 'US-EAST' |
| Role-based | Filter by user role membership | department IN ('engineering', 'product') |
| Hierarchical | Filter by organizational hierarchy | org_unit_id IN (SELECT id FROM org_units WHERE path LIKE '/corp/eng/%') |
| Time-based | Filter by temporal constraints | created_at >= '2026-01-01' |
| Row classification | Filter by data classification label | classification_level <= 'CONFIDENTIAL' |
| Custom expression | Arbitrary SQL predicate | (amount < 10000 OR approver_id IS NOT NULL) |
WHERE Clause Injection
After collecting filters for all tables, the RlsFilterService injects them into the SQL query:
Injection into Existing WHERE Clause
String injectWhereClause(String query, String whereClause) {
Matcher whereMatcher = WHERE_PATTERN.matcher(query);
if (whereMatcher.find()) {
// Query already has WHERE - combine with AND
// Find existing WHERE clause boundaries
String existingWhere = extractExistingWhere(query, whereMatcher);
return query.substring(0, whereEnd) +
"(" + existingWhere + ") AND (" + whereClause + ")" +
query.substring(clauseEnd);
} else {
// No WHERE clause - insert before GROUP BY/ORDER BY/LIMIT
int insertPosition = findInsertPosition(query);
return query.substring(0, insertPosition).trim() +
" WHERE " + whereClause +
(insertPosition < query.length() ? " " + query.substring(insertPosition) : "");
}
}Examples of Filter Injection
Simple SELECT with no WHERE clause:
-- Original
SELECT * FROM orders
-- After RLS (user restricted to region 'US-EAST')
SELECT * FROM orders WHERE orders.region = 'US-EAST'SELECT with existing WHERE clause:
-- Original
SELECT * FROM orders WHERE status = 'active' ORDER BY created_at
-- After RLS
SELECT * FROM orders WHERE (status = 'active') AND (orders.region = 'US-EAST') ORDER BY created_atMulti-table JOIN with different policies per table:
-- Original
SELECT o.id, c.name, o.amount
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 100
-- After RLS (user restricted to region 'US-EAST' on orders,
-- and to department 'retail' on customers)
SELECT o.id, c.name, o.amount
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE (o.amount > 100) AND (o.region = 'US-EAST') AND (c.department = 'retail')Column Qualification
When a filter references a column name without a table qualifier, the RlsFilterService automatically qualifies it with the table alias:
private String qualifyFilter(String filter, String tableRef) {
// "region = 'US-EAST'" becomes "orders.region = 'US-EAST'"
// or "o.region = 'US-EAST'" if the table has alias "o"
}This prevents ambiguous column references in multi-table queries.
RLS Filter Result
The complete filter result is returned to the execution service:
public static class RlsFilterResult {
private String originalQuery; // The original SQL
private String filteredQuery; // The modified SQL with RLS predicates
private boolean filtersApplied; // Whether any filters were applied
private Map<String, String> appliedFilters; // Table -> filter mapping
private List<String> filteredTables; // Tables that received filters
private List<AppliedPolicyInfo> appliedPolicies; // Policy metadata for audit
private String reason; // Reason if no filters applied
private String error; // Error message if processing failed
private long processingTimeMs; // Time spent on RLS processing
}Metrics and Monitoring
The RlsFilterService emits Micrometer metrics:
| Metric | Type | Description |
|---|---|---|
rls.filter.applied | Counter | Number of queries with RLS filters applied |
rls.filter.skipped | Counter | Number of queries where RLS was skipped |
rls.filter.time | Timer | Time spent applying RLS filters |
rls.filter.tables_per_query | Histogram | Number of tables filtered per query |
rls.filter.policies_per_query | Histogram | Number of policies applied per query |
Observability for Compliance
Every RLS evaluation is logged with:
{
"event": "rls_filter_applied",
"tenantId": "tenant-123",
"userId": "user-456",
"executionId": "exec-789",
"tablesFiltered": ["orders", "customers"],
"policiesApplied": ["region_restriction", "department_isolation"],
"processingTimeMs": 12,
"timestamp": "2026-02-12T10:30:00Z"
}This log entry is written to the immutable audit trail for compliance and forensic analysis.
Emergency Bypass
In exceptional circumstances, authorized users can request an RLS bypass:
RlsFilterContext context = RlsFilterContext.builder()
.tenantId(tenantId)
.userId(userId)
.requestBypass(true)
.bypassJustification("Incident INC-2026-0042: investigating data anomaly")
.build();Bypass requests:
- Require the
RLS_BYPASSpermission, which is typically restricted to tenant administrators - Require a written justification that is stored in the audit log
- Are flagged in the query audit trail with a bypass indicator
- Generate an alert to the security monitoring system
- Have a configurable maximum duration (default: 1 hour)
RLS and Caching Interaction
RLS creates a challenge for result caching: the same SQL query may return different results for different users depending on their RLS policies. The cache key includes a hash of the user's effective roles to ensure proper isolation:
String cacheKey = SHA256(
tenantId +
normalizedSql +
catalog + schema +
userRolesHash // Different roles -> different cache entries
);This means:
- Two users with the same roles will share cached results (correct behavior since they see the same data)
- Two users with different roles will have separate cache entries (correct behavior since they see different data)
- An admin who bypasses RLS will have a separate cache entry from a user with RLS applied
Limitations and Future Work
| Limitation | Description | Planned Resolution |
|---|---|---|
| Regex-based parsing | Current SQL parsing uses regex patterns, not a full SQL parser | Migrate to JSqlParser AST-based extraction |
| Subquery support | RLS filters are applied to top-level tables; subqueries may not be covered | Add recursive subquery traversal |
| CTE support | Common Table Expressions are not yet filtered | Add CTE table reference extraction |
| View expansion | Views are filtered at the view level, not at underlying table level | Integrate with catalog to expand view definitions |
Related Sections
- SQL Execution -- RLS in the execution pipeline
- Data Masking -- Column-level masking after RLS
- Optimization -- RLS impact on caching
- Governance Service -- Policy definition and management