feat(db): enforce runtime RLS boundaries

This commit is contained in:
2026-03-22 22:49:47 +04:00
parent 7665af0268
commit 97b5edcc0a
24 changed files with 2054 additions and 545 deletions

View File

@@ -1,6 +1,6 @@
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { createDbClient, type DbSessionContext, schema } from '@household/db'
import type { FinanceRepository } from '@household/ports'
import {
instantFromDatabaseValue,
@@ -22,14 +22,22 @@ function toCurrencyCode(raw: string): CurrencyCode {
export function createDbFinanceRepository(
databaseUrl: string,
householdId: string
householdId: string,
options: {
sessionContext?: DbSessionContext
} = {}
): {
repository: FinanceRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 5,
prepare: false
prepare: false,
...(options.sessionContext
? {
sessionContext: options.sessionContext
}
: {})
})
async function loadPurchaseParticipants(purchaseIds: readonly string[]): Promise<

View File

@@ -1,6 +1,6 @@
import { and, asc, eq, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { createDbClient, type DbSessionContext, schema } from '@household/db'
import {
instantToDate,
normalizeSupportedLocale,
@@ -334,13 +334,23 @@ function utilityCategorySlug(name: string): string {
.slice(0, 48)
}
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
export function createDbHouseholdConfigurationRepository(
databaseUrl: string,
options: {
sessionContext?: DbSessionContext
} = {}
): {
repository: HouseholdConfigurationRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 5,
prepare: false
prepare: false,
...(options.sessionContext
? {
sessionContext: options.sessionContext
}
: {})
})
const defaultUtilityCategories = [

View File

@@ -23,6 +23,7 @@
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1"
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
"0022_harden_rls.sql": "d2e24b3e5b7ec7ef9da7e90c0ddf0e408764f3578af3872f76b9b3198ffbd70e"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -155,6 +155,13 @@
"when": 1774200000000,
"tag": "0021_sharp_payer",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1774204831000,
"tag": "0022_harden_rls",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,34 @@
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
export interface DbSessionContext {
telegramUserId?: string
householdId?: string
memberId?: string
isAdmin?: boolean
isWorker?: boolean
}
export interface DbClientOptions {
max?: number
prepare?: boolean
sessionContext?: DbSessionContext
}
function quoteRuntimeOptionValue(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
function appendRuntimeOption(
options: string[],
key: string,
value: string | boolean | undefined
): void {
if (value === undefined) {
return
}
options.push(`-c ${key}=${quoteRuntimeOptionValue(String(value))}`)
}
export function createDbClient(databaseUrl: string, options: DbClientOptions = {}) {
@@ -17,7 +42,17 @@ export function createDbClient(databaseUrl: string, options: DbClientOptions = {
url.searchParams.delete('options')
// Set search_path via options parameter (required for PgBouncer compatibility)
url.searchParams.set('options', `-c search_path=${dbSchema}`)
const runtimeOptions = [`-c search_path=${dbSchema}`]
appendRuntimeOption(
runtimeOptions,
'app.telegram_user_id',
options.sessionContext?.telegramUserId
)
appendRuntimeOption(runtimeOptions, 'app.household_id', options.sessionContext?.householdId)
appendRuntimeOption(runtimeOptions, 'app.member_id', options.sessionContext?.memberId)
appendRuntimeOption(runtimeOptions, 'app.is_admin', options.sessionContext?.isAdmin)
appendRuntimeOption(runtimeOptions, 'app.is_worker', options.sessionContext?.isWorker)
url.searchParams.set('options', runtimeOptions.join(' '))
const cleanUrl = url.toString()

View File

@@ -1,2 +1,3 @@
export { createDbClient } from './client'
export type { DbSessionContext } from './client'
export * as schema from './schema'