@objectstack/plugin-security 8.0.0 → 9.0.0

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.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
- const requested = [...roles, ...explicitPermissionSets];
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
- const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
2728
- if (allRlsPolicies.length > 0 && opCtx.ast) {
2729
- const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
2730
- const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
2731
- let dropped = 0;
2732
- const compilable = objectFields ? allRlsPolicies.filter((p) => {
2733
- const targetField = this.extractTargetField(p.using);
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
  */