@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.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
- const requested = [...roles, ...explicitPermissionSets];
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
- return next();
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
- const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
2754
- if (allRlsPolicies.length > 0 && opCtx.ast) {
2755
- const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
2756
- const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
2757
- let dropped = 0;
2758
- const compilable = objectFields ? allRlsPolicies.filter((p) => {
2759
- const targetField = this.extractTargetField(p.using);
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
  */