Skip to main content

@kysera/rls

Row-Level Security plugin for Kysera - Implement declarative authorization policies for multi-tenant applications with automatic query transformation.

Installation

npm install @kysera/rls

Overview

MetricValue
Bundle Size~10 KB (minified)
Dependencies@kysera/core (workspace), @kysera/executor (workspace)
Peer Dependencieskysely >=0.28.8

Exports

// Main plugin
export { rlsPlugin } from './plugin'

// Schema definition
export { defineRLSSchema, mergeRLSSchemas } from './policy/schema'

// Policy builders
export { allow, deny, filter, validate } from './policy/builder'

// Context management
export { rlsContext, createRLSContext, withRLSContext, withRLSContextAsync } from './context'

// Errors
export { RLSError, RLSPolicyViolation, RLSPolicyEvaluationError, RLSContextError } from './errors'

// Types
export type {
RLSPluginOptions,
RLSSchema,
RLSAuthContext,
RLSContext,
TableRLSConfig,
PolicyDefinition,
PolicyEvaluationContext,
RLSRequestContext
}

rlsPlugin

Creates a Row-Level Security plugin instance.

function rlsPlugin<DB = unknown>(options: RLSPluginOptions<DB>): Plugin

RLSPluginOptions

interface RLSPluginOptions<DB = unknown> {
/**
* RLS policy schema defining access rules per table
*/
schema: RLSSchema<DB>

/**
* Tables to exclude from RLS entirely (global bypass)
*/
excludeTables?: string[]

/**
* Roles that bypass RLS for all tables (global bypass)
*/
bypassRoles?: string[]

/**
* Logger for RLS operations
*/
logger?: KyseraLogger

/**
* Require RLS context for all operations
* Throws RLSContextError if context not set
* @default true (changed in v0.8.0 for secure-by-default)
*/
requireContext?: boolean

/**
* Allow queries without filtering when context is missing
* Only applies when requireContext is false
* @default false
*/
allowUnfilteredQueries?: boolean

/**
* Log policy decisions for debugging
* @default false
*/
auditDecisions?: boolean

/**
* Custom handler for policy violations
*/
onViolation?: (violation: RLSPolicyViolation) => void
}

Configuration Examples

import { rlsPlugin, defineRLSSchema, filter, allow } from '@kysera/rls'

// Basic multi-tenant setup
const plugin = rlsPlugin({
schema: defineRLSSchema({
users: {
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))]
}
})
})

// Full setup with bypass rules
const plugin = rlsPlugin({
schema: rlsSchema,
excludeTables: ['migrations', 'system_config'],
bypassRoles: ['superadmin'],
requireContext: true,
auditDecisions: true,
onViolation: violation => {
logger.warn('RLS violation', violation)
}
})

Schema Definition

defineRLSSchema

Define RLS policies for your tables.

function defineRLSSchema<DB>(config: Record<string, TableRLSConfig>): RLSSchema<DB>

TableRLSConfig

interface TableRLSConfig {
/**
* List of policies for this table
*/
policies: PolicyDefinition[]

/**
* Deny access when no policy matches
* @default true
*/
defaultDeny?: boolean

/**
* Roles that bypass RLS for this table only
*/
skipFor?: string[]
}

Schema Example

const rlsSchema = defineRLSSchema<Database>({
users: {
policies: [
// Multi-tenant isolation
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),

// Users can update their own profile
allow('update', ctx => ctx.auth.userId === ctx.row?.id)
],
defaultDeny: true
},

posts: {
policies: [
// Tenant isolation
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),

// Authors can modify their posts
allow(
['update', 'delete'],
ctx => ctx.auth.userId === ctx.row?.author_id || ctx.auth.roles?.includes('admin')
)
]
},

audit_logs: {
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))],
skipFor: ['admin', 'superuser'], // Admins see all
defaultDeny: true
},

// No policies = full access
public_content: {}
})

mergeRLSSchemas

Combine multiple schemas into one.

function mergeRLSSchemas<DB>(...schemas: RLSSchema<DB>[]): RLSSchema<DB>

Example:

const tenantSchema = defineRLSSchema({
/* tenant policies */
})
const roleSchema = defineRLSSchema({
/* role-based policies */
})
const customSchema = defineRLSSchema({
/* custom policies */
})

const fullSchema = mergeRLSSchemas(tenantSchema, roleSchema, customSchema)

Policy Builders

allow

Grant permission based on a condition.

function allow(
operations: Operation | Operation[],
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
): PolicyDefinition

Operations: 'read', 'create', 'update', 'delete'

Examples:

// Authors can update their own posts
allow('update', ctx => ctx.auth.userId === ctx.row?.author_id)

// Admins can do anything
allow(['read', 'create', 'update', 'delete'], ctx => ctx.auth.roles?.includes('admin'))

// Members can read
allow('read', ctx => ctx.auth.roles?.includes('member'))

deny

