Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions src/rls/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ describe('loadPolicies - Policy Fetching and Parsing', () => {
describe('applyRLS - Query Modification', () => {
beforeEach(() => {
vi.resetAllMocks()
mockConfig.role = 'client'
mockDataSource.context.sub = 'user123'
vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValue([
{
actions: 'SELECT',
actions: '*',
schema: 'public',
table: 'users',
column: 'user_id',
Expand Down Expand Up @@ -106,7 +107,8 @@ describe('applyRLS - Query Modification', () => {
config: mockConfig,
})

expect(modifiedSql).toContain("WHERE `name` = 'Alice'")
expect(modifiedSql).toContain("`name` = 'Alice'")
expect(modifiedSql).toContain("`user_id` = 'user123'")
})

it('should modify UPDATE queries with additional WHERE clause', async () => {
Expand All @@ -118,7 +120,9 @@ describe('applyRLS - Query Modification', () => {
config: mockConfig,
})

expect(modifiedSql).toContain("`name` = 'Bob' WHERE `age` = 25")
expect(modifiedSql).toContain("`name` = 'Bob'")
expect(modifiedSql).toContain('`age` = 25')
expect(modifiedSql).toContain("`user_id` = 'user123'")
})

it('should modify INSERT queries to enforce column values', async () => {
Expand Down Expand Up @@ -164,6 +168,8 @@ describe('applyRLS - Edge Cases', () => {

describe('applyRLS - Multi-Table Queries', () => {
beforeEach(() => {
mockConfig.role = 'client'
mockDataSource.context.sub = 'user123'
vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValue([
{
actions: 'SELECT',
Expand Down Expand Up @@ -200,15 +206,15 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})

expect(modifiedSql).toContain("WHERE `users.user_id` = 'user123'")
expect(modifiedSql).toContain("AND `orders.user_id` = 'user123'")
expect(modifiedSql).toContain("`users`.`user_id` = 'user123'")
expect(modifiedSql).toContain("`orders`.`user_id` = 'user123'")
})

it('should apply RLS policies to multiple tables in a JOIN', async () => {
it('should apply RLS policies to aliased tables in a JOIN', async () => {
const sql = `
SELECT users.name, orders.total
FROM users
JOIN orders ON users.id = orders.user_id
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id
`

const modifiedSql = await applyRLS({
Expand All @@ -218,8 +224,8 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})

expect(modifiedSql).toContain("WHERE (users.user_id = 'user123')")
expect(modifiedSql).toContain("AND (orders.user_id = 'user123')")
expect(modifiedSql).toContain("`u`.`user_id` = 'user123'")
expect(modifiedSql).toContain("`o`.`user_id` = 'user123'")
})

it('should apply RLS policies to subqueries inside FROM clause', async () => {
Expand All @@ -236,6 +242,6 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})

expect(modifiedSql).toContain("WHERE `users.user_id` = 'user123'")
expect(modifiedSql).toContain("`user_id` = 'user123'")
})
})
123 changes: 75 additions & 48 deletions src/rls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ function normalizeIdentifier(name: string): string {
return name
}

function tableNamesMatch(policyTable: string, queryTable: string): boolean {
if (!policyTable || !queryTable) return false
const p = normalizeIdentifier(policyTable)
const q = normalizeIdentifier(queryTable)
if (p === q) return true
const pParts = p.split('.')
const qParts = q.split('.')
const pBare = pParts[pParts.length - 1]
const qBare = qParts[qParts.length - 1]
if (pBare === qBare) {
if (pParts.length === 1 || qParts.length === 1) return true
return pParts[0] === qParts[0]
}
return false
}

export async function loadPolicies(dataSource: DataSource): Promise<Policy[]> {
try {
const statement =
Expand Down Expand Up @@ -232,69 +248,71 @@ function applyRLSToAst(ast: any): void {
traverseWhere(ast.where)
}

const tablesWithRules: Record<string, string[]> = {}
policies.forEach((policy) => {
const tbl = normalizeIdentifier(policy.condition.left.table)
if (!tablesWithRules[tbl]) {
tablesWithRules[tbl] = []
}
tablesWithRules[tbl].push(policy.action)
})

const statementType = ast.type?.toUpperCase()
if (!['SELECT', 'UPDATE', 'DELETE', 'INSERT'].includes(statementType)) {
return
}

let tables: string[] = []
let tables: { name: string; alias?: string | null }[] = []
if (statementType === 'INSERT') {
let tableName = normalizeIdentifier(ast.table[0].table)
if (tableName.includes('.')) {
tableName = tableName.split('.')[1]
const t = ast.table?.[0]
if (t) {
tables = [{ name: normalizeIdentifier(t.table), alias: t.as }]
}
tables = [tableName]
} else if (statementType === 'UPDATE') {
tables = ast.table.map((tableRef: any) => {
let tableName = normalizeIdentifier(tableRef.table)
if (tableName.includes('.')) {
tableName = tableName.split('.')[1]
}
return tableName
})
tables = (ast.table || []).map((tableRef: any) => ({
name: normalizeIdentifier(tableRef.table),
alias: tableRef.as,
}))
} else {
// SELECT or DELETE
tables =
ast.from?.map((fromTable: any) => {
let tableName = normalizeIdentifier(fromTable.table)
if (tableName.includes('.')) {
tableName = tableName.split('.')[1]
}
return tableName
}) || []
tables = (ast.from || [])
.filter((fromTable: any) => fromTable.table)
.map((fromTable: any) => ({
name: normalizeIdentifier(fromTable.table),
alias: fromTable.as,
}))
}

const restrictedTables = Object.keys(tablesWithRules)

for (const table of tables) {
if (restrictedTables.includes(table)) {
const allowedActions = tablesWithRules[table]
if (!allowedActions.includes(statementType)) {
const matchingPolicies = policies.filter((p) =>
tableNamesMatch(p.condition.left.table, table.name)
)
if (matchingPolicies.length > 0) {
const allowedActions = matchingPolicies.map((p) => p.action)
if (
!allowedActions.includes(statementType) &&
!allowedActions.includes('*')
) {
throw new Error(
`Unauthorized access: No matching rules for ${statementType} on restricted table ${table}`
`Unauthorized access: No matching rules for ${statementType} on restricted table ${table.name}`
)
}
}
}

const hasMultipleTables = tables.length > 1
policies
.filter(
(policy) => policy.action === statementType || policy.action === '*'
)
.forEach(({ action, condition }) => {
const targetTable = normalizeIdentifier(condition.left.table)
const isTargetTable = tables.includes(targetTable)
const matchedTable = tables.find((t) =>
tableNamesMatch(targetTable, t.name)
)

if (!matchedTable) return

if (!isTargetTable) return
const conditionToApply = {
...condition,
left: {
...condition.left,
table: hasMultipleTables
? matchedTable.alias || matchedTable.name
: null,
},
}

if (action !== 'INSERT') {
// Add condition to WHERE with parentheses
Expand All @@ -308,15 +326,12 @@ function applyRLSToAst(ast: any): void {
parentheses: true,
},
right: {
...condition,
...conditionToApply,
parentheses: true,
},
}
} else {
ast.where = {
...condition,
parentheses: true,
}
ast.where = conditionToApply
}
} else {
// For INSERT, enforce column values
Expand Down Expand Up @@ -349,8 +364,11 @@ function applyRLSToAst(ast: any): void {
})

ast.from?.forEach((fromItem: any) => {
if (fromItem.expr && fromItem.expr.type === 'select') {
applyRLSToAst(fromItem.expr)
const subAst =
fromItem.expr?.ast ||
(fromItem.expr?.type === 'select' ? fromItem.expr : null)
if (subAst) {
applyRLSToAst(subAst)
}

// Handle both single join and array of joins
Expand All @@ -359,8 +377,11 @@ function applyRLSToAst(ast: any): void {
? fromItem.join
: [fromItem]
joins.forEach((joinItem: any) => {
if (joinItem.expr && joinItem.expr.type === 'select') {
applyRLSToAst(joinItem.expr)
const joinSubAst =
joinItem.expr?.ast ||
(joinItem.expr?.type === 'select' ? joinItem.expr : null)
if (joinSubAst) {
applyRLSToAst(joinSubAst)
}
})
}
Expand All @@ -371,8 +392,11 @@ function applyRLSToAst(ast: any): void {
}

ast.columns?.forEach((column: any) => {
if (column.expr && column.expr.type === 'select') {
applyRLSToAst(column.expr)
const colSubAst =
column.expr?.ast ||
(column.expr?.type === 'select' ? column.expr : null)
if (colSubAst) {
applyRLSToAst(colSubAst)
}
})
}
Expand All @@ -382,6 +406,9 @@ function traverseWhere(node: any): void {
if (node.type === 'select') {
applyRLSToAst(node)
}
if (node.ast?.type === 'select') {
applyRLSToAst(node.ast)
}
if (node.left) traverseWhere(node.left)
if (node.right) traverseWhere(node.right)
}