rls-guard
Version:
A CLI tool for managing PostgreSQL Row Level Security (RLS) policies as code
208 lines (179 loc) • 6.91 kB
text/typescript
// Expression Parser - Convert SQL expressions back to helper functions
// This module analyzes PostgreSQL RLS expressions and tries to map them back to our helper functions
/**
* Parse a PostgreSQL RLS expression and attempt to convert it to helper function calls
* @param {string} expression - SQL expression from RLS policy
* @returns {Object} Parsed expression with helper function mapping
*/
export function parseExpression(expression: string): { helper: string, raw: string, confidence: number, params?: any } {
if (!expression || expression.trim() === '') {
return { helper: 'publicAccess', raw: expression, confidence: 1.0 };
}
const normalized = expression.trim().toLowerCase();
// Handle simple boolean values
if (normalized === 'true') {
return { helper: 'publicAccess', raw: expression, confidence: 1.0 };
}
if (normalized === 'false') {
return { helper: 'noAccess', raw: expression, confidence: 1.0 };
}
// Try to match against known helper patterns
const patterns = [
{
name: 'currentUserId',
regex: /\(?(\w+)\s*=\s*\(?current_setting\('app\.current_user_id'(?:::text)?\)(?:::uuid)?\)?/i,
confidence: 0.9,
extract: (match: RegExpMatchArray) => ({ column: match[1] })
},
{
name: 'tenantId',
regex: /(\w+)\s*=\s*current_setting\('app\.tenant_id'\)::uuid/i,
confidence: 0.9,
extract: (match: RegExpMatchArray) => ({ column: match[1] })
},
{
name: 'roleCheck',
regex: /current_setting\('app\.user_role'\)\s*=\s*'([^']+)'/i,
confidence: 0.9,
extract: (match: RegExpMatchArray) => ({ role: match[1] })
},
{
name: 'recentData',
regex: /\(?(\w+)\s*>=\s*\(?(?:current_date|CURRENT_DATE)\s*-\s*(?:'(\d+)\s*days?'::interval|interval\s*'(\d+)\s*days?')\)?/i,
confidence: 0.8,
extract: (match: RegExpMatchArray) => ({ column: match[1], days: parseInt(match[2] || match[3]) })
},
{
name: 'timeWindow',
regex: /(\w+)\s*>=\s*now\(\)\s*-\s*interval\s*'(\d+)\s*hours?'/i,
confidence: 0.8,
extract: (match: RegExpMatchArray) => ({ column: match[1], hours: parseInt(match[2]) })
},
{
name: 'ownerOnly',
regex: /(\w+)\s*=\s*(\w+)/i,
confidence: 0.6,
extract: (match: RegExpMatchArray) => ({ userColumn: match[1], ownerColumn: match[2] })
}
];
// Try to match against patterns
for (const pattern of patterns) {
const match = expression.match(pattern.regex);
if (match) {
return {
helper: pattern.name,
params: pattern.extract(match),
raw: expression,
confidence: pattern.confidence
};
}
}
// Check for complex expressions that might combine multiple conditions
if (expression.includes(' AND ') || expression.includes(' OR ')) {
return parseComplexExpression(expression);
}
// If no pattern matches, return as raw expression
return {
helper: 'custom',
raw: expression,
confidence: 0.0
};
}
/**
* Parse complex expressions with AND/OR operators
* @param {string} expression - Complex SQL expression
* @returns {Object} Parsed complex expression
*/
function parseComplexExpression(expression: string): { helper: string, conditions?: any[], raw: string, confidence: number } {
// For now, treat complex expressions as custom
// Future enhancement: recursively parse sub-expressions
const conditions = [];
// Split by AND/OR and try to parse each part
const parts = expression.split(/\s+(AND|OR)\s+/i);
for (let i = 0; i < parts.length; i += 2) {
const part = parts[i];
const parsed = parseExpression(part);
if (parsed.helper !== 'custom') {
conditions.push(parsed);
}
}
if (conditions.length > 0) {
return {
helper: 'complex',
conditions: conditions,
raw: expression,
confidence: Math.min(...conditions.map(c => c.confidence)) * 0.8
};
}
return {
helper: 'custom',
raw: expression,
confidence: 0.0
};
}
/**
* Generate helper function call from parsed expression
* @param {Object} parsed - Parsed expression object
* @returns {string} Helper function call as string
*/
export function generateHelperCall(parsed: { helper: string, params?: any, raw: string }): string {
if (!parsed || parsed.helper === 'custom') {
return `"${parsed.raw}"`;
}
switch (parsed.helper) {
case 'publicAccess':
return 'publicAccess()';
case 'noAccess':
return 'noAccess()';
case 'currentUserId':
return parsed.params.column === 'user_id'
? 'currentUserId()'
: `currentUserId("${parsed.params.column}")`;
case 'tenantId':
return parsed.params.column === 'tenant_id'
? 'tenantId()'
: `tenantId("${parsed.params.column}")`;
case 'roleCheck':
return `roleCheck("${parsed.params.role}")`;
case 'recentData':
return parsed.params.column === 'created_at' && parsed.params.days === 90
? 'recentData()'
: `recentData("${parsed.params.column}", ${parsed.params.days})`;
case 'timeWindow':
return `timeWindow("${parsed.params.column}", ${parsed.params.hours})`;
case 'ownerOnly':
return parsed.params.userColumn === 'user_id' && parsed.params.ownerColumn === 'owner_id'
? 'ownerOnly()'
: `ownerOnly("${parsed.params.userColumn}", "${parsed.params.ownerColumn}")`;
case 'complex':
const conditions = (parsed as any).conditions?.map((c: any) => generateHelperCall(c)).join(', ');
return `/* Complex: ${parsed.raw} */ "${parsed.raw}"`;
default:
return `"${parsed.raw}"`;
}
}
/**
* Analyze expression and provide suggestions for improvement
* @param {Object} parsed - Parsed expression object
* @returns {Object} Analysis with suggestions
*/
export function analyzeExpression(parsed: { confidence: number, helper: string }): { confidence: number, suggestions: string[], warnings: string[] } {
const analysis = {
confidence: parsed.confidence,
suggestions: [],
warnings: []
};
if (parsed.confidence < 0.5) {
analysis.warnings.push('Low confidence in helper function mapping' as never);
analysis.suggestions.push('Consider using a custom expression or updating helper patterns' as never);
}
if (parsed.helper === 'custom') {
analysis.suggestions.push('Expression could not be mapped to helper function' as never);
analysis.suggestions.push('Review if this pattern should be added to available helpers' as never);
}
if (parsed.helper === 'ownerOnly' && parsed.confidence < 0.8) {
analysis.warnings.push('Simple column comparison detected - verify this is actually owner-based access' as never);
analysis.suggestions.push('Consider if this should use currentUserId() instead' as never);
}
return analysis;
}