Explicitly deny access based on a condition.

function deny(
operations: Operation | Operation[],
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
): PolicyDefinition

Examples:

// Never allow deleting system users
deny('delete', ctx => ctx.row?.is_system === true)

// Guests cannot modify
deny(['create', 'update', 'delete'], ctx => ctx.auth.roles?.includes('guest'))

filter

Add WHERE conditions to queries automatically.

Synchronous Only

Filter conditions must be synchronous functions. Async filter policies are not currently supported and will result in runtime errors. Use allow() or validate() for policies that require async operations.

function filter(
operations: Operation | Operation[],
getFilter: (ctx: PolicyEvaluationContext) => Record<string, unknown>
): PolicyDefinition

Examples:

// Tenant isolation - all queries filtered by tenant_id
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))

// Status-based filtering
filter('read', ctx =>
ctx.auth.roles?.includes('admin')
? {} // No filter for admins
: { status: 'active', visibility: 'public' }
)

// Multi-org support
filter('read', ctx => ({
organization_id: { $in: ctx.auth.organizationIds }
}))

validate

Validate input data before operations.

function validate(
operations: Operation | Operation[],
validator: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
): PolicyDefinition

Examples:

// Users can only create posts for themselves
validate('create', ctx => ctx.data?.author_id === ctx.auth.userId)

// Users can only update within their tenant
validate(
'update',
ctx => ctx.data?.tenant_id === undefined || ctx.data?.tenant_id === ctx.auth.tenantId
)

Context Management

rlsContext

AsyncLocalStorage-based context manager for RLS authentication.

const rlsContext: {
// Set and run within context
runAsync<T>(context: RLSContext, fn: () => Promise<T>): Promise<T>
run<T>(context: RLSContext, fn: () => T): T

// Get current context
getContext(): RLSContext
getContextOrNull(): RLSContext | null
hasContext(): boolean

// Context helpers
getAuth(): RLSAuthContext
getUserId(): string | number
getTenantId(): string | number | undefined
hasRole(role: string): boolean
hasPermission(permission: string): boolean
isSystem(): boolean

// System context (bypass RLS)
asSystem<T>(fn: () => T): T
asSystemAsync<T>(fn: () => Promise<T>): Promise<T>
}

RLSContext and RLSAuthContext

interface RLSContext<TUser = unknown, TMeta = unknown> {
/**
* Authentication context (required)
*/
auth: RLSAuthContext<TUser>

/**
* Request context (optional)
*/
request?: RLSRequestContext

/**
* Custom metadata (optional)
*/
meta?: TMeta

/**
* Context creation timestamp
*/
timestamp: Date
}

interface RLSAuthContext<TUser = unknown> {
/**
* User ID - required
*/
userId: string | number

/**
* User roles - required
*/
roles: string[]

/**
* Tenant ID for multi-tenant apps
*/
tenantId?: string | number

/**
* Organization IDs for multi-org scenarios
*/
organizationIds?: (string | number)[]

/**
* Permission strings
*/
permissions?: string[]

/**
* Custom user attributes
*/
attributes?: Record<string, unknown>

/**
* Full user object if needed
*/
user?: TUser

/**
* Bypass all policies
* @default false
*/
isSystem?: boolean
}

interface RLSRequestContext {
requestId?: string
ipAddress?: string
userAgent?: string
timestamp: Date
headers?: Record<string, string>
}

Setting Context

import { rlsContext } from '@kysera/rls'

// Express middleware
app.use(async (req, res, next) => {
const user = await authenticate(req)

await rlsContext.runAsync(
{
auth: {
userId: user.id,
tenantId: user.tenantId,
roles: user.roles,
permissions: user.permissions
},
request: {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
requestId: req.id,
timestamp: new Date()
},
timestamp: new Date()
},
async () => {
next()
}
)
})

withRLSContext and withRLSContextAsync

Helper functions for wrapping operations.

function withRLSContext<T>(context: RLSContext, fn: () => T): T
async function withRLSContextAsync<T>(context: RLSContext, fn: () => Promise<T>): Promise<T>

Example:

const result = await withRLSContextAsync(
{
auth: { userId: 1, roles: ['user'], tenantId: 'acme' },
timestamp: new Date()
},
async () => {
return await postRepo.findAll()
}
)

System Context (Bypass)

// System operations bypass all policies
await rlsContext.runAsync(
{
auth: {
userId: 'system',
roles: [],
isSystem: true // Bypass all RLS
},
timestamp: new Date()
},
async () => {
// Full access to all data
const allPosts = await postRepo.findAll()
}
)

// Or within existing context
await repo.withoutRLS(async () => {
// Temporarily bypass RLS
return await repo.findAll()
})

Policy Evaluation Context

Context available when evaluating policies.

