@kysera/rls 0.8.1 → 0.8.3
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 +2841 -11
- package/dist/index.js +2451 -1
- 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 +11 -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/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/dist/index.js
CHANGED
|
@@ -312,6 +312,9 @@ function allow(operation, condition, options) {
|
|
|
312
312
|
if (options?.hints !== void 0) {
|
|
313
313
|
policy.hints = options.hints;
|
|
314
314
|
}
|
|
315
|
+
if (options?.condition !== void 0) {
|
|
316
|
+
policy.activationCondition = options.condition;
|
|
317
|
+
}
|
|
315
318
|
return policy;
|
|
316
319
|
}
|
|
317
320
|
function deny(operation, condition, options) {
|
|
@@ -328,6 +331,9 @@ function deny(operation, condition, options) {
|
|
|
328
331
|
if (options?.hints !== void 0) {
|
|
329
332
|
policy.hints = options.hints;
|
|
330
333
|
}
|
|
334
|
+
if (options?.condition !== void 0) {
|
|
335
|
+
policy.activationCondition = options.condition;
|
|
336
|
+
}
|
|
331
337
|
return policy;
|
|
332
338
|
}
|
|
333
339
|
function filter(operation, condition, options) {
|
|
@@ -343,6 +349,9 @@ function filter(operation, condition, options) {
|
|
|
343
349
|
if (options?.hints !== void 0) {
|
|
344
350
|
policy.hints = options.hints;
|
|
345
351
|
}
|
|
352
|
+
if (options?.condition !== void 0) {
|
|
353
|
+
policy.activationCondition = options.condition;
|
|
354
|
+
}
|
|
346
355
|
return policy;
|
|
347
356
|
}
|
|
348
357
|
function validate(operation, condition, options) {
|
|
@@ -359,6 +368,57 @@ function validate(operation, condition, options) {
|
|
|
359
368
|
if (options?.hints !== void 0) {
|
|
360
369
|
policy.hints = options.hints;
|
|
361
370
|
}
|
|
371
|
+
if (options?.condition !== void 0) {
|
|
372
|
+
policy.activationCondition = options.condition;
|
|
373
|
+
}
|
|
374
|
+
return policy;
|
|
375
|
+
}
|
|
376
|
+
function whenEnvironment(environments, policyFn) {
|
|
377
|
+
const policy = policyFn();
|
|
378
|
+
const existingCondition = policy.activationCondition;
|
|
379
|
+
policy.activationCondition = (ctx) => {
|
|
380
|
+
const envMatch = environments.includes(ctx.environment ?? "");
|
|
381
|
+
if (!envMatch) return false;
|
|
382
|
+
return existingCondition ? existingCondition(ctx) : true;
|
|
383
|
+
};
|
|
384
|
+
return policy;
|
|
385
|
+
}
|
|
386
|
+
function whenFeature(feature, policyFn) {
|
|
387
|
+
const policy = policyFn();
|
|
388
|
+
const existingCondition = policy.activationCondition;
|
|
389
|
+
policy.activationCondition = (ctx) => {
|
|
390
|
+
let featureEnabled = false;
|
|
391
|
+
if (Array.isArray(ctx.features)) {
|
|
392
|
+
featureEnabled = ctx.features.includes(feature);
|
|
393
|
+
} else if (ctx.features && typeof ctx.features === "object" && "has" in ctx.features) {
|
|
394
|
+
featureEnabled = ctx.features.has(feature);
|
|
395
|
+
} else if (ctx.features && typeof ctx.features === "object") {
|
|
396
|
+
featureEnabled = !!ctx.features[feature];
|
|
397
|
+
}
|
|
398
|
+
if (!featureEnabled) return false;
|
|
399
|
+
return existingCondition ? existingCondition(ctx) : true;
|
|
400
|
+
};
|
|
401
|
+
return policy;
|
|
402
|
+
}
|
|
403
|
+
function whenTimeRange(startHour, endHour, policyFn) {
|
|
404
|
+
const policy = policyFn();
|
|
405
|
+
const existingCondition = policy.activationCondition;
|
|
406
|
+
policy.activationCondition = (ctx) => {
|
|
407
|
+
const hour = (ctx.timestamp ?? /* @__PURE__ */ new Date()).getHours();
|
|
408
|
+
let inRange;
|
|
409
|
+
if (startHour > endHour) {
|
|
410
|
+
inRange = hour >= startHour || hour < endHour;
|
|
411
|
+
} else {
|
|
412
|
+
inRange = hour >= startHour && hour < endHour;
|
|
413
|
+
}
|
|
414
|
+
if (!inRange) return false;
|
|
415
|
+
return existingCondition ? existingCondition(ctx) : true;
|
|
416
|
+
};
|
|
417
|
+
return policy;
|
|
418
|
+
}
|
|
419
|
+
function whenCondition(condition, policyFn) {
|
|
420
|
+
const policy = policyFn();
|
|
421
|
+
policy.activationCondition = condition;
|
|
362
422
|
return policy;
|
|
363
423
|
}
|
|
364
424
|
var PolicyRegistry = class {
|
|
@@ -1640,6 +1700,2396 @@ function normalizeOperations(operation) {
|
|
|
1640
1700
|
return [operation];
|
|
1641
1701
|
}
|
|
1642
1702
|
|
|
1643
|
-
|
|
1703
|
+
// src/resolvers/types.ts
|
|
1704
|
+
var InMemoryCacheProvider = class {
|
|
1705
|
+
cache = /* @__PURE__ */ new Map();
|
|
1706
|
+
get(key) {
|
|
1707
|
+
const entry = this.cache.get(key);
|
|
1708
|
+
if (!entry) return Promise.resolve(null);
|
|
1709
|
+
if (Date.now() > entry.expiresAt) {
|
|
1710
|
+
this.cache.delete(key);
|
|
1711
|
+
return Promise.resolve(null);
|
|
1712
|
+
}
|
|
1713
|
+
return Promise.resolve(entry.value);
|
|
1714
|
+
}
|
|
1715
|
+
set(key, value, ttlSeconds) {
|
|
1716
|
+
this.cache.set(key, {
|
|
1717
|
+
value,
|
|
1718
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
1719
|
+
});
|
|
1720
|
+
return Promise.resolve();
|
|
1721
|
+
}
|
|
1722
|
+
delete(key) {
|
|
1723
|
+
this.cache.delete(key);
|
|
1724
|
+
return Promise.resolve();
|
|
1725
|
+
}
|
|
1726
|
+
deletePattern(pattern) {
|
|
1727
|
+
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
1728
|
+
for (const key of this.cache.keys()) {
|
|
1729
|
+
if (key.startsWith(prefix)) {
|
|
1730
|
+
this.cache.delete(key);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
return Promise.resolve();
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Clear all cached entries
|
|
1737
|
+
*/
|
|
1738
|
+
clear() {
|
|
1739
|
+
this.cache.clear();
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Get current cache size
|
|
1743
|
+
*/
|
|
1744
|
+
get size() {
|
|
1745
|
+
return this.cache.size;
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
// src/resolvers/manager.ts
|
|
1750
|
+
var ResolverManager = class {
|
|
1751
|
+
resolvers = /* @__PURE__ */ new Map();
|
|
1752
|
+
cacheProvider;
|
|
1753
|
+
defaultCacheTtl;
|
|
1754
|
+
parallelResolution;
|
|
1755
|
+
resolverTimeout;
|
|
1756
|
+
logger;
|
|
1757
|
+
constructor(options = {}) {
|
|
1758
|
+
this.cacheProvider = options.cacheProvider ?? new InMemoryCacheProvider();
|
|
1759
|
+
this.defaultCacheTtl = options.defaultCacheTtl ?? 300;
|
|
1760
|
+
this.parallelResolution = options.parallelResolution ?? true;
|
|
1761
|
+
this.resolverTimeout = options.resolverTimeout ?? 5e3;
|
|
1762
|
+
this.logger = options.logger;
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Register a context resolver
|
|
1766
|
+
*
|
|
1767
|
+
* @param resolver - Resolver to register
|
|
1768
|
+
* @throws RLSError if resolver with same name already exists
|
|
1769
|
+
*/
|
|
1770
|
+
register(resolver) {
|
|
1771
|
+
if (this.resolvers.has(resolver.name)) {
|
|
1772
|
+
throw new RLSError(
|
|
1773
|
+
`Resolver "${resolver.name}" is already registered`,
|
|
1774
|
+
RLSErrorCodes.RLS_SCHEMA_INVALID
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
if (resolver.dependsOn) {
|
|
1778
|
+
for (const dep of resolver.dependsOn) {
|
|
1779
|
+
if (dep === resolver.name) {
|
|
1780
|
+
throw new RLSError(
|
|
1781
|
+
`Resolver "${resolver.name}" cannot depend on itself`,
|
|
1782
|
+
RLSErrorCodes.RLS_SCHEMA_INVALID
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
this.resolvers.set(resolver.name, resolver);
|
|
1788
|
+
this.logger?.debug?.(`[ResolverManager] Registered resolver: ${resolver.name}`);
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Unregister a context resolver
|
|
1792
|
+
*
|
|
1793
|
+
* @param name - Name of resolver to unregister
|
|
1794
|
+
* @returns true if resolver was removed, false if it didn't exist
|
|
1795
|
+
*/
|
|
1796
|
+
unregister(name) {
|
|
1797
|
+
const removed = this.resolvers.delete(name);
|
|
1798
|
+
if (removed) {
|
|
1799
|
+
this.logger?.debug?.(`[ResolverManager] Unregistered resolver: ${name}`);
|
|
1800
|
+
}
|
|
1801
|
+
return removed;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Check if a resolver is registered
|
|
1805
|
+
*
|
|
1806
|
+
* @param name - Resolver name
|
|
1807
|
+
*/
|
|
1808
|
+
hasResolver(name) {
|
|
1809
|
+
return this.resolvers.has(name);
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Get all registered resolver names
|
|
1813
|
+
*/
|
|
1814
|
+
getResolverNames() {
|
|
1815
|
+
return Array.from(this.resolvers.keys());
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Resolve context data using all registered resolvers
|
|
1819
|
+
*
|
|
1820
|
+
* @param baseContext - Base context to resolve
|
|
1821
|
+
* @returns Enhanced context with resolved data
|
|
1822
|
+
*
|
|
1823
|
+
* @example
|
|
1824
|
+
* ```typescript
|
|
1825
|
+
* const baseCtx = {
|
|
1826
|
+
* auth: { userId: '123', roles: ['user'], tenantId: 'acme' },
|
|
1827
|
+
* timestamp: new Date()
|
|
1828
|
+
* };
|
|
1829
|
+
*
|
|
1830
|
+
* const enhancedCtx = await manager.resolve(baseCtx);
|
|
1831
|
+
* // enhancedCtx.auth.resolved contains all resolved data
|
|
1832
|
+
* ```
|
|
1833
|
+
*/
|
|
1834
|
+
async resolve(baseContext) {
|
|
1835
|
+
const startTime = Date.now();
|
|
1836
|
+
const resolverOrder = this.getResolverOrder();
|
|
1837
|
+
this.logger?.debug?.(`[ResolverManager] Starting resolution for user ${baseContext.auth.userId}`, {
|
|
1838
|
+
resolverCount: resolverOrder.length,
|
|
1839
|
+
resolvers: resolverOrder.map((r) => r.name)
|
|
1840
|
+
});
|
|
1841
|
+
const results = /* @__PURE__ */ new Map();
|
|
1842
|
+
if (this.parallelResolution) {
|
|
1843
|
+
await this.resolveParallel(baseContext, resolverOrder, results);
|
|
1844
|
+
} else {
|
|
1845
|
+
await this.resolveSequential(baseContext, resolverOrder, results);
|
|
1846
|
+
}
|
|
1847
|
+
const mergedResolved = this.mergeResolvedData(results);
|
|
1848
|
+
const enhancedContext = {
|
|
1849
|
+
auth: {
|
|
1850
|
+
...baseContext.auth,
|
|
1851
|
+
resolved: mergedResolved
|
|
1852
|
+
},
|
|
1853
|
+
timestamp: baseContext.timestamp
|
|
1854
|
+
};
|
|
1855
|
+
if (baseContext.meta !== void 0) {
|
|
1856
|
+
enhancedContext.meta = baseContext.meta;
|
|
1857
|
+
}
|
|
1858
|
+
const duration = Date.now() - startTime;
|
|
1859
|
+
this.logger?.info?.(`[ResolverManager] Resolution completed`, {
|
|
1860
|
+
userId: baseContext.auth.userId,
|
|
1861
|
+
durationMs: duration,
|
|
1862
|
+
resolverCount: results.size
|
|
1863
|
+
});
|
|
1864
|
+
return enhancedContext;
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Resolve a single resolver (useful for partial updates)
|
|
1868
|
+
*
|
|
1869
|
+
* @param name - Resolver name
|
|
1870
|
+
* @param baseContext - Base context
|
|
1871
|
+
* @returns Resolved data from the specific resolver
|
|
1872
|
+
*/
|
|
1873
|
+
async resolveOne(name, baseContext) {
|
|
1874
|
+
const resolver = this.resolvers.get(name);
|
|
1875
|
+
if (!resolver) {
|
|
1876
|
+
this.logger?.warn?.(`[ResolverManager] Resolver not found: ${name}`);
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
return await this.resolveWithCache(resolver, baseContext);
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Invalidate cached data for a user
|
|
1883
|
+
*
|
|
1884
|
+
* @param userId - User ID whose cache should be invalidated
|
|
1885
|
+
* @param resolverName - Optional specific resolver to invalidate
|
|
1886
|
+
*/
|
|
1887
|
+
async invalidateCache(userId, resolverName) {
|
|
1888
|
+
if (resolverName) {
|
|
1889
|
+
const resolver = this.resolvers.get(resolverName);
|
|
1890
|
+
if (resolver?.cacheKey) {
|
|
1891
|
+
const key = resolver.cacheKey({ auth: { userId, roles: [] }, timestamp: /* @__PURE__ */ new Date() });
|
|
1892
|
+
if (key) {
|
|
1893
|
+
await this.cacheProvider.delete(key);
|
|
1894
|
+
this.logger?.debug?.(`[ResolverManager] Invalidated cache for ${resolverName}: ${key}`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
} else {
|
|
1898
|
+
const pattern = `rls:*:${userId}:*`;
|
|
1899
|
+
if (this.cacheProvider.deletePattern) {
|
|
1900
|
+
await this.cacheProvider.deletePattern(pattern);
|
|
1901
|
+
}
|
|
1902
|
+
this.logger?.debug?.(`[ResolverManager] Invalidated all cache for user ${userId}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Clear all cached data
|
|
1907
|
+
*/
|
|
1908
|
+
async clearCache() {
|
|
1909
|
+
if (this.cacheProvider instanceof InMemoryCacheProvider) {
|
|
1910
|
+
this.cacheProvider.clear();
|
|
1911
|
+
} else if (this.cacheProvider.deletePattern) {
|
|
1912
|
+
await this.cacheProvider.deletePattern("rls:*");
|
|
1913
|
+
}
|
|
1914
|
+
this.logger?.info?.("[ResolverManager] Cleared all cache");
|
|
1915
|
+
}
|
|
1916
|
+
// ============================================================================
|
|
1917
|
+
// Private Methods
|
|
1918
|
+
// ============================================================================
|
|
1919
|
+
/**
|
|
1920
|
+
* Get resolvers in dependency order (topological sort)
|
|
1921
|
+
*/
|
|
1922
|
+
getResolverOrder() {
|
|
1923
|
+
const ordered = [];
|
|
1924
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1925
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1926
|
+
const visit = (name) => {
|
|
1927
|
+
if (visited.has(name)) return;
|
|
1928
|
+
if (visiting.has(name)) {
|
|
1929
|
+
throw new RLSError(
|
|
1930
|
+
`Circular dependency detected in resolvers involving "${name}"`,
|
|
1931
|
+
RLSErrorCodes.RLS_SCHEMA_INVALID
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
const resolver = this.resolvers.get(name);
|
|
1935
|
+
if (!resolver) return;
|
|
1936
|
+
visiting.add(name);
|
|
1937
|
+
if (resolver.dependsOn) {
|
|
1938
|
+
for (const dep of resolver.dependsOn) {
|
|
1939
|
+
visit(dep);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
visiting.delete(name);
|
|
1943
|
+
visited.add(name);
|
|
1944
|
+
ordered.push(resolver);
|
|
1945
|
+
};
|
|
1946
|
+
for (const name of this.resolvers.keys()) {
|
|
1947
|
+
visit(name);
|
|
1948
|
+
}
|
|
1949
|
+
return ordered.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Resolve resolvers sequentially
|
|
1953
|
+
*/
|
|
1954
|
+
async resolveSequential(baseContext, resolvers, results) {
|
|
1955
|
+
for (const resolver of resolvers) {
|
|
1956
|
+
const data = await this.resolveWithCache(resolver, baseContext);
|
|
1957
|
+
if (data) {
|
|
1958
|
+
results.set(resolver.name, data);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Resolve resolvers in parallel (respecting dependencies)
|
|
1964
|
+
*/
|
|
1965
|
+
async resolveParallel(baseContext, resolvers, results) {
|
|
1966
|
+
const levels = [];
|
|
1967
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
1968
|
+
const getLevel = (resolver) => {
|
|
1969
|
+
if (!resolver.dependsOn || resolver.dependsOn.length === 0) {
|
|
1970
|
+
return 0;
|
|
1971
|
+
}
|
|
1972
|
+
let maxDepLevel = 0;
|
|
1973
|
+
for (const dep of resolver.dependsOn) {
|
|
1974
|
+
const depResolver = this.resolvers.get(dep);
|
|
1975
|
+
if (depResolver) {
|
|
1976
|
+
maxDepLevel = Math.max(maxDepLevel, getLevel(depResolver) + 1);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return maxDepLevel;
|
|
1980
|
+
};
|
|
1981
|
+
for (const resolver of resolvers) {
|
|
1982
|
+
const level = getLevel(resolver);
|
|
1983
|
+
while (levels.length <= level) {
|
|
1984
|
+
levels.push([]);
|
|
1985
|
+
}
|
|
1986
|
+
levels[level].push(resolver);
|
|
1987
|
+
assigned.add(resolver.name);
|
|
1988
|
+
}
|
|
1989
|
+
for (const level of levels) {
|
|
1990
|
+
await Promise.all(
|
|
1991
|
+
level.map(async (resolver) => {
|
|
1992
|
+
const data = await this.resolveWithCache(resolver, baseContext);
|
|
1993
|
+
if (data) {
|
|
1994
|
+
results.set(resolver.name, data);
|
|
1995
|
+
}
|
|
1996
|
+
})
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Resolve a single resolver with caching
|
|
2002
|
+
*/
|
|
2003
|
+
async resolveWithCache(resolver, baseContext) {
|
|
2004
|
+
const startTime = Date.now();
|
|
2005
|
+
try {
|
|
2006
|
+
if (resolver.cacheKey) {
|
|
2007
|
+
const cacheKey = resolver.cacheKey(baseContext);
|
|
2008
|
+
if (cacheKey) {
|
|
2009
|
+
const cached = await this.cacheProvider.get(cacheKey);
|
|
2010
|
+
if (cached) {
|
|
2011
|
+
this.logger?.debug?.(`[ResolverManager] Cache hit for ${resolver.name}`, { cacheKey });
|
|
2012
|
+
return cached;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
const data = await this.withTimeout(
|
|
2017
|
+
resolver.resolve(baseContext),
|
|
2018
|
+
this.resolverTimeout,
|
|
2019
|
+
`Resolver "${resolver.name}" timed out after ${this.resolverTimeout}ms`
|
|
2020
|
+
);
|
|
2021
|
+
if (resolver.cacheKey) {
|
|
2022
|
+
const cacheKey = resolver.cacheKey(baseContext);
|
|
2023
|
+
if (cacheKey) {
|
|
2024
|
+
const ttl = resolver.cacheTtl ?? this.defaultCacheTtl;
|
|
2025
|
+
await this.cacheProvider.set(cacheKey, data, ttl);
|
|
2026
|
+
this.logger?.debug?.(`[ResolverManager] Cached ${resolver.name}`, { cacheKey, ttl });
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const duration = Date.now() - startTime;
|
|
2030
|
+
this.logger?.debug?.(`[ResolverManager] Resolved ${resolver.name}`, { durationMs: duration });
|
|
2031
|
+
return data;
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
const duration = Date.now() - startTime;
|
|
2034
|
+
this.logger?.error?.(`[ResolverManager] Failed to resolve ${resolver.name}`, {
|
|
2035
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2036
|
+
durationMs: duration
|
|
2037
|
+
});
|
|
2038
|
+
if (resolver.required !== false) {
|
|
2039
|
+
throw new RLSError(
|
|
2040
|
+
`Required resolver "${resolver.name}" failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2041
|
+
RLSErrorCodes.RLS_POLICY_EVALUATION_ERROR
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
return null;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Execute a promise with timeout
|
|
2049
|
+
*/
|
|
2050
|
+
async withTimeout(promise, timeoutMs, message) {
|
|
2051
|
+
return await Promise.race([
|
|
2052
|
+
promise,
|
|
2053
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
2054
|
+
reject(new Error(message));
|
|
2055
|
+
}, timeoutMs))
|
|
2056
|
+
]);
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Merge resolved data from multiple resolvers
|
|
2060
|
+
*/
|
|
2061
|
+
mergeResolvedData(results) {
|
|
2062
|
+
const merged = {
|
|
2063
|
+
resolvedAt: /* @__PURE__ */ new Date()
|
|
2064
|
+
};
|
|
2065
|
+
for (const [name, data] of results) {
|
|
2066
|
+
merged[name] = data;
|
|
2067
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2068
|
+
if (key !== "resolvedAt" && key !== "cacheKey") {
|
|
2069
|
+
if (merged[key] === void 0) {
|
|
2070
|
+
merged[key] = value;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return merged;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
function createResolverManager(options) {
|
|
2079
|
+
return new ResolverManager(options);
|
|
2080
|
+
}
|
|
2081
|
+
function createResolver(config) {
|
|
2082
|
+
return {
|
|
2083
|
+
required: true,
|
|
2084
|
+
priority: 0,
|
|
2085
|
+
...config
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// src/rebac/types.ts
|
|
2090
|
+
function orgMembershipPath(resourceTable, organizationColumn = "organization_id") {
|
|
2091
|
+
return {
|
|
2092
|
+
name: `${resourceTable}_org_membership`,
|
|
2093
|
+
description: `Access ${resourceTable} through organization membership`,
|
|
2094
|
+
steps: [
|
|
2095
|
+
{
|
|
2096
|
+
from: resourceTable,
|
|
2097
|
+
to: "organizations",
|
|
2098
|
+
fromColumn: organizationColumn,
|
|
2099
|
+
toColumn: "id"
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
from: "organizations",
|
|
2103
|
+
to: "employees",
|
|
2104
|
+
fromColumn: "id",
|
|
2105
|
+
toColumn: "organization_id"
|
|
2106
|
+
}
|
|
2107
|
+
]
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
function shopOrgMembershipPath(resourceTable, shopColumn = "shop_id") {
|
|
2111
|
+
return {
|
|
2112
|
+
name: `${resourceTable}_shop_org_membership`,
|
|
2113
|
+
description: `Access ${resourceTable} through shop's organization membership`,
|
|
2114
|
+
steps: [
|
|
2115
|
+
{
|
|
2116
|
+
from: resourceTable,
|
|
2117
|
+
to: "shops",
|
|
2118
|
+
fromColumn: shopColumn,
|
|
2119
|
+
toColumn: "id"
|
|
2120
|
+
},
|
|
2121
|
+
{
|
|
2122
|
+
from: "shops",
|
|
2123
|
+
to: "organizations",
|
|
2124
|
+
fromColumn: "organization_id",
|
|
2125
|
+
toColumn: "id"
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
from: "organizations",
|
|
2129
|
+
to: "employees",
|
|
2130
|
+
fromColumn: "id",
|
|
2131
|
+
toColumn: "organization_id"
|
|
2132
|
+
}
|
|
2133
|
+
]
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function teamHierarchyPath(resourceTable, teamColumn = "team_id") {
|
|
2137
|
+
return {
|
|
2138
|
+
name: `${resourceTable}_team_access`,
|
|
2139
|
+
description: `Access ${resourceTable} through team membership`,
|
|
2140
|
+
steps: [
|
|
2141
|
+
{
|
|
2142
|
+
from: resourceTable,
|
|
2143
|
+
to: "teams",
|
|
2144
|
+
fromColumn: teamColumn,
|
|
2145
|
+
toColumn: "id"
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
from: "teams",
|
|
2149
|
+
to: "team_members",
|
|
2150
|
+
fromColumn: "id",
|
|
2151
|
+
toColumn: "team_id"
|
|
2152
|
+
}
|
|
2153
|
+
]
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
var ReBAcRegistry = class {
|
|
2157
|
+
tables = /* @__PURE__ */ new Map();
|
|
2158
|
+
globalRelationships = /* @__PURE__ */ new Map();
|
|
2159
|
+
logger;
|
|
2160
|
+
constructor(schema, options) {
|
|
2161
|
+
this.logger = options?.logger ?? silentLogger;
|
|
2162
|
+
if (schema) {
|
|
2163
|
+
this.loadSchema(schema);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Load ReBAC schema
|
|
2168
|
+
*/
|
|
2169
|
+
loadSchema(schema) {
|
|
2170
|
+
for (const [table, config] of Object.entries(schema)) {
|
|
2171
|
+
if (!config) continue;
|
|
2172
|
+
this.registerTable(table, config);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Register ReBAC configuration for a single table
|
|
2177
|
+
*/
|
|
2178
|
+
registerTable(table, config) {
|
|
2179
|
+
const compiled = {
|
|
2180
|
+
relationships: /* @__PURE__ */ new Map(),
|
|
2181
|
+
policies: []
|
|
2182
|
+
};
|
|
2183
|
+
for (const rel of config.relationships) {
|
|
2184
|
+
const compiledPath = this.compileRelationshipPath(rel, table);
|
|
2185
|
+
compiled.relationships.set(rel.name, compiledPath);
|
|
2186
|
+
this.globalRelationships.set(rel.name, compiledPath);
|
|
2187
|
+
}
|
|
2188
|
+
for (let i = 0; i < config.policies.length; i++) {
|
|
2189
|
+
const policy = config.policies[i];
|
|
2190
|
+
if (!policy) continue;
|
|
2191
|
+
const policyName = policy.name ?? `${table}_rebac_policy_${i}`;
|
|
2192
|
+
const compiledPolicy = this.compilePolicy(policy, policyName, table, compiled.relationships);
|
|
2193
|
+
compiled.policies.push(compiledPolicy);
|
|
2194
|
+
}
|
|
2195
|
+
compiled.policies.sort((a, b) => b.priority - a.priority);
|
|
2196
|
+
this.tables.set(table, compiled);
|
|
2197
|
+
this.logger.info?.(`[ReBAC] Registered table: ${table}`, {
|
|
2198
|
+
relationships: config.relationships.length,
|
|
2199
|
+
policies: config.policies.length
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Register a global relationship path (available to all tables)
|
|
2204
|
+
*/
|
|
2205
|
+
registerRelationship(path) {
|
|
2206
|
+
if (!path.steps.length) {
|
|
2207
|
+
throw new RLSSchemaError(`Relationship path "${path.name}" has no steps`, {
|
|
2208
|
+
path: path.name
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
const compiled = this.compileRelationshipPath(path, path.steps[0].from);
|
|
2212
|
+
this.globalRelationships.set(path.name, compiled);
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Get ReBAC policies for a table and operation
|
|
2216
|
+
*/
|
|
2217
|
+
getPolicies(table, operation) {
|
|
2218
|
+
const config = this.tables.get(table);
|
|
2219
|
+
if (!config) return [];
|
|
2220
|
+
return config.policies.filter((p) => p.operations.has(operation) || p.operations.has("all"));
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Get a specific relationship path
|
|
2224
|
+
*/
|
|
2225
|
+
getRelationship(name, table) {
|
|
2226
|
+
if (table) {
|
|
2227
|
+
const tableConfig = this.tables.get(table);
|
|
2228
|
+
const tablePath = tableConfig?.relationships.get(name);
|
|
2229
|
+
if (tablePath) return tablePath;
|
|
2230
|
+
}
|
|
2231
|
+
return this.globalRelationships.get(name);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Check if table has ReBAC configuration
|
|
2235
|
+
*/
|
|
2236
|
+
hasTable(table) {
|
|
2237
|
+
return this.tables.has(table);
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Get all registered table names
|
|
2241
|
+
*/
|
|
2242
|
+
getTables() {
|
|
2243
|
+
return Array.from(this.tables.keys());
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Clear all registrations
|
|
2247
|
+
*/
|
|
2248
|
+
clear() {
|
|
2249
|
+
this.tables.clear();
|
|
2250
|
+
this.globalRelationships.clear();
|
|
2251
|
+
}
|
|
2252
|
+
// ============================================================================
|
|
2253
|
+
// Private Methods
|
|
2254
|
+
// ============================================================================
|
|
2255
|
+
/**
|
|
2256
|
+
* Compile a relationship path definition
|
|
2257
|
+
*/
|
|
2258
|
+
compileRelationshipPath(path, sourceTable) {
|
|
2259
|
+
if (path.steps.length === 0) {
|
|
2260
|
+
throw new RLSSchemaError(`Relationship path "${path.name}" must have at least one step`, {
|
|
2261
|
+
path: path.name
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
const compiledSteps = path.steps.map((step, index) => {
|
|
2265
|
+
if (!step.from || !step.to) {
|
|
2266
|
+
throw new RLSSchemaError(
|
|
2267
|
+
`Relationship step ${index} in "${path.name}" must have 'from' and 'to' tables`,
|
|
2268
|
+
{ path: path.name, step: index }
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
return {
|
|
2272
|
+
from: step.from,
|
|
2273
|
+
to: step.to,
|
|
2274
|
+
fromColumn: step.fromColumn ?? `${step.to}_id`,
|
|
2275
|
+
toColumn: step.toColumn ?? "id",
|
|
2276
|
+
alias: step.alias ?? step.to,
|
|
2277
|
+
joinType: step.joinType ?? "inner",
|
|
2278
|
+
additionalConditions: step.additionalConditions ?? {}
|
|
2279
|
+
};
|
|
2280
|
+
});
|
|
2281
|
+
for (let i = 1; i < compiledSteps.length; i++) {
|
|
2282
|
+
const prevStep = compiledSteps[i - 1];
|
|
2283
|
+
const currentStep = compiledSteps[i];
|
|
2284
|
+
if (currentStep.from !== prevStep.to && currentStep.from !== prevStep.alias) {
|
|
2285
|
+
throw new RLSSchemaError(
|
|
2286
|
+
`Relationship path "${path.name}" has broken chain at step ${i}: expected '${prevStep.to}' but got '${currentStep.from}'`,
|
|
2287
|
+
{ path: path.name, step: i }
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
const lastStep = compiledSteps[compiledSteps.length - 1];
|
|
2292
|
+
return {
|
|
2293
|
+
name: path.name,
|
|
2294
|
+
steps: compiledSteps,
|
|
2295
|
+
sourceTable,
|
|
2296
|
+
targetTable: lastStep.to
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Compile a ReBAC policy definition
|
|
2301
|
+
*/
|
|
2302
|
+
compilePolicy(policy, name, table, tableRelationships) {
|
|
2303
|
+
const relationshipPath = tableRelationships.get(policy.relationshipPath) ?? this.globalRelationships.get(policy.relationshipPath);
|
|
2304
|
+
if (!relationshipPath) {
|
|
2305
|
+
throw new RLSSchemaError(
|
|
2306
|
+
`ReBAC policy "${name}" references unknown relationship path "${policy.relationshipPath}"`,
|
|
2307
|
+
{ policy: name, table, relationshipPath: policy.relationshipPath }
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
const ops = Array.isArray(policy.operation) ? policy.operation : [policy.operation];
|
|
2311
|
+
const expandedOps = ops.flatMap(
|
|
2312
|
+
(op) => op === "all" ? ["read", "create", "update", "delete"] : [op]
|
|
2313
|
+
);
|
|
2314
|
+
const getEndConditions = typeof policy.endCondition === "function" ? policy.endCondition : () => policy.endCondition;
|
|
2315
|
+
return {
|
|
2316
|
+
name,
|
|
2317
|
+
type: policy.policyType ?? "allow",
|
|
2318
|
+
operations: new Set(expandedOps),
|
|
2319
|
+
relationshipPath,
|
|
2320
|
+
getEndConditions,
|
|
2321
|
+
priority: policy.priority ?? 0
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
function createReBAcRegistry(schema, options) {
|
|
2326
|
+
return new ReBAcRegistry(schema, options);
|
|
2327
|
+
}
|
|
2328
|
+
var ReBAcTransformer = class {
|
|
2329
|
+
constructor(registry, options = {}) {
|
|
2330
|
+
this.registry = registry;
|
|
2331
|
+
this.options = options;
|
|
2332
|
+
this.options = {
|
|
2333
|
+
qualifyColumns: true,
|
|
2334
|
+
dialect: "postgres",
|
|
2335
|
+
...options
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Transform a SELECT query by applying ReBAC policies
|
|
2340
|
+
*
|
|
2341
|
+
* @param qb - Query builder to transform
|
|
2342
|
+
* @param table - Table being queried
|
|
2343
|
+
* @param operation - Operation being performed
|
|
2344
|
+
* @returns Transformed query builder
|
|
2345
|
+
*/
|
|
2346
|
+
transform(qb, table, operation = "read") {
|
|
2347
|
+
const ctx = rlsContext.getContextOrNull();
|
|
2348
|
+
if (!ctx) {
|
|
2349
|
+
return qb;
|
|
2350
|
+
}
|
|
2351
|
+
if (ctx.auth.isSystem) {
|
|
2352
|
+
return qb;
|
|
2353
|
+
}
|
|
2354
|
+
const policies = this.registry.getPolicies(table, operation);
|
|
2355
|
+
if (policies.length === 0) {
|
|
2356
|
+
return qb;
|
|
2357
|
+
}
|
|
2358
|
+
let result = qb;
|
|
2359
|
+
for (const policy of policies) {
|
|
2360
|
+
result = this.applyPolicy(result, policy, ctx, table);
|
|
2361
|
+
}
|
|
2362
|
+
return result;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Generate EXISTS condition SQL for a policy
|
|
2366
|
+
*
|
|
2367
|
+
* This method can be used to get the raw SQL for debugging or manual query building.
|
|
2368
|
+
*
|
|
2369
|
+
* @param policy - ReBAC policy to generate SQL for
|
|
2370
|
+
* @param ctx - RLS context
|
|
2371
|
+
* @param mainTable - Main query table
|
|
2372
|
+
* @param mainTableAlias - Alias for main table
|
|
2373
|
+
* @returns SQL string and parameters
|
|
2374
|
+
*/
|
|
2375
|
+
generateExistsSql(policy, ctx, mainTable, mainTableAlias) {
|
|
2376
|
+
const { relationshipPath } = policy;
|
|
2377
|
+
const evalCtx = this.createEvalContext(ctx, mainTable);
|
|
2378
|
+
const endConditions = policy.getEndConditions(evalCtx);
|
|
2379
|
+
const alias = mainTableAlias ?? mainTable;
|
|
2380
|
+
const params = [];
|
|
2381
|
+
let paramIndex = 1;
|
|
2382
|
+
const steps = relationshipPath.steps;
|
|
2383
|
+
if (steps.length === 0) {
|
|
2384
|
+
return { sql: "TRUE", params: [] };
|
|
2385
|
+
}
|
|
2386
|
+
const firstStep = steps[0];
|
|
2387
|
+
let sql3 = `SELECT 1 FROM ${this.quote(firstStep.to)}`;
|
|
2388
|
+
if (firstStep.alias !== firstStep.to) {
|
|
2389
|
+
sql3 += ` AS ${this.quote(firstStep.alias)}`;
|
|
2390
|
+
}
|
|
2391
|
+
for (let i = 1; i < steps.length; i++) {
|
|
2392
|
+
const step = steps[i];
|
|
2393
|
+
const joinType = step.joinType === "left" ? "LEFT JOIN" : step.joinType === "right" ? "RIGHT JOIN" : "JOIN";
|
|
2394
|
+
sql3 += ` ${joinType} ${this.quote(step.to)}`;
|
|
2395
|
+
if (step.alias !== step.to) {
|
|
2396
|
+
sql3 += ` AS ${this.quote(step.alias)}`;
|
|
2397
|
+
}
|
|
2398
|
+
const prevStep = steps[i - 1];
|
|
2399
|
+
const prevAlias = prevStep.alias;
|
|
2400
|
+
sql3 += ` ON ${this.quote(prevAlias)}.${this.quote(step.fromColumn)} = ${this.quote(step.alias)}.${this.quote(step.toColumn)}`;
|
|
2401
|
+
if (Object.keys(step.additionalConditions).length > 0) {
|
|
2402
|
+
for (const [col, val] of Object.entries(step.additionalConditions)) {
|
|
2403
|
+
if (val === null) {
|
|
2404
|
+
sql3 += ` AND ${this.quote(step.alias)}.${this.quote(col)} IS NULL`;
|
|
2405
|
+
} else {
|
|
2406
|
+
sql3 += ` AND ${this.quote(step.alias)}.${this.quote(col)} = ${this.param(paramIndex++)}`;
|
|
2407
|
+
params.push(val);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
sql3 += ` WHERE ${this.quote(firstStep.alias)}.${this.quote(firstStep.toColumn)} = ${this.quote(alias)}.${this.quote(firstStep.fromColumn)}`;
|
|
2413
|
+
if (Object.keys(firstStep.additionalConditions).length > 0) {
|
|
2414
|
+
for (const [col, val] of Object.entries(firstStep.additionalConditions)) {
|
|
2415
|
+
if (val === null) {
|
|
2416
|
+
sql3 += ` AND ${this.quote(firstStep.alias)}.${this.quote(col)} IS NULL`;
|
|
2417
|
+
} else {
|
|
2418
|
+
sql3 += ` AND ${this.quote(firstStep.alias)}.${this.quote(col)} = ${this.param(paramIndex++)}`;
|
|
2419
|
+
params.push(val);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
const lastStep = steps[steps.length - 1];
|
|
2424
|
+
for (const [col, val] of Object.entries(endConditions)) {
|
|
2425
|
+
if (val === null) {
|
|
2426
|
+
sql3 += ` AND ${this.quote(lastStep.alias)}.${this.quote(col)} IS NULL`;
|
|
2427
|
+
} else if (val === void 0) {
|
|
2428
|
+
continue;
|
|
2429
|
+
} else if (Array.isArray(val)) {
|
|
2430
|
+
if (val.length === 0) {
|
|
2431
|
+
sql3 += ` AND FALSE`;
|
|
2432
|
+
} else {
|
|
2433
|
+
const placeholders = val.map(() => this.param(paramIndex++)).join(", ");
|
|
2434
|
+
sql3 += ` AND ${this.quote(lastStep.alias)}.${this.quote(col)} IN (${placeholders})`;
|
|
2435
|
+
params.push(...val);
|
|
2436
|
+
}
|
|
2437
|
+
} else {
|
|
2438
|
+
sql3 += ` AND ${this.quote(lastStep.alias)}.${this.quote(col)} = ${this.param(paramIndex++)}`;
|
|
2439
|
+
params.push(val);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
const existsExpr = policy.type === "deny" ? `NOT EXISTS (${sql3})` : `EXISTS (${sql3})`;
|
|
2443
|
+
return { sql: existsExpr, params };
|
|
2444
|
+
}
|
|
2445
|
+
// ============================================================================
|
|
2446
|
+
// Private Methods
|
|
2447
|
+
// ============================================================================
|
|
2448
|
+
/**
|
|
2449
|
+
* Apply a single ReBAC policy to a query
|
|
2450
|
+
*
|
|
2451
|
+
* NOTE: Uses type casting for dynamic SQL because Kysely's type system
|
|
2452
|
+
* requires compile-time known types, but ReBAC policies work with
|
|
2453
|
+
* runtime-generated EXISTS clauses.
|
|
2454
|
+
*/
|
|
2455
|
+
applyPolicy(qb, policy, ctx, table) {
|
|
2456
|
+
const { sql: existsSql, params } = this.generateExistsSql(policy, ctx, table, this.options.mainTableAlias);
|
|
2457
|
+
const sqlParts = existsSql.split(/\$\d+/);
|
|
2458
|
+
const rawBuilder = sql.join(
|
|
2459
|
+
sqlParts.map((part, i) => {
|
|
2460
|
+
if (i < params.length) {
|
|
2461
|
+
return sql`${sql.raw(part)}${params[i]}`;
|
|
2462
|
+
}
|
|
2463
|
+
return sql.raw(part);
|
|
2464
|
+
})
|
|
2465
|
+
);
|
|
2466
|
+
return qb.where(rawBuilder);
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Create evaluation context for policy conditions
|
|
2470
|
+
*/
|
|
2471
|
+
createEvalContext(ctx, table) {
|
|
2472
|
+
return {
|
|
2473
|
+
auth: ctx.auth,
|
|
2474
|
+
table,
|
|
2475
|
+
operation: "read",
|
|
2476
|
+
...ctx.meta !== void 0 && { meta: ctx.meta }
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Quote an identifier for the target dialect
|
|
2481
|
+
*/
|
|
2482
|
+
quote(identifier) {
|
|
2483
|
+
switch (this.options.dialect) {
|
|
2484
|
+
case "mysql":
|
|
2485
|
+
return `\`${identifier}\``;
|
|
2486
|
+
case "sqlite":
|
|
2487
|
+
return `"${identifier}"`;
|
|
2488
|
+
case "postgres":
|
|
2489
|
+
default:
|
|
2490
|
+
return `"${identifier}"`;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Generate parameter placeholder for the target dialect
|
|
2495
|
+
*/
|
|
2496
|
+
param(index) {
|
|
2497
|
+
switch (this.options.dialect) {
|
|
2498
|
+
case "mysql":
|
|
2499
|
+
case "sqlite":
|
|
2500
|
+
return "?";
|
|
2501
|
+
case "postgres":
|
|
2502
|
+
default:
|
|
2503
|
+
return `$${index}`;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
function allowRelation(operation, relationshipPath, endCondition, options) {
|
|
2508
|
+
const policy = {
|
|
2509
|
+
type: "filter",
|
|
2510
|
+
operation,
|
|
2511
|
+
relationshipPath,
|
|
2512
|
+
endCondition,
|
|
2513
|
+
policyType: "allow"
|
|
2514
|
+
};
|
|
2515
|
+
if (options?.name !== void 0) {
|
|
2516
|
+
policy.name = options.name;
|
|
2517
|
+
}
|
|
2518
|
+
if (options?.priority !== void 0) {
|
|
2519
|
+
policy.priority = options.priority;
|
|
2520
|
+
}
|
|
2521
|
+
return policy;
|
|
2522
|
+
}
|
|
2523
|
+
function denyRelation(operation, relationshipPath, endCondition, options) {
|
|
2524
|
+
const policy = {
|
|
2525
|
+
type: "filter",
|
|
2526
|
+
operation,
|
|
2527
|
+
relationshipPath,
|
|
2528
|
+
endCondition,
|
|
2529
|
+
policyType: "deny",
|
|
2530
|
+
priority: options?.priority ?? 100
|
|
2531
|
+
// Higher priority for deny
|
|
2532
|
+
};
|
|
2533
|
+
if (options?.name !== void 0) {
|
|
2534
|
+
policy.name = options.name;
|
|
2535
|
+
}
|
|
2536
|
+
return policy;
|
|
2537
|
+
}
|
|
2538
|
+
function createReBAcTransformer(registry, options) {
|
|
2539
|
+
return new ReBAcTransformer(registry, options);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// src/field-access/types.ts
|
|
2543
|
+
function neverAccessible() {
|
|
2544
|
+
return {
|
|
2545
|
+
read: () => false,
|
|
2546
|
+
write: () => false,
|
|
2547
|
+
omitWhenHidden: true
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
function ownerOnly(ownerField = "id") {
|
|
2551
|
+
return {
|
|
2552
|
+
read: (ctx) => {
|
|
2553
|
+
const rowValue = ctx.row?.[ownerField];
|
|
2554
|
+
return String(ctx.auth.userId) === String(rowValue);
|
|
2555
|
+
},
|
|
2556
|
+
write: (ctx) => {
|
|
2557
|
+
const rowValue = ctx.row?.[ownerField];
|
|
2558
|
+
return String(ctx.auth.userId) === String(rowValue);
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
function ownerOrRoles(roles, ownerField = "id") {
|
|
2563
|
+
return {
|
|
2564
|
+
read: (ctx) => {
|
|
2565
|
+
const rowValue = ctx.row?.[ownerField];
|
|
2566
|
+
return String(ctx.auth.userId) === String(rowValue) || roles.some((r) => ctx.auth.roles.includes(r));
|
|
2567
|
+
},
|
|
2568
|
+
write: (ctx) => {
|
|
2569
|
+
const rowValue = ctx.row?.[ownerField];
|
|
2570
|
+
return String(ctx.auth.userId) === String(rowValue) || roles.some((r) => ctx.auth.roles.includes(r));
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
function rolesOnly(roles) {
|
|
2575
|
+
return {
|
|
2576
|
+
read: (ctx) => roles.some((r) => ctx.auth.roles.includes(r)),
|
|
2577
|
+
write: (ctx) => roles.some((r) => ctx.auth.roles.includes(r))
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
function readOnly(readCondition) {
|
|
2581
|
+
return {
|
|
2582
|
+
read: readCondition ?? (() => true),
|
|
2583
|
+
write: () => false
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
function publicReadRestrictedWrite(writeCondition) {
|
|
2587
|
+
return {
|
|
2588
|
+
read: () => true,
|
|
2589
|
+
write: writeCondition
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
function maskedField(maskFn, readCondition) {
|
|
2593
|
+
return {
|
|
2594
|
+
read: readCondition,
|
|
2595
|
+
write: readCondition,
|
|
2596
|
+
maskedValue: void 0,
|
|
2597
|
+
// Will be computed by maskFn
|
|
2598
|
+
maskFn
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
var FieldAccessRegistry = class {
|
|
2602
|
+
tables = /* @__PURE__ */ new Map();
|
|
2603
|
+
logger;
|
|
2604
|
+
constructor(schema, options) {
|
|
2605
|
+
this.logger = options?.logger ?? silentLogger;
|
|
2606
|
+
if (schema) {
|
|
2607
|
+
this.loadSchema(schema);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Load field access schema
|
|
2612
|
+
*/
|
|
2613
|
+
loadSchema(schema) {
|
|
2614
|
+
for (const [table, config] of Object.entries(schema)) {
|
|
2615
|
+
if (!config) continue;
|
|
2616
|
+
this.registerTable(table, config);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Register field access configuration for a table
|
|
2621
|
+
*/
|
|
2622
|
+
registerTable(table, config) {
|
|
2623
|
+
const compiled = {
|
|
2624
|
+
table,
|
|
2625
|
+
defaultAccess: config.default ?? "allow",
|
|
2626
|
+
skipFor: config.skipFor ?? [],
|
|
2627
|
+
fields: /* @__PURE__ */ new Map()
|
|
2628
|
+
};
|
|
2629
|
+
for (const [field, fieldConfig] of Object.entries(config.fields)) {
|
|
2630
|
+
if (!fieldConfig) continue;
|
|
2631
|
+
const compiledField = this.compileFieldConfig(field, fieldConfig);
|
|
2632
|
+
compiled.fields.set(field, compiledField);
|
|
2633
|
+
}
|
|
2634
|
+
this.tables.set(table, compiled);
|
|
2635
|
+
this.logger.info?.(`[FieldAccess] Registered table: ${table}`, {
|
|
2636
|
+
fields: compiled.fields.size,
|
|
2637
|
+
defaultAccess: compiled.defaultAccess
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Check if a field is readable in the current context
|
|
2642
|
+
*
|
|
2643
|
+
* @param table - Table name
|
|
2644
|
+
* @param field - Field name
|
|
2645
|
+
* @param ctx - Evaluation context
|
|
2646
|
+
* @returns True if field is readable
|
|
2647
|
+
*/
|
|
2648
|
+
async canReadField(table, field, ctx) {
|
|
2649
|
+
const config = this.tables.get(table);
|
|
2650
|
+
if (!config) {
|
|
2651
|
+
return true;
|
|
2652
|
+
}
|
|
2653
|
+
if (config.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2654
|
+
return true;
|
|
2655
|
+
}
|
|
2656
|
+
if (ctx.auth.isSystem) {
|
|
2657
|
+
return true;
|
|
2658
|
+
}
|
|
2659
|
+
const fieldConfig = config.fields.get(field);
|
|
2660
|
+
if (!fieldConfig) {
|
|
2661
|
+
return config.defaultAccess === "allow";
|
|
2662
|
+
}
|
|
2663
|
+
try {
|
|
2664
|
+
const result = fieldConfig.canRead(ctx);
|
|
2665
|
+
return result instanceof Promise ? await result : result;
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
this.logger.error?.(`[FieldAccess] Error checking read access for ${table}.${field}`, {
|
|
2668
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2669
|
+
});
|
|
2670
|
+
return false;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Check if a field is writable in the current context
|
|
2675
|
+
*
|
|
2676
|
+
* @param table - Table name
|
|
2677
|
+
* @param field - Field name
|
|
2678
|
+
* @param ctx - Evaluation context
|
|
2679
|
+
* @returns True if field is writable
|
|
2680
|
+
*/
|
|
2681
|
+
async canWriteField(table, field, ctx) {
|
|
2682
|
+
const config = this.tables.get(table);
|
|
2683
|
+
if (!config) {
|
|
2684
|
+
return true;
|
|
2685
|
+
}
|
|
2686
|
+
if (config.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2689
|
+
if (ctx.auth.isSystem) {
|
|
2690
|
+
return true;
|
|
2691
|
+
}
|
|
2692
|
+
const fieldConfig = config.fields.get(field);
|
|
2693
|
+
if (!fieldConfig) {
|
|
2694
|
+
return config.defaultAccess === "allow";
|
|
2695
|
+
}
|
|
2696
|
+
try {
|
|
2697
|
+
const result = fieldConfig.canWrite(ctx);
|
|
2698
|
+
return result instanceof Promise ? await result : result;
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
this.logger.error?.(`[FieldAccess] Error checking write access for ${table}.${field}`, {
|
|
2701
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2702
|
+
});
|
|
2703
|
+
return false;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Get field configuration
|
|
2708
|
+
*
|
|
2709
|
+
* @param table - Table name
|
|
2710
|
+
* @param field - Field name
|
|
2711
|
+
* @returns Compiled field access config or undefined
|
|
2712
|
+
*/
|
|
2713
|
+
getFieldConfig(table, field) {
|
|
2714
|
+
return this.tables.get(table)?.fields.get(field);
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Get table configuration
|
|
2718
|
+
*
|
|
2719
|
+
* @param table - Table name
|
|
2720
|
+
* @returns Compiled table field access config or undefined
|
|
2721
|
+
*/
|
|
2722
|
+
getTableConfig(table) {
|
|
2723
|
+
return this.tables.get(table);
|
|
2724
|
+
}
|
|
2725
|
+
/**
|
|
2726
|
+
* Check if table has field access configuration
|
|
2727
|
+
*/
|
|
2728
|
+
hasTable(table) {
|
|
2729
|
+
return this.tables.has(table);
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Get all registered table names
|
|
2733
|
+
*/
|
|
2734
|
+
getTables() {
|
|
2735
|
+
return Array.from(this.tables.keys());
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Get all fields with explicit configuration for a table
|
|
2739
|
+
*
|
|
2740
|
+
* @param table - Table name
|
|
2741
|
+
* @returns Array of field names
|
|
2742
|
+
*/
|
|
2743
|
+
getConfiguredFields(table) {
|
|
2744
|
+
const config = this.tables.get(table);
|
|
2745
|
+
return config ? Array.from(config.fields.keys()) : [];
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Clear all configurations
|
|
2749
|
+
*/
|
|
2750
|
+
clear() {
|
|
2751
|
+
this.tables.clear();
|
|
2752
|
+
}
|
|
2753
|
+
// ============================================================================
|
|
2754
|
+
// Private Methods
|
|
2755
|
+
// ============================================================================
|
|
2756
|
+
/**
|
|
2757
|
+
* Compile a field access configuration
|
|
2758
|
+
*/
|
|
2759
|
+
compileFieldConfig(field, config) {
|
|
2760
|
+
return {
|
|
2761
|
+
field,
|
|
2762
|
+
canRead: config.read ?? (() => true),
|
|
2763
|
+
canWrite: config.write ?? (() => true),
|
|
2764
|
+
maskedValue: config.maskedValue ?? null,
|
|
2765
|
+
omitWhenHidden: config.omitWhenHidden ?? false
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
function createFieldAccessRegistry(schema, options) {
|
|
2770
|
+
return new FieldAccessRegistry(schema, options);
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// src/field-access/processor.ts
|
|
2774
|
+
var FieldAccessProcessor = class {
|
|
2775
|
+
constructor(registry, defaultMaskValue = null) {
|
|
2776
|
+
this.registry = registry;
|
|
2777
|
+
this.defaultMaskValue = defaultMaskValue;
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Apply field access control to a single row
|
|
2781
|
+
*
|
|
2782
|
+
* @param table - Table name
|
|
2783
|
+
* @param row - Row data
|
|
2784
|
+
* @param options - Processing options
|
|
2785
|
+
* @returns Masked row with metadata
|
|
2786
|
+
*/
|
|
2787
|
+
async maskRow(table, row, options = {}) {
|
|
2788
|
+
const ctx = this.getContext();
|
|
2789
|
+
if (!ctx) {
|
|
2790
|
+
return {
|
|
2791
|
+
data: row,
|
|
2792
|
+
maskedFields: [],
|
|
2793
|
+
omittedFields: []
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
if (ctx.auth.isSystem) {
|
|
2797
|
+
return {
|
|
2798
|
+
data: row,
|
|
2799
|
+
maskedFields: [],
|
|
2800
|
+
omittedFields: []
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
const tableConfig = this.registry.getTableConfig(table);
|
|
2804
|
+
if (!tableConfig) {
|
|
2805
|
+
return {
|
|
2806
|
+
data: row,
|
|
2807
|
+
maskedFields: [],
|
|
2808
|
+
omittedFields: []
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
if (tableConfig.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2812
|
+
return {
|
|
2813
|
+
data: row,
|
|
2814
|
+
maskedFields: [],
|
|
2815
|
+
omittedFields: []
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
const evalCtx = this.createEvalContext(ctx, row, table);
|
|
2819
|
+
const result = {};
|
|
2820
|
+
const maskedFields = [];
|
|
2821
|
+
const omittedFields = [];
|
|
2822
|
+
for (const [field, value] of Object.entries(row)) {
|
|
2823
|
+
if (options.excludeFields?.includes(field)) {
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
if (options.includeFields && !options.includeFields.includes(field)) {
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
const fieldResult = await this.evaluateFieldAccess(
|
|
2830
|
+
tableConfig,
|
|
2831
|
+
field,
|
|
2832
|
+
value,
|
|
2833
|
+
evalCtx,
|
|
2834
|
+
options
|
|
2835
|
+
);
|
|
2836
|
+
if (fieldResult.omit) {
|
|
2837
|
+
omittedFields.push(field);
|
|
2838
|
+
} else if (!fieldResult.accessible) {
|
|
2839
|
+
maskedFields.push(field);
|
|
2840
|
+
result[field] = fieldResult.value;
|
|
2841
|
+
} else {
|
|
2842
|
+
result[field] = value;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
return {
|
|
2846
|
+
data: result,
|
|
2847
|
+
maskedFields,
|
|
2848
|
+
omittedFields
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Apply field access control to multiple rows
|
|
2853
|
+
*
|
|
2854
|
+
* @param table - Table name
|
|
2855
|
+
* @param rows - Array of rows
|
|
2856
|
+
* @param options - Processing options
|
|
2857
|
+
* @returns Array of masked rows
|
|
2858
|
+
*/
|
|
2859
|
+
async maskRows(table, rows, options = {}) {
|
|
2860
|
+
return await Promise.all(rows.map((row) => this.maskRow(table, row, options)));
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Validate that all fields in mutation data are writable
|
|
2864
|
+
*
|
|
2865
|
+
* @param table - Table name
|
|
2866
|
+
* @param data - Mutation data
|
|
2867
|
+
* @param existingRow - Existing row (for update operations)
|
|
2868
|
+
* @throws RLSPolicyViolation if any field is not writable
|
|
2869
|
+
*/
|
|
2870
|
+
async validateWrite(table, data, existingRow) {
|
|
2871
|
+
const ctx = this.getContext();
|
|
2872
|
+
if (!ctx) {
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
if (ctx.auth.isSystem) {
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
const tableConfig = this.registry.getTableConfig(table);
|
|
2879
|
+
if (!tableConfig) {
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
if (tableConfig.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const evalCtx = this.createEvalContext(ctx, existingRow ?? {}, table, data);
|
|
2886
|
+
const unwritableFields = [];
|
|
2887
|
+
for (const field of Object.keys(data)) {
|
|
2888
|
+
const canWrite = await this.registry.canWriteField(table, field, evalCtx);
|
|
2889
|
+
if (!canWrite) {
|
|
2890
|
+
unwritableFields.push(field);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
if (unwritableFields.length > 0) {
|
|
2894
|
+
throw new RLSPolicyViolation(
|
|
2895
|
+
"write",
|
|
2896
|
+
table,
|
|
2897
|
+
`Cannot write to protected fields: ${unwritableFields.join(", ")}`
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Filter mutation data to only include writable fields
|
|
2903
|
+
*
|
|
2904
|
+
* @param table - Table name
|
|
2905
|
+
* @param data - Mutation data
|
|
2906
|
+
* @param existingRow - Existing row (for update operations)
|
|
2907
|
+
* @returns Filtered data with only writable fields
|
|
2908
|
+
*/
|
|
2909
|
+
async filterWritableFields(table, data, existingRow) {
|
|
2910
|
+
const ctx = this.getContext();
|
|
2911
|
+
if (!ctx) {
|
|
2912
|
+
return { data, removedFields: [] };
|
|
2913
|
+
}
|
|
2914
|
+
if (ctx.auth.isSystem) {
|
|
2915
|
+
return { data, removedFields: [] };
|
|
2916
|
+
}
|
|
2917
|
+
const tableConfig = this.registry.getTableConfig(table);
|
|
2918
|
+
if (!tableConfig) {
|
|
2919
|
+
return { data, removedFields: [] };
|
|
2920
|
+
}
|
|
2921
|
+
if (tableConfig.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2922
|
+
return { data, removedFields: [] };
|
|
2923
|
+
}
|
|
2924
|
+
const evalCtx = this.createEvalContext(ctx, existingRow ?? {}, table, data);
|
|
2925
|
+
const result = {};
|
|
2926
|
+
const removedFields = [];
|
|
2927
|
+
for (const [field, value] of Object.entries(data)) {
|
|
2928
|
+
const canWrite = await this.registry.canWriteField(table, field, evalCtx);
|
|
2929
|
+
if (canWrite) {
|
|
2930
|
+
result[field] = value;
|
|
2931
|
+
} else {
|
|
2932
|
+
removedFields.push(field);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return { data: result, removedFields };
|
|
2936
|
+
}
|
|
2937
|
+
/**
|
|
2938
|
+
* Get list of readable fields for a table
|
|
2939
|
+
*
|
|
2940
|
+
* @param table - Table name
|
|
2941
|
+
* @param row - Row data (for context-dependent fields)
|
|
2942
|
+
* @returns Array of readable field names
|
|
2943
|
+
*/
|
|
2944
|
+
async getReadableFields(table, row) {
|
|
2945
|
+
const ctx = this.getContext();
|
|
2946
|
+
if (!ctx || ctx.auth.isSystem) {
|
|
2947
|
+
return Object.keys(row);
|
|
2948
|
+
}
|
|
2949
|
+
const tableConfig = this.registry.getTableConfig(table);
|
|
2950
|
+
if (!tableConfig) {
|
|
2951
|
+
return Object.keys(row);
|
|
2952
|
+
}
|
|
2953
|
+
if (tableConfig.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2954
|
+
return Object.keys(row);
|
|
2955
|
+
}
|
|
2956
|
+
const evalCtx = this.createEvalContext(ctx, row, table);
|
|
2957
|
+
const readable = [];
|
|
2958
|
+
for (const field of Object.keys(row)) {
|
|
2959
|
+
const canRead = await this.registry.canReadField(table, field, evalCtx);
|
|
2960
|
+
if (canRead) {
|
|
2961
|
+
readable.push(field);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return readable;
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Get list of writable fields for a table
|
|
2968
|
+
*
|
|
2969
|
+
* @param table - Table name
|
|
2970
|
+
* @param row - Existing row data (for context-dependent fields)
|
|
2971
|
+
* @returns Array of writable field names
|
|
2972
|
+
*/
|
|
2973
|
+
async getWritableFields(table, row) {
|
|
2974
|
+
const ctx = this.getContext();
|
|
2975
|
+
if (!ctx || ctx.auth.isSystem) {
|
|
2976
|
+
return Object.keys(row);
|
|
2977
|
+
}
|
|
2978
|
+
const tableConfig = this.registry.getTableConfig(table);
|
|
2979
|
+
if (!tableConfig) {
|
|
2980
|
+
return Object.keys(row);
|
|
2981
|
+
}
|
|
2982
|
+
if (tableConfig.skipFor.some((role) => ctx.auth.roles.includes(role))) {
|
|
2983
|
+
return Object.keys(row);
|
|
2984
|
+
}
|
|
2985
|
+
const evalCtx = this.createEvalContext(ctx, row, table);
|
|
2986
|
+
const writable = [];
|
|
2987
|
+
for (const field of Object.keys(row)) {
|
|
2988
|
+
const canWrite = await this.registry.canWriteField(table, field, evalCtx);
|
|
2989
|
+
if (canWrite) {
|
|
2990
|
+
writable.push(field);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return writable;
|
|
2994
|
+
}
|
|
2995
|
+
// ============================================================================
|
|
2996
|
+
// Private Methods
|
|
2997
|
+
// ============================================================================
|
|
2998
|
+
/**
|
|
2999
|
+
* Get current RLS context
|
|
3000
|
+
*/
|
|
3001
|
+
getContext() {
|
|
3002
|
+
return rlsContext.getContextOrNull();
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Create evaluation context
|
|
3006
|
+
*/
|
|
3007
|
+
createEvalContext(ctx, row, table, data) {
|
|
3008
|
+
return {
|
|
3009
|
+
auth: ctx.auth,
|
|
3010
|
+
row,
|
|
3011
|
+
data,
|
|
3012
|
+
table,
|
|
3013
|
+
...ctx.meta !== void 0 && { meta: ctx.meta }
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Evaluate field access for a specific field
|
|
3018
|
+
*/
|
|
3019
|
+
async evaluateFieldAccess(tableConfig, field, value, ctx, options) {
|
|
3020
|
+
const fieldConfig = tableConfig.fields.get(field);
|
|
3021
|
+
if (!fieldConfig) {
|
|
3022
|
+
const accessible = tableConfig.defaultAccess === "allow";
|
|
3023
|
+
return {
|
|
3024
|
+
accessible,
|
|
3025
|
+
value: accessible ? value : this.defaultMaskValue,
|
|
3026
|
+
omit: !accessible && options.throwOnDenied !== true
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
try {
|
|
3030
|
+
const canRead = await fieldConfig.canRead(ctx);
|
|
3031
|
+
if (canRead) {
|
|
3032
|
+
return {
|
|
3033
|
+
accessible: true,
|
|
3034
|
+
value
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
if (options.throwOnDenied) {
|
|
3038
|
+
throw new RLSPolicyViolation("read", ctx.table ?? "unknown", `Cannot read field: ${field}`);
|
|
3039
|
+
}
|
|
3040
|
+
const configWithMask = fieldConfig;
|
|
3041
|
+
const maskedValue = configWithMask.maskFn ? configWithMask.maskFn(value) : fieldConfig.maskedValue ?? this.defaultMaskValue;
|
|
3042
|
+
return {
|
|
3043
|
+
accessible: false,
|
|
3044
|
+
reason: `Field "${field}" is not accessible`,
|
|
3045
|
+
value: maskedValue,
|
|
3046
|
+
omit: fieldConfig.omitWhenHidden
|
|
3047
|
+
};
|
|
3048
|
+
} catch (error) {
|
|
3049
|
+
if (error instanceof RLSPolicyViolation) {
|
|
3050
|
+
throw error;
|
|
3051
|
+
}
|
|
3052
|
+
return {
|
|
3053
|
+
accessible: false,
|
|
3054
|
+
reason: `Error evaluating access: ${error instanceof Error ? error.message : String(error)}`,
|
|
3055
|
+
value: this.defaultMaskValue,
|
|
3056
|
+
omit: true
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
3061
|
+
function createFieldAccessProcessor(registry, defaultMaskValue) {
|
|
3062
|
+
return new FieldAccessProcessor(registry, defaultMaskValue);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// src/composition/builder.ts
|
|
3066
|
+
function definePolicy(config, policies) {
|
|
3067
|
+
const result = {
|
|
3068
|
+
name: config.name,
|
|
3069
|
+
policies
|
|
3070
|
+
};
|
|
3071
|
+
if (config.description !== void 0) {
|
|
3072
|
+
result.description = config.description;
|
|
3073
|
+
}
|
|
3074
|
+
if (config.tags !== void 0) {
|
|
3075
|
+
result.tags = config.tags;
|
|
3076
|
+
}
|
|
3077
|
+
return result;
|
|
3078
|
+
}
|
|
3079
|
+
function defineFilterPolicy(name, filterFn, options) {
|
|
3080
|
+
return {
|
|
3081
|
+
name,
|
|
3082
|
+
policies: [
|
|
3083
|
+
filter("read", filterFn, {
|
|
3084
|
+
name: `${name}-filter`,
|
|
3085
|
+
...options?.priority !== void 0 && { priority: options.priority }
|
|
3086
|
+
})
|
|
3087
|
+
]
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
function defineAllowPolicy(name, operation, condition, options) {
|
|
3091
|
+
return {
|
|
3092
|
+
name,
|
|
3093
|
+
policies: [
|
|
3094
|
+
allow(operation, condition, {
|
|
3095
|
+
name: `${name}-allow`,
|
|
3096
|
+
...options?.priority !== void 0 && { priority: options.priority }
|
|
3097
|
+
})
|
|
3098
|
+
]
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
function defineDenyPolicy(name, operation, condition, options) {
|
|
3102
|
+
return {
|
|
3103
|
+
name,
|
|
3104
|
+
policies: [
|
|
3105
|
+
deny(operation, condition, {
|
|
3106
|
+
name: `${name}-deny`,
|
|
3107
|
+
priority: options?.priority ?? 100
|
|
3108
|
+
})
|
|
3109
|
+
]
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
function defineValidatePolicy(name, operation, condition, options) {
|
|
3113
|
+
return {
|
|
3114
|
+
name,
|
|
3115
|
+
policies: [
|
|
3116
|
+
validate(operation, condition, {
|
|
3117
|
+
name: `${name}-validate`,
|
|
3118
|
+
...options?.priority !== void 0 && { priority: options.priority }
|
|
3119
|
+
})
|
|
3120
|
+
]
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
function defineCombinedPolicy(name, config) {
|
|
3124
|
+
const policies = [];
|
|
3125
|
+
if (config.filter) {
|
|
3126
|
+
policies.push(
|
|
3127
|
+
filter("read", config.filter, {
|
|
3128
|
+
name: `${name}-filter`
|
|
3129
|
+
})
|
|
3130
|
+
);
|
|
3131
|
+
}
|
|
3132
|
+
if (config.allow) {
|
|
3133
|
+
for (const [op, condition] of Object.entries(config.allow)) {
|
|
3134
|
+
if (condition) {
|
|
3135
|
+
policies.push(
|
|
3136
|
+
allow(op, condition, {
|
|
3137
|
+
name: `${name}-allow-${op}`
|
|
3138
|
+
})
|
|
3139
|
+
);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
if (config.deny) {
|
|
3144
|
+
for (const [op, condition] of Object.entries(config.deny)) {
|
|
3145
|
+
if (condition) {
|
|
3146
|
+
policies.push(
|
|
3147
|
+
deny(op, condition, {
|
|
3148
|
+
name: `${name}-deny-${op}`,
|
|
3149
|
+
priority: 100
|
|
3150
|
+
})
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
if (config.validate) {
|
|
3156
|
+
if (config.validate.create) {
|
|
3157
|
+
policies.push(
|
|
3158
|
+
validate("create", config.validate.create, {
|
|
3159
|
+
name: `${name}-validate-create`
|
|
3160
|
+
})
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
if (config.validate.update) {
|
|
3164
|
+
policies.push(
|
|
3165
|
+
validate("update", config.validate.update, {
|
|
3166
|
+
name: `${name}-validate-update`
|
|
3167
|
+
})
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
return {
|
|
3172
|
+
name,
|
|
3173
|
+
policies
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
function createTenantIsolationPolicy(config = {}) {
|
|
3177
|
+
const { tenantColumn = "tenant_id", validateOnMutation = true } = config;
|
|
3178
|
+
const policies = [
|
|
3179
|
+
// Filter reads by tenant
|
|
3180
|
+
filter("read", (ctx) => ({ [tenantColumn]: ctx.auth.tenantId }), {
|
|
3181
|
+
name: "tenant-isolation-filter",
|
|
3182
|
+
priority: 1e3
|
|
3183
|
+
})
|
|
3184
|
+
];
|
|
3185
|
+
if (validateOnMutation) {
|
|
3186
|
+
policies.push(
|
|
3187
|
+
validate("create", (ctx) => {
|
|
3188
|
+
const data = ctx.data;
|
|
3189
|
+
return data?.[tenantColumn] === ctx.auth.tenantId;
|
|
3190
|
+
}, {
|
|
3191
|
+
name: "tenant-isolation-validate-create"
|
|
3192
|
+
}),
|
|
3193
|
+
validate("update", (ctx) => {
|
|
3194
|
+
const data = ctx.data;
|
|
3195
|
+
if (data?.[tenantColumn] !== void 0) {
|
|
3196
|
+
return data[tenantColumn] === ctx.auth.tenantId;
|
|
3197
|
+
}
|
|
3198
|
+
return true;
|
|
3199
|
+
}, {
|
|
3200
|
+
name: "tenant-isolation-validate-update"
|
|
3201
|
+
})
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
3204
|
+
return {
|
|
3205
|
+
name: "tenantIsolation",
|
|
3206
|
+
description: `Filter by ${tenantColumn} for multi-tenancy`,
|
|
3207
|
+
policies,
|
|
3208
|
+
tags: ["multi-tenant", "isolation"]
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
function createOwnershipPolicy(config = {}) {
|
|
3212
|
+
const { ownerColumn = "owner_id", ownerOperations = ["read", "update", "delete"], canDelete = true } = config;
|
|
3213
|
+
const policies = [];
|
|
3214
|
+
const ops = ownerOperations.filter((op) => op !== "delete" || canDelete);
|
|
3215
|
+
if (ops.length > 0) {
|
|
3216
|
+
policies.push(
|
|
3217
|
+
allow(ops, (ctx) => {
|
|
3218
|
+
const row = ctx.row;
|
|
3219
|
+
return ctx.auth.userId === row?.[ownerColumn];
|
|
3220
|
+
}, {
|
|
3221
|
+
name: "ownership-allow"
|
|
3222
|
+
})
|
|
3223
|
+
);
|
|
3224
|
+
}
|
|
3225
|
+
if (!canDelete && ownerOperations.includes("delete")) {
|
|
3226
|
+
policies.push(
|
|
3227
|
+
deny("delete", () => true, {
|
|
3228
|
+
name: "ownership-no-delete",
|
|
3229
|
+
priority: 150
|
|
3230
|
+
})
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
return {
|
|
3234
|
+
name: "ownership",
|
|
3235
|
+
description: `Owner access via ${ownerColumn}`,
|
|
3236
|
+
policies,
|
|
3237
|
+
tags: ["ownership"]
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
function createSoftDeletePolicy(config = {}) {
|
|
3241
|
+
const { deletedColumn = "deleted_at", filterOnRead = true, preventHardDelete = true } = config;
|
|
3242
|
+
const policies = [];
|
|
3243
|
+
if (filterOnRead) {
|
|
3244
|
+
policies.push(
|
|
3245
|
+
filter("read", () => ({ [deletedColumn]: null }), {
|
|
3246
|
+
name: "soft-delete-filter",
|
|
3247
|
+
priority: 900
|
|
3248
|
+
})
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
if (preventHardDelete) {
|
|
3252
|
+
policies.push(
|
|
3253
|
+
deny("delete", () => true, {
|
|
3254
|
+
name: "soft-delete-no-hard-delete",
|
|
3255
|
+
priority: 150
|
|
3256
|
+
})
|
|
3257
|
+
);
|
|
3258
|
+
}
|
|
3259
|
+
return {
|
|
3260
|
+
name: "softDelete",
|
|
3261
|
+
description: `Soft delete via ${deletedColumn}`,
|
|
3262
|
+
policies,
|
|
3263
|
+
tags: ["soft-delete"]
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
function createStatusAccessPolicy(config) {
|
|
3267
|
+
const { statusColumn = "status", publicStatuses = [], editableStatuses = [], deletableStatuses = [] } = config;
|
|
3268
|
+
const policies = [];
|
|
3269
|
+
if (publicStatuses.length > 0) {
|
|
3270
|
+
policies.push(
|
|
3271
|
+
allow("read", (ctx) => {
|
|
3272
|
+
const row = ctx.row;
|
|
3273
|
+
return publicStatuses.includes(row?.[statusColumn]);
|
|
3274
|
+
}, {
|
|
3275
|
+
name: "status-public-read"
|
|
3276
|
+
})
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
if (editableStatuses.length > 0) {
|
|
3280
|
+
policies.push(
|
|
3281
|
+
deny("update", (ctx) => {
|
|
3282
|
+
const row = ctx.row;
|
|
3283
|
+
return !editableStatuses.includes(row?.[statusColumn]);
|
|
3284
|
+
}, {
|
|
3285
|
+
name: "status-restrict-update",
|
|
3286
|
+
priority: 100
|
|
3287
|
+
})
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
if (deletableStatuses.length > 0) {
|
|
3291
|
+
policies.push(
|
|
3292
|
+
deny("delete", (ctx) => {
|
|
3293
|
+
const row = ctx.row;
|
|
3294
|
+
return !deletableStatuses.includes(row?.[statusColumn]);
|
|
3295
|
+
}, {
|
|
3296
|
+
name: "status-restrict-delete",
|
|
3297
|
+
priority: 100
|
|
3298
|
+
})
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
return {
|
|
3302
|
+
name: "statusAccess",
|
|
3303
|
+
description: `Status-based access via ${statusColumn}`,
|
|
3304
|
+
policies,
|
|
3305
|
+
tags: ["status"]
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
function createAdminPolicy(roles) {
|
|
3309
|
+
return {
|
|
3310
|
+
name: "adminBypass",
|
|
3311
|
+
description: `Admin access for roles: ${roles.join(", ")}`,
|
|
3312
|
+
policies: [
|
|
3313
|
+
allow("all", (ctx) => roles.some((r) => ctx.auth.roles.includes(r)), {
|
|
3314
|
+
name: "admin-bypass",
|
|
3315
|
+
priority: 500
|
|
3316
|
+
})
|
|
3317
|
+
],
|
|
3318
|
+
tags: ["admin"]
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
function composePolicies(name, policies) {
|
|
3322
|
+
const allPolicies = [];
|
|
3323
|
+
const allTags = /* @__PURE__ */ new Set();
|
|
3324
|
+
for (const policy of policies) {
|
|
3325
|
+
allPolicies.push(...policy.policies);
|
|
3326
|
+
policy.tags?.forEach((tag) => allTags.add(tag));
|
|
3327
|
+
}
|
|
3328
|
+
return {
|
|
3329
|
+
name,
|
|
3330
|
+
description: `Composed from: ${policies.map((p) => p.name).join(", ")}`,
|
|
3331
|
+
policies: allPolicies,
|
|
3332
|
+
tags: Array.from(allTags)
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
function extendPolicy(base, additional) {
|
|
3336
|
+
const result = {
|
|
3337
|
+
name: `${base.name}_extended`,
|
|
3338
|
+
policies: [...base.policies, ...additional]
|
|
3339
|
+
};
|
|
3340
|
+
if (base.description !== void 0) {
|
|
3341
|
+
result.description = base.description;
|
|
3342
|
+
}
|
|
3343
|
+
if (base.tags !== void 0) {
|
|
3344
|
+
result.tags = base.tags;
|
|
3345
|
+
}
|
|
3346
|
+
return result;
|
|
3347
|
+
}
|
|
3348
|
+
function overridePolicy(base, overrides) {
|
|
3349
|
+
const newPolicies = base.policies.map((policy) => {
|
|
3350
|
+
const override = policy.name ? overrides[policy.name] : void 0;
|
|
3351
|
+
return override ?? policy;
|
|
3352
|
+
});
|
|
3353
|
+
const result = {
|
|
3354
|
+
name: `${base.name}_overridden`,
|
|
3355
|
+
policies: newPolicies
|
|
3356
|
+
};
|
|
3357
|
+
if (base.description !== void 0) {
|
|
3358
|
+
result.description = base.description;
|
|
3359
|
+
}
|
|
3360
|
+
if (base.tags !== void 0) {
|
|
3361
|
+
result.tags = base.tags;
|
|
3362
|
+
}
|
|
3363
|
+
return result;
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
// src/audit/types.ts
|
|
3367
|
+
var ConsoleAuditAdapter = class {
|
|
3368
|
+
options;
|
|
3369
|
+
constructor(options = {}) {
|
|
3370
|
+
this.options = {
|
|
3371
|
+
format: options.format ?? "text",
|
|
3372
|
+
colors: options.colors ?? true,
|
|
3373
|
+
includeTimestamp: options.includeTimestamp ?? true
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
log(event) {
|
|
3377
|
+
if (this.options.format === "json") {
|
|
3378
|
+
console.log(JSON.stringify(event));
|
|
3379
|
+
} else {
|
|
3380
|
+
const prefix = this.getPrefix(event.decision);
|
|
3381
|
+
const timestamp = this.options.includeTimestamp ? `[${event.timestamp.toISOString()}] ` : "";
|
|
3382
|
+
console.log(
|
|
3383
|
+
`${timestamp}${prefix} RLS ${event.decision.toUpperCase()}: ${event.operation} on ${event.table}` + (event.policyName ? ` (policy: ${event.policyName})` : "") + (event.reason ? ` - ${event.reason}` : "") + (event.userId ? ` [user: ${event.userId}]` : "")
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
return Promise.resolve();
|
|
3387
|
+
}
|
|
3388
|
+
async logBatch(events) {
|
|
3389
|
+
for (const event of events) {
|
|
3390
|
+
await this.log(event);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
getPrefix(decision) {
|
|
3394
|
+
if (!this.options.colors) {
|
|
3395
|
+
return decision === "allow" ? "\u2713" : decision === "deny" ? "\u2717" : "~";
|
|
3396
|
+
}
|
|
3397
|
+
switch (decision) {
|
|
3398
|
+
case "allow":
|
|
3399
|
+
return "\x1B[32m\u2713\x1B[0m";
|
|
3400
|
+
// Green
|
|
3401
|
+
case "deny":
|
|
3402
|
+
return "\x1B[31m\u2717\x1B[0m";
|
|
3403
|
+
// Red
|
|
3404
|
+
case "filter":
|
|
3405
|
+
return "\x1B[33m~\x1B[0m";
|
|
3406
|
+
// Yellow
|
|
3407
|
+
default:
|
|
3408
|
+
return "?";
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
var InMemoryAuditAdapter = class {
|
|
3413
|
+
events = [];
|
|
3414
|
+
maxSize;
|
|
3415
|
+
constructor(maxSize = 1e4) {
|
|
3416
|
+
this.maxSize = maxSize;
|
|
3417
|
+
}
|
|
3418
|
+
log(event) {
|
|
3419
|
+
this.events.push(event);
|
|
3420
|
+
if (this.events.length > this.maxSize) {
|
|
3421
|
+
this.events = this.events.slice(-this.maxSize);
|
|
3422
|
+
}
|
|
3423
|
+
return Promise.resolve();
|
|
3424
|
+
}
|
|
3425
|
+
logBatch(events) {
|
|
3426
|
+
this.events.push(...events);
|
|
3427
|
+
if (this.events.length > this.maxSize) {
|
|
3428
|
+
this.events = this.events.slice(-this.maxSize);
|
|
3429
|
+
}
|
|
3430
|
+
return Promise.resolve();
|
|
3431
|
+
}
|
|
3432
|
+
/**
|
|
3433
|
+
* Get all logged events
|
|
3434
|
+
*/
|
|
3435
|
+
getEvents() {
|
|
3436
|
+
return [...this.events];
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Query events
|
|
3440
|
+
*/
|
|
3441
|
+
query(params) {
|
|
3442
|
+
let results = [...this.events];
|
|
3443
|
+
if (params.userId !== void 0) {
|
|
3444
|
+
results = results.filter((e) => e.userId === params.userId);
|
|
3445
|
+
}
|
|
3446
|
+
if (params.tenantId !== void 0) {
|
|
3447
|
+
results = results.filter((e) => e.tenantId === params.tenantId);
|
|
3448
|
+
}
|
|
3449
|
+
if (params.table) {
|
|
3450
|
+
results = results.filter((e) => e.table === params.table);
|
|
3451
|
+
}
|
|
3452
|
+
if (params.operation) {
|
|
3453
|
+
results = results.filter((e) => e.operation === params.operation);
|
|
3454
|
+
}
|
|
3455
|
+
if (params.decision) {
|
|
3456
|
+
results = results.filter((e) => e.decision === params.decision);
|
|
3457
|
+
}
|
|
3458
|
+
if (params.startTime) {
|
|
3459
|
+
results = results.filter((e) => e.timestamp >= params.startTime);
|
|
3460
|
+
}
|
|
3461
|
+
if (params.endTime) {
|
|
3462
|
+
results = results.filter((e) => e.timestamp < params.endTime);
|
|
3463
|
+
}
|
|
3464
|
+
if (params.requestId) {
|
|
3465
|
+
results = results.filter((e) => e.requestId === params.requestId);
|
|
3466
|
+
}
|
|
3467
|
+
if (params.offset) {
|
|
3468
|
+
results = results.slice(params.offset);
|
|
3469
|
+
}
|
|
3470
|
+
if (params.limit) {
|
|
3471
|
+
results = results.slice(0, params.limit);
|
|
3472
|
+
}
|
|
3473
|
+
return results;
|
|
3474
|
+
}
|
|
3475
|
+
/**
|
|
3476
|
+
* Get statistics
|
|
3477
|
+
*/
|
|
3478
|
+
getStats(params) {
|
|
3479
|
+
let events = this.events;
|
|
3480
|
+
if (params?.startTime) {
|
|
3481
|
+
events = events.filter((e) => e.timestamp >= params.startTime);
|
|
3482
|
+
}
|
|
3483
|
+
if (params?.endTime) {
|
|
3484
|
+
events = events.filter((e) => e.timestamp < params.endTime);
|
|
3485
|
+
}
|
|
3486
|
+
const byDecision = { allow: 0, deny: 0, filter: 0 };
|
|
3487
|
+
const byOperation = { read: 0, create: 0, update: 0, delete: 0, all: 0 };
|
|
3488
|
+
const byTable = {};
|
|
3489
|
+
for (const event of events) {
|
|
3490
|
+
byDecision[event.decision]++;
|
|
3491
|
+
byOperation[event.operation]++;
|
|
3492
|
+
byTable[event.table] = (byTable[event.table] ?? 0) + 1;
|
|
3493
|
+
}
|
|
3494
|
+
return {
|
|
3495
|
+
totalEvents: events.length,
|
|
3496
|
+
byDecision,
|
|
3497
|
+
byOperation,
|
|
3498
|
+
byTable,
|
|
3499
|
+
timeRange: {
|
|
3500
|
+
start: events[0]?.timestamp ?? /* @__PURE__ */ new Date(),
|
|
3501
|
+
end: events[events.length - 1]?.timestamp ?? /* @__PURE__ */ new Date()
|
|
3502
|
+
}
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Clear all events
|
|
3507
|
+
*/
|
|
3508
|
+
clear() {
|
|
3509
|
+
this.events = [];
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* Get event count
|
|
3513
|
+
*/
|
|
3514
|
+
get size() {
|
|
3515
|
+
return this.events.length;
|
|
3516
|
+
}
|
|
3517
|
+
};
|
|
3518
|
+
|
|
3519
|
+
// src/audit/logger.ts
|
|
3520
|
+
var AuditLogger = class {
|
|
3521
|
+
adapter;
|
|
3522
|
+
config;
|
|
3523
|
+
buffer = [];
|
|
3524
|
+
flushTimer = null;
|
|
3525
|
+
isShuttingDown = false;
|
|
3526
|
+
constructor(config) {
|
|
3527
|
+
this.adapter = config.adapter;
|
|
3528
|
+
const baseConfig = {
|
|
3529
|
+
enabled: config.enabled ?? true,
|
|
3530
|
+
defaults: config.defaults ?? {
|
|
3531
|
+
logAllowed: false,
|
|
3532
|
+
logDenied: true,
|
|
3533
|
+
logFilters: false
|
|
3534
|
+
},
|
|
3535
|
+
tables: config.tables ?? {},
|
|
3536
|
+
bufferSize: config.bufferSize ?? 100,
|
|
3537
|
+
flushInterval: config.flushInterval ?? 5e3,
|
|
3538
|
+
async: config.async ?? true,
|
|
3539
|
+
sampleRate: config.sampleRate ?? 1
|
|
3540
|
+
};
|
|
3541
|
+
this.config = config.onError !== void 0 ? { ...baseConfig, onError: config.onError } : baseConfig;
|
|
3542
|
+
if (this.config.flushInterval > 0) {
|
|
3543
|
+
this.startFlushTimer();
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
/**
|
|
3547
|
+
* Log a policy decision
|
|
3548
|
+
*
|
|
3549
|
+
* @param operation - Database operation
|
|
3550
|
+
* @param table - Table name
|
|
3551
|
+
* @param decision - Decision result
|
|
3552
|
+
* @param policyName - Name of the policy
|
|
3553
|
+
* @param options - Additional options
|
|
3554
|
+
*/
|
|
3555
|
+
async logDecision(operation, table, decision, policyName, options) {
|
|
3556
|
+
if (!this.config.enabled || this.isShuttingDown) {
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
if (this.config.sampleRate < 1 && Math.random() > this.config.sampleRate) {
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
const tableConfig = this.getTableConfig(table);
|
|
3563
|
+
if (!this.shouldLog(decision, tableConfig)) {
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
const ctx = rlsContext.getContextOrNull();
|
|
3567
|
+
const event = this.buildEvent(operation, table, decision, policyName, ctx, tableConfig, options);
|
|
3568
|
+
if (tableConfig.filter && !tableConfig.filter(event)) {
|
|
3569
|
+
event.filtered = true;
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
await this.logEvent(event);
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* Log an allow decision
|
|
3576
|
+
*/
|
|
3577
|
+
async logAllow(operation, table, policyName, options) {
|
|
3578
|
+
await this.logDecision(operation, table, "allow", policyName, options);
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3581
|
+
* Log a deny decision
|
|
3582
|
+
*/
|
|
3583
|
+
async logDeny(operation, table, policyName, options) {
|
|
3584
|
+
await this.logDecision(operation, table, "deny", policyName, options);
|
|
3585
|
+
}
|
|
3586
|
+
/**
|
|
3587
|
+
* Log a filter application
|
|
3588
|
+
*/
|
|
3589
|
+
async logFilter(table, policyName, options) {
|
|
3590
|
+
await this.logDecision("read", table, "filter", policyName, options);
|
|
3591
|
+
}
|
|
3592
|
+
/**
|
|
3593
|
+
* Flush buffered events
|
|
3594
|
+
*/
|
|
3595
|
+
async flush() {
|
|
3596
|
+
if (this.buffer.length === 0) {
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
const eventsToFlush = [...this.buffer];
|
|
3600
|
+
this.buffer = [];
|
|
3601
|
+
try {
|
|
3602
|
+
if (this.adapter.logBatch) {
|
|
3603
|
+
await this.adapter.logBatch(eventsToFlush);
|
|
3604
|
+
} else {
|
|
3605
|
+
for (const event of eventsToFlush) {
|
|
3606
|
+
await this.adapter.log(event);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
this.config.onError?.(error instanceof Error ? error : new Error(String(error)), eventsToFlush);
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
/**
|
|
3614
|
+
* Close the logger
|
|
3615
|
+
*/
|
|
3616
|
+
async close() {
|
|
3617
|
+
this.isShuttingDown = true;
|
|
3618
|
+
if (this.flushTimer) {
|
|
3619
|
+
clearInterval(this.flushTimer);
|
|
3620
|
+
this.flushTimer = null;
|
|
3621
|
+
}
|
|
3622
|
+
await this.flush();
|
|
3623
|
+
await this.adapter.flush?.();
|
|
3624
|
+
await this.adapter.close?.();
|
|
3625
|
+
}
|
|
3626
|
+
/**
|
|
3627
|
+
* Get buffer size
|
|
3628
|
+
*/
|
|
3629
|
+
get bufferSize() {
|
|
3630
|
+
return this.buffer.length;
|
|
3631
|
+
}
|
|
3632
|
+
/**
|
|
3633
|
+
* Check if logger is enabled
|
|
3634
|
+
*/
|
|
3635
|
+
get enabled() {
|
|
3636
|
+
return this.config.enabled;
|
|
3637
|
+
}
|
|
3638
|
+
/**
|
|
3639
|
+
* Enable or disable logging
|
|
3640
|
+
*/
|
|
3641
|
+
setEnabled(enabled) {
|
|
3642
|
+
this.config.enabled = enabled;
|
|
3643
|
+
}
|
|
3644
|
+
// ============================================================================
|
|
3645
|
+
// Private Methods
|
|
3646
|
+
// ============================================================================
|
|
3647
|
+
/**
|
|
3648
|
+
* Get table-specific config with defaults
|
|
3649
|
+
*/
|
|
3650
|
+
getTableConfig(table) {
|
|
3651
|
+
const tableOverride = this.config.tables[table];
|
|
3652
|
+
return {
|
|
3653
|
+
...this.config.defaults,
|
|
3654
|
+
...tableOverride,
|
|
3655
|
+
enabled: tableOverride?.enabled ?? true
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
/**
|
|
3659
|
+
* Check if decision should be logged
|
|
3660
|
+
*/
|
|
3661
|
+
shouldLog(decision, tableConfig) {
|
|
3662
|
+
if (!tableConfig.enabled) {
|
|
3663
|
+
return false;
|
|
3664
|
+
}
|
|
3665
|
+
switch (decision) {
|
|
3666
|
+
case "allow":
|
|
3667
|
+
return tableConfig.logAllowed ?? false;
|
|
3668
|
+
case "deny":
|
|
3669
|
+
return tableConfig.logDenied ?? true;
|
|
3670
|
+
case "filter":
|
|
3671
|
+
return tableConfig.logFilters ?? false;
|
|
3672
|
+
default:
|
|
3673
|
+
return false;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Build audit event
|
|
3678
|
+
*/
|
|
3679
|
+
buildEvent(operation, table, decision, policyName, ctx, tableConfig, options) {
|
|
3680
|
+
const event = {
|
|
3681
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3682
|
+
userId: ctx?.auth.userId ?? "anonymous",
|
|
3683
|
+
operation,
|
|
3684
|
+
table,
|
|
3685
|
+
decision
|
|
3686
|
+
};
|
|
3687
|
+
if (ctx?.auth.tenantId !== void 0) {
|
|
3688
|
+
event.tenantId = ctx.auth.tenantId;
|
|
3689
|
+
}
|
|
3690
|
+
if (policyName) {
|
|
3691
|
+
event.policyName = policyName;
|
|
3692
|
+
}
|
|
3693
|
+
if (options?.reason) {
|
|
3694
|
+
event.reason = options.reason;
|
|
3695
|
+
}
|
|
3696
|
+
if (options?.rowIds && options.rowIds.length > 0) {
|
|
3697
|
+
event.rowIds = options.rowIds;
|
|
3698
|
+
}
|
|
3699
|
+
if (options?.queryHash) {
|
|
3700
|
+
event.queryHash = options.queryHash;
|
|
3701
|
+
}
|
|
3702
|
+
if (options?.durationMs !== void 0) {
|
|
3703
|
+
event.durationMs = options.durationMs;
|
|
3704
|
+
}
|
|
3705
|
+
if (ctx?.request) {
|
|
3706
|
+
if (ctx.request.requestId) {
|
|
3707
|
+
event.requestId = ctx.request.requestId;
|
|
3708
|
+
}
|
|
3709
|
+
if (ctx.request.ipAddress) {
|
|
3710
|
+
event.ipAddress = ctx.request.ipAddress;
|
|
3711
|
+
}
|
|
3712
|
+
if (ctx.request.userAgent) {
|
|
3713
|
+
event.userAgent = ctx.request.userAgent;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
const context = this.buildContext(ctx, tableConfig, options?.context);
|
|
3717
|
+
if (context !== void 0) {
|
|
3718
|
+
event.context = context;
|
|
3719
|
+
}
|
|
3720
|
+
return event;
|
|
3721
|
+
}
|
|
3722
|
+
/**
|
|
3723
|
+
* Build context object with filtering
|
|
3724
|
+
*/
|
|
3725
|
+
buildContext(ctx, tableConfig, additionalContext) {
|
|
3726
|
+
const context = {};
|
|
3727
|
+
if (ctx?.auth.roles && ctx.auth.roles.length > 0) {
|
|
3728
|
+
context["roles"] = ctx.auth.roles;
|
|
3729
|
+
}
|
|
3730
|
+
if (ctx?.auth.organizationIds && ctx.auth.organizationIds.length > 0) {
|
|
3731
|
+
context["organizationIds"] = ctx.auth.organizationIds;
|
|
3732
|
+
}
|
|
3733
|
+
if (ctx?.meta && typeof ctx.meta === "object") {
|
|
3734
|
+
Object.assign(context, ctx.meta);
|
|
3735
|
+
}
|
|
3736
|
+
if (additionalContext) {
|
|
3737
|
+
Object.assign(context, additionalContext);
|
|
3738
|
+
}
|
|
3739
|
+
let filteredContext = context;
|
|
3740
|
+
if (tableConfig.includeContext && tableConfig.includeContext.length > 0) {
|
|
3741
|
+
filteredContext = {};
|
|
3742
|
+
for (const key of tableConfig.includeContext) {
|
|
3743
|
+
if (key in context) {
|
|
3744
|
+
filteredContext[key] = context[key];
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
if (tableConfig.excludeContext && tableConfig.excludeContext.length > 0) {
|
|
3749
|
+
for (const key of tableConfig.excludeContext) {
|
|
3750
|
+
const { [key]: _, ...rest } = filteredContext;
|
|
3751
|
+
filteredContext = rest;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
return Object.keys(filteredContext).length > 0 ? filteredContext : void 0;
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* Log event to buffer or directly
|
|
3758
|
+
*/
|
|
3759
|
+
async logEvent(event) {
|
|
3760
|
+
if (this.config.async && this.config.bufferSize > 0) {
|
|
3761
|
+
this.buffer.push(event);
|
|
3762
|
+
if (this.buffer.length >= this.config.bufferSize) {
|
|
3763
|
+
await this.flush();
|
|
3764
|
+
}
|
|
3765
|
+
} else if (this.config.async) {
|
|
3766
|
+
this.adapter.log(event).catch((error) => {
|
|
3767
|
+
this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [event]);
|
|
3768
|
+
});
|
|
3769
|
+
} else {
|
|
3770
|
+
try {
|
|
3771
|
+
await this.adapter.log(event);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [event]);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Start the flush timer
|
|
3779
|
+
*/
|
|
3780
|
+
startFlushTimer() {
|
|
3781
|
+
this.flushTimer = setInterval(() => {
|
|
3782
|
+
this.flush().catch((error) => {
|
|
3783
|
+
this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [...this.buffer]);
|
|
3784
|
+
});
|
|
3785
|
+
}, this.config.flushInterval);
|
|
3786
|
+
if (this.flushTimer.unref) {
|
|
3787
|
+
this.flushTimer.unref();
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
};
|
|
3791
|
+
function createAuditLogger(config) {
|
|
3792
|
+
return new AuditLogger(config);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
// src/testing/index.ts
|
|
3796
|
+
var PolicyTester = class {
|
|
3797
|
+
registry;
|
|
3798
|
+
constructor(schema) {
|
|
3799
|
+
this.registry = new PolicyRegistry(schema);
|
|
3800
|
+
}
|
|
3801
|
+
/**
|
|
3802
|
+
* Evaluate policies for an operation
|
|
3803
|
+
*
|
|
3804
|
+
* @param table - Table name
|
|
3805
|
+
* @param operation - Operation to test
|
|
3806
|
+
* @param context - Test context
|
|
3807
|
+
* @returns Evaluation result
|
|
3808
|
+
*/
|
|
3809
|
+
async evaluate(table, operation, context) {
|
|
3810
|
+
const evaluatedPolicies = [];
|
|
3811
|
+
if (!this.registry.hasTable(table)) {
|
|
3812
|
+
return {
|
|
3813
|
+
allowed: true,
|
|
3814
|
+
decisionType: "default",
|
|
3815
|
+
reason: "Table has no RLS policies",
|
|
3816
|
+
evaluatedPolicies
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
if (context.auth.isSystem) {
|
|
3820
|
+
return {
|
|
3821
|
+
allowed: true,
|
|
3822
|
+
decisionType: "allow",
|
|
3823
|
+
reason: "System user bypasses RLS",
|
|
3824
|
+
evaluatedPolicies
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
const skipFor = this.registry.getSkipFor(table);
|
|
3828
|
+
if (skipFor.some((role) => context.auth.roles.includes(role))) {
|
|
3829
|
+
return {
|
|
3830
|
+
allowed: true,
|
|
3831
|
+
decisionType: "allow",
|
|
3832
|
+
reason: `Role bypass: ${skipFor.find((r) => context.auth.roles.includes(r))}`,
|
|
3833
|
+
evaluatedPolicies
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3836
|
+
const evalCtx = {
|
|
3837
|
+
auth: context.auth,
|
|
3838
|
+
row: context.row,
|
|
3839
|
+
data: context.data,
|
|
3840
|
+
table,
|
|
3841
|
+
operation,
|
|
3842
|
+
...context.meta !== void 0 && { meta: context.meta }
|
|
3843
|
+
};
|
|
3844
|
+
const denies = this.registry.getDenies(table, operation);
|
|
3845
|
+
for (const deny2 of denies) {
|
|
3846
|
+
const result = await this.evaluatePolicy(deny2, evalCtx);
|
|
3847
|
+
evaluatedPolicies.push({
|
|
3848
|
+
name: deny2.name,
|
|
3849
|
+
type: "deny",
|
|
3850
|
+
result
|
|
3851
|
+
});
|
|
3852
|
+
if (result) {
|
|
3853
|
+
return {
|
|
3854
|
+
allowed: false,
|
|
3855
|
+
policyName: deny2.name,
|
|
3856
|
+
decisionType: "deny",
|
|
3857
|
+
reason: `Denied by policy: ${deny2.name}`,
|
|
3858
|
+
evaluatedPolicies
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
if ((operation === "create" || operation === "update") && context.data) {
|
|
3863
|
+
const validates = this.registry.getValidates(table, operation);
|
|
3864
|
+
for (const validate2 of validates) {
|
|
3865
|
+
const result = await this.evaluatePolicy(validate2, evalCtx);
|
|
3866
|
+
evaluatedPolicies.push({
|
|
3867
|
+
name: validate2.name,
|
|
3868
|
+
type: "validate",
|
|
3869
|
+
result
|
|
3870
|
+
});
|
|
3871
|
+
if (!result) {
|
|
3872
|
+
return {
|
|
3873
|
+
allowed: false,
|
|
3874
|
+
policyName: validate2.name,
|
|
3875
|
+
decisionType: "deny",
|
|
3876
|
+
reason: `Validation failed: ${validate2.name}`,
|
|
3877
|
+
evaluatedPolicies
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
const allows = this.registry.getAllows(table, operation);
|
|
3883
|
+
const defaultDeny = this.registry.hasDefaultDeny(table);
|
|
3884
|
+
if (defaultDeny && allows.length === 0) {
|
|
3885
|
+
return {
|
|
3886
|
+
allowed: false,
|
|
3887
|
+
decisionType: "default",
|
|
3888
|
+
reason: "No allow policies defined (default deny)",
|
|
3889
|
+
evaluatedPolicies
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
for (const allow2 of allows) {
|
|
3893
|
+
const result = await this.evaluatePolicy(allow2, evalCtx);
|
|
3894
|
+
evaluatedPolicies.push({
|
|
3895
|
+
name: allow2.name,
|
|
3896
|
+
type: "allow",
|
|
3897
|
+
result
|
|
3898
|
+
});
|
|
3899
|
+
if (result) {
|
|
3900
|
+
return {
|
|
3901
|
+
allowed: true,
|
|
3902
|
+
policyName: allow2.name,
|
|
3903
|
+
decisionType: "allow",
|
|
3904
|
+
reason: `Allowed by policy: ${allow2.name}`,
|
|
3905
|
+
evaluatedPolicies
|
|
3906
|
+
};
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
if (defaultDeny) {
|
|
3910
|
+
return {
|
|
3911
|
+
allowed: false,
|
|
3912
|
+
decisionType: "default",
|
|
3913
|
+
reason: "No allow policies matched (default deny)",
|
|
3914
|
+
evaluatedPolicies
|
|
3915
|
+
};
|
|
3916
|
+
}
|
|
3917
|
+
return {
|
|
3918
|
+
allowed: true,
|
|
3919
|
+
decisionType: "default",
|
|
3920
|
+
reason: "No policies matched (default allow)",
|
|
3921
|
+
evaluatedPolicies
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
/**
|
|
3925
|
+
* Get filter conditions for read operations
|
|
3926
|
+
*
|
|
3927
|
+
* @param table - Table name
|
|
3928
|
+
* @param operation - Must be 'read'
|
|
3929
|
+
* @param context - Test context
|
|
3930
|
+
* @returns Filter conditions
|
|
3931
|
+
*/
|
|
3932
|
+
getFilters(table, _operation, context) {
|
|
3933
|
+
const conditions = {};
|
|
3934
|
+
const appliedFilters = [];
|
|
3935
|
+
if (!this.registry.hasTable(table)) {
|
|
3936
|
+
return { conditions, appliedFilters };
|
|
3937
|
+
}
|
|
3938
|
+
if (context.auth.isSystem) {
|
|
3939
|
+
return { conditions, appliedFilters };
|
|
3940
|
+
}
|
|
3941
|
+
const skipFor = this.registry.getSkipFor(table);
|
|
3942
|
+
if (skipFor.some((role) => context.auth.roles.includes(role))) {
|
|
3943
|
+
return { conditions, appliedFilters };
|
|
3944
|
+
}
|
|
3945
|
+
const filters = this.registry.getFilters(table);
|
|
3946
|
+
const evalCtx = {
|
|
3947
|
+
auth: context.auth,
|
|
3948
|
+
...context.meta !== void 0 && { meta: context.meta }
|
|
3949
|
+
};
|
|
3950
|
+
for (const filter2 of filters) {
|
|
3951
|
+
const filterConditions = filter2.getConditions(evalCtx);
|
|
3952
|
+
Object.assign(conditions, filterConditions);
|
|
3953
|
+
appliedFilters.push(filter2.name);
|
|
3954
|
+
}
|
|
3955
|
+
return { conditions, appliedFilters };
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Test if a specific policy allows the operation
|
|
3959
|
+
*
|
|
3960
|
+
* @param table - Table name
|
|
3961
|
+
* @param policyName - Name of the policy to test
|
|
3962
|
+
* @param context - Test context
|
|
3963
|
+
* @returns True if policy allows
|
|
3964
|
+
*/
|
|
3965
|
+
async testPolicy(table, policyName, context) {
|
|
3966
|
+
const operations = ["read", "create", "update", "delete"];
|
|
3967
|
+
for (const op of operations) {
|
|
3968
|
+
const evalCtx = {
|
|
3969
|
+
auth: context.auth,
|
|
3970
|
+
row: context.row,
|
|
3971
|
+
data: context.data,
|
|
3972
|
+
table,
|
|
3973
|
+
operation: op,
|
|
3974
|
+
...context.meta !== void 0 && { meta: context.meta }
|
|
3975
|
+
};
|
|
3976
|
+
const allows = this.registry.getAllows(table, op);
|
|
3977
|
+
const allow2 = allows.find((p) => p.name === policyName);
|
|
3978
|
+
if (allow2) {
|
|
3979
|
+
const result = await this.evaluatePolicy(allow2, evalCtx);
|
|
3980
|
+
return { found: true, result };
|
|
3981
|
+
}
|
|
3982
|
+
const denies = this.registry.getDenies(table, op);
|
|
3983
|
+
const deny2 = denies.find((p) => p.name === policyName);
|
|
3984
|
+
if (deny2) {
|
|
3985
|
+
const result = await this.evaluatePolicy(deny2, evalCtx);
|
|
3986
|
+
return { found: true, result };
|
|
3987
|
+
}
|
|
3988
|
+
const validates = this.registry.getValidates(table, op);
|
|
3989
|
+
const validate2 = validates.find((p) => p.name === policyName);
|
|
3990
|
+
if (validate2) {
|
|
3991
|
+
const result = await this.evaluatePolicy(validate2, evalCtx);
|
|
3992
|
+
return { found: true, result };
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
return { found: false };
|
|
3996
|
+
}
|
|
3997
|
+
/**
|
|
3998
|
+
* List all policies for a table
|
|
3999
|
+
*/
|
|
4000
|
+
listPolicies(table) {
|
|
4001
|
+
const operations = ["read", "create", "update", "delete"];
|
|
4002
|
+
const allowSet = /* @__PURE__ */ new Set();
|
|
4003
|
+
const denySet = /* @__PURE__ */ new Set();
|
|
4004
|
+
const validateSet = /* @__PURE__ */ new Set();
|
|
4005
|
+
for (const op of operations) {
|
|
4006
|
+
this.registry.getAllows(table, op).forEach((p) => allowSet.add(p.name));
|
|
4007
|
+
this.registry.getDenies(table, op).forEach((p) => denySet.add(p.name));
|
|
4008
|
+
this.registry.getValidates(table, op).forEach((p) => validateSet.add(p.name));
|
|
4009
|
+
}
|
|
4010
|
+
return {
|
|
4011
|
+
allows: Array.from(allowSet),
|
|
4012
|
+
denies: Array.from(denySet),
|
|
4013
|
+
filters: this.registry.getFilters(table).map((f) => f.name),
|
|
4014
|
+
validates: Array.from(validateSet)
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
/**
|
|
4018
|
+
* Get all registered tables
|
|
4019
|
+
*/
|
|
4020
|
+
getTables() {
|
|
4021
|
+
return this.registry.getTables();
|
|
4022
|
+
}
|
|
4023
|
+
/**
|
|
4024
|
+
* Evaluate a single policy
|
|
4025
|
+
*/
|
|
4026
|
+
async evaluatePolicy(policy, ctx) {
|
|
4027
|
+
try {
|
|
4028
|
+
const result = policy.evaluate(ctx);
|
|
4029
|
+
return result instanceof Promise ? await result : result;
|
|
4030
|
+
} catch {
|
|
4031
|
+
return false;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
function createPolicyTester(schema) {
|
|
4036
|
+
return new PolicyTester(schema);
|
|
4037
|
+
}
|
|
4038
|
+
function createTestAuthContext(overrides) {
|
|
4039
|
+
return {
|
|
4040
|
+
roles: [],
|
|
4041
|
+
isSystem: false,
|
|
4042
|
+
...overrides
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
function createTestRow(data) {
|
|
4046
|
+
return { ...data };
|
|
4047
|
+
}
|
|
4048
|
+
var policyAssertions = {
|
|
4049
|
+
/**
|
|
4050
|
+
* Assert that the result is allowed
|
|
4051
|
+
*/
|
|
4052
|
+
assertAllowed(result, message) {
|
|
4053
|
+
if (!result.allowed) {
|
|
4054
|
+
throw new Error(
|
|
4055
|
+
message ?? `Expected policy to allow, but was denied: ${result.reason}`
|
|
4056
|
+
);
|
|
4057
|
+
}
|
|
4058
|
+
},
|
|
4059
|
+
/**
|
|
4060
|
+
* Assert that the result is denied
|
|
4061
|
+
*/
|
|
4062
|
+
assertDenied(result, message) {
|
|
4063
|
+
if (result.allowed) {
|
|
4064
|
+
throw new Error(
|
|
4065
|
+
message ?? `Expected policy to deny, but was allowed: ${result.reason}`
|
|
4066
|
+
);
|
|
4067
|
+
}
|
|
4068
|
+
},
|
|
4069
|
+
/**
|
|
4070
|
+
* Assert that a specific policy made the decision
|
|
4071
|
+
*/
|
|
4072
|
+
assertPolicyUsed(result, policyName, message) {
|
|
4073
|
+
if (result.policyName !== policyName) {
|
|
4074
|
+
throw new Error(
|
|
4075
|
+
message ?? `Expected policy "${policyName}" but was "${result.policyName}"`
|
|
4076
|
+
);
|
|
4077
|
+
}
|
|
4078
|
+
},
|
|
4079
|
+
/**
|
|
4080
|
+
* Assert that filters include expected conditions
|
|
4081
|
+
*/
|
|
4082
|
+
assertFiltersInclude(result, expected, message) {
|
|
4083
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
4084
|
+
if (result.conditions[key] !== value) {
|
|
4085
|
+
throw new Error(
|
|
4086
|
+
message ?? `Expected filter condition ${key}=${JSON.stringify(value)} but got ${JSON.stringify(result.conditions[key])}`
|
|
4087
|
+
);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
};
|
|
4092
|
+
|
|
4093
|
+
export { AuditLogger, ConsoleAuditAdapter, FieldAccessProcessor, FieldAccessRegistry, InMemoryAuditAdapter, InMemoryCacheProvider, PolicyRegistry, PolicyTester, RLSContextError, RLSContextValidationError, RLSError, RLSErrorCodes, RLSPluginOptionsSchema, RLSPolicyEvaluationError, RLSPolicyViolation, RLSSchemaError, ReBAcRegistry, ReBAcTransformer, ResolverManager, allow, allowRelation, composePolicies, createAdminPolicy, createAuditLogger, createEvaluationContext, createFieldAccessProcessor, createFieldAccessRegistry, createOwnershipPolicy, createPolicyTester, createRLSContext, createReBAcRegistry, createReBAcTransformer, createResolver, createResolverManager, createSoftDeletePolicy, createStatusAccessPolicy, createTenantIsolationPolicy, createTestAuthContext, createTestRow, deepMerge, defineAllowPolicy, defineCombinedPolicy, defineDenyPolicy, defineFilterPolicy, definePolicy, defineRLSSchema, defineValidatePolicy, deny, denyRelation, extendPolicy, filter, hashString, isAsyncFunction, maskedField, mergeRLSSchemas, neverAccessible, normalizeOperations, orgMembershipPath, overridePolicy, ownerOnly, ownerOrRoles, policyAssertions, publicReadRestrictedWrite, readOnly, rlsContext, rlsPlugin, rolesOnly, safeEvaluate, shopOrgMembershipPath, teamHierarchyPath, validate, whenCondition, whenEnvironment, whenFeature, whenTimeRange, withRLSContext, withRLSContextAsync };
|
|
1644
4094
|
//# sourceMappingURL=index.js.map
|
|
1645
4095
|
//# sourceMappingURL=index.js.map
|