@kysera/rls
v0.8.2
Published
Row-Level Security plugin for Kysely - declarative policies, query transformation, native RLS support
Downloads
206
Maintainers
Readme
@kysera/rls
Row-Level Security Plugin for Kysera - Declarative authorization policies with automatic query transformation through @kysera/executor's Unified Execution Layer and AsyncLocalStorage-based context management.
Overview
@kysera/rls provides Row-Level Security (RLS) for Kysera through a declarative policy system. It automatically filters queries and enforces authorization rules at the database access layer, ensuring data isolation and access control without manual filtering in your application code.
What is Row-Level Security?
RLS controls access to individual rows in database tables based on user context. Instead of manually adding WHERE clauses to every query, RLS policies are defined once and automatically applied to all database operations.
Key Features:
- Declarative Policy DSL - Define rules with
allow,deny,filter, andvalidatebuilders - Automatic Query Transformation - SELECT queries are filtered automatically via
interceptQueryhook - Repository Extensions - Wraps mutation methods via
extendRepositoryhook for policy enforcement - Type-Safe Context - Full TypeScript inference with reduced
anyusage through type utilities - Multi-Tenant Isolation - Built-in patterns for SaaS tenant separation
- Plugin Architecture - Works with both Repository and DAL patterns via @kysera/executor's Unified Execution Layer
- Zero Runtime Overhead - Policies compiled at initialization
- AsyncLocalStorage Context - Request-scoped context without prop drilling
- Optional Dependency - Listed in peerDependencies with
optional: truefor @kysera/repository
Installation
npm install @kysera/rls kysely
# or
pnpm add @kysera/rls kysely
# or
yarn add @kysera/rls kyselyDependencies:
kysely>= 0.28.8 (peer dependency)@kysera/core>= 0.7.0 - Core utilities (auto-installed)@kysera/executor>= 0.7.0 - Unified Execution Layer (auto-installed)@kysera/repository>= 0.7.0 or@kysera/dal>= 0.7.0 - For Repository or DAL patterns (install as needed)
Note: @kysera/rls is listed in peerDependencies with optional: true for @kysera/repository, allowing flexible installation based on your needs.
Quick Start
1. Define RLS Schema
import { defineRLSSchema, filter, allow, validate } from '@kysera/rls'
interface Database {
posts: {
id: number
title: string
content: string
author_id: number
tenant_id: number
status: 'draft' | 'published'
}
}
const rlsSchema = defineRLSSchema<Database>({
posts: {
policies: [
// Multi-tenant isolation - filter by tenant
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
// Authors can edit their own posts
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.author_id),
// Validate new posts belong to user's tenant
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
],
defaultDeny: true // Require explicit allow
}
})2. Register Plugin with Unified Execution Layer
import { createExecutor } from '@kysera/executor'
import { createORM } from '@kysera/repository'
import { rlsPlugin, rlsContext } from '@kysera/rls'
import { Kysely, PostgresDialect } from 'kysely'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
/* config */
})
})
// Step 1: Create executor with RLS plugin
const executor = await createExecutor(db, [
rlsPlugin({ schema: rlsSchema })
])
// Step 2: Create ORM with plugin-enabled executor
const orm = await createORM(executor, [])3. Execute Queries within RLS Context
import { rlsContext } from '@kysera/rls'
// In your request handler
app.use(async (req, res, next) => {
const user = await authenticate(req)
await rlsContext.runAsync(
{
auth: {
userId: user.id,
tenantId: user.tenantId,
roles: user.roles,
isSystem: false
},
timestamp: new Date()
},
async () => {
// All queries automatically filtered by policies
const posts = await orm.posts.findAll()
res.json(posts)
}
)
})Plugin Architecture
Integration with @kysera/executor's Unified Execution Layer
The RLS plugin is built on @kysera/executor's Unified Execution Layer, which provides seamless plugin support that works with both Repository and DAL patterns.
Plugin Metadata:
{
name: '@kysera/rls',
version: '0.8.0',
priority: 50, // Runs after soft-delete (0), before audit (100)
dependencies: [],
}Type Utilities:
The plugin uses type utilities to reduce any usage and improve type safety:
- Conditional types for precise type inference
- Generic constraints for database schemas
- Utility types for context and policy definitions
How It Works
The RLS plugin implements two key hooks from @kysera/executor's plugin system:
1. interceptQuery - Query Filtering (SELECT)
Registered via createExecutor(), the interceptQuery hook intercepts all query builder operations to apply RLS filtering:
// Step 1: Register plugin with Unified Execution Layer
const executor = await createExecutor(db, [
rlsPlugin({ schema: rlsSchema })
])
// Step 2: Execute a SELECT query
const posts = await orm.posts.findAll()
// Step 3: The plugin interceptQuery hook:
// 1. Checks for RLS context (rlsContext.getContextOrNull())
// 2. Checks if system user (ctx.auth.isSystem) or bypass role
// 3. Applies filter policies as WHERE conditions via SelectTransformer
// 4. Returns filtered query builder
// 5. For mutations, marks metadata['__rlsRequired'] = trueKey behavior:
- SELECT operations: Policies are applied immediately as WHERE clauses
- INSERT/UPDATE/DELETE: Marked for validation (actual enforcement in
extendRepository) - Skip conditions:
excludeTables,metadata['skipRLS'],requireContext, system user, bypass roles
2. extendRepository - Mutation Enforcement (CREATE/UPDATE/DELETE)
Registered via createExecutor(), the extendRepository hook wraps repository mutation methods to enforce RLS policies:
// Step 1: Plugin registered with Unified Execution Layer
const executor = await createExecutor(db, [rlsPlugin({ schema: rlsSchema })])
// Step 2: Call a mutation
await repo.update(postId, { title: 'New Title' })
// Step 3: The plugin extendRepository hook:
// 1. Wraps create/update/delete methods
// 2. Fetches existing row using getRawDb() (bypasses RLS filtering)
// 3. Evaluates allow/deny policies via MutationGuard
// 4. If allowed, calls original method
// 5. If denied, throws RLSPolicyViolation
// 6. Adds withoutRLS() and canAccess() utility methodsWhy use getRawDb()? To prevent infinite recursion - we need to fetch the existing row without triggering RLS filtering. The getRawDb() function from @kysera/executor returns the original Kysely instance that bypasses all plugin hooks.
Core Concepts
Policy Types
1. allow - Grant Access
Grants access when condition evaluates to true. Multiple allow policies use OR logic.
// Allow users to read their own posts
allow('read', ctx => ctx.auth.userId === ctx.row.author_id)
// Allow admins all operations
allow('all', ctx => ctx.auth.roles.includes('admin'))
// Allow updates only for drafts
allow('update', ctx => ctx.row.status === 'draft')2. deny - Block Access
Blocks access when condition evaluates to true. Deny policies override allow policies.
// Deny access to banned users
deny('all', ctx => ctx.auth.attributes?.banned === true)
// Prevent deletion of published posts
deny('delete', ctx => ctx.row.status === 'published')
// Unconditional deny
deny('all') // Always deny3. filter - Automatic Filtering
Adds WHERE conditions to SELECT queries automatically.
// Filter by tenant
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
// Dynamic filtering
filter('read', ctx =>
ctx.auth.roles.includes('admin')
? {} // No filtering for admins
: { status: 'published' }
)
// Multiple conditions
filter('read', ctx => ({
organization_id: ctx.auth.organizationIds?.[0],
deleted_at: null
}))4. validate - Mutation Validation
Validates data during CREATE/UPDATE operations.
// Validate tenant ownership
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
// Validate status transitions
validate('update', ctx => {
const validTransitions = {
draft: ['published', 'archived'],
published: ['archived'],
archived: []
}
return !ctx.data.status || validTransitions[ctx.row.status]?.includes(ctx.data.status)
})Operations
| Operation | SQL | Description |
| --------- | ------ | ----------------------------- |
| read | SELECT | Control what users can view |
| create | INSERT | Control what users can create |
| update | UPDATE | Control what users can modify |
| delete | DELETE | Control what users can remove |
| all | All | Apply to all operations |
// Single operation
allow('read', ctx => /* ... */)
// Multiple operations
allow(['read', 'update'], ctx => /* ... */)
// All operations
deny('all', ctx => ctx.auth.suspended)Policy Evaluation Order
1. Check bypass conditions (system user, bypass roles)
→ If bypassed, ALLOW and skip all policies
2. Evaluate DENY policies (priority: highest first)
→ If ANY deny matches, REJECT immediately
3. Evaluate ALLOW policies (priority: highest first)
→ If NO allow matches and defaultDeny=true, REJECT
4. Apply FILTER policies (for SELECT)
→ Combine all filters with AND
5. Apply VALIDATE policies (for CREATE/UPDATE)
→ All validations must pass
6. Execute queryPriority System:
- Higher priority = evaluated first
- Deny policies default to priority
100 - Allow/filter/validate default to priority
0
defineRLSSchema<Database>({
posts: {
policies: [
// Highest priority
deny('all', ctx => ctx.auth.suspended, { priority: 200 }),
// Default deny priority
deny('delete', ctx => ctx.row.locked),
// Custom priority
allow('read', ctx => ctx.auth.premium, { priority: 50 }),
// Default priority
allow('read', ctx => ctx.row.public)
]
}
})Policy Builders
allow(operation, condition, options?)
// Basic allow
allow('read', ctx => ctx.auth.userId === ctx.row.user_id)
// Multiple operations
allow(['read', 'update'], ctx => ctx.row.owner_id === ctx.auth.userId)
// All operations
allow('all', ctx => ctx.auth.roles.includes('admin'))
// With options
allow('read', ctx => ctx.auth.verified, {
name: 'verified-users-only',
priority: 10,
hints: { indexColumns: ['verified'], selectivity: 'high' }
})
// Async condition
allow('update', async ctx => {
const hasPermission = await checkPermission(ctx.auth.userId, 'posts:edit')
return hasPermission
})deny(operation, condition?, options?)
// Basic deny
deny('delete', ctx => ctx.row.status === 'published')
// Deny all operations
deny('all', ctx => ctx.auth.attributes?.banned === true)
// Unconditional deny
deny('all') // Always deny
// With priority
deny('all', ctx => ctx.auth.suspended, {
name: 'block-suspended-users',
priority: 200
})filter(operation, condition, options?)
// Simple filter
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
// Multiple conditions
filter('read', ctx => ({
organization_id: ctx.auth.organizationIds?.[0],
deleted_at: null,
status: 'active'
}))
// Dynamic filter
filter('read', ctx => {
if (ctx.auth.roles.includes('admin')) {
return {} // No filtering
}
return { status: 'published', public: true }
})
// With hints
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
name: 'tenant-isolation',
priority: 1000,
hints: { indexColumns: ['tenant_id'], selectivity: 'high' }
})validate(operation, condition, options?)
// Validate create
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
// Validate update
validate('update', ctx => {
const allowedFields = ['title', 'content', 'tags']
return Object.keys(ctx.data).every(key => allowedFields.includes(key))
})
// Both create and update
validate('all', ctx => !ctx.data.price || ctx.data.price >= 0)Policy Options
interface PolicyOptions {
/** Policy name for debugging */
name?: string
/** Priority (higher runs first) */
priority?: number
/** Performance hints */
hints?: {
indexColumns?: string[]
selectivity?: 'high' | 'medium' | 'low'
leakproof?: boolean
stable?: boolean
}
}RLS Context
RLSContext Interface
The RLS context is stored and managed using AsyncLocalStorage, providing automatic context propagation across async boundaries:
interface RLSContext<TUser = unknown, TMeta = unknown> {
auth: {
userId: string | number // Required
roles: string[] // Required
tenantId?: string | number // Optional
organizationIds?: (string | number)[]
permissions?: string[]
attributes?: Record<string, unknown>
user?: TUser
isSystem?: boolean // Default: false
}
request?: {
requestId?: string
ipAddress?: string
userAgent?: string
timestamp: Date
headers?: Record<string, string>
}
meta?: TMeta
timestamp: Date
}Context Storage: The plugin uses AsyncLocalStorage internally to store the RLS context, which:
- Automatically propagates through async/await chains
- Is isolated per request (no cross-contamination)
- Requires no manual passing of context objects
- Works seamlessly with transactions
Context Management
The RLS plugin provides a singleton rlsContext manager that wraps AsyncLocalStorage for context management.
rlsContext.runAsync(context, fn)
Run async function within RLS context (most common usage):
await rlsContext.runAsync(
{
auth: {
userId: 123,
roles: ['user'],
tenantId: 'acme-corp',
isSystem: false
},
timestamp: new Date()
},
async () => {
// All queries within this block use this context
const posts = await orm.posts.findAll()
// Context propagates through async operations
await orm.posts.create({ title: 'New Post' })
}
)rlsContext.run(context, fn)
Run synchronous function within RLS context:
const result = rlsContext.run(context, () => {
// Synchronous operations
return someValue
})createRLSContext(options)
Create and validate RLS context with proper defaults:
import { createRLSContext } from '@kysera/rls'
const ctx = createRLSContext({
auth: {
userId: 123,
roles: ['user', 'editor'],
tenantId: 'acme-corp',
permissions: ['posts:read', 'posts:write']
},
// Optional request context
request: {
requestId: 'req-abc123',
ipAddress: '192.168.1.1',
timestamp: new Date()
},
// Optional metadata
meta: {
featureFlags: ['beta_access']
}
})
await rlsContext.runAsync(ctx, async () => {
// ...
})Context Helper Methods
The rlsContext singleton provides helper methods for accessing context:
// Get current context (throws RLSContextError if not set)
const ctx = rlsContext.getContext()
// Get context or null (safe, no throw)
const ctx = rlsContext.getContextOrNull()
// Check if running within context
if (rlsContext.hasContext()) {
// Context is available
}
// Get auth context (throws if no context)
const auth = rlsContext.getAuth()
// Get user ID (throws if no context)
const userId = rlsContext.getUserId()
// Get tenant ID (throws if no context)
const tenantId = rlsContext.getTenantId()
// Check if user has role
if (rlsContext.hasRole('admin')) {
// User has admin role
}
// Check if user has permission
if (rlsContext.hasPermission('posts:delete')) {
// User can delete posts
}
// Check if running in system context
if (rlsContext.isSystem()) {
// Bypasses RLS policies
}
// Run as system user (bypass RLS)
await rlsContext.asSystemAsync(async () => {
// All operations bypass RLS policies
const allPosts = await orm.posts.findAll()
})
// Synchronous system context
const result = rlsContext.asSystem(() => {
return someOperation()
})Important: The context helpers (getContext, getAuth, etc.) throw RLSContextError if called outside of a context. Always use getContextOrNull() or hasContext() if you need to check conditionally.
Repository Extensions
When the RLS plugin is enabled, repositories are automatically extended with utility methods via the extendRepository hook:
withoutRLS(fn)
Bypass RLS policies for specific operations by running them in a system context:
// Fetch all posts including other tenants (bypasses RLS)
const allPosts = await repo.withoutRLS(async () => {
return repo.findAll()
})
// Compare filtered vs unfiltered results
await rlsContext.runAsync(userContext, async () => {
const userPosts = await repo.findAll() // Filtered by RLS policies
const allPosts = await repo.withoutRLS(async () => {
return repo.findAll() // Bypasses RLS, returns all records
})
console.log(`User can see ${userPosts.length} of ${allPosts.length} total posts`)
})Implementation: withoutRLS internally calls rlsContext.asSystemAsync(fn), which sets auth.isSystem = true for the duration of the callback.
canAccess(operation, row)
Check if the current user can perform an operation on a specific row:
const post = await repo.findById(postId)
// Check read access
const canRead = await repo.canAccess('read', post)
// Check update access before showing edit UI
const canUpdate = await repo.canAccess('update', post)
if (canUpdate) {
// Show edit button in UI
}
// Pre-flight check to avoid policy violations
if (await repo.canAccess('delete', post)) {
await repo.delete(post.id)
} else {
console.log('User cannot delete this post')
}
// Check multiple operations
const operations = ['read', 'update', 'delete'] as const
for (const op of operations) {
const allowed = await repo.canAccess(op, post)
console.log(`${op}: ${allowed}`)
}Implementation: canAccess evaluates the RLS policies against the provided row using the MutationGuard, returning true if allowed and false if denied or no context exists.
Supported Operations:
'read'- Check if user can view the row'create'- Check if user can create with this data'update'- Check if user can update the row'delete'- Check if user can delete the row
DAL Pattern Support
RLS works seamlessly with the DAL pattern through @kysera/executor's Unified Execution Layer:
import { createExecutor } from '@kysera/executor'
import { createContext, createQuery, withTransaction } from '@kysera/dal'
import { rlsPlugin, defineRLSSchema, filter, rlsContext } from '@kysera/rls'
// Define schema
const rlsSchema = defineRLSSchema<Database>({
posts: {
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))]
}
})
// Step 1: Register RLS plugin with Unified Execution Layer
const executor = await createExecutor(db, [rlsPlugin({ schema: rlsSchema })])
// Step 2: Create DAL context - plugins automatically apply
const dalCtx = createContext(executor)
// Step 3: Define queries - RLS applied automatically
const getPosts = createQuery(ctx => ctx.db.selectFrom('posts').selectAll().execute())
// Execute within RLS context
await rlsContext.runAsync(
{
auth: { userId: 1, tenantId: 'acme', roles: ['user'], isSystem: false },
timestamp: new Date()
},
async () => {
// Automatically filtered by tenant
const posts = await getPosts(dalCtx)
// Transactions propagate RLS context
await withTransaction(dalCtx, async txCtx => {
const txPosts = await getPosts(txCtx)
})
}
)Plugin Configuration
rlsPlugin(options)
interface RLSPluginOptions<DB = unknown> {
/** RLS policy schema (required) */
schema: RLSSchema<DB>
/**
* Tables to exclude from RLS (always bypass)
*/
excludeTables?: string[]
/** Roles that bypass RLS entirely */
bypassRoles?: string[]
/** Logger for RLS operations */
logger?: KyseraLogger
/**
* Require RLS context (throws if missing)
* @default true - SECURE BY DEFAULT
*
* SECURITY: Changed to true in v0.8.0+ for secure-by-default.
* When true, missing context throws RLSContextError.
* Only set to false if you have other security controls.
*/
requireContext?: boolean
/**
* Allow unfiltered queries when context is missing
* @default false - SECURE BY DEFAULT
*
* WARNING: Setting true allows queries without RLS filtering
* when context is missing. Only enable if you understand the
* security implications (e.g., background jobs, system ops).
*
* When requireContext=false and allowUnfilteredQueries=false:
* - Missing context returns empty results with warnings
*/
allowUnfilteredQueries?: boolean
/** Enable audit logging of decisions */
auditDecisions?: boolean
/** Custom violation handler */
onViolation?: (violation: RLSPolicyViolation) => void
/** Primary key column name (default: 'id') */
primaryKeyColumn?: string
}Example:
import { rlsPlugin } from '@kysera/rls'
import { createLogger } from '@kysera/core'
const plugin = rlsPlugin({
schema: rlsSchema,
excludeTables: ['audit_logs', 'migrations'],
bypassRoles: ['admin', 'system'],
logger: createLogger({ level: 'info' }),
requireContext: true,
auditDecisions: true,
onViolation: violation => {
auditLog.record({
type: 'rls_violation',
operation: violation.operation,
table: violation.table,
timestamp: new Date()
})
}
})
const orm = await createORM(db, [plugin])Security Configuration (v0.8.0+)
BREAKING CHANGE: Starting in v0.8.0, requireContext defaults to true for secure-by-default behavior.
Secure Defaults (Recommended)
// Default behavior - secure by default
const plugin = rlsPlugin({
schema: rlsSchema
// requireContext: true (implicit)
// allowUnfilteredQueries: false (implicit)
})
// Missing context throws RLSContextError
await orm.posts.findAll() // ❌ Throws: RLS context requiredBackground Jobs / System Operations
For operations that legitimately run without user context (e.g., cron jobs, system maintenance):
const plugin = rlsPlugin({
schema: rlsSchema,
requireContext: false, // Don't throw on missing context
allowUnfilteredQueries: true // Allow queries without filtering
})
// OR use system context for privileged operations:
await rlsContext.asSystemAsync(async () => {
await orm.posts.findAll() // ✅ Runs as system user
})Defensive Mode (No throws, but safe)
For applications transitioning to RLS or with mixed code paths:
const plugin = rlsPlugin({
schema: rlsSchema,
requireContext: false, // Don't throw
allowUnfilteredQueries: false // Return empty results
// Missing context logs warning and returns no rows
})Security Matrix:
| requireContext | allowUnfilteredQueries | Missing Context Behavior |
| -------------- | ---------------------- | --------------------------------------- |
| true (default) | N/A | Throws RLSContextError (secure) |
| false | false (default) | Returns empty results (safe) |
| false | true | Allows unfiltered access (unsafe) |
⚠️ Security Warning: Only use allowUnfilteredQueries: true if you:
- Understand the security implications
- Have other security controls in place
- Are running background jobs or system operations without user context
Error Handling
The RLS plugin provides specialized error classes for different failure scenarios:
Error Types
import {
RLSError, // Base error class
RLSContextError, // Missing context
RLSPolicyViolation, // Access denied (expected)
RLSPolicyEvaluationError, // Bug in policy code (unexpected)
RLSSchemaError, // Invalid schema
RLSContextValidationError // Invalid context
} from '@kysera/rls'Error Scenarios
RLSContextError
Thrown when RLS context is missing but required:
try {
// No context set, but requireContext: true
await orm.posts.findAll()
} catch (error) {
if (error instanceof RLSContextError) {
// error.code === 'RLS_CONTEXT_MISSING'
console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()')
}
}When thrown:
- Operations executed outside
rlsContext.runAsync()whenrequireContext: true - Calling
rlsContext.getContext()without active context - Attempting
asSystem()without existing context
RLSPolicyViolation
Thrown when operation is denied by policies (this is expected, not a bug):
try {
// User tries to update a post they don't own
await orm.posts.update(1, { title: 'New Title' })
} catch (error) {
if (error instanceof RLSPolicyViolation) {
// error.code === 'RLS_POLICY_VIOLATION'
console.error({
operation: error.operation, // 'update'
table: error.table, // 'posts'
reason: error.reason, // 'User does not own this post'
policyName: error.policyName // 'ownership_policy' (if named)
})
// Return 403 Forbidden to client
res.status(403).json({
error: 'Access denied',
message: error.reason
})
}
}When thrown:
denypolicy condition evaluates totrue- No
allowpolicy matches anddefaultDeny: true validatepolicy fails during CREATE/UPDATE
RLSPolicyEvaluationError
Thrown when policy condition throws an error (this is a bug in your policy code):
try {
await orm.posts.findAll()
} catch (error) {
if (error instanceof RLSPolicyEvaluationError) {
// error.code === 'RLS_POLICY_EVALUATION_ERROR'
console.error({
operation: error.operation, // 'read'
table: error.table, // 'posts'
policyName: error.policyName, // 'tenant_filter'
originalError: error.originalError // TypeError: Cannot read property 'tenantId' of undefined
})
// This is a bug - fix your policy code!
// Example: Policy tried to access ctx.auth.tenantId but it was undefined
}
}When thrown:
- Policy condition function throws an error
- Policy tries to access undefined properties
- Async policy rejects with an error
Debugging: The originalError property and stack trace are preserved to help identify the issue in your policy code.
RLSContextValidationError
Thrown when RLS context is malformed:
try {
const ctx = createRLSContext({
auth: {
// Missing userId!
roles: ['user']
}
})
} catch (error) {
if (error instanceof RLSContextValidationError) {
// error.code === 'RLS_CONTEXT_INVALID'
console.error({
message: error.message, // 'userId is required in auth context'
field: error.field // 'userId'
})
}
}RLSSchemaError
Thrown when RLS schema is invalid:
try {
const schema = defineRLSSchema({
posts: {
policies: [
// Invalid policy!
{ type: 'invalid-type', operation: 'read', condition: () => true }
]
}
})
} catch (error) {
if (error instanceof RLSSchemaError) {
// error.code === 'RLS_SCHEMA_INVALID'
console.error(error.details)
}
}Error Comparison Table
| Error | Meaning | Severity | Action |
| --------------------------- | --------------- | -------- | ------------------------------------------- |
| RLSContextError | Missing context | Error | Ensure code runs in rlsContext.runAsync() |
| RLSPolicyViolation | Access denied | Expected | Return 403 to client, normal behavior |
| RLSPolicyEvaluationError | Policy bug | Critical | Fix the policy code immediately |
| RLSContextValidationError | Invalid context | Error | Fix context creation |
| RLSSchemaError | Invalid schema | Error | Fix schema definition |
Error Codes
All RLS errors include a code property for programmatic handling:
import { RLSErrorCodes } from '@kysera/rls'
// RLSErrorCodes.RLS_CONTEXT_MISSING
// RLSErrorCodes.RLS_POLICY_VIOLATION
// RLSErrorCodes.RLS_POLICY_EVALUATION_ERROR
// RLSErrorCodes.RLS_CONTEXT_INVALID
// RLSErrorCodes.RLS_SCHEMA_INVALID
// RLSErrorCodes.RLS_POLICY_INVALIDArchitecture & Implementation
Plugin Lifecycle
The RLS plugin follows the standard @kysera/executor plugin lifecycle:
Initialization (
onInit):- Creates
PolicyRegistryfrom schema - Validates all policies
- Compiles policies for runtime
- Creates
SelectTransformerandMutationGuardinstances
- Creates
Query Interception (
interceptQuery):- Called for every query builder operation
- Checks skip conditions (skipTables, metadata, system user, bypass roles)
- For SELECT: Applies filter policies via
SelectTransformer - For mutations: Marks
metadata['__rlsRequired'] = true
Repository Extension (
extendRepository):- Wraps
create,update,deletemethods - Evaluates policies via
MutationGuard - Uses
getRawDb()to fetch existing rows (bypasses RLS) - Adds
withoutRLS()andcanAccess()utility methods
- Wraps
Key Components
PolicyRegistry:
- Stores and indexes compiled policies by table and operation
- Validates schema structure
- Provides fast policy lookup
SelectTransformer:
- Transforms SELECT queries by adding WHERE conditions
- Combines multiple filter policies with AND logic
- Evaluates filter conditions in context
MutationGuard:
- Evaluates allow/deny policies for mutations
- Enforces policy evaluation order (deny → allow → validate)
- Throws
RLSPolicyViolationorRLSPolicyEvaluationError
AsyncLocalStorage:
- Provides context isolation per request
- Automatic propagation through async/await chains
- No manual context passing required
Performance Considerations
Compiled Policies:
- Policies are compiled once at initialization
- No runtime parsing or compilation overhead
Filter Application:
- Filters applied as SQL WHERE clauses
- Database handles filtering efficiently
- Index hints available via
PolicyOptions.hints
Context Access:
- AsyncLocalStorage is very fast (V8-optimized)
- Context lookup has negligible overhead
Bypass Mechanisms:
- System context bypass is immediate (no policy evaluation)
excludeTablesbypass is immediate (no policy evaluation)- Bypass roles checked before policy evaluation
Transaction Support
RLS context automatically propagates through transactions:
await rlsContext.runAsync(userContext, async () => {
// Context available in transaction
await orm.transaction(async trx => {
// All queries use the same RLS context
const user = await trx.users.findById(userId)
await trx.posts.create({ title: 'Post', authorId: userId })
})
})Note: DAL transactions with executor preserve RLS context:
await withTransaction(executor, async txCtx => {
// RLS context preserved in transaction
const posts = await getPosts(txCtx)
})Common Patterns
Multi-Tenant Isolation
const schema = defineRLSSchema<Database>({
posts: {
policies: [
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
],
defaultDeny: true
}
})
app.use(async (req, res, next) => {
const user = await authenticate(req)
await rlsContext.runAsync(
{ auth: { userId: user.id, tenantId: user.tenant_id, roles: user.roles } },
async () => {
const posts = await orm.posts.findAll()
res.json(posts)
}
)
})Owner-Based Access
const schema = defineRLSSchema<Database>({
posts: {
policies: [
// Public posts visible to all
filter('read', ctx => ({ public: true })),
// Or own posts
allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
// Only owner can update/delete
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.author_id)
]
}
})Role-Based Access Control
const schema = defineRLSSchema<Database>({
posts: {
policies: [
// Admins can do everything
allow('all', ctx => ctx.auth.roles.includes('admin')),
// Editors can read and update
allow(['read', 'update'], ctx => ctx.auth.roles.includes('editor')),
// Regular users read only
allow('read', ctx => ctx.auth.roles.includes('user'))
]
}
})TypeScript Support
Full type inference for policies:
interface Database {
posts: {
id: number
title: string
author_id: number
tenant_id: string
}
}
const schema = defineRLSSchema<Database>({
posts: {
policies: [
allow('read', ctx => {
const post = ctx.row // Type: Database['posts']
const userId = ctx.auth.userId // Type: string | number
return post.author_id === userId
}),
validate('update', ctx => {
const data = ctx.data // Type: Partial<Database['posts']>
const title = data.title // Type: string | undefined
return !title || title.length > 0
})
]
}
})API Reference
Core Exports
// Schema definition
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
// Policy builders
export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls'
// Plugin
export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls'
// Context management
export {
rlsContext,
createRLSContext,
withRLSContext,
withRLSContextAsync,
type RLSContext
} from '@kysera/rls'
// Errors
export {
RLSError,
RLSContextError,
RLSPolicyViolation,
RLSPolicyEvaluationError,
RLSSchemaError,
RLSContextValidationError,
RLSErrorCodes
} from '@kysera/rls'Advanced Features
The following advanced features provide enterprise-grade capabilities for complex authorization scenarios.
Context Resolvers (Pre-resolved Async Data)
Context resolvers allow you to pre-fetch and cache async data (like organization memberships, permissions from external services) before policy evaluation. This keeps policies synchronous and fast.
import {
ResolverManager,
createResolverManager,
createResolver,
type EnhancedRLSContext
} from '@kysera/rls'
// Define a resolver for organization memberships
const orgResolver = createResolver({
name: 'org-memberships',
resolve: async (ctx) => {
// Fetch from database or external service
const memberships = await db
.selectFrom('organization_members')
.where('user_id', '=', ctx.auth.userId)
.select(['organization_id', 'role'])
.execute()
return {
resolvedAt: new Date(),
organizationIds: memberships.map(m => m.organization_id),
orgRoles: memberships.reduce((acc, m) => {
acc[m.organization_id] = m.role
return acc
}, {} as Record<string, string>)
}
},
cacheKey: (ctx) => `rls:org:${ctx.auth.userId}`,
cacheTtl: 300 // 5 minutes
})
// Create manager and register resolvers
const manager = createResolverManager({
defaultCacheTtl: 300,
parallelResolution: true,
resolverTimeout: 5000
})
manager.register(orgResolver)
// Resolve context before using RLS
const baseCtx = {
auth: { userId: '123', roles: ['user'] },
timestamp: new Date()
}
const enhancedCtx = await manager.resolve(baseCtx)
// enhancedCtx.auth.resolved contains: { organizationIds, orgRoles, resolvedAt }
// Use in RLS context - policies can access resolved data synchronously
await rlsContext.runAsync(enhancedCtx, async () => {
const posts = await orm.posts.findAll()
})Resolver Dependencies:
const permissionResolver = createResolver({
name: 'permissions',
dependsOn: ['org-memberships'], // Runs after org-memberships
resolve: async (ctx) => {
const orgIds = ctx.auth.resolved?.organizationIds ?? []
const permissions = await fetchPermissions(ctx.auth.userId, orgIds)
return { resolvedAt: new Date(), permissions }
}
})Cache Invalidation:
// Invalidate specific resolver cache
await manager.invalidateCache(userId, 'org-memberships')
// Invalidate all caches for a user
await manager.invalidateCache(userId)
// Clear all caches
await manager.clearCache()Field-Level Access Control
Control access to individual columns within rows based on user context. Supports reading, writing, and masking.
import {
createFieldAccessRegistry,
createFieldAccessProcessor,
ownerOnly,
rolesOnly,
maskedField,
publicReadRestrictedWrite,
type FieldAccessSchema
} from '@kysera/rls'
// Define field access rules
const fieldSchema: FieldAccessSchema = {
users: {
fields: {
// Email only visible to owner or admin
email: ownerOnly('id'),
// Salary only visible to HR
salary: rolesOnly(['hr', 'admin']),
// SSN always masked except for owner
ssn: maskedField('***-**-****', ownerOnly('id')),
// Phone public read, restricted write
phone: publicReadRestrictedWrite(['admin']),
// Custom rule
notes: {
read: ctx => ctx.auth.roles.includes('manager'),
write: ctx => ctx.auth.roles.includes('admin')
}
},
defaultMask: '[REDACTED]'
}
}
// Create registry and processor
const registry = createFieldAccessRegistry(fieldSchema)
const processor = createFieldAccessProcessor(registry)
// Check field access
const canReadEmail = registry.canReadField('users', 'email', {
auth: { userId: '1', roles: ['user'] },
row: { id: '1', email: '[email protected]' }
})
// Mask sensitive fields in rows
const rows = await db.selectFrom('users').selectAll().execute()
const maskedRows = processor.maskRows('users', rows, {
auth: { userId: '1', roles: ['user'] }
})
// Results have sensitive fields masked based on rulesPredefined Access Patterns:
| Pattern | Description |
|---------|-------------|
| ownerOnly(field) | Only row owner can read/write |
| rolesOnly(roles) | Only specified roles can access |
| readOnly() | Anyone can read, no one can write |
| neverAccessible() | Always hidden |
| publicReadRestrictedWrite(roles) | Anyone reads, roles write |
| maskedField(mask, condition) | Shows mask unless condition passes |
| ownerOrRoles(roles, field) | Owner or specified roles |
Relationship-Based Access Control (ReBAC)
Define access based on relationships between entities using EXISTS subqueries. Ideal for complex organizational hierarchies.
import {
ReBAcRegistry,
ReBAcTransformer,
createReBAcRegistry,
createReBAcTransformer,
orgMembershipPath,
shopOrgMembershipPath,
allowRelation,
denyRelation,
type ReBAcSchema
} from '@kysera/rls'
// Define relationship paths
const rebacSchema: ReBAcSchema<Database> = {
products: {
relationships: [
// products -> shops -> organizations -> org_members
shopOrgMembershipPath('products', 'shop_id')
],
policies: [
{
name: 'org-member-access',
policyType: 'allow',
operation: ['read', 'update'],
relationshipPath: 'products_shop_org_membership',
endCondition: ctx => ({
user_id: ctx.auth.userId,
status: 'active'
})
}
]
},
documents: {
relationships: [
// Custom path: documents -> projects -> teams -> team_members
{
name: 'documents_team_membership',
steps: [
{ from: 'documents', to: 'projects', fromColumn: 'project_id', toColumn: 'id' },
{ from: 'projects', to: 'teams', fromColumn: 'team_id', toColumn: 'id' },
{ from: 'teams', to: 'team_members', fromColumn: 'id', toColumn: 'team_id' }
]
}
],
policies: [
allowRelation('read', 'documents_team_membership', ctx => ({
user_id: ctx.auth.userId
}))
]
}
}
// Create registry and transformer
const registry = createReBAcRegistry(rebacSchema)
const transformer = createReBAcTransformer(registry)
// Transform queries to add EXISTS subqueries
await rlsContext.runAsync(ctx, async () => {
// Original: SELECT * FROM products
// Transformed: SELECT * FROM products WHERE EXISTS (
// SELECT 1 FROM shops
// JOIN organizations ON ...
// JOIN org_members ON ...
// WHERE org_members.user_id = $1 AND org_members.status = 'active'
// )
const products = await transformer.transformSelect(
db.selectFrom('products').selectAll(),
'products',
'read'
).execute()
})Predefined Relationship Patterns:
// Organization membership: table -> organizations -> org_members
orgMembershipPath('documents', 'organization_id')
// Shop-based: table -> shops -> organizations -> org_members
shopOrgMembershipPath('products', 'shop_id')
// Team hierarchy: table -> teams (recursive) -> team_members
teamHierarchyPath('tasks', 'team_id')Policy Composition & Reusable Policies
Create reusable policy templates that can be composed and extended across tables.
import {
createTenantIsolationPolicy,
createOwnershipPolicy,
createSoftDeletePolicy,
createStatusAccessPolicy,
createAdminPolicy,
composePolicies,
extendPolicy,
defineFilterPolicy,
defineAllowPolicy,
defineDenyPolicy,
defineValidatePolicy
} from '@kysera/rls'
// Use predefined policy templates
const tenantPolicy = createTenantIsolationPolicy({
tenantColumn: 'tenant_id',
validateOnCreate: true
})
const ownerPolicy = createOwnershipPolicy({
ownerColumn: 'user_id',
allowedOperations: ['update', 'delete']
})
const softDeletePolicy = createSoftDeletePolicy({
deletedAtColumn: 'deleted_at',
includeDeleted: false
})
const statusPolicy = createStatusAccessPolicy({
statusColumn: 'status',
publicStatuses: ['published'],
draftStatuses: ['draft'],
archivedStatuses: ['archived']
})
const adminPolicy = createAdminPolicy({
adminRoles: ['admin', 'superadmin']
})
// Compose policies together
const combinedPolicy = composePolicies(
tenantPolicy,
ownerPolicy,
softDeletePolicy,
adminPolicy
)
// Use in schema
const schema = defineRLSSchema<Database>({
posts: {
policies: combinedPolicy.policies,
defaultDeny: true
}
})Custom Reusable Policies:
// Define reusable filter
const publicPostsFilter = defineFilterPolicy(
'public-posts',
ctx => ({ is_public: true, deleted_at: null })
)
// Define reusable allow policy
const ownerEditPolicy = defineAllowPolicy(
'owner-edit',
['update', 'delete'],
ctx => ctx.auth.userId === ctx.row?.author_id
)
// Define reusable deny policy
const preventDeletePublished = defineDenyPolicy(
'no-delete-published',
'delete',
ctx => ctx.row?.status === 'published'
)
// Define reusable validation
const tenantValidation = defineValidatePolicy(
'tenant-validation',
['create', 'update'],
ctx => !ctx.data?.tenant_id || ctx.data.tenant_id === ctx.auth.tenantId
)
// Extend existing policy
const extendedPolicy = extendPolicy(tenantPolicy, {
additionalPolicies: [ownerEditPolicy.policies[0]!]
})Audit Trail Integration
Log all RLS policy decisions with buffering, sampling, and filtering capabilities.
import {
AuditLogger,
createAuditLogger,
ConsoleAuditAdapter,
InMemoryAuditAdapter,
type AuditConfig,
type RLSAuditAdapter
} from '@kysera/rls'
// Custom database adapter
class DatabaseAuditAdapter implements RLSAuditAdapter {
constructor(private db: Kysely<AuditDB>) {}
async log(event: RLSAuditEvent): Promise<void> {
await this.db.insertInto('rls_audit_log')
.values({
user_id: String(event.userId),
operation: event.operation,
table_name: event.table,
decision: event.decision,
policy_name: event.policyName,
reason: event.reason,
context: JSON.stringify(event.context),
created_at: event.timestamp
})
.execute()
}
async logBatch(events: RLSAuditEvent[]): Promise<void> {
await this.db.insertInto('rls_audit_log')
.values(events.map(e => ({
user_id: String(e.userId),
operation: e.operation,
table_name: e.table,
decision: e.decision,
policy_name: e.policyName,
context: JSON.stringify(e.context),
created_at: e.timestamp
})))
.execute()
}
}
// Create audit logger
const auditLogger = createAuditLogger({
adapter: new DatabaseAuditAdapter(auditDb),
enabled: true,
bufferSize: 100, // Buffer up to 100 events
flushInterval: 5000, // Flush every 5 seconds
sampleRate: 1.0, // Log 100% of events (use 0.1 for 10%)
async: true, // Fire-and-forget logging
// Default settings for all tables
defaults: {
logAllowed: false, // Don't log allow decisions
logDenied: true, // Log all denials
logFilters: false // Don't log filter applications
},
// Table-specific settings
tables: {
sensitive_data: {
logAllowed: true, // Log all access to sensitive tables
includeContext: ['requestId', 'ipAddress']
},
public_content: {
enabled: false // Don't audit public content
}
},
// Error handler
onError: (error, events) => {
console.error('Audit logging failed:', error, events.length, 'events lost')
}
})
// Log decisions
await auditLogger.logAllow('read', 'posts', 'ownership-allow')
await auditLogger.logDeny('delete', 'posts', 'status-check', {
reason: 'Cannot delete published posts',
rowIds: [123]
})
await auditLogger.logFilter('posts', 'tenant-filter')
// Ensure all events are flushed
await auditLogger.flush()
// Graceful shutdown
await auditLogger.close()Built-in Adapters:
// Console adapter (development)
const consoleAdapter = new ConsoleAuditAdapter({
format: 'text', // or 'json'
colors: true,
includeTimestamp: true
})
// In-memory adapter (testing)
const memoryAdapter = new InMemoryAuditAdapter(10000) // max 10k events
const events = memoryAdapter.getEvents()
const stats = memoryAdapter.getStats()
const filtered = memoryAdapter.query({
userId: '123',
decision: 'deny',
startTime: new Date('2024-01-01')
})Policy Testing Utilities
Unit test your RLS policies without a database connection.
import {
PolicyTester,
createPolicyTester,
createTestAuthContext,
createTestRow,
policyAssertions
} from '@kysera/rls'
const tester = createPolicyTester(rlsSchema)
describe('Post RLS Policies', () => {
it('should allow owner to update their post', async () => {
const result = await tester.evaluate('posts', 'update', {
auth: createTestAuthContext({
userId: 'user-1',
roles: ['user'],
tenantId: 'tenant-1'
}),
row: createTestRow({
id: 'post-1',
author_id: 'user-1',
tenant_id: 'tenant-1',
status: 'draft'
})
})
expect(result.allowed).toBe(true)
expect(result.policyName).toBe('ownership-allow')
expect(result.decisionType).toBe('allow')
})
it('should deny non-owner update', async () => {
const result = await tester.evaluate('posts', 'update', {
auth: createTestAuthContext({
userId: 'user-2',
roles: ['user']
}),
row: createTestRow({
id: 'post-1',
author_id: 'user-1'
})
})
policyAssertions.assertDenied(result)
expect(result.reason).toContain('not owner')
})
it('should apply tenant filter correctly', () => {
const filters = tester.getFilters('posts', 'read', {
auth: createTestAuthContext({
userId: 'user-1',
tenantId: 'tenant-1'
})
})
policyAssertions.assertFiltersInclude(filters, {
tenant_id: 'tenant-1'
})
expect(filters.appliedFilters).toContain('tenant-isolation')
})
it('should bypass for admin role', async () => {
const result = await tester.evaluate('posts', 'delete', {
auth: createTestAuthContext({
userId: 'admin-1',
roles: ['admin']
}),
row: createTestRow({ id: 'post-1' })
})
expect(result.allowed).toBe(true)
expect(result.reason).toContain('Role bypass')
})
it('should test specific policy', async () => {
const { found, result } = await tester.testPolicy(
'posts',
'ownership-allow',
{
auth: createTestAuthContext({ userId: 'user-1' }),
row: createTestRow({ author_id: 'user-1' })
}
)
expect(found).toBe(true)
expect(result).toBe(true)
})
})
// List all policies for debugging
const policies = tester.listPolicies('posts')
console.log('Allows:', policies.allows)
console.log('Denies:', policies.denies)
console.log('Filters:', policies.filters)
console.log('Validates:', policies.validates)Assertion Helpers:
import { policyAssertions } from '@kysera/rls'
// Assert allowed
policyAssertions.assertAllowed(result, 'Custom error message')
// Assert denied
policyAssertions.assertDenied(result)
// Assert specific policy made decision
policyAssertions.assertPolicyUsed(result, 'ownership-allow')
// Assert filter conditions
policyAssertions.assertFiltersInclude(filterResult, {
tenant_id: 'expected-tenant',
deleted_at: null
})Conditional Policy Activation
Activate policies based on environment, feature flags, or time-based conditions.
import {
whenEnvironment,
whenFeature,
whenTimeRange,
whenCondition,
defineRLSSchema,
filter,
allow,
deny
} from '@kysera/rls'
const schema = defineRLSSchema<Database>({
posts: {
policies: [
// Only active in production
whenEnvironment('production', () =>
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
),
// Only when feature flag is enabled
whenFeature('strict-rls', () =>
deny('delete', ctx => ctx.row?.status === 'published')
),
// Business hours only (9 AM - 6 PM)
whenTimeRange(9, 18, () =>
allow('create', ctx => ctx.auth.roles.includes('user'))
),
// Overnight maintenance (10 PM - 6 AM, crosses midnight)
whenTimeRange(22, 6, () =>
deny('all', () => true, { name: 'maintenance-mode' })
),
// Custom condition
whenCondition(
ctx => ctx.meta?.featureFlags?.includes('beta') ?? false,
() => allow('read', ctx => ctx.auth.attributes?.betaTester === true)
),
// Nested conditions
whenEnvironment('production', () =>
whenFeature('audit-mode', () =>
filter('read', ctx => ({
...ctx.auth.tenantId && { tenant_id: ctx.auth.tenantId },
audit_enabled: true
}))
)
)
]
}
})Setting Activation Context:
await rlsContext.runAsync({
auth: {
userId: user.id,
roles: user.roles,
tenantId: user.tenantId
},
timestamp: new Date(),
meta: {
environment: process.env.NODE_ENV,
features: new Set(['strict-rls', 'audit-mode']),
featureFlags: ['beta', 'new-ui']
}
}, async () => {
// Policies will check activation conditions
const posts = await orm.posts.findAll()
})Feature Flag Integration:
// With object-style features
meta: {
features: {
'strict-rls': true,
'audit-mode': false,
'beta-features': user.isBetaTester
}
}
// With Set
meta: {
features: new Set(['strict-rls', 'audit-mode'])
}
// With array
meta: {
features: ['strict-rls', 'audit-mode']
}Complete API 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,
type ContextResolver,
type EnhancedRLSContext,
type ResolvedData
} from '@kysera/rls'
// ReBAC
export {
ReBAcRegistry,
ReBAcTransformer,
createReBAcRegistry,
createReBAcTransformer,
orgMembershipPath,
shopOrgMembershipPath,
teamHierarchyPath,
allowRelation,
denyRelation,
type RelationshipPath,
type ReBAcSchema
} from '@kysera/rls'
// Field Access
export {
FieldAccessRegistry,
FieldAccessProcessor,
createFieldAccessRegistry,
createFieldAccessProcessor,
ownerOnly,
rolesOnly,
readOnly,
neverAccessible,
publicReadRestrictedWrite,
maskedField,
ownerOrRoles,
type FieldAccessSchema
} from '@kysera/rls'
// Policy Composition
export {
createTenantIsolationPolicy,
createOwnershipPolicy,
createSoftDeletePolicy,
createStatusAccessPolicy,
createAdminPolicy,
composePolicies,
extendPolicy,
defineFilterPolicy,
defineAllowPolicy,
defineDenyPolicy,
defineValidatePolicy,
defineCombinedPolicy,
type ReusablePolicy
} from '@kysera/rls'
// Audit Trail
export {
AuditLogger,
createAuditLogger,
ConsoleAuditAdapter,
InMemoryAuditAdapter,
type RLSAuditEvent,
type RLSAuditAdapter,
type AuditConfig
} from '@kysera/rls'
// Testing
export {
PolicyTester,
createPolicyTester,
createTestAuthContext,
createTestRow,
policyAssertions,
type PolicyEvaluationResult,
type FilterEvaluationResult,
type TestContext
} from '@kysera/rls'
// Errors
export {
RLSError,
RLSContextError,
RLSPolicyViolation,
RLSPolicyEvaluationError,
RLSSchemaError,
RLSContextValidationError,
RLSErrorCodes
} from '@kysera/rls'License
MIT