interface PolicyEvaluationContext<TAuth = unknown, TRow = unknown, TData = unknown, TDB = unknown> {
/**
* Authentication context
*/
auth: RLSAuthContext<TAuth>

/**
* Current row data (for update/delete)
*/
row?: TRow

/**
* Input data (for create/update)
*/
data?: TData

/**
* Request context
*/
request?: RLSRequestContext

/**
* Database instance for complex policies
*/
db?: Kysely<TDB>

/**
* Custom metadata
*/
meta?: Record<string, unknown>

/**
* Current table name
*/
table?: string

/**
* Current operation
*/
operation?: 'read' | 'create' | 'update' | 'delete'
}

Policy Precedence

Policies are evaluated in this order:

  1. deny policies - evaluated first, any match denies access
  2. allow policies - evaluated next, any match grants access
  3. filter policies - add WHERE conditions to queries
  4. validate policies - check input data
  5. defaultDeny - determines behavior when no policy matches
const rlsSchema = defineRLSSchema({
posts: {
policies: [
// 1. Deny takes precedence
deny('delete', ctx => ctx.row?.is_pinned === true),

// 2. Allow grants access
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row?.author_id),

// 3. Filter adds WHERE conditions
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),

// 4. Validate checks input
validate('create', ctx => ctx.data?.author_id === ctx.auth.userId)
],
defaultDeny: true // 5. Deny if nothing matches
}
})

Bypass Options

Global Bypass

Applied at plugin initialization, affects all tables:

rlsPlugin({
schema: rlsSchema,
excludeTables: ['migrations', 'system_config'], // Exclude these tables
bypassRoles: ['superadmin'] // These roles bypass all
})

Table-Level Bypass

Applied per-table in schema definition:

const rlsSchema = defineRLSSchema({
users: {
policies: [...],
skipFor: ['hr_admin'], // HR admins bypass RLS on users only
},
posts: {
policies: [...],
skipFor: ['content_admin'], // Content admins bypass RLS on posts only
},
})

Error Handling

Error Types

import {
RLSError,
RLSPolicyViolation,
RLSPolicyEvaluationError,
RLSContextError
} from '@kysera/rls'

// Base error class
class RLSError extends Error {
code: string
}

// Policy violation (legitimate access denial)
class RLSPolicyViolation extends RLSError {
operation: string
table: string
reason: string
policyName?: string
}

// Policy evaluation error (bug in policy code)
class RLSPolicyEvaluationError extends RLSError {
operation: string
table: string
policyName?: string
originalError?: Error
}

// Missing context
class RLSContextError extends RLSError {
message: 'RLS context not set'
}

RLSPolicyViolation

Thrown when access is legitimately denied by an RLS policy.

Properties:

  • operation - The operation that was denied (read, create, update, delete)
  • table - The table where the violation occurred
  • reason - Human-readable reason for the denial
  • policyName - Name of the policy that denied access (optional)
  • code - Error code: 'RLS_POLICY_VIOLATION'

When to use:

  • User doesn't have permission for an operation
  • Policy condition evaluated to false (access denied)
  • Should result in HTTP 403 response

Example:

try {
await postRepo.delete(postId)
} catch (error) {
if (error instanceof RLSPolicyViolation) {
console.error('Access denied:', {
operation: error.operation, // 'delete'
table: error.table, // 'posts'
reason: error.reason, // 'User does not own this post'
policyName: error.policyName // 'ownership_policy'
})
res.status(403).json({ error: 'Permission denied' })
}
}

RLSPolicyEvaluationError

Thrown when a policy condition throws an error during evaluation. This is distinct from RLSPolicyViolation - it indicates a bug in the policy code itself, not a legitimate access denial.

Properties:

  • operation - The operation being performed when the error occurred
  • table - The table where the error occurred
  • policyName - Name of the policy that threw (optional)
  • originalError - The original error thrown by the policy (optional)
  • code - Error code: 'RLS_POLICY_EVALUATION_ERROR'
  • stack - Combined stack trace including the original error's stack

When thrown:

  • Policy condition throws an unexpected error
  • Bug in policy code (e.g., accessing undefined property)
  • Type errors in policy logic

Why separate from RLSPolicyViolation:

  • Helps distinguish between "access denied" (expected) and "policy bug" (unexpected)
  • Preserves original stack trace for debugging
  • Indicates need to fix policy code, not user permissions

Example:

// Policy with a bug
allow('read', ctx => {
// Bug: someField might be undefined
return ctx.row.someField.value
})

// When this policy runs:
try {
await postRepo.findAll()
} catch (error) {
if (error instanceof RLSPolicyEvaluationError) {
// This indicates a bug in the policy, not user permissions
logger.error('Policy bug detected:', {
operation: error.operation, // 'read'
table: error.table, // 'posts'
policyName: error.policyName, // 'read_policy'
originalError: error.originalError // TypeError: Cannot read property 'value' of undefined
})

// Full stack trace including original error
console.error(error.stack)

res.status(500).json({ error: 'Internal server error' })
}
}

