@objectstack/plugin-security 8.0.0 → 8.0.1
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/dist/index.d.mts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +130 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -2540,6 +2540,14 @@ var SecurityPlugin = class {
|
|
|
2540
2540
|
* zero rows.
|
|
2541
2541
|
*/
|
|
2542
2542
|
this.tenancyDisabledCache = /* @__PURE__ */ new Map();
|
|
2543
|
+
/**
|
|
2544
|
+
* Service handles captured in `start()` so the request-time RLS resolution
|
|
2545
|
+
* (used by BOTH the engine middleware and the public {@link getReadFilter}
|
|
2546
|
+
* service method) shares one code path. `null` until `start()` wires them.
|
|
2547
|
+
*/
|
|
2548
|
+
this.metadata = null;
|
|
2549
|
+
this.ql = null;
|
|
2550
|
+
this.logger = {};
|
|
2543
2551
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2544
2552
|
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
2545
2553
|
}
|
|
@@ -2605,6 +2613,9 @@ var SecurityPlugin = class {
|
|
|
2605
2613
|
ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
|
|
2606
2614
|
return;
|
|
2607
2615
|
}
|
|
2616
|
+
this.metadata = metadata;
|
|
2617
|
+
this.ql = ql;
|
|
2618
|
+
this.logger = ctx.logger;
|
|
2608
2619
|
try {
|
|
2609
2620
|
const orgScoping = ctx.getService("org-scoping");
|
|
2610
2621
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2639,6 +2650,17 @@ var SecurityPlugin = class {
|
|
|
2639
2650
|
fields: typeof r.field_permissions === "string" ? JSON.parse(r.field_permissions || "{}") : r.field_permissions ?? {}
|
|
2640
2651
|
}));
|
|
2641
2652
|
} : void 0;
|
|
2653
|
+
this.dbLoader = dbLoader;
|
|
2654
|
+
try {
|
|
2655
|
+
ctx.registerService("security", {
|
|
2656
|
+
getReadFilter: (object, context) => this.getReadFilter(object, context)
|
|
2657
|
+
});
|
|
2658
|
+
ctx.logger.info('[security] registered "security" service (getReadFilter) for raw-SQL RLS bridging');
|
|
2659
|
+
} catch (e) {
|
|
2660
|
+
ctx.logger.warn?.('[security] failed to register "security" service', {
|
|
2661
|
+
error: e.message
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2642
2664
|
ql.registerMiddleware(async (opCtx, next) => {
|
|
2643
2665
|
if (opCtx.context?.isSystem) {
|
|
2644
2666
|
return next();
|
|
@@ -2650,25 +2672,7 @@ var SecurityPlugin = class {
|
|
|
2650
2672
|
}
|
|
2651
2673
|
let permissionSets = [];
|
|
2652
2674
|
try {
|
|
2653
|
-
|
|
2654
|
-
if (requested.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
2655
|
-
requested.push(this.fallbackPermissionSet);
|
|
2656
|
-
}
|
|
2657
|
-
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2658
|
-
requested,
|
|
2659
|
-
metadata,
|
|
2660
|
-
this.bootstrapPermissionSets,
|
|
2661
|
-
dbLoader
|
|
2662
|
-
);
|
|
2663
|
-
if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
2664
|
-
const fallback = await this.permissionEvaluator.resolvePermissionSets(
|
|
2665
|
-
[this.fallbackPermissionSet],
|
|
2666
|
-
metadata,
|
|
2667
|
-
this.bootstrapPermissionSets,
|
|
2668
|
-
dbLoader
|
|
2669
|
-
);
|
|
2670
|
-
permissionSets = fallback;
|
|
2671
|
-
}
|
|
2675
|
+
permissionSets = await this.resolvePermissionSetsForContext(opCtx.context);
|
|
2672
2676
|
} catch (e) {
|
|
2673
2677
|
ctx.logger.error(
|
|
2674
2678
|
`[security] permission resolution failed for operation '${opCtx.operation}' on object '${opCtx.object}' (user ${opCtx.context?.userId ?? "unknown"}) \u2014 denying request (fail-closed)`,
|
|
@@ -2724,25 +2728,13 @@ var SecurityPlugin = class {
|
|
|
2724
2728
|
}
|
|
2725
2729
|
}
|
|
2726
2730
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
if (!targetField) return true;
|
|
2735
|
-
if (objectFields.has(targetField)) return true;
|
|
2736
|
-
if (tenancyDisabled && targetField === "organization_id") {
|
|
2737
|
-
return false;
|
|
2738
|
-
}
|
|
2739
|
-
dropped++;
|
|
2740
|
-
return false;
|
|
2741
|
-
}) : allRlsPolicies;
|
|
2742
|
-
let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
|
|
2743
|
-
if (rlsFilter == null && dropped > 0) {
|
|
2744
|
-
rlsFilter = { ...RLS_DENY_FILTER };
|
|
2745
|
-
}
|
|
2731
|
+
if (opCtx.ast) {
|
|
2732
|
+
const rlsFilter = await this.computeRlsFilter(
|
|
2733
|
+
permissionSets,
|
|
2734
|
+
opCtx.object,
|
|
2735
|
+
opCtx.operation,
|
|
2736
|
+
opCtx.context
|
|
2737
|
+
);
|
|
2746
2738
|
if (rlsFilter) {
|
|
2747
2739
|
if (opCtx.ast.where) {
|
|
2748
2740
|
opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
|
|
@@ -2824,6 +2816,106 @@ var SecurityPlugin = class {
|
|
|
2824
2816
|
}
|
|
2825
2817
|
async destroy() {
|
|
2826
2818
|
}
|
|
2819
|
+
/**
|
|
2820
|
+
* ADR-0021 D-C — resolve the per-request READ scope (tenant + RLS predicate)
|
|
2821
|
+
* for one object as a canonical `FilterCondition`, WITHOUT touching the
|
|
2822
|
+
* ObjectQL engine. This is the seam the analytics raw-SQL path bridges to so
|
|
2823
|
+
* it enforces the SAME row scoping the engine middleware applies on `find`.
|
|
2824
|
+
*
|
|
2825
|
+
* Returns:
|
|
2826
|
+
* - `undefined` → no scope applies (system context, or an unauthenticated
|
|
2827
|
+
* request with no userId/roles/permissions — authn is gated elsewhere).
|
|
2828
|
+
* - a `FilterCondition` → AND it into the object's scan (the join's `ON`/
|
|
2829
|
+
* `WHERE` for analytics; the where clause for a plain find).
|
|
2830
|
+
* - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or
|
|
2831
|
+
* resolution failed — fail-closed to zero rows. NEVER returns "allow all"
|
|
2832
|
+
* on error, so a degraded permission subsystem cannot leak cross-tenant
|
|
2833
|
+
* rows through analytics.
|
|
2834
|
+
*
|
|
2835
|
+
* Async because permission-set resolution can hit the database; the analytics
|
|
2836
|
+
* service pre-resolves these per request (base + every joined object) before
|
|
2837
|
+
* the synchronous SQL builder runs.
|
|
2838
|
+
*/
|
|
2839
|
+
async getReadFilter(object, context) {
|
|
2840
|
+
if (context?.isSystem) return void 0;
|
|
2841
|
+
const roles = context?.roles ?? [];
|
|
2842
|
+
const explicit = context?.permissions ?? [];
|
|
2843
|
+
if (roles.length === 0 && explicit.length === 0 && !context?.userId) {
|
|
2844
|
+
return void 0;
|
|
2845
|
+
}
|
|
2846
|
+
try {
|
|
2847
|
+
const permissionSets = await this.resolvePermissionSetsForContext(context);
|
|
2848
|
+
const filter = await this.computeRlsFilter(permissionSets, object, "find", context);
|
|
2849
|
+
return filter ?? void 0;
|
|
2850
|
+
} catch (e) {
|
|
2851
|
+
this.logger.error?.(
|
|
2852
|
+
`[security] getReadFilter failed for object '${object}' (user ${context?.userId ?? "unknown"}) \u2014 denying (fail-closed)`,
|
|
2853
|
+
e instanceof Error ? e : new Error(String(e))
|
|
2854
|
+
);
|
|
2855
|
+
return { ...RLS_DENY_FILTER };
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Resolve the effective permission sets for an execution context — roles +
|
|
2860
|
+
* explicit permission sets, with the configured baseline applied both as an
|
|
2861
|
+
* implicit request (when none were named) and as a post-resolution fallback
|
|
2862
|
+
* (when named ones resolved to nothing). Shared by the engine middleware and
|
|
2863
|
+
* {@link getReadFilter} so both enforce identical RLS. May throw if the
|
|
2864
|
+
* underlying metadata/db resolution fails (callers fail-closed).
|
|
2865
|
+
*/
|
|
2866
|
+
async resolvePermissionSetsForContext(context) {
|
|
2867
|
+
const roles = context?.roles ?? [];
|
|
2868
|
+
const explicitPermissionSets = context?.permissions ?? [];
|
|
2869
|
+
const requested = [...roles, ...explicitPermissionSets];
|
|
2870
|
+
if (requested.length === 0 && context?.userId && this.fallbackPermissionSet) {
|
|
2871
|
+
requested.push(this.fallbackPermissionSet);
|
|
2872
|
+
}
|
|
2873
|
+
let permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2874
|
+
requested,
|
|
2875
|
+
this.metadata,
|
|
2876
|
+
this.bootstrapPermissionSets,
|
|
2877
|
+
this.dbLoader
|
|
2878
|
+
);
|
|
2879
|
+
if (permissionSets.length === 0 && context?.userId && this.fallbackPermissionSet) {
|
|
2880
|
+
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2881
|
+
[this.fallbackPermissionSet],
|
|
2882
|
+
this.metadata,
|
|
2883
|
+
this.bootstrapPermissionSets,
|
|
2884
|
+
this.dbLoader
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
return permissionSets;
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Compile the applicable RLS policies for (object, operation) into a single
|
|
2891
|
+
* `FilterCondition`, applying the field-existence safety net (wildcard
|
|
2892
|
+
* policies targeting a column the object lacks fail-closed to the deny
|
|
2893
|
+
* sentinel, unless the object explicitly opted out of tenancy). Shared by the
|
|
2894
|
+
* engine middleware and {@link getReadFilter}. Returns `null` when no policy
|
|
2895
|
+
* applies (caller adds no filter).
|
|
2896
|
+
*/
|
|
2897
|
+
async computeRlsFilter(permissionSets, object, operation, context) {
|
|
2898
|
+
const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation);
|
|
2899
|
+
if (allRlsPolicies.length === 0) return null;
|
|
2900
|
+
const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql);
|
|
2901
|
+
const tenancyDisabled = this.tenancyDisabledCache.get(object) === true;
|
|
2902
|
+
let dropped = 0;
|
|
2903
|
+
const compilable = objectFields ? allRlsPolicies.filter((p) => {
|
|
2904
|
+
const targetField = this.extractTargetField(p.using);
|
|
2905
|
+
if (!targetField) return true;
|
|
2906
|
+
if (objectFields.has(targetField)) return true;
|
|
2907
|
+
if (tenancyDisabled && targetField === "organization_id") {
|
|
2908
|
+
return false;
|
|
2909
|
+
}
|
|
2910
|
+
dropped++;
|
|
2911
|
+
return false;
|
|
2912
|
+
}) : allRlsPolicies;
|
|
2913
|
+
let rlsFilter = this.rlsCompiler.compileFilter(compilable, context);
|
|
2914
|
+
if (rlsFilter == null && dropped > 0) {
|
|
2915
|
+
rlsFilter = { ...RLS_DENY_FILTER };
|
|
2916
|
+
}
|
|
2917
|
+
return rlsFilter;
|
|
2918
|
+
}
|
|
2827
2919
|
/**
|
|
2828
2920
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
2829
2921
|
*/
|