@kysera/rls 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +819 -3
- package/dist/index.d.ts +2824 -8
- package/dist/index.js +2453 -2
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/{types-Dowjd6zG.d.ts → types-CyqksFKU.d.ts} +72 -1
- package/package.json +6 -6
- package/src/audit/index.ts +25 -0
- package/src/audit/logger.ts +465 -0
- package/src/audit/types.ts +625 -0
- package/src/composition/builder.ts +556 -0
- package/src/composition/index.ts +43 -0
- package/src/composition/types.ts +415 -0
- package/src/field-access/index.ts +38 -0
- package/src/field-access/processor.ts +442 -0
- package/src/field-access/registry.ts +259 -0
- package/src/field-access/types.ts +453 -0
- package/src/index.ts +180 -2
- package/src/plugin.ts +3 -1
- package/src/policy/builder.ts +187 -10
- package/src/policy/types.ts +84 -0
- package/src/rebac/index.ts +30 -0
- package/src/rebac/registry.ts +303 -0
- package/src/rebac/transformer.ts +391 -0
- package/src/rebac/types.ts +412 -0
- package/src/resolvers/index.ts +30 -0
- package/src/resolvers/manager.ts +507 -0
- package/src/resolvers/types.ts +447 -0
- package/src/testing/index.ts +554 -0
package/README.md
CHANGED
|
@@ -780,7 +780,7 @@ interface RLSPluginOptions<DB = unknown> {
|
|
|
780
780
|
* Require RLS context (throws if missing)
|
|
781
781
|
* @default true - SECURE BY DEFAULT
|
|
782
782
|
*
|
|
783
|
-
* SECURITY: Changed to true in v0.
|
|
783
|
+
* SECURITY: Changed to true in v0.8.0+ for secure-by-default.
|
|
784
784
|
* When true, missing context throws RLSContextError.
|
|
785
785
|
* Only set to false if you have other security controls.
|
|
786
786
|
*/
|
|
@@ -836,9 +836,9 @@ const plugin = rlsPlugin({
|
|
|
836
836
|
const orm = await createORM(db, [plugin])
|
|
837
837
|
```
|
|
838
838
|
|
|
839
|
-
### Security Configuration (v0.
|
|
839
|
+
### Security Configuration (v0.8.0+)
|
|
840
840
|
|
|
841
|
-
**BREAKING CHANGE**: Starting in v0.
|
|
841
|
+
**BREAKING CHANGE**: Starting in v0.8.0, `requireContext` defaults to `true` for secure-by-default behavior.
|
|
842
842
|
|
|
843
843
|
#### Secure Defaults (Recommended)
|
|
844
844
|
|
|
@@ -1316,6 +1316,822 @@ export {
|
|
|
1316
1316
|
|
|
1317
1317
|
---
|
|
1318
1318
|
|
|
1319
|
+
## Advanced Features
|
|
1320
|
+
|
|
1321
|
+
The following advanced features provide enterprise-grade capabilities for complex authorization scenarios.
|
|
1322
|
+
|
|
1323
|
+
### Context Resolvers (Pre-resolved Async Data)
|
|
1324
|
+
|
|
1325
|
+
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.
|
|
1326
|
+
|
|
1327
|
+
```typescript
|
|
1328
|
+
import {
|
|
1329
|
+
ResolverManager,
|
|
1330
|
+
createResolverManager,
|
|
1331
|
+
createResolver,
|
|
1332
|
+
type EnhancedRLSContext
|
|
1333
|
+
} from '@kysera/rls'
|
|
1334
|
+
|
|
1335
|
+
// Define a resolver for organization memberships
|
|
1336
|
+
const orgResolver = createResolver({
|
|
1337
|
+
name: 'org-memberships',
|
|
1338
|
+
resolve: async (ctx) => {
|
|
1339
|
+
// Fetch from database or external service
|
|
1340
|
+
const memberships = await db
|
|
1341
|
+
.selectFrom('organization_members')
|
|
1342
|
+
.where('user_id', '=', ctx.auth.userId)
|
|
1343
|
+
.select(['organization_id', 'role'])
|
|
1344
|
+
.execute()
|
|
1345
|
+
|
|
1346
|
+
return {
|
|
1347
|
+
resolvedAt: new Date(),
|
|
1348
|
+
organizationIds: memberships.map(m => m.organization_id),
|
|
1349
|
+
orgRoles: memberships.reduce((acc, m) => {
|
|
1350
|
+
acc[m.organization_id] = m.role
|
|
1351
|
+
return acc
|
|
1352
|
+
}, {} as Record<string, string>)
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
cacheKey: (ctx) => `rls:org:${ctx.auth.userId}`,
|
|
1356
|
+
cacheTtl: 300 // 5 minutes
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
// Create manager and register resolvers
|
|
1360
|
+
const manager = createResolverManager({
|
|
1361
|
+
defaultCacheTtl: 300,
|
|
1362
|
+
parallelResolution: true,
|
|
1363
|
+
resolverTimeout: 5000
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
manager.register(orgResolver)
|
|
1367
|
+
|
|
1368
|
+
// Resolve context before using RLS
|
|
1369
|
+
const baseCtx = {
|
|
1370
|
+
auth: { userId: '123', roles: ['user'] },
|
|
1371
|
+
timestamp: new Date()
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const enhancedCtx = await manager.resolve(baseCtx)
|
|
1375
|
+
// enhancedCtx.auth.resolved contains: { organizationIds, orgRoles, resolvedAt }
|
|
1376
|
+
|
|
1377
|
+
// Use in RLS context - policies can access resolved data synchronously
|
|
1378
|
+
await rlsContext.runAsync(enhancedCtx, async () => {
|
|
1379
|
+
const posts = await orm.posts.findAll()
|
|
1380
|
+
})
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
**Resolver Dependencies:**
|
|
1384
|
+
|
|
1385
|
+
```typescript
|
|
1386
|
+
const permissionResolver = createResolver({
|
|
1387
|
+
name: 'permissions',
|
|
1388
|
+
dependsOn: ['org-memberships'], // Runs after org-memberships
|
|
1389
|
+
resolve: async (ctx) => {
|
|
1390
|
+
const orgIds = ctx.auth.resolved?.organizationIds ?? []
|
|
1391
|
+
const permissions = await fetchPermissions(ctx.auth.userId, orgIds)
|
|
1392
|
+
return { resolvedAt: new Date(), permissions }
|
|
1393
|
+
}
|
|
1394
|
+
})
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
**Cache Invalidation:**
|
|
1398
|
+
|
|
1399
|
+
```typescript
|
|
1400
|
+
// Invalidate specific resolver cache
|
|
1401
|
+
await manager.invalidateCache(userId, 'org-memberships')
|
|
1402
|
+
|
|
1403
|
+
// Invalidate all caches for a user
|
|
1404
|
+
await manager.invalidateCache(userId)
|
|
1405
|
+
|
|
1406
|
+
// Clear all caches
|
|
1407
|
+
await manager.clearCache()
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
---
|
|
1411
|
+
|
|
1412
|
+
### Field-Level Access Control
|
|
1413
|
+
|
|
1414
|
+
Control access to individual columns within rows based on user context. Supports reading, writing, and masking.
|
|
1415
|
+
|
|
1416
|
+
```typescript
|
|
1417
|
+
import {
|
|
1418
|
+
createFieldAccessRegistry,
|
|
1419
|
+
createFieldAccessProcessor,
|
|
1420
|
+
ownerOnly,
|
|
1421
|
+
rolesOnly,
|
|
1422
|
+
maskedField,
|
|
1423
|
+
publicReadRestrictedWrite,
|
|
1424
|
+
type FieldAccessSchema
|
|
1425
|
+
} from '@kysera/rls'
|
|
1426
|
+
|
|
1427
|
+
// Define field access rules
|
|
1428
|
+
const fieldSchema: FieldAccessSchema = {
|
|
1429
|
+
users: {
|
|
1430
|
+
fields: {
|
|
1431
|
+
// Email only visible to owner or admin
|
|
1432
|
+
email: ownerOnly('id'),
|
|
1433
|
+
|
|
1434
|
+
// Salary only visible to HR
|
|
1435
|
+
salary: rolesOnly(['hr', 'admin']),
|
|
1436
|
+
|
|
1437
|
+
// SSN always masked except for owner
|
|
1438
|
+
ssn: maskedField('***-**-****', ownerOnly('id')),
|
|
1439
|
+
|
|
1440
|
+
// Phone public read, restricted write
|
|
1441
|
+
phone: publicReadRestrictedWrite(['admin']),
|
|
1442
|
+
|
|
1443
|
+
// Custom rule
|
|
1444
|
+
notes: {
|
|
1445
|
+
read: ctx => ctx.auth.roles.includes('manager'),
|
|
1446
|
+
write: ctx => ctx.auth.roles.includes('admin')
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
defaultMask: '[REDACTED]'
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Create registry and processor
|
|
1454
|
+
const registry = createFieldAccessRegistry(fieldSchema)
|
|
1455
|
+
const processor = createFieldAccessProcessor(registry)
|
|
1456
|
+
|
|
1457
|
+
// Check field access
|
|
1458
|
+
const canReadEmail = registry.canReadField('users', 'email', {
|
|
1459
|
+
auth: { userId: '1', roles: ['user'] },
|
|
1460
|
+
row: { id: '1', email: 'user@example.com' }
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
// Mask sensitive fields in rows
|
|
1464
|
+
const rows = await db.selectFrom('users').selectAll().execute()
|
|
1465
|
+
const maskedRows = processor.maskRows('users', rows, {
|
|
1466
|
+
auth: { userId: '1', roles: ['user'] }
|
|
1467
|
+
})
|
|
1468
|
+
// Results have sensitive fields masked based on rules
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
**Predefined Access Patterns:**
|
|
1472
|
+
|
|
1473
|
+
| Pattern | Description |
|
|
1474
|
+
|---------|-------------|
|
|
1475
|
+
| `ownerOnly(field)` | Only row owner can read/write |
|
|
1476
|
+
| `rolesOnly(roles)` | Only specified roles can access |
|
|
1477
|
+
| `readOnly()` | Anyone can read, no one can write |
|
|
1478
|
+
| `neverAccessible()` | Always hidden |
|
|
1479
|
+
| `publicReadRestrictedWrite(roles)` | Anyone reads, roles write |
|
|
1480
|
+
| `maskedField(mask, condition)` | Shows mask unless condition passes |
|
|
1481
|
+
| `ownerOrRoles(roles, field)` | Owner or specified roles |
|
|
1482
|
+
|
|
1483
|
+
---
|
|
1484
|
+
|
|
1485
|
+
### Relationship-Based Access Control (ReBAC)
|
|
1486
|
+
|
|
1487
|
+
Define access based on relationships between entities using EXISTS subqueries. Ideal for complex organizational hierarchies.
|
|
1488
|
+
|
|
1489
|
+
```typescript
|
|
1490
|
+
import {
|
|
1491
|
+
ReBAcRegistry,
|
|
1492
|
+
ReBAcTransformer,
|
|
1493
|
+
createReBAcRegistry,
|
|
1494
|
+
createReBAcTransformer,
|
|
1495
|
+
orgMembershipPath,
|
|
1496
|
+
shopOrgMembershipPath,
|
|
1497
|
+
allowRelation,
|
|
1498
|
+
denyRelation,
|
|
1499
|
+
type ReBAcSchema
|
|
1500
|
+
} from '@kysera/rls'
|
|
1501
|
+
|
|
1502
|
+
// Define relationship paths
|
|
1503
|
+
const rebacSchema: ReBAcSchema<Database> = {
|
|
1504
|
+
products: {
|
|
1505
|
+
relationships: [
|
|
1506
|
+
// products -> shops -> organizations -> org_members
|
|
1507
|
+
shopOrgMembershipPath('products', 'shop_id')
|
|
1508
|
+
],
|
|
1509
|
+
policies: [
|
|
1510
|
+
{
|
|
1511
|
+
name: 'org-member-access',
|
|
1512
|
+
policyType: 'allow',
|
|
1513
|
+
operation: ['read', 'update'],
|
|
1514
|
+
relationshipPath: 'products_shop_org_membership',
|
|
1515
|
+
endCondition: ctx => ({
|
|
1516
|
+
user_id: ctx.auth.userId,
|
|
1517
|
+
status: 'active'
|
|
1518
|
+
})
|
|
1519
|
+
}
|
|
1520
|
+
]
|
|
1521
|
+
},
|
|
1522
|
+
|
|
1523
|
+
documents: {
|
|
1524
|
+
relationships: [
|
|
1525
|
+
// Custom path: documents -> projects -> teams -> team_members
|
|
1526
|
+
{
|
|
1527
|
+
name: 'documents_team_membership',
|
|
1528
|
+
steps: [
|
|
1529
|
+
{ from: 'documents', to: 'projects', fromColumn: 'project_id', toColumn: 'id' },
|
|
1530
|
+
{ from: 'projects', to: 'teams', fromColumn: 'team_id', toColumn: 'id' },
|
|
1531
|
+
{ from: 'teams', to: 'team_members', fromColumn: 'id', toColumn: 'team_id' }
|
|
1532
|
+
]
|
|
1533
|
+
}
|
|
1534
|
+
],
|
|
1535
|
+
policies: [
|
|
1536
|
+
allowRelation('read', 'documents_team_membership', ctx => ({
|
|
1537
|
+
user_id: ctx.auth.userId
|
|
1538
|
+
}))
|
|
1539
|
+
]
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Create registry and transformer
|
|
1544
|
+
const registry = createReBAcRegistry(rebacSchema)
|
|
1545
|
+
const transformer = createReBAcTransformer(registry)
|
|
1546
|
+
|
|
1547
|
+
// Transform queries to add EXISTS subqueries
|
|
1548
|
+
await rlsContext.runAsync(ctx, async () => {
|
|
1549
|
+
// Original: SELECT * FROM products
|
|
1550
|
+
// Transformed: SELECT * FROM products WHERE EXISTS (
|
|
1551
|
+
// SELECT 1 FROM shops
|
|
1552
|
+
// JOIN organizations ON ...
|
|
1553
|
+
// JOIN org_members ON ...
|
|
1554
|
+
// WHERE org_members.user_id = $1 AND org_members.status = 'active'
|
|
1555
|
+
// )
|
|
1556
|
+
const products = await transformer.transformSelect(
|
|
1557
|
+
db.selectFrom('products').selectAll(),
|
|
1558
|
+
'products',
|
|
1559
|
+
'read'
|
|
1560
|
+
).execute()
|
|
1561
|
+
})
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
**Predefined Relationship Patterns:**
|
|
1565
|
+
|
|
1566
|
+
```typescript
|
|
1567
|
+
// Organization membership: table -> organizations -> org_members
|
|
1568
|
+
orgMembershipPath('documents', 'organization_id')
|
|
1569
|
+
|
|
1570
|
+
// Shop-based: table -> shops -> organizations -> org_members
|
|
1571
|
+
shopOrgMembershipPath('products', 'shop_id')
|
|
1572
|
+
|
|
1573
|
+
// Team hierarchy: table -> teams (recursive) -> team_members
|
|
1574
|
+
teamHierarchyPath('tasks', 'team_id')
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
---
|
|
1578
|
+
|
|
1579
|
+
### Policy Composition & Reusable Policies
|
|
1580
|
+
|
|
1581
|
+
Create reusable policy templates that can be composed and extended across tables.
|
|
1582
|
+
|
|
1583
|
+
```typescript
|
|
1584
|
+
import {
|
|
1585
|
+
createTenantIsolationPolicy,
|
|
1586
|
+
createOwnershipPolicy,
|
|
1587
|
+
createSoftDeletePolicy,
|
|
1588
|
+
createStatusAccessPolicy,
|
|
1589
|
+
createAdminPolicy,
|
|
1590
|
+
composePolicies,
|
|
1591
|
+
extendPolicy,
|
|
1592
|
+
defineFilterPolicy,
|
|
1593
|
+
defineAllowPolicy,
|
|
1594
|
+
defineDenyPolicy,
|
|
1595
|
+
defineValidatePolicy
|
|
1596
|
+
} from '@kysera/rls'
|
|
1597
|
+
|
|
1598
|
+
// Use predefined policy templates
|
|
1599
|
+
const tenantPolicy = createTenantIsolationPolicy({
|
|
1600
|
+
tenantColumn: 'tenant_id',
|
|
1601
|
+
validateOnCreate: true
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
const ownerPolicy = createOwnershipPolicy({
|
|
1605
|
+
ownerColumn: 'user_id',
|
|
1606
|
+
allowedOperations: ['update', 'delete']
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
const softDeletePolicy = createSoftDeletePolicy({
|
|
1610
|
+
deletedAtColumn: 'deleted_at',
|
|
1611
|
+
includeDeleted: false
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
const statusPolicy = createStatusAccessPolicy({
|
|
1615
|
+
statusColumn: 'status',
|
|
1616
|
+
publicStatuses: ['published'],
|
|
1617
|
+
draftStatuses: ['draft'],
|
|
1618
|
+
archivedStatuses: ['archived']
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
const adminPolicy = createAdminPolicy({
|
|
1622
|
+
adminRoles: ['admin', 'superadmin']
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
// Compose policies together
|
|
1626
|
+
const combinedPolicy = composePolicies(
|
|
1627
|
+
tenantPolicy,
|
|
1628
|
+
ownerPolicy,
|
|
1629
|
+
softDeletePolicy,
|
|
1630
|
+
adminPolicy
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
// Use in schema
|
|
1634
|
+
const schema = defineRLSSchema<Database>({
|
|
1635
|
+
posts: {
|
|
1636
|
+
policies: combinedPolicy.policies,
|
|
1637
|
+
defaultDeny: true
|
|
1638
|
+
}
|
|
1639
|
+
})
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
**Custom Reusable Policies:**
|
|
1643
|
+
|
|
1644
|
+
```typescript
|
|
1645
|
+
// Define reusable filter
|
|
1646
|
+
const publicPostsFilter = defineFilterPolicy(
|
|
1647
|
+
'public-posts',
|
|
1648
|
+
ctx => ({ is_public: true, deleted_at: null })
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
// Define reusable allow policy
|
|
1652
|
+
const ownerEditPolicy = defineAllowPolicy(
|
|
1653
|
+
'owner-edit',
|
|
1654
|
+
['update', 'delete'],
|
|
1655
|
+
ctx => ctx.auth.userId === ctx.row?.author_id
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
// Define reusable deny policy
|
|
1659
|
+
const preventDeletePublished = defineDenyPolicy(
|
|
1660
|
+
'no-delete-published',
|
|
1661
|
+
'delete',
|
|
1662
|
+
ctx => ctx.row?.status === 'published'
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
// Define reusable validation
|
|
1666
|
+
const tenantValidation = defineValidatePolicy(
|
|
1667
|
+
'tenant-validation',
|
|
1668
|
+
['create', 'update'],
|
|
1669
|
+
ctx => !ctx.data?.tenant_id || ctx.data.tenant_id === ctx.auth.tenantId
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
// Extend existing policy
|
|
1673
|
+
const extendedPolicy = extendPolicy(tenantPolicy, {
|
|
1674
|
+
additionalPolicies: [ownerEditPolicy.policies[0]!]
|
|
1675
|
+
})
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
---
|
|
1679
|
+
|
|
1680
|
+
### Audit Trail Integration
|
|
1681
|
+
|
|
1682
|
+
Log all RLS policy decisions with buffering, sampling, and filtering capabilities.
|
|
1683
|
+
|
|
1684
|
+
```typescript
|
|
1685
|
+
import {
|
|
1686
|
+
AuditLogger,
|
|
1687
|
+
createAuditLogger,
|
|
1688
|
+
ConsoleAuditAdapter,
|
|
1689
|
+
InMemoryAuditAdapter,
|
|
1690
|
+
type AuditConfig,
|
|
1691
|
+
type RLSAuditAdapter
|
|
1692
|
+
} from '@kysera/rls'
|
|
1693
|
+
|
|
1694
|
+
// Custom database adapter
|
|
1695
|
+
class DatabaseAuditAdapter implements RLSAuditAdapter {
|
|
1696
|
+
constructor(private db: Kysely<AuditDB>) {}
|
|
1697
|
+
|
|
1698
|
+
async log(event: RLSAuditEvent): Promise<void> {
|
|
1699
|
+
await this.db.insertInto('rls_audit_log')
|
|
1700
|
+
.values({
|
|
1701
|
+
user_id: String(event.userId),
|
|
1702
|
+
operation: event.operation,
|
|
1703
|
+
table_name: event.table,
|
|
1704
|
+
decision: event.decision,
|
|
1705
|
+
policy_name: event.policyName,
|
|
1706
|
+
reason: event.reason,
|
|
1707
|
+
context: JSON.stringify(event.context),
|
|
1708
|
+
created_at: event.timestamp
|
|
1709
|
+
})
|
|
1710
|
+
.execute()
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async logBatch(events: RLSAuditEvent[]): Promise<void> {
|
|
1714
|
+
await this.db.insertInto('rls_audit_log')
|
|
1715
|
+
.values(events.map(e => ({
|
|
1716
|
+
user_id: String(e.userId),
|
|
1717
|
+
operation: e.operation,
|
|
1718
|
+
table_name: e.table,
|
|
1719
|
+
decision: e.decision,
|
|
1720
|
+
policy_name: e.policyName,
|
|
1721
|
+
context: JSON.stringify(e.context),
|
|
1722
|
+
created_at: e.timestamp
|
|
1723
|
+
})))
|
|
1724
|
+
.execute()
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Create audit logger
|
|
1729
|
+
const auditLogger = createAuditLogger({
|
|
1730
|
+
adapter: new DatabaseAuditAdapter(auditDb),
|
|
1731
|
+
enabled: true,
|
|
1732
|
+
bufferSize: 100, // Buffer up to 100 events
|
|
1733
|
+
flushInterval: 5000, // Flush every 5 seconds
|
|
1734
|
+
sampleRate: 1.0, // Log 100% of events (use 0.1 for 10%)
|
|
1735
|
+
async: true, // Fire-and-forget logging
|
|
1736
|
+
|
|
1737
|
+
// Default settings for all tables
|
|
1738
|
+
defaults: {
|
|
1739
|
+
logAllowed: false, // Don't log allow decisions
|
|
1740
|
+
logDenied: true, // Log all denials
|
|
1741
|
+
logFilters: false // Don't log filter applications
|
|
1742
|
+
},
|
|
1743
|
+
|
|
1744
|
+
// Table-specific settings
|
|
1745
|
+
tables: {
|
|
1746
|
+
sensitive_data: {
|
|
1747
|
+
logAllowed: true, // Log all access to sensitive tables
|
|
1748
|
+
includeContext: ['requestId', 'ipAddress']
|
|
1749
|
+
},
|
|
1750
|
+
public_content: {
|
|
1751
|
+
enabled: false // Don't audit public content
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
|
|
1755
|
+
// Error handler
|
|
1756
|
+
onError: (error, events) => {
|
|
1757
|
+
console.error('Audit logging failed:', error, events.length, 'events lost')
|
|
1758
|
+
}
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
// Log decisions
|
|
1762
|
+
await auditLogger.logAllow('read', 'posts', 'ownership-allow')
|
|
1763
|
+
await auditLogger.logDeny('delete', 'posts', 'status-check', {
|
|
1764
|
+
reason: 'Cannot delete published posts',
|
|
1765
|
+
rowIds: [123]
|
|
1766
|
+
})
|
|
1767
|
+
await auditLogger.logFilter('posts', 'tenant-filter')
|
|
1768
|
+
|
|
1769
|
+
// Ensure all events are flushed
|
|
1770
|
+
await auditLogger.flush()
|
|
1771
|
+
|
|
1772
|
+
// Graceful shutdown
|
|
1773
|
+
await auditLogger.close()
|
|
1774
|
+
```
|
|
1775
|
+
|
|
1776
|
+
**Built-in Adapters:**
|
|
1777
|
+
|
|
1778
|
+
```typescript
|
|
1779
|
+
// Console adapter (development)
|
|
1780
|
+
const consoleAdapter = new ConsoleAuditAdapter({
|
|
1781
|
+
format: 'text', // or 'json'
|
|
1782
|
+
colors: true,
|
|
1783
|
+
includeTimestamp: true
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
// In-memory adapter (testing)
|
|
1787
|
+
const memoryAdapter = new InMemoryAuditAdapter(10000) // max 10k events
|
|
1788
|
+
const events = memoryAdapter.getEvents()
|
|
1789
|
+
const stats = memoryAdapter.getStats()
|
|
1790
|
+
const filtered = memoryAdapter.query({
|
|
1791
|
+
userId: '123',
|
|
1792
|
+
decision: 'deny',
|
|
1793
|
+
startTime: new Date('2024-01-01')
|
|
1794
|
+
})
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
---
|
|
1798
|
+
|
|
1799
|
+
### Policy Testing Utilities
|
|
1800
|
+
|
|
1801
|
+
Unit test your RLS policies without a database connection.
|
|
1802
|
+
|
|
1803
|
+
```typescript
|
|
1804
|
+
import {
|
|
1805
|
+
PolicyTester,
|
|
1806
|
+
createPolicyTester,
|
|
1807
|
+
createTestAuthContext,
|
|
1808
|
+
createTestRow,
|
|
1809
|
+
policyAssertions
|
|
1810
|
+
} from '@kysera/rls'
|
|
1811
|
+
|
|
1812
|
+
const tester = createPolicyTester(rlsSchema)
|
|
1813
|
+
|
|
1814
|
+
describe('Post RLS Policies', () => {
|
|
1815
|
+
it('should allow owner to update their post', async () => {
|
|
1816
|
+
const result = await tester.evaluate('posts', 'update', {
|
|
1817
|
+
auth: createTestAuthContext({
|
|
1818
|
+
userId: 'user-1',
|
|
1819
|
+
roles: ['user'],
|
|
1820
|
+
tenantId: 'tenant-1'
|
|
1821
|
+
}),
|
|
1822
|
+
row: createTestRow({
|
|
1823
|
+
id: 'post-1',
|
|
1824
|
+
author_id: 'user-1',
|
|
1825
|
+
tenant_id: 'tenant-1',
|
|
1826
|
+
status: 'draft'
|
|
1827
|
+
})
|
|
1828
|
+
})
|
|
1829
|
+
|
|
1830
|
+
expect(result.allowed).toBe(true)
|
|
1831
|
+
expect(result.policyName).toBe('ownership-allow')
|
|
1832
|
+
expect(result.decisionType).toBe('allow')
|
|
1833
|
+
})
|
|
1834
|
+
|
|
1835
|
+
it('should deny non-owner update', async () => {
|
|
1836
|
+
const result = await tester.evaluate('posts', 'update', {
|
|
1837
|
+
auth: createTestAuthContext({
|
|
1838
|
+
userId: 'user-2',
|
|
1839
|
+
roles: ['user']
|
|
1840
|
+
}),
|
|
1841
|
+
row: createTestRow({
|
|
1842
|
+
id: 'post-1',
|
|
1843
|
+
author_id: 'user-1'
|
|
1844
|
+
})
|
|
1845
|
+
})
|
|
1846
|
+
|
|
1847
|
+
policyAssertions.assertDenied(result)
|
|
1848
|
+
expect(result.reason).toContain('not owner')
|
|
1849
|
+
})
|
|
1850
|
+
|
|
1851
|
+
it('should apply tenant filter correctly', () => {
|
|
1852
|
+
const filters = tester.getFilters('posts', 'read', {
|
|
1853
|
+
auth: createTestAuthContext({
|
|
1854
|
+
userId: 'user-1',
|
|
1855
|
+
tenantId: 'tenant-1'
|
|
1856
|
+
})
|
|
1857
|
+
})
|
|
1858
|
+
|
|
1859
|
+
policyAssertions.assertFiltersInclude(filters, {
|
|
1860
|
+
tenant_id: 'tenant-1'
|
|
1861
|
+
})
|
|
1862
|
+
expect(filters.appliedFilters).toContain('tenant-isolation')
|
|
1863
|
+
})
|
|
1864
|
+
|
|
1865
|
+
it('should bypass for admin role', async () => {
|
|
1866
|
+
const result = await tester.evaluate('posts', 'delete', {
|
|
1867
|
+
auth: createTestAuthContext({
|
|
1868
|
+
userId: 'admin-1',
|
|
1869
|
+
roles: ['admin']
|
|
1870
|
+
}),
|
|
1871
|
+
row: createTestRow({ id: 'post-1' })
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
expect(result.allowed).toBe(true)
|
|
1875
|
+
expect(result.reason).toContain('Role bypass')
|
|
1876
|
+
})
|
|
1877
|
+
|
|
1878
|
+
it('should test specific policy', async () => {
|
|
1879
|
+
const { found, result } = await tester.testPolicy(
|
|
1880
|
+
'posts',
|
|
1881
|
+
'ownership-allow',
|
|
1882
|
+
{
|
|
1883
|
+
auth: createTestAuthContext({ userId: 'user-1' }),
|
|
1884
|
+
row: createTestRow({ author_id: 'user-1' })
|
|
1885
|
+
}
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
expect(found).toBe(true)
|
|
1889
|
+
expect(result).toBe(true)
|
|
1890
|
+
})
|
|
1891
|
+
})
|
|
1892
|
+
|
|
1893
|
+
// List all policies for debugging
|
|
1894
|
+
const policies = tester.listPolicies('posts')
|
|
1895
|
+
console.log('Allows:', policies.allows)
|
|
1896
|
+
console.log('Denies:', policies.denies)
|
|
1897
|
+
console.log('Filters:', policies.filters)
|
|
1898
|
+
console.log('Validates:', policies.validates)
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
**Assertion Helpers:**
|
|
1902
|
+
|
|
1903
|
+
```typescript
|
|
1904
|
+
import { policyAssertions } from '@kysera/rls'
|
|
1905
|
+
|
|
1906
|
+
// Assert allowed
|
|
1907
|
+
policyAssertions.assertAllowed(result, 'Custom error message')
|
|
1908
|
+
|
|
1909
|
+
// Assert denied
|
|
1910
|
+
policyAssertions.assertDenied(result)
|
|
1911
|
+
|
|
1912
|
+
// Assert specific policy made decision
|
|
1913
|
+
policyAssertions.assertPolicyUsed(result, 'ownership-allow')
|
|
1914
|
+
|
|
1915
|
+
// Assert filter conditions
|
|
1916
|
+
policyAssertions.assertFiltersInclude(filterResult, {
|
|
1917
|
+
tenant_id: 'expected-tenant',
|
|
1918
|
+
deleted_at: null
|
|
1919
|
+
})
|
|
1920
|
+
```
|
|
1921
|
+
|
|
1922
|
+
---
|
|
1923
|
+
|
|
1924
|
+
### Conditional Policy Activation
|
|
1925
|
+
|
|
1926
|
+
Activate policies based on environment, feature flags, or time-based conditions.
|
|
1927
|
+
|
|
1928
|
+
```typescript
|
|
1929
|
+
import {
|
|
1930
|
+
whenEnvironment,
|
|
1931
|
+
whenFeature,
|
|
1932
|
+
whenTimeRange,
|
|
1933
|
+
whenCondition,
|
|
1934
|
+
defineRLSSchema,
|
|
1935
|
+
filter,
|
|
1936
|
+
allow,
|
|
1937
|
+
deny
|
|
1938
|
+
} from '@kysera/rls'
|
|
1939
|
+
|
|
1940
|
+
const schema = defineRLSSchema<Database>({
|
|
1941
|
+
posts: {
|
|
1942
|
+
policies: [
|
|
1943
|
+
// Only active in production
|
|
1944
|
+
whenEnvironment('production', () =>
|
|
1945
|
+
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
|
|
1946
|
+
),
|
|
1947
|
+
|
|
1948
|
+
// Only when feature flag is enabled
|
|
1949
|
+
whenFeature('strict-rls', () =>
|
|
1950
|
+
deny('delete', ctx => ctx.row?.status === 'published')
|
|
1951
|
+
),
|
|
1952
|
+
|
|
1953
|
+
// Business hours only (9 AM - 6 PM)
|
|
1954
|
+
whenTimeRange(9, 18, () =>
|
|
1955
|
+
allow('create', ctx => ctx.auth.roles.includes('user'))
|
|
1956
|
+
),
|
|
1957
|
+
|
|
1958
|
+
// Overnight maintenance (10 PM - 6 AM, crosses midnight)
|
|
1959
|
+
whenTimeRange(22, 6, () =>
|
|
1960
|
+
deny('all', () => true, { name: 'maintenance-mode' })
|
|
1961
|
+
),
|
|
1962
|
+
|
|
1963
|
+
// Custom condition
|
|
1964
|
+
whenCondition(
|
|
1965
|
+
ctx => ctx.meta?.featureFlags?.includes('beta') ?? false,
|
|
1966
|
+
() => allow('read', ctx => ctx.auth.attributes?.betaTester === true)
|
|
1967
|
+
),
|
|
1968
|
+
|
|
1969
|
+
// Nested conditions
|
|
1970
|
+
whenEnvironment('production', () =>
|
|
1971
|
+
whenFeature('audit-mode', () =>
|
|
1972
|
+
filter('read', ctx => ({
|
|
1973
|
+
...ctx.auth.tenantId && { tenant_id: ctx.auth.tenantId },
|
|
1974
|
+
audit_enabled: true
|
|
1975
|
+
}))
|
|
1976
|
+
)
|
|
1977
|
+
)
|
|
1978
|
+
]
|
|
1979
|
+
}
|
|
1980
|
+
})
|
|
1981
|
+
```
|
|
1982
|
+
|
|
1983
|
+
**Setting Activation Context:**
|
|
1984
|
+
|
|
1985
|
+
```typescript
|
|
1986
|
+
await rlsContext.runAsync({
|
|
1987
|
+
auth: {
|
|
1988
|
+
userId: user.id,
|
|
1989
|
+
roles: user.roles,
|
|
1990
|
+
tenantId: user.tenantId
|
|
1991
|
+
},
|
|
1992
|
+
timestamp: new Date(),
|
|
1993
|
+
meta: {
|
|
1994
|
+
environment: process.env.NODE_ENV,
|
|
1995
|
+
features: new Set(['strict-rls', 'audit-mode']),
|
|
1996
|
+
featureFlags: ['beta', 'new-ui']
|
|
1997
|
+
}
|
|
1998
|
+
}, async () => {
|
|
1999
|
+
// Policies will check activation conditions
|
|
2000
|
+
const posts = await orm.posts.findAll()
|
|
2001
|
+
})
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
**Feature Flag Integration:**
|
|
2005
|
+
|
|
2006
|
+
```typescript
|
|
2007
|
+
// With object-style features
|
|
2008
|
+
meta: {
|
|
2009
|
+
features: {
|
|
2010
|
+
'strict-rls': true,
|
|
2011
|
+
'audit-mode': false,
|
|
2012
|
+
'beta-features': user.isBetaTester
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// With Set
|
|
2017
|
+
meta: {
|
|
2018
|
+
features: new Set(['strict-rls', 'audit-mode'])
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// With array
|
|
2022
|
+
meta: {
|
|
2023
|
+
features: ['strict-rls', 'audit-mode']
|
|
2024
|
+
}
|
|
2025
|
+
```
|
|
2026
|
+
|
|
2027
|
+
---
|
|
2028
|
+
|
|
2029
|
+
## Complete API Exports
|
|
2030
|
+
|
|
2031
|
+
```typescript
|
|
2032
|
+
// Core
|
|
2033
|
+
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
|
|
2034
|
+
export { allow, deny, filter, validate } from '@kysera/rls'
|
|
2035
|
+
export { whenEnvironment, whenFeature, whenTimeRange, whenCondition } from '@kysera/rls'
|
|
2036
|
+
export { rlsPlugin } from '@kysera/rls'
|
|
2037
|
+
export { rlsContext, createRLSContext, withRLSContext, withRLSContextAsync } from '@kysera/rls'
|
|
2038
|
+
|
|
2039
|
+
// Context Resolvers
|
|
2040
|
+
export {
|
|
2041
|
+
ResolverManager,
|
|
2042
|
+
createResolverManager,
|
|
2043
|
+
createResolver,
|
|
2044
|
+
InMemoryCacheProvider,
|
|
2045
|
+
type ContextResolver,
|
|
2046
|
+
type EnhancedRLSContext,
|
|
2047
|
+
type ResolvedData
|
|
2048
|
+
} from '@kysera/rls'
|
|
2049
|
+
|
|
2050
|
+
// ReBAC
|
|
2051
|
+
export {
|
|
2052
|
+
ReBAcRegistry,
|
|
2053
|
+
ReBAcTransformer,
|
|
2054
|
+
createReBAcRegistry,
|
|
2055
|
+
createReBAcTransformer,
|
|
2056
|
+
orgMembershipPath,
|
|
2057
|
+
shopOrgMembershipPath,
|
|
2058
|
+
teamHierarchyPath,
|
|
2059
|
+
allowRelation,
|
|
2060
|
+
denyRelation,
|
|
2061
|
+
type RelationshipPath,
|
|
2062
|
+
type ReBAcSchema
|
|
2063
|
+
} from '@kysera/rls'
|
|
2064
|
+
|
|
2065
|
+
// Field Access
|
|
2066
|
+
export {
|
|
2067
|
+
FieldAccessRegistry,
|
|
2068
|
+
FieldAccessProcessor,
|
|
2069
|
+
createFieldAccessRegistry,
|
|
2070
|
+
createFieldAccessProcessor,
|
|
2071
|
+
ownerOnly,
|
|
2072
|
+
rolesOnly,
|
|
2073
|
+
readOnly,
|
|
2074
|
+
neverAccessible,
|
|
2075
|
+
publicReadRestrictedWrite,
|
|
2076
|
+
maskedField,
|
|
2077
|
+
ownerOrRoles,
|
|
2078
|
+
type FieldAccessSchema
|
|
2079
|
+
} from '@kysera/rls'
|
|
2080
|
+
|
|
2081
|
+
// Policy Composition
|
|
2082
|
+
export {
|
|
2083
|
+
createTenantIsolationPolicy,
|
|
2084
|
+
createOwnershipPolicy,
|
|
2085
|
+
createSoftDeletePolicy,
|
|
2086
|
+
createStatusAccessPolicy,
|
|
2087
|
+
createAdminPolicy,
|
|
2088
|
+
composePolicies,
|
|
2089
|
+
extendPolicy,
|
|
2090
|
+
defineFilterPolicy,
|
|
2091
|
+
defineAllowPolicy,
|
|
2092
|
+
defineDenyPolicy,
|
|
2093
|
+
defineValidatePolicy,
|
|
2094
|
+
defineCombinedPolicy,
|
|
2095
|
+
type ReusablePolicy
|
|
2096
|
+
} from '@kysera/rls'
|
|
2097
|
+
|
|
2098
|
+
// Audit Trail
|
|
2099
|
+
export {
|
|
2100
|
+
AuditLogger,
|
|
2101
|
+
createAuditLogger,
|
|
2102
|
+
ConsoleAuditAdapter,
|
|
2103
|
+
InMemoryAuditAdapter,
|
|
2104
|
+
type RLSAuditEvent,
|
|
2105
|
+
type RLSAuditAdapter,
|
|
2106
|
+
type AuditConfig
|
|
2107
|
+
} from '@kysera/rls'
|
|
2108
|
+
|
|
2109
|
+
// Testing
|
|
2110
|
+
export {
|
|
2111
|
+
PolicyTester,
|
|
2112
|
+
createPolicyTester,
|
|
2113
|
+
createTestAuthContext,
|
|
2114
|
+
createTestRow,
|
|
2115
|
+
policyAssertions,
|
|
2116
|
+
type PolicyEvaluationResult,
|
|
2117
|
+
type FilterEvaluationResult,
|
|
2118
|
+
type TestContext
|
|
2119
|
+
} from '@kysera/rls'
|
|
2120
|
+
|
|
2121
|
+
// Errors
|
|
2122
|
+
export {
|
|
2123
|
+
RLSError,
|
|
2124
|
+
RLSContextError,
|
|
2125
|
+
RLSPolicyViolation,
|
|
2126
|
+
RLSPolicyEvaluationError,
|
|
2127
|
+
RLSSchemaError,
|
|
2128
|
+
RLSContextValidationError,
|
|
2129
|
+
RLSErrorCodes
|
|
2130
|
+
} from '@kysera/rls'
|
|
2131
|
+
```
|
|
2132
|
+
|
|
2133
|
+
---
|
|
2134
|
+
|
|
1319
2135
|
## License
|
|
1320
2136
|
|
|
1321
2137
|
MIT
|