Best practices:

  • Log these errors with full stack traces for debugging
  • Return HTTP 500 (not 403) since this is a server error
  • Fix the policy code to handle edge cases properly
  • Add null checks and proper error handling in policies

Example of fixing a policy:

// Before (buggy):
allow('read', ctx => {
return ctx.row.someField.value // Can throw
})

// After (safe):
allow('read', ctx => {
return ctx.row?.someField?.value ?? false // Safe navigation
})

RLSContextError

Thrown when RLS context is missing but required for an operation.

When thrown:

  • Operation executed outside of rlsContext.runAsync()
  • requireContext: true is set in plugin options
  • No authentication context available

Example:

try {
// Missing context
await postRepo.findAll()
} catch (error) {
if (error instanceof RLSContextError) {
res.status(401).json({ error: 'Not authenticated' })
}
}

Handling Errors

try {
await postRepo.delete(postId)
} catch (error) {
if (error instanceof RLSPolicyViolation) {
// User doesn't have permission (legitimate access denial)
res.status(403).json({
error: 'Permission denied',
table: error.table,
operation: error.operation,
reason: error.reason
})
}

if (error instanceof RLSPolicyEvaluationError) {
// Bug in policy code - should be investigated
logger.error('Policy evaluation error:', {
operation: error.operation,
table: error.table,
policyName: error.policyName,
originalError: error.originalError,
stack: error.stack
})
res.status(500).json({ error: 'Internal server error' })
}

if (error instanceof RLSContextError) {
// No RLS context set
res.status(401).json({ error: 'Not authenticated' })
}
}

Custom Violation Handler

rlsPlugin({
schema: rlsSchema,
onViolation: violation => {
logger.warn('RLS violation', {
user: violation.userId,
table: violation.table,
operation: violation.operation,
reason: violation.reason
})

// Send to monitoring
monitoring.trackEvent('rls_violation', violation)
}
})

How RLS Works

The plugin implements row-level security at the application layer using @kysera/executor for query transformations:

// Original query
const posts = await postRepo.findAll()

// With RLS filter policy
// SQL: SELECT * FROM posts WHERE tenant_id = $1

// Filter is automatically added based on context

Architecture

  1. createORM or createExecutor creates a plugin-aware executor
  2. The executor wraps Kysely with a Proxy that intercepts query methods
  3. RLS plugin's interceptQuery hook is called for every query operation
  4. For SELECT queries, filter policies add WHERE conditions
  5. For mutations (INSERT/UPDATE/DELETE), validation happens in extendRepository
  6. Works with both Repository and DAL patterns via unified Plugin interface

Query Interception

// Plugin implementation (simplified)
interceptQuery(qb, context) {
const rlsCtx = rlsContext.getStore()

if (!rlsCtx && requireContext) {
throw new RLSContextError()
}

// Check bypass rules
if (rlsCtx?.auth.isSystem) {
return qb // No filtering
}

// Apply filter policies
for (const policy of filterPolicies) {
if (policy.operations.includes(context.operation)) {
const filter = policy.getFilter({ auth: rlsCtx.auth, ...context })
qb = qb.where(filter)
}
}

return qb
}

Intercepted operations:

  • selectFrom → Automatic filtering via interceptQuery
  • insertInto → Context available, validation in extendRepository
  • updateTable → Context available, validation in extendRepository
  • deleteFrom → Context available, validation in extendRepository

getRawDb() for Internal Queries

When implementing RLS policies that need to fetch existing rows (e.g., for update/delete validation), the plugin uses getRawDb() from @kysera/executor to bypass RLS filtering and prevent infinite recursion:

import { getRawDb } from '@kysera/executor'

// Inside plugin's extendRepository
const rawDb = getRawDb(baseRepo.executor)

// Fetch existing row without RLS filtering
const existingRow = await rawDb
.selectFrom(table)
.selectAll()
.where('id', '=', id)
.executeTakeFirst()

This ensures that internal queries used for policy evaluation don't trigger RLS filtering themselves.

Multi-Tenant Patterns

Discriminator Column

const tenantSchema = defineRLSSchema<Database>({
users: {
policies: [
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
validate('create', ctx => ctx.data?.tenant_id === ctx.auth.tenantId)
]
},
posts: {
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))]
}
})

Subdomain Extraction

// Express middleware
app.use(async (req, res, next) => {
const tenantId = extractTenantFromSubdomain(req.hostname)
// 'acme.app.com' → 'acme'

const user = await authenticate(req)

await rlsContext.runAsync(
{
auth: {
userId: user.id,
tenantId,
roles: user.roles
},
timestamp: new Date()
},
next
)
})

Multi-Organization

filter('read', ctx => ({
organization_id: { $in: ctx.auth.organizationIds }
}))

Usage with Repository Pattern

import { createORM, createRepositoryFactory } from '@kysera/repository'
import { rlsPlugin, defineRLSSchema, filter, allow, rlsContext } from '@kysera/rls'

