@objectstack/service-analytics 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.cts CHANGED
@@ -131,8 +131,13 @@ interface AnalyticsServiceConfig {
131
131
  * object (exactly what `RLSCompiler` emits). The service binds the active
132
132
  * context per query and the strategy compiles the filter into alias-qualified
133
133
  * SQL injected into every base and joined table.
134
+ *
135
+ * MAY be async: the production bridge resolves RLS from the `security`
136
+ * service's `getReadFilter`, which can hit the database. The service
137
+ * pre-resolves the scope for every base + joined object of a query (before
138
+ * the synchronous SQL builder runs), so a sync return still works unchanged.
134
139
  */
135
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
140
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
136
141
  /**
137
142
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
138
143
  * Joins outside this set are rejected by the strategy. Compiled datasets
@@ -186,6 +191,20 @@ declare class AnalyticsService implements IAnalyticsService {
186
191
  * `getReadScope(objectName)` that already knows the active tenant.
187
192
  */
188
193
  private callCtx;
194
+ /**
195
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
196
+ * AND every joined object of the query's cube, keyed by object name. This is
197
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
198
+ * when the provider (security `getReadFilter`) resolves asynchronously.
199
+ *
200
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
201
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
202
+ * declared relationships), so no scanned object is ever left unscoped.
203
+ *
204
+ * Fail-closed: if the provider throws for an object, the whole query is
205
+ * rejected rather than emitting SQL with that object unscoped.
206
+ */
207
+ private resolveReadScopes;
189
208
  /**
190
209
  * Execute an analytical query by delegating to the first capable strategy.
191
210
  */
@@ -270,7 +289,7 @@ interface AnalyticsServicePluginOptions {
270
289
  * emits). When omitted, the plugin auto-bridges to a registered `'security'`
271
290
  * service exposing `getReadFilter(object, context)` if one is present.
272
291
  */
273
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
292
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
274
293
  /**
275
294
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
276
295
  * Typically wired from the dataset registry's compiled `allowedRelationships`.
package/dist/index.d.ts CHANGED
@@ -131,8 +131,13 @@ interface AnalyticsServiceConfig {
131
131
  * object (exactly what `RLSCompiler` emits). The service binds the active
132
132
  * context per query and the strategy compiles the filter into alias-qualified
133
133
  * SQL injected into every base and joined table.
134
+ *
135
+ * MAY be async: the production bridge resolves RLS from the `security`
136
+ * service's `getReadFilter`, which can hit the database. The service
137
+ * pre-resolves the scope for every base + joined object of a query (before
138
+ * the synchronous SQL builder runs), so a sync return still works unchanged.
134
139
  */
135
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
140
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
136
141
  /**
137
142
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
138
143
  * Joins outside this set are rejected by the strategy. Compiled datasets
@@ -186,6 +191,20 @@ declare class AnalyticsService implements IAnalyticsService {
186
191
  * `getReadScope(objectName)` that already knows the active tenant.
187
192
  */
188
193
  private callCtx;
194
+ /**
195
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
196
+ * AND every joined object of the query's cube, keyed by object name. This is
197
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
198
+ * when the provider (security `getReadFilter`) resolves asynchronously.
199
+ *
200
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
201
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
202
+ * declared relationships), so no scanned object is ever left unscoped.
203
+ *
204
+ * Fail-closed: if the provider throws for an object, the whole query is
205
+ * rejected rather than emitting SQL with that object unscoped.
206
+ */
207
+ private resolveReadScopes;
189
208
  /**
190
209
  * Execute an analytical query by delegating to the first capable strategy.
191
210
  */
@@ -270,7 +289,7 @@ interface AnalyticsServicePluginOptions {
270
289
  * emits). When omitted, the plugin auto-bridges to a registered `'security'`
271
290
  * service exposing `getReadFilter(object, context)` if one is present.
272
291
  */
273
- getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined;
292
+ getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
274
293
  /**
275
294
  * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
276
295
  * Typically wired from the dataset registry's compiled `allowedRelationships`.
package/dist/index.js CHANGED
@@ -1139,13 +1139,60 @@ var AnalyticsService = class {
1139
1139
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
1140
1140
  * `getReadScope(objectName)` that already knows the active tenant.
1141
1141
  */
1142
- callCtx(context) {
1142
+ async callCtx(query, context) {
1143
1143
  if (!this.readScopeProvider) return this.baseCtx;
1144
+ const scopes = await this.resolveReadScopes(query, context);
1144
1145
  return {
1145
1146
  ...this.baseCtx,
1146
- getReadScope: (objectName) => this.readScopeProvider(objectName, context)
1147
+ getReadScope: (objectName) => scopes.get(objectName) ?? null
1147
1148
  };
1148
1149
  }
1150
+ /**
1151
+ * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
1152
+ * AND every joined object of the query's cube, keyed by object name. This is
1153
+ * the async pre-pass that lets the synchronous strategy enforce scoping even
1154
+ * when the provider (security `getReadFilter`) resolves asynchronously.
1155
+ *
1156
+ * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
1157
+ * SUPERSET of what the strategy actually scans (the strategy only joins along
1158
+ * declared relationships), so no scanned object is ever left unscoped.
1159
+ *
1160
+ * Fail-closed: if the provider throws for an object, the whole query is
1161
+ * rejected rather than emitting SQL with that object unscoped.
1162
+ */
1163
+ async resolveReadScopes(query, context) {
1164
+ const map = /* @__PURE__ */ new Map();
1165
+ const provider = this.readScopeProvider;
1166
+ if (!provider || !query.cube) return map;
1167
+ const cube = this.cubeRegistry.get(query.cube);
1168
+ if (!cube) return map;
1169
+ const objects = /* @__PURE__ */ new Set();
1170
+ if (typeof cube.sql === "string" && cube.sql.trim()) {
1171
+ objects.add(cube.sql.trim());
1172
+ }
1173
+ const joins = cube.joins;
1174
+ if (joins) {
1175
+ for (const [alias, j] of Object.entries(joins)) {
1176
+ objects.add(j?.name ?? alias);
1177
+ }
1178
+ }
1179
+ for (const object of objects) {
1180
+ let filter;
1181
+ try {
1182
+ filter = await provider(object, context);
1183
+ } catch (e) {
1184
+ this.logger.error?.(
1185
+ `[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
1186
+ e instanceof Error ? e : new Error(String(e))
1187
+ );
1188
+ throw new Error(
1189
+ `[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
1190
+ );
1191
+ }
1192
+ if (filter != null) map.set(object, filter);
1193
+ }
1194
+ return map;
1195
+ }
1149
1196
  /**
1150
1197
  * Execute an analytical query by delegating to the first capable strategy.
1151
1198
  */
@@ -1154,7 +1201,7 @@ var AnalyticsService = class {
1154
1201
  throw new Error("Cube name is required in analytics query");
1155
1202
  }
1156
1203
  this.ensureCube(query);
1157
- const ctx = this.callCtx(context);
1204
+ const ctx = await this.callCtx(query, context);
1158
1205
  const strategy = this.resolveStrategy(query, ctx);
1159
1206
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
1160
1207
  return strategy.execute(query, ctx);
@@ -1209,7 +1256,7 @@ var AnalyticsService = class {
1209
1256
  throw new Error("Cube name is required for SQL generation");
1210
1257
  }
1211
1258
  this.ensureCube(query);
1212
- const ctx = this.callCtx(context);
1259
+ const ctx = await this.callCtx(query, context);
1213
1260
  const strategy = this.resolveStrategy(query, ctx);
1214
1261
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
1215
1262
  return strategy.generateSql(query, ctx);