@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.d.mts CHANGED
@@ -82,10 +82,58 @@ declare class SecurityPlugin implements Plugin {
82
82
  * zero rows.
83
83
  */
84
84
  private readonly tenancyDisabledCache;
85
+ /**
86
+ * Service handles captured in `start()` so the request-time RLS resolution
87
+ * (used by BOTH the engine middleware and the public {@link getReadFilter}
88
+ * service method) shares one code path. `null` until `start()` wires them.
89
+ */
90
+ private metadata;
91
+ private ql;
92
+ private dbLoader?;
93
+ private logger;
85
94
  constructor(options?: SecurityPluginOptions);
86
95
  init(ctx: PluginContext): Promise<void>;
87
96
  start(ctx: PluginContext): Promise<void>;
88
97
  destroy(): Promise<void>;
98
+ /**
99
+ * ADR-0021 D-C — resolve the per-request READ scope (tenant + RLS predicate)
100
+ * for one object as a canonical `FilterCondition`, WITHOUT touching the
101
+ * ObjectQL engine. This is the seam the analytics raw-SQL path bridges to so
102
+ * it enforces the SAME row scoping the engine middleware applies on `find`.
103
+ *
104
+ * Returns:
105
+ * - `undefined` → no scope applies (system context, or an unauthenticated
106
+ * request with no userId/roles/permissions — authn is gated elsewhere).
107
+ * - a `FilterCondition` → AND it into the object's scan (the join's `ON`/
108
+ * `WHERE` for analytics; the where clause for a plain find).
109
+ * - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or
110
+ * resolution failed — fail-closed to zero rows. NEVER returns "allow all"
111
+ * on error, so a degraded permission subsystem cannot leak cross-tenant
112
+ * rows through analytics.
113
+ *
114
+ * Async because permission-set resolution can hit the database; the analytics
115
+ * service pre-resolves these per request (base + every joined object) before
116
+ * the synchronous SQL builder runs.
117
+ */
118
+ getReadFilter(object: string, context?: any): Promise<Record<string, unknown> | null | undefined>;
119
+ /**
120
+ * Resolve the effective permission sets for an execution context — roles +
121
+ * explicit permission sets, with the configured baseline applied both as an
122
+ * implicit request (when none were named) and as a post-resolution fallback
123
+ * (when named ones resolved to nothing). Shared by the engine middleware and
124
+ * {@link getReadFilter} so both enforce identical RLS. May throw if the
125
+ * underlying metadata/db resolution fails (callers fail-closed).
126
+ */
127
+ private resolvePermissionSetsForContext;
128
+ /**
129
+ * Compile the applicable RLS policies for (object, operation) into a single
130
+ * `FilterCondition`, applying the field-existence safety net (wildcard
131
+ * policies targeting a column the object lacks fail-closed to the deny
132
+ * sentinel, unless the object explicitly opted out of tenancy). Shared by the
133
+ * engine middleware and {@link getReadFilter}. Returns `null` when no policy
134
+ * applies (caller adds no filter).
135
+ */
136
+ private computeRlsFilter;
89
137
  /**
90
138
  * Collect all RLS policies from permission sets applicable to the given object/operation.
91
139
  */
@@ -774,10 +822,9 @@ declare const securityObjects: ((Omit<{
774
822
  } | undefined;
775
823
  chart?: {
776
824
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
777
- xAxisField: string;
778
- yAxisFields: string[];
779
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
780
- groupByField?: string | undefined;
825
+ dataset: string;
826
+ values: string[];
827
+ dimensions?: string[] | undefined;
781
828
  } | undefined;
782
829
  description?: string | undefined;
783
830
  sharing?: {
@@ -3612,10 +3659,9 @@ declare const securityObjects: ((Omit<{
3612
3659
  } | undefined;
3613
3660
  chart?: {
3614
3661
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
3615
- xAxisField: string;
3616
- yAxisFields: string[];
3617
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
3618
- groupByField?: string | undefined;
3662
+ dataset: string;
3663
+ values: string[];
3664
+ dimensions?: string[] | undefined;
3619
3665
  } | undefined;
3620
3666
  description?: string | undefined;
3621
3667
  sharing?: {
@@ -7044,10 +7090,9 @@ declare const securityObjects: ((Omit<{
7044
7090
  } | undefined;
7045
7091
  chart?: {
7046
7092
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
7047
- xAxisField: string;
7048
- yAxisFields: string[];
7049
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
7050
- groupByField?: string | undefined;
7093
+ dataset: string;
7094
+ values: string[];
7095
+ dimensions?: string[] | undefined;
7051
7096
  } | undefined;
7052
7097
  description?: string | undefined;
7053
7098
  sharing?: {
@@ -9293,10 +9338,9 @@ declare const securityObjects: ((Omit<{
9293
9338
  } | undefined;
9294
9339
  chart?: {
9295
9340
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
9296
- xAxisField: string;
9297
- yAxisFields: string[];
9298
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
9299
- groupByField?: string | undefined;
9341
+ dataset: string;
9342
+ values: string[];
9343
+ dimensions?: string[] | undefined;
9300
9344
  } | undefined;
9301
9345
  description?: string | undefined;
9302
9346
  sharing?: {
package/dist/index.d.ts CHANGED
@@ -82,10 +82,58 @@ declare class SecurityPlugin implements Plugin {
82
82
  * zero rows.
83
83
  */
84
84
  private readonly tenancyDisabledCache;
85
+ /**
86
+ * Service handles captured in `start()` so the request-time RLS resolution
87
+ * (used by BOTH the engine middleware and the public {@link getReadFilter}
88
+ * service method) shares one code path. `null` until `start()` wires them.
89
+ */
90
+ private metadata;
91
+ private ql;
92
+ private dbLoader?;
93
+ private logger;
85
94
  constructor(options?: SecurityPluginOptions);
86
95
  init(ctx: PluginContext): Promise<void>;
87
96
  start(ctx: PluginContext): Promise<void>;
88
97
  destroy(): Promise<void>;
98
+ /**
99
+ * ADR-0021 D-C — resolve the per-request READ scope (tenant + RLS predicate)
100
+ * for one object as a canonical `FilterCondition`, WITHOUT touching the
101
+ * ObjectQL engine. This is the seam the analytics raw-SQL path bridges to so
102
+ * it enforces the SAME row scoping the engine middleware applies on `find`.
103
+ *
104
+ * Returns:
105
+ * - `undefined` → no scope applies (system context, or an unauthenticated
106
+ * request with no userId/roles/permissions — authn is gated elsewhere).
107
+ * - a `FilterCondition` → AND it into the object's scan (the join's `ON`/
108
+ * `WHERE` for analytics; the where clause for a plain find).
109
+ * - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or
110
+ * resolution failed — fail-closed to zero rows. NEVER returns "allow all"
111
+ * on error, so a degraded permission subsystem cannot leak cross-tenant
112
+ * rows through analytics.
113
+ *
114
+ * Async because permission-set resolution can hit the database; the analytics
115
+ * service pre-resolves these per request (base + every joined object) before
116
+ * the synchronous SQL builder runs.
117
+ */
118
+ getReadFilter(object: string, context?: any): Promise<Record<string, unknown> | null | undefined>;
119
+ /**
120
+ * Resolve the effective permission sets for an execution context — roles +
121
+ * explicit permission sets, with the configured baseline applied both as an
122
+ * implicit request (when none were named) and as a post-resolution fallback
123
+ * (when named ones resolved to nothing). Shared by the engine middleware and
124
+ * {@link getReadFilter} so both enforce identical RLS. May throw if the
125
+ * underlying metadata/db resolution fails (callers fail-closed).
126
+ */
127
+ private resolvePermissionSetsForContext;
128
+ /**
129
+ * Compile the applicable RLS policies for (object, operation) into a single
130
+ * `FilterCondition`, applying the field-existence safety net (wildcard
131
+ * policies targeting a column the object lacks fail-closed to the deny
132
+ * sentinel, unless the object explicitly opted out of tenancy). Shared by the
133
+ * engine middleware and {@link getReadFilter}. Returns `null` when no policy
134
+ * applies (caller adds no filter).
135
+ */
136
+ private computeRlsFilter;
89
137
  /**
90
138
  * Collect all RLS policies from permission sets applicable to the given object/operation.
91
139
  */
@@ -774,10 +822,9 @@ declare const securityObjects: ((Omit<{
774
822
  } | undefined;
775
823
  chart?: {
776
824
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
777
- xAxisField: string;
778
- yAxisFields: string[];
779
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
780
- groupByField?: string | undefined;
825
+ dataset: string;
826
+ values: string[];
827
+ dimensions?: string[] | undefined;
781
828
  } | undefined;
782
829
  description?: string | undefined;
783
830
  sharing?: {
@@ -3612,10 +3659,9 @@ declare const securityObjects: ((Omit<{
3612
3659
  } | undefined;
3613
3660
  chart?: {
3614
3661
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
3615
- xAxisField: string;
3616
- yAxisFields: string[];
3617
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
3618
- groupByField?: string | undefined;
3662
+ dataset: string;
3663
+ values: string[];
3664
+ dimensions?: string[] | undefined;
3619
3665
  } | undefined;
3620
3666
  description?: string | undefined;
3621
3667
  sharing?: {
@@ -7044,10 +7090,9 @@ declare const securityObjects: ((Omit<{
7044
7090
  } | undefined;
7045
7091
  chart?: {
7046
7092
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
7047
- xAxisField: string;
7048
- yAxisFields: string[];
7049
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
7050
- groupByField?: string | undefined;
7093
+ dataset: string;
7094
+ values: string[];
7095
+ dimensions?: string[] | undefined;
7051
7096
  } | undefined;
7052
7097
  description?: string | undefined;
7053
7098
  sharing?: {
@@ -9293,10 +9338,9 @@ declare const securityObjects: ((Omit<{
9293
9338
  } | undefined;
9294
9339
  chart?: {
9295
9340
  chartType: "bar" | "line" | "pie" | "area" | "scatter";
9296
- xAxisField: string;
9297
- yAxisFields: string[];
9298
- aggregation?: "count" | "min" | "max" | "sum" | "avg" | undefined;
9299
- groupByField?: string | undefined;
9341
+ dataset: string;
9342
+ values: string[];
9343
+ dimensions?: string[] | undefined;
9300
9344
  } | undefined;
9301
9345
  description?: string | undefined;
9302
9346
  sharing?: {
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,25 +2704,7 @@ 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
2709
  ctx.logger.error(
2706
2710
  `[security] permission resolution failed for operation '${opCtx.operation}' on object '${opCtx.object}' (user ${opCtx.context?.userId ?? "unknown"}) \u2014 denying request (fail-closed)`,
@@ -2756,25 +2760,13 @@ var SecurityPlugin = class {
2756
2760
  }
2757
2761
  }
2758
2762
  }
2759
- const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
2760
- if (allRlsPolicies.length > 0 && opCtx.ast) {
2761
- const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
2762
- const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
2763
- let dropped = 0;
2764
- const compilable = objectFields ? allRlsPolicies.filter((p) => {
2765
- const targetField = this.extractTargetField(p.using);
2766
- if (!targetField) return true;
2767
- if (objectFields.has(targetField)) return true;
2768
- if (tenancyDisabled && targetField === "organization_id") {
2769
- return false;
2770
- }
2771
- dropped++;
2772
- return false;
2773
- }) : allRlsPolicies;
2774
- let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
2775
- if (rlsFilter == null && dropped > 0) {
2776
- rlsFilter = { ...RLS_DENY_FILTER };
2777
- }
2763
+ if (opCtx.ast) {
2764
+ const rlsFilter = await this.computeRlsFilter(
2765
+ permissionSets,
2766
+ opCtx.object,
2767
+ opCtx.operation,
2768
+ opCtx.context
2769
+ );
2778
2770
  if (rlsFilter) {
2779
2771
  if (opCtx.ast.where) {
2780
2772
  opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
@@ -2856,6 +2848,106 @@ var SecurityPlugin = class {
2856
2848
  }
2857
2849
  async destroy() {
2858
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
+ }
2859
2951
  /**
2860
2952
  * Collect all RLS policies from permission sets applicable to the given object/operation.
2861
2953
  */