const rlsSchema = defineRLSSchema<Database>({
posts: {
policies: [
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
allow(['update', 'delete'], ctx =>
ctx.auth.userId === ctx.row?.author_id
),
],
defaultDeny: true,
},
})

const orm = await createORM(db, [
rlsPlugin({ schema: rlsSchema })
])

const postRepo = orm.createRepository((executor) => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'posts',
mapRow: (row) => ({ ... }),
schemas: { create: ... }
})
})

// Use within RLS context
await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'acme', roles: ['user'] },
timestamp: new Date()
},
async () => {
// Automatically filtered by tenant_id
const posts = await postRepo.findAll()

// Will throw RLSPolicyViolation if not author
await postRepo.delete(postId)
}
)

Repository Extensions

RLS plugin adds these methods to repositories:

// Bypass RLS for specific operation (requires existing context)
await repo.withoutRLS(async () => {
return await repo.findAll() // No RLS filtering
})

// Check if current user can perform operation
const post = await repo.findById(1)
const canUpdate = await repo.canAccess('update', post)
if (canUpdate) {
await repo.update(1, { title: 'New title' })
}

Usage with DAL Pattern

RLS filtering now works with DAL pattern via executor interception.

import { createQuery, createContext, withTransaction } from '@kysera/dal'
import { createExecutor } from '@kysera/executor'
import { rlsPlugin, defineRLSSchema, filter, rlsContext } from '@kysera/rls'

// Define schema
const rlsSchema = defineRLSSchema<Database>({
posts: {
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))]
}
})

// Create executor with RLS plugin
const executor = await createExecutor(db, [rlsPlugin({ schema: rlsSchema })])

// Define DAL queries
const getAllPosts = createQuery(ctx => ctx.db.selectFrom('posts').selectAll().execute())

const getPostById = createQuery((ctx, id: number) =>
ctx.db.selectFrom('posts').where('id', '=', id).executeTakeFirst()
)

// Use within RLS context
await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'acme', roles: ['user'] },
timestamp: new Date()
},
async () => {
// RLS filter automatically applied
const posts = await getAllPosts(executor)

// Works in transactions too
await withTransaction(executor, async txCtx => {
const post = await getPostById(txCtx, 1)
// RLS filtering still active in transaction
})
}
)

Note: DAL pattern supports filter policies only. Validation policies (allow, deny, validate) only work with Repository pattern since they require repository method interception.

CQRS-lite Pattern (Repository + DAL)

Combine both patterns for writes and complex reads:

import { createORM } from '@kysera/repository'
import { createQuery } from '@kysera/dal'
import { rlsPlugin, rlsContext } from '@kysera/rls'

const orm = await createORM(db, [rlsPlugin({ schema: rlsSchema })])

// Complex read query (DAL)
const getDashboardStats = createQuery((ctx, userId: number) =>
ctx.db
.selectFrom('posts')
.select(eb => [eb.fn.count('id').as('total'), eb.fn.avg('views').as('avgViews')])
.where('author_id', '=', userId)
.executeTakeFirst()
)

await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'acme', roles: ['user'] },
timestamp: new Date()
},
async () => {
await orm.transaction(async ctx => {
// Repository for writes (with RLS validation)
const userRepo = orm.createRepository(createUserRepository)
const user = await userRepo.create({ name: 'Alice' })

// DAL for complex reads (with RLS filtering)
const stats = await getDashboardStats(ctx, user.id)
})
}
)

Best Practices

1. Always Set Context

app.use(async (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}

await rlsContext.runAsync(
{
auth: req.user,
timestamp: new Date()
},
next
)
})

2. Use System Context Sparingly

// Only for background jobs, migrations, etc.
await rlsContext.runAsync(
{
auth: { userId: 'system', roles: [], isSystem: true },
timestamp: new Date()
},
async () => {
// Full access - use carefully!
}
)

3. Test Policies

describe('Post RLS Policies', () => {
it('should filter by tenant', async () => {
await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'acme', roles: [] },
timestamp: new Date()
},
async () => {
const posts = await postRepo.findAll()
expect(posts.every(p => p.tenant_id === 'acme')).toBe(true)
}
)
})

it('should deny cross-tenant access', async () => {
await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'other', roles: [] },
timestamp: new Date()
},
async () => {
const post = await postRepo.findById(acmePostId)
expect(post).toBeNull()
}
)
})
})

4. Combine with Database RLS

For maximum security, combine application-level RLS with PostgreSQL native RLS:

-- PostgreSQL native RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON posts
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::int);
// Set PostgreSQL config before queries
await db.executeQuery(sql`SET app.tenant_id = ${tenantId}`)

TypeScript Types

Full Type Definitions

type Operation = 'read' | 'create' | 'update' | 'delete' | 'all'

type PolicyDefinition = AllowPolicy | DenyPolicy | FilterPolicy | ValidatePolicy

