@objectstack/plugin-security 7.9.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 +1523 -40
- package/dist/index.d.ts +1523 -40
- package/dist/index.js +137 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +137 -39
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -2572,6 +2572,14 @@ var SecurityPlugin = class {
|
|
|
2572
2572
|
* zero rows.
|
|
2573
2573
|
*/
|
|
2574
2574
|
this.tenancyDisabledCache = /* @__PURE__ */ new Map();
|
|
2575
|
+
/**
|
|
2576
|
+
* Service handles captured in `start()` so the request-time RLS resolution
|
|
2577
|
+
* (used by BOTH the engine middleware and the public {@link getReadFilter}
|
|
2578
|
+
* service method) shares one code path. `null` until `start()` wires them.
|
|
2579
|
+
*/
|
|
2580
|
+
this.metadata = null;
|
|
2581
|
+
this.ql = null;
|
|
2582
|
+
this.logger = {};
|
|
2575
2583
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2576
2584
|
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
2577
2585
|
}
|
|
@@ -2637,6 +2645,9 @@ var SecurityPlugin = class {
|
|
|
2637
2645
|
ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
|
|
2638
2646
|
return;
|
|
2639
2647
|
}
|
|
2648
|
+
this.metadata = metadata;
|
|
2649
|
+
this.ql = ql;
|
|
2650
|
+
this.logger = ctx.logger;
|
|
2640
2651
|
try {
|
|
2641
2652
|
const orgScoping = ctx.getService("org-scoping");
|
|
2642
2653
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2671,6 +2682,17 @@ var SecurityPlugin = class {
|
|
|
2671
2682
|
fields: typeof r.field_permissions === "string" ? JSON.parse(r.field_permissions || "{}") : r.field_permissions ?? {}
|
|
2672
2683
|
}));
|
|
2673
2684
|
} : void 0;
|
|
2685
|
+
this.dbLoader = dbLoader;
|
|
2686
|
+
try {
|
|
2687
|
+
ctx.registerService("security", {
|
|
2688
|
+
getReadFilter: (object, context) => this.getReadFilter(object, context)
|
|
2689
|
+
});
|
|
2690
|
+
ctx.logger.info('[security] registered "security" service (getReadFilter) for raw-SQL RLS bridging');
|
|
2691
|
+
} catch (e) {
|
|
2692
|
+
ctx.logger.warn?.('[security] failed to register "security" service', {
|
|
2693
|
+
error: e.message
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2674
2696
|
ql.registerMiddleware(async (opCtx, next) => {
|
|
2675
2697
|
if (opCtx.context?.isSystem) {
|
|
2676
2698
|
return next();
|
|
@@ -2682,27 +2704,15 @@ var SecurityPlugin = class {
|
|
|
2682
2704
|
}
|
|
2683
2705
|
let permissionSets = [];
|
|
2684
2706
|
try {
|
|
2685
|
-
|
|
2686
|
-
if (requested.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
2687
|
-
requested.push(this.fallbackPermissionSet);
|
|
2688
|
-
}
|
|
2689
|
-
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2690
|
-
requested,
|
|
2691
|
-
metadata,
|
|
2692
|
-
this.bootstrapPermissionSets,
|
|
2693
|
-
dbLoader
|
|
2694
|
-
);
|
|
2695
|
-
if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
2696
|
-
const fallback = await this.permissionEvaluator.resolvePermissionSets(
|
|
2697
|
-
[this.fallbackPermissionSet],
|
|
2698
|
-
metadata,
|
|
2699
|
-
this.bootstrapPermissionSets,
|
|
2700
|
-
dbLoader
|
|
2701
|
-
);
|
|
2702
|
-
permissionSets = fallback;
|
|
2703
|
-
}
|
|
2707
|
+
permissionSets = await this.resolvePermissionSetsForContext(opCtx.context);
|
|
2704
2708
|
} catch (e) {
|
|
2705
|
-
|
|
2709
|
+
ctx.logger.error(
|
|
2710
|
+
`[security] permission resolution failed for operation '${opCtx.operation}' on object '${opCtx.object}' (user ${opCtx.context?.userId ?? "unknown"}) \u2014 denying request (fail-closed)`,
|
|
2711
|
+
e instanceof Error ? e : new Error(String(e))
|
|
2712
|
+
);
|
|
2713
|
+
throw new PermissionDeniedError(
|
|
2714
|
+
`[Security] Access denied: permission subsystem unavailable for operation '${opCtx.operation}' on object '${opCtx.object}'`
|
|
2715
|
+
);
|
|
2706
2716
|
}
|
|
2707
2717
|
if (permissionSets.length > 0) {
|
|
2708
2718
|
const allowed = this.permissionEvaluator.checkObjectPermission(
|
|
@@ -2750,25 +2760,13 @@ var SecurityPlugin = class {
|
|
|
2750
2760
|
}
|
|
2751
2761
|
}
|
|
2752
2762
|
}
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
if (!targetField) return true;
|
|
2761
|
-
if (objectFields.has(targetField)) return true;
|
|
2762
|
-
if (tenancyDisabled && targetField === "organization_id") {
|
|
2763
|
-
return false;
|
|
2764
|
-
}
|
|
2765
|
-
dropped++;
|
|
2766
|
-
return false;
|
|
2767
|
-
}) : allRlsPolicies;
|
|
2768
|
-
let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
|
|
2769
|
-
if (rlsFilter == null && dropped > 0) {
|
|
2770
|
-
rlsFilter = { ...RLS_DENY_FILTER };
|
|
2771
|
-
}
|
|
2763
|
+
if (opCtx.ast) {
|
|
2764
|
+
const rlsFilter = await this.computeRlsFilter(
|
|
2765
|
+
permissionSets,
|
|
2766
|
+
opCtx.object,
|
|
2767
|
+
opCtx.operation,
|
|
2768
|
+
opCtx.context
|
|
2769
|
+
);
|
|
2772
2770
|
if (rlsFilter) {
|
|
2773
2771
|
if (opCtx.ast.where) {
|
|
2774
2772
|
opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
|
|
@@ -2850,6 +2848,106 @@ var SecurityPlugin = class {
|
|
|
2850
2848
|
}
|
|
2851
2849
|
async destroy() {
|
|
2852
2850
|
}
|
|
2851
|
+
/**
|
|
2852
|
+
* ADR-0021 D-C — resolve the per-request READ scope (tenant + RLS predicate)
|
|
2853
|
+
* for one object as a canonical `FilterCondition`, WITHOUT touching the
|
|
2854
|
+
* ObjectQL engine. This is the seam the analytics raw-SQL path bridges to so
|
|
2855
|
+
* it enforces the SAME row scoping the engine middleware applies on `find`.
|
|
2856
|
+
*
|
|
2857
|
+
* Returns:
|
|
2858
|
+
* - `undefined` → no scope applies (system context, or an unauthenticated
|
|
2859
|
+
* request with no userId/roles/permissions — authn is gated elsewhere).
|
|
2860
|
+
* - a `FilterCondition` → AND it into the object's scan (the join's `ON`/
|
|
2861
|
+
* `WHERE` for analytics; the where clause for a plain find).
|
|
2862
|
+
* - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or
|
|
2863
|
+
* resolution failed — fail-closed to zero rows. NEVER returns "allow all"
|
|
2864
|
+
* on error, so a degraded permission subsystem cannot leak cross-tenant
|
|
2865
|
+
* rows through analytics.
|
|
2866
|
+
*
|
|
2867
|
+
* Async because permission-set resolution can hit the database; the analytics
|
|
2868
|
+
* service pre-resolves these per request (base + every joined object) before
|
|
2869
|
+
* the synchronous SQL builder runs.
|
|
2870
|
+
*/
|
|
2871
|
+
async getReadFilter(object, context) {
|
|
2872
|
+
if (context?.isSystem) return void 0;
|
|
2873
|
+
const roles = context?.roles ?? [];
|
|
2874
|
+
const explicit = context?.permissions ?? [];
|
|
2875
|
+
if (roles.length === 0 && explicit.length === 0 && !context?.userId) {
|
|
2876
|
+
return void 0;
|
|
2877
|
+
}
|
|
2878
|
+
try {
|
|
2879
|
+
const permissionSets = await this.resolvePermissionSetsForContext(context);
|
|
2880
|
+
const filter = await this.computeRlsFilter(permissionSets, object, "find", context);
|
|
2881
|
+
return filter ?? void 0;
|
|
2882
|
+
} catch (e) {
|
|
2883
|
+
this.logger.error?.(
|
|
2884
|
+
`[security] getReadFilter failed for object '${object}' (user ${context?.userId ?? "unknown"}) \u2014 denying (fail-closed)`,
|
|
2885
|
+
e instanceof Error ? e : new Error(String(e))
|
|
2886
|
+
);
|
|
2887
|
+
return { ...RLS_DENY_FILTER };
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
/**
|
|
2891
|
+
* Resolve the effective permission sets for an execution context — roles +
|
|
2892
|
+
* explicit permission sets, with the configured baseline applied both as an
|
|
2893
|
+
* implicit request (when none were named) and as a post-resolution fallback
|
|
2894
|
+
* (when named ones resolved to nothing). Shared by the engine middleware and
|
|
2895
|
+
* {@link getReadFilter} so both enforce identical RLS. May throw if the
|
|
2896
|
+
* underlying metadata/db resolution fails (callers fail-closed).
|
|
2897
|
+
*/
|
|
2898
|
+
async resolvePermissionSetsForContext(context) {
|
|
2899
|
+
const roles = context?.roles ?? [];
|
|
2900
|
+
const explicitPermissionSets = context?.permissions ?? [];
|
|
2901
|
+
const requested = [...roles, ...explicitPermissionSets];
|
|
2902
|
+
if (requested.length === 0 && context?.userId && this.fallbackPermissionSet) {
|
|
2903
|
+
requested.push(this.fallbackPermissionSet);
|
|
2904
|
+
}
|
|
2905
|
+
let permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2906
|
+
requested,
|
|
2907
|
+
this.metadata,
|
|
2908
|
+
this.bootstrapPermissionSets,
|
|
2909
|
+
this.dbLoader
|
|
2910
|
+
);
|
|
2911
|
+
if (permissionSets.length === 0 && context?.userId && this.fallbackPermissionSet) {
|
|
2912
|
+
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
2913
|
+
[this.fallbackPermissionSet],
|
|
2914
|
+
this.metadata,
|
|
2915
|
+
this.bootstrapPermissionSets,
|
|
2916
|
+
this.dbLoader
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
return permissionSets;
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Compile the applicable RLS policies for (object, operation) into a single
|
|
2923
|
+
* `FilterCondition`, applying the field-existence safety net (wildcard
|
|
2924
|
+
* policies targeting a column the object lacks fail-closed to the deny
|
|
2925
|
+
* sentinel, unless the object explicitly opted out of tenancy). Shared by the
|
|
2926
|
+
* engine middleware and {@link getReadFilter}. Returns `null` when no policy
|
|
2927
|
+
* applies (caller adds no filter).
|
|
2928
|
+
*/
|
|
2929
|
+
async computeRlsFilter(permissionSets, object, operation, context) {
|
|
2930
|
+
const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation);
|
|
2931
|
+
if (allRlsPolicies.length === 0) return null;
|
|
2932
|
+
const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql);
|
|
2933
|
+
const tenancyDisabled = this.tenancyDisabledCache.get(object) === true;
|
|
2934
|
+
let dropped = 0;
|
|
2935
|
+
const compilable = objectFields ? allRlsPolicies.filter((p) => {
|
|
2936
|
+
const targetField = this.extractTargetField(p.using);
|
|
2937
|
+
if (!targetField) return true;
|
|
2938
|
+
if (objectFields.has(targetField)) return true;
|
|
2939
|
+
if (tenancyDisabled && targetField === "organization_id") {
|
|
2940
|
+
return false;
|
|
2941
|
+
}
|
|
2942
|
+
dropped++;
|
|
2943
|
+
return false;
|
|
2944
|
+
}) : allRlsPolicies;
|
|
2945
|
+
let rlsFilter = this.rlsCompiler.compileFilter(compilable, context);
|
|
2946
|
+
if (rlsFilter == null && dropped > 0) {
|
|
2947
|
+
rlsFilter = { ...RLS_DENY_FILTER };
|
|
2948
|
+
}
|
|
2949
|
+
return rlsFilter;
|
|
2950
|
+
}
|
|
2853
2951
|
/**
|
|
2854
2952
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
2855
2953
|
*/
|