npm package: @kysera/rls
Description: Row-Level Security plugin for Kysely - declarative policies, query transformation, native RLS support
Author: Kysera Team
Latest version: 0.8.8

# @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.

[![npm version](https://img.shields.io/npm/v/@kysera/rls.svg)](https://www.npmjs.com/package/@kysera/rls)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9%2B-blue)](https://www.typescriptlang.org/)

---

## 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`, and `validate` builders
- **Automatic Query Transformation** - SELECT queries are filtered automatically via `interceptQuery` hook
- **Repository Extensions** - Wraps mutation methods via `extendRepository` hook for policy enforcement
- **Type-Safe Context** - Full TypeScript inference with reduced `any` usage 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: true` for @kysera/repository

---

## Installation

```bash
npm install @kysera/rls kysely
# or
pnpm add @kysera/rls kysely
# or
yarn add @kysera/rls kysely
```

**Dependencies:**

- `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

```typescript
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

```typescript
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

```typescript
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:**

```typescript
{
  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:

```typescript
// 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'] = true
```

**Key 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:

```typescript
// 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 methods
```

**Why 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.

```typescript
// 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.

```typescript
// 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 deny
```

#### 3. `filter` - Automatic Filtering

Adds WHERE conditions to SELECT queries automatically.

```typescript
// 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.

```typescript
// 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       |

```typescript
// 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 query
```

**Priority System:**

- Higher priority = evaluated first
- Deny policies default to priority `100`
- Allow/filter/validate default to priority `0`

```typescript
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?)`

```typescript
// 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?)`

```typescript
// 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?)`

```typescript
// 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?)`

```typescript
// 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

```typescript
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:

```typescript
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):

```typescript
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:

```typescript
const result = rlsContext.run(context, () => {
  // Synchronous operations
  return someValue
})
```

#### `createRLSContext(options)`

Create and validate RLS context with proper defaults:

```typescript
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:

```typescript
// 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:

```typescript
// 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:

```typescript
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:

```typescript
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)`

```typescript
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:**

```typescript
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)

```typescript
// 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 required
```

#### Background Jobs / System Operations

For operations that legitimately run without user context (e.g., cron jobs, system maintenance):

```typescript
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:

```typescript
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:
1. Understand the security implications
2. Have other security controls in place
3. 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

```typescript
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:

```typescript
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()` when `requireContext: 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):

```typescript
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:**

- `deny` policy condition evaluates to `true`
- No `allow` policy matches and `defaultDeny: true`
- `validate` policy fails during CREATE/UPDATE

#### `RLSPolicyEvaluationError`

Thrown when policy condition throws an error (this is a bug in your policy code):

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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_INVALID
```

---

## Architecture & Implementation

### Plugin Lifecycle

The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:

1. **Initialization (`onInit`):**
   - Creates `PolicyRegistry` from schema
   - Validates all policies
   - Compiles policies for runtime
   - Creates `SelectTransformer` and `MutationGuard` instances

2. **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`

3. **Repository Extension (`extendRepository`):**
   - Wraps `create`, `update`, `delete` methods
   - Evaluates policies via `MutationGuard`
   - Uses `getRawDb()` to fetch existing rows (bypasses RLS)
   - Adds `withoutRLS()` and `canAccess()` utility methods

### 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 `RLSPolicyViolation` or `RLSPolicyEvaluationError`

**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)
- `excludeTables` bypass is immediate (no policy evaluation)
- Bypass roles checked before policy evaluation

### Transaction Support

RLS context automatically propagates through transactions:

```typescript
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:

```typescript
await withTransaction(executor, async txCtx => {
  // RLS context preserved in transaction
  const posts = await getPosts(txCtx)
})
```

---

## Common Patterns

### Multi-Tenant Isolation

```typescript
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

```typescript
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

```typescript
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:

```typescript
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

```typescript
// 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.

```typescript
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:**

```typescript
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:**

```typescript
// 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.

```typescript
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: 'user@example.com' }
})

// 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 rules
```

**Predefined 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.

```typescript
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:**

```typescript
// 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.

```typescript
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:**

```typescript
// 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.

```typescript
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:**

```typescript
// 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.

```typescript
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:**

```typescript
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.

```typescript
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:**

```typescript
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:**

```typescript
// 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

```typescript
// 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