interface AllowPolicy {
type: 'allow'
operation: Operation | Operation[]
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
name?: string
priority?: number
}

interface DenyPolicy {
type: 'deny'
operation: Operation | Operation[]
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
name?: string
priority?: number
}

interface FilterPolicy {
type: 'filter'
operation: Operation | Operation[]
condition: (ctx: PolicyEvaluationContext) => Record<string, unknown>
name?: string
priority?: number
}

interface ValidatePolicy {
type: 'validate'
operation: Operation | Operation[]
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
name?: string
priority?: number
}

Context Resolvers

Pre-fetch and cache async data before policy evaluation.

ResolverManager

class ResolverManager<TResolved extends ResolvedData = ResolvedData> {
constructor(options?: ResolverManagerOptions)

// Register/unregister resolvers
register<T extends ResolvedData>(resolver: ContextResolver<T>): void
unregister(name: string): boolean
hasResolver(name: string): boolean
getResolverNames(): string[]

// Resolve context
resolve(baseContext: BaseResolverContext): Promise<EnhancedRLSContext<unknown, TResolved>>
resolveOne<T>(name: string, baseContext: BaseResolverContext): Promise<T | null>

// Cache management
invalidateCache(userId: string | number, resolverName?: string): Promise<void>
clearCache(): Promise<void>
}

createResolver

function createResolver<TResolved extends ResolvedData>(
config: ContextResolver<TResolved>
): ContextResolver<TResolved>

interface ContextResolver<TResolved = ResolvedData> {
name: string
resolve: (ctx: BaseResolverContext) => Promise<TResolved>
cacheKey?: (ctx: BaseResolverContext) => string | null
cacheTtl?: number
dependsOn?: string[]
required?: boolean
priority?: number
}

ResolverManagerOptions

interface ResolverManagerOptions {
cacheProvider?: ResolverCacheProvider
defaultCacheTtl?: number // Default: 300
parallelResolution?: boolean // Default: true
resolverTimeout?: number // Default: 5000ms
logger?: KyseraLogger
}

Field-Level Access Control

FieldAccessRegistry

class FieldAccessRegistry {
constructor(schema?: FieldAccessSchema, options?: { logger?: KyseraLogger })

loadSchema(schema: FieldAccessSchema): void
registerTable(table: string, config: TableFieldAccessConfig): void

canReadField(table: string, field: string, ctx: FieldAccessContext): boolean
canWriteField(table: string, field: string, ctx: FieldAccessContext): boolean
getFieldMask(table: string, field: string): unknown

hasTable(table: string): boolean
getTables(): string[]
}

FieldAccessProcessor

class FieldAccessProcessor {
constructor(registry: FieldAccessRegistry, options?: FieldAccessOptions)

maskRow<T extends Record<string, unknown>>(
table: string,
row: T,
ctx: FieldAccessContext
): MaskedRow<T>

maskRows<T extends Record<string, unknown>>(
table: string,
rows: T[],
ctx: FieldAccessContext
): MaskedRow<T>[]

getWritableFields(table: string, ctx: FieldAccessContext): string[]
filterWritableData<T>(table: string, data: T, ctx: FieldAccessContext): Partial<T>
}

Predefined Access Patterns

// Only row owner can read/write
function ownerOnly(ownerField?: string): FieldAccessConfig

// Only specified roles can access
function rolesOnly(roles: string[]): FieldAccessConfig

// Anyone can read, no one can write
function readOnly(): FieldAccessConfig

// Always hidden
function neverAccessible(): FieldAccessConfig

// Anyone reads, specified roles write
function publicReadRestrictedWrite(writeRoles: string[]): FieldAccessConfig

// Shows mask unless condition passes
function maskedField(mask: unknown, accessConfig: FieldAccessConfig): FieldAccessConfig

// Owner or specified roles
function ownerOrRoles(roles: string[], ownerField?: string): FieldAccessConfig

ReBAC (Relationship-Based Access Control)

ReBAcRegistry

class ReBAcRegistry<DB = unknown> {
constructor(schema?: ReBAcSchema<DB>, options?: { logger?: KyseraLogger })

loadSchema(schema: ReBAcSchema<DB>): void
registerTable(table: string, config: TableReBAcConfig): void
registerRelationship(path: RelationshipPath): void

getPolicies(table: string, operation: Operation): CompiledReBAcPolicy[]
getRelationship(name: string, table?: string): CompiledRelationshipPath | undefined

hasTable(table: string): boolean
getTables(): string[]
clear(): void
}

ReBAcTransformer

class ReBAcTransformer<DB = unknown> {
constructor(registry: ReBAcRegistry<DB>, options?: ReBAcQueryOptions)

transformSelect<T extends SelectQueryBuilder<DB, any, any>>(
qb: T,
table: string,
operation: Operation
): T

buildExistsSubquery(
db: Kysely<DB>,
path: CompiledRelationshipPath,
sourceTable: string,
endConditions: Record<string, unknown>
): RawBuilder<unknown>
}

Predefined Relationship Paths

// table -> organizations -> org_members
function orgMembershipPath(
resourceTable: string,
orgIdColumn?: string
): RelationshipPath

// table -> shops -> organizations -> org_members
function shopOrgMembershipPath(
resourceTable: string,
shopIdColumn?: string
): RelationshipPath

// table -> teams (recursive) -> team_members
function teamHierarchyPath(
resourceTable: string,
teamIdColumn?: string
): RelationshipPath

Policy Builders

function allowRelation(
operation: Operation | Operation[],
relationshipPath: string,
endCondition: ReBAcEndCondition
): ReBAcPolicyDefinition

function denyRelation(
operation: Operation | Operation[],
relationshipPath: string,
endCondition: ReBAcEndCondition
): ReBAcPolicyDefinition

Policy Composition

Predefined Policy Templates

function createTenantIsolationPolicy(config: TenantIsolationConfig): ReusablePolicy
function createOwnershipPolicy(config: OwnershipConfig): ReusablePolicy
function createSoftDeletePolicy(config: SoftDeleteConfig): ReusablePolicy
function createStatusAccessPolicy(config: StatusAccessConfig): ReusablePolicy
function createAdminPolicy(config: { adminRoles: string[] }): ReusablePolicy

Configuration Types

interface TenantIsolationConfig {
tenantColumn?: string // Default: 'tenant_id'
validateOnCreate?: boolean // Default: true
validateOnUpdate?: boolean // Default: false
}

interface OwnershipConfig {
ownerColumn?: string // Default: 'user_id'
allowedOperations?: Operation[] // Default: ['update', 'delete']
}

interface SoftDeleteConfig {
deletedAtColumn?: string // Default: 'deleted_at'
includeDeleted?: boolean // Default: false
}

interface StatusAccessConfig {
statusColumn?: string // Default: 'status'
publicStatuses?: string[]
draftStatuses?: string[]
archivedStatuses?: string[]
}

Composition Functions

function composePolicies(...policies: ReusablePolicy[]): ReusablePolicy
function extendPolicy(base: ReusablePolicy, options: { additionalPolicies?: PolicyDefinition[] }): ReusablePolicy
function overridePolicy(base: ReusablePolicy, overrides: Partial<ReusablePolicy>): ReusablePolicy

Policy Definition Builders

function defineFilterPolicy(
name: string,
filterFn: (ctx: PolicyEvaluationContext) => Record<string, unknown>,
options?: { priority?: number }
): ReusablePolicy

function defineAllowPolicy(
name: string,
operations: Operation | Operation[],
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
options?: { priority?: number }
): ReusablePolicy

function defineDenyPolicy(
name: string,
operations: Operation | Operation[],
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
options?: { priority?: number }
): ReusablePolicy

function defineValidatePolicy(
name: string,
operations: Operation | Operation[],
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
options?: { priority?: number }
): ReusablePolicy

function defineCombinedPolicy(
name: string,
policies: PolicyDefinition[]
): ReusablePolicy

Audit Trail

AuditLogger

class AuditLogger {
constructor(config: AuditConfig)

// Log decisions
logDecision(
operation: Operation,
table: string,
decision: AuditDecision,
policyName?: string,
options?: AuditLogOptions
): Promise<void>

logAllow(operation: Operation, table: string, policyName?: string, options?: AuditLogOptions): Promise<void>
logDeny(operation: Operation, table: string, policyName?: string, options?: AuditLogOptions): Promise<void>
logFilter(table: string, policyName?: string, options?: { context?: Record<string, unknown> }): Promise<void>

// Buffer management
flush(): Promise<void>
close(): Promise<void>

// Properties
get bufferSize(): number
get enabled(): boolean
setEnabled(enabled: boolean): void
}

AuditConfig

interface AuditConfig {
adapter: RLSAuditAdapter
enabled?: boolean // Default: true
bufferSize?: number // Default: 100
flushInterval?: number // Default: 5000ms
async?: boolean // Default: true
sampleRate?: number // Default: 1.0

defaults?: {
logAllowed?: boolean // Default: false
logDenied?: boolean // Default: true
logFilters?: boolean // Default: false
includeContext?: string[]
excludeContext?: string[]
}

tables?: Record<string, TableAuditConfig>
onError?: (error: Error, events: RLSAuditEvent[]) => void
}

Built-in Adapters

class ConsoleAuditAdapter implements RLSAuditAdapter {
constructor(options?: {
format?: 'text' | 'json'
colors?: boolean
includeTimestamp?: boolean
})
}

class InMemoryAuditAdapter implements RLSAuditAdapter {
constructor(maxSize?: number)

getEvents(): RLSAuditEvent[]
query(params: AuditQueryParams): RLSAuditEvent[]
getStats(params?: { startTime?: Date; endTime?: Date }): AuditStats
clear(): void
get size(): number
}

RLSAuditEvent

interface RLSAuditEvent {
timestamp: Date
userId: string | number
tenantId?: string | number
operation: Operation
table: string
policyName?: string
decision: 'allow' | 'deny' | 'filter'
reason?: string
context?: Record<string, unknown>
rowIds?: (string | number)[]
queryHash?: string
requestId?: string
ipAddress?: string
userAgent?: string
durationMs?: number
}

Policy Testing

PolicyTester

class PolicyTester<DB = unknown> {
constructor(schema: RLSSchema<DB>)

// Evaluate full policy chain
evaluate(
table: string,
operation: Operation,
context: TestContext
): Promise<PolicyEvaluationResult>

// Get filter conditions
getFilters(
table: string,
operation: 'read',
context: Pick<TestContext, 'auth' | 'meta'>
): FilterEvaluationResult

// Test specific policy
testPolicy(
table: string,
policyName: string,
context: TestContext
): Promise<{ found: boolean; result?: boolean }>

// Introspection
listPolicies(table: string): {
allows: string[]
denies: string[]
filters: string[]
validates: string[]
}

getTables(): string[]
}

Result Types

interface PolicyEvaluationResult {
allowed: boolean
policyName?: string
decisionType: 'allow' | 'deny' | 'default'
reason?: string
evaluatedPolicies: {
name: string
type: 'allow' | 'deny' | 'validate'
result: boolean
}[]
}

interface FilterEvaluationResult {
conditions: Record<string, unknown>
appliedFilters: string[]
}

Test Helpers

function createTestAuthContext(
overrides: Partial<RLSAuthContext> & { userId: string | number }
): RLSAuthContext

function createTestRow<T extends Record<string, unknown>>(data: T): T

const policyAssertions: {
assertAllowed(result: PolicyEvaluationResult, message?: string): void
assertDenied(result: PolicyEvaluationResult, message?: string): void
assertPolicyUsed(result: PolicyEvaluationResult, policyName: string, message?: string): void
assertFiltersInclude(result: FilterEvaluationResult, expected: Record<string, unknown>, message?: string): void
}

Conditional Policy Activation

Activation Wrappers

function whenEnvironment(
env: string,
policyFn: () => PolicyDefinition
): ConditionalPolicyDefinition

function whenFeature(
feature: string,
policyFn: () => PolicyDefinition
): ConditionalPolicyDefinition

function whenTimeRange(
startHour: number,
endHour: number,
policyFn: () => PolicyDefinition
): ConditionalPolicyDefinition

function whenCondition(
condition: (ctx: PolicyActivationContext) => boolean,
policyFn: () => PolicyDefinition
): ConditionalPolicyDefinition

PolicyActivationContext

interface PolicyActivationContext extends PolicyEvaluationContext {
environment?: string
features?: Set<string> | string[] | Record<string, unknown>
timestamp?: Date
}

Complete Exports

// Core
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
export { allow, deny, filter, validate } from '@kysera/rls'
export { whenEnvironment, whenFeature, whenTimeRange, whenCondition } from '@kysera/rls'
export { rlsPlugin } from '@kysera/rls'
export { rlsContext, createRLSContext, withRLSContext, withRLSContextAsync } from '@kysera/rls'

// Context Resolvers
export { ResolverManager, createResolverManager, createResolver, InMemoryCacheProvider } from '@kysera/rls'

// ReBAC
export { ReBAcRegistry, ReBAcTransformer, createReBAcRegistry, createReBAcTransformer } from '@kysera/rls'
export { orgMembershipPath, shopOrgMembershipPath, teamHierarchyPath, allowRelation, denyRelation } from '@kysera/rls'

// Field Access
export { FieldAccessRegistry, FieldAccessProcessor, createFieldAccessRegistry, createFieldAccessProcessor } from '@kysera/rls'
export { ownerOnly, rolesOnly, readOnly, neverAccessible, publicReadRestrictedWrite, maskedField, ownerOrRoles } from '@kysera/rls'

// Policy Composition
export { createTenantIsolationPolicy, createOwnershipPolicy, createSoftDeletePolicy, createStatusAccessPolicy, createAdminPolicy } from '@kysera/rls'
export { composePolicies, extendPolicy, overridePolicy } from '@kysera/rls'
export { defineFilterPolicy, defineAllowPolicy, defineDenyPolicy, defineValidatePolicy, defineCombinedPolicy } from '@kysera/rls'

// Audit Trail
export { AuditLogger, createAuditLogger, ConsoleAuditAdapter, InMemoryAuditAdapter } from '@kysera/rls'

// Testing
export { PolicyTester, createPolicyTester, createTestAuthContext, createTestRow, policyAssertions } from '@kysera/rls'

// Errors
export { RLSError, RLSContextError, RLSPolicyViolation, RLSPolicyEvaluationError, RLSSchemaError, RLSContextValidationError, RLSErrorCodes } from '@kysera/rls'

See Also