measurr 0.3.0 → 0.4.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/src/analytics.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { z } from 'zod'
2
2
  import type { CompiledStatement, Dialect, SelectStatement } from './compile.js'
3
- import type { AnyDataset, AnyDimension } from './dataset.js'
3
+ import type {
4
+ AnyDataset,
5
+ AnyDimension,
6
+ AnyMeasure,
7
+ Dataset
8
+ } from './dataset.js'
4
9
  import {
5
10
  type NormalizedQuery,
6
11
  type ParsedQuery,
@@ -111,6 +116,16 @@ export type AnalyticsQueryEvent = {
111
116
  error?: unknown
112
117
  }
113
118
 
119
+ /** The measure and dimension keys of a dataset (of any dataset, for a union). */
120
+ export type FieldKey<D> =
121
+ D extends Dataset<string, infer M, infer Dm> ? keyof (M & Dm) & string : never
122
+
123
+ /** The access keys a dataset's measures and dimensions are marked with. */
124
+ export type AccessKey<D> =
125
+ D extends Dataset<string, infer M, infer Dm>
126
+ ? NonNullable<(M & Dm)[keyof (M & Dm)]['access']>
127
+ : never
128
+
114
129
  export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
115
130
  datasets: DS
116
131
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
@@ -129,6 +144,21 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
129
144
  authorize?:
130
145
  | ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>)
131
146
  | undefined
147
+ /**
148
+ * Whether `ctx` may use the measure or dimension `key` of `dataset`, which is marked with
149
+ * `access`. Called only for marked fields, once per access key: a ratio also needs the
150
+ * access of the measures it divides. Required when any field is marked. It decides which
151
+ * fields `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every
152
+ * query. `access` is the union of the keys the datasets use, so a new key shows up here.
153
+ */
154
+ authorizeField?:
155
+ | ((
156
+ dataset: DS[number],
157
+ key: FieldKey<DS[number]>,
158
+ access: AccessKey<DS[number]>,
159
+ ctx: Ctx
160
+ ) => boolean | Promise<boolean>)
161
+ | undefined
132
162
  /** IANA timezone periods and time buckets resolve in. UTC when omitted. */
133
163
  timezone?:
134
164
  | ((
@@ -183,15 +213,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
183
213
  datasets?: readonly K[] | undefined
184
214
  }): z.ZodType<DatasetQuery<Extract<DS[number], { key: K }>>>
185
215
  /**
186
- * The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
187
- * Rejects with `forbidden` when none are allowed.
216
+ * The input schema for the datasets `ctx` may query, without the fields `authorizeField`
217
+ * refuses it, for a tool built per caller. Rejects with `forbidden` when none are allowed.
188
218
  */
189
219
  querySchema(options: {
190
220
  ctx: Ctx
191
221
  }): Promise<z.ZodType<DatasetQuery<DS[number]>>>
192
222
  /** The result schema for one dataset. */
193
223
  resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>
194
- /** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
224
+ /** The datasets `ctx` may query, with the measures and dimensions it may use. */
195
225
  listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>
196
226
  }
197
227
 
@@ -203,6 +233,13 @@ export type AnalyticsResult<A, Q> =
203
233
 
204
234
  type ShapedResult = QueryResult<Row, Row>
205
235
 
236
+ /** A measure or dimension marked with `access`, with every access key it needs. */
237
+ type RestrictedField = {
238
+ kind: 'measure' | 'dimension'
239
+ key: string
240
+ access: readonly string[]
241
+ }
242
+
206
243
  /** A parsed, authorized query with everything the hooks decide resolved. */
207
244
  type Prepared = {
208
245
  dataset: AnyDataset
@@ -222,6 +259,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
222
259
  const sourceNames = Object.keys(options.sources)
223
260
  const byKey = new Map<string, AnyDataset>()
224
261
  const sourceOf = new Map<string, AnalyticsSource>()
262
+ const restrictedOf = new Map<string, RestrictedField[]>()
225
263
 
226
264
  for (const dataset of datasets) {
227
265
  if (byKey.has(dataset.key))
@@ -231,6 +269,14 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
231
269
  const source = resolveSource(dataset, options.sources, sourceNames)
232
270
  sourceOf.set(dataset.key, source)
233
271
  compileEverything(dataset, source.dialect)
272
+
273
+ const restricted = restrictedFields(dataset)
274
+ if (restricted.length > 0 && !options.authorizeField) {
275
+ throw new Error(
276
+ `Dataset ${dataset.key} marks fields with access, but createAnalytics has no authorizeField`
277
+ )
278
+ }
279
+ restrictedOf.set(dataset.key, restricted)
234
280
  }
235
281
 
236
282
  const now = options.now ?? (() => new Date())
@@ -250,12 +296,18 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
250
296
  return source
251
297
  }
252
298
 
253
- const querySchemaFor = (keys: readonly string[] | undefined) => {
254
- const selected = keys ? keys.map(datasetOf) : datasets
255
- const selectionKey = selected
256
- .map((dataset) => dataset.key)
257
- .sort()
258
- .join(',')
299
+ /** One schema per set of datasets (or views of them without some fields). */
300
+ const querySchemaFor = (selected: readonly AnyDataset[]) => {
301
+ const keyOf = (dataset: AnyDataset) => {
302
+ if (byKey.get(dataset.key) === dataset) return dataset.key
303
+
304
+ const fields = [
305
+ ...Object.keys(dataset.measures),
306
+ ...Object.keys(dataset.dimensions)
307
+ ]
308
+ return `${dataset.key}(${fields.join(' ')})`
309
+ }
310
+ const selectionKey = selected.map(keyOf).sort().join(',')
259
311
 
260
312
  let schema = querySchemas.get(selectionKey)
261
313
  if (!schema) {
@@ -274,14 +326,54 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
274
326
  return schema
275
327
  }
276
328
 
277
- const allowedDatasets = async (ctx: Ctx) => {
278
- const { authorize } = options
279
- if (!authorize) return datasets
329
+ /** The marked fields of `dataset` that `ctx` may not use. */
330
+ const deniedFields = async (
331
+ dataset: AnyDataset,
332
+ ctx: Ctx
333
+ ): Promise<RestrictedField[]> => {
334
+ const restricted = restrictedOf.get(dataset.key) ?? []
335
+ // Keys and access marks are read from the datasets themselves, so they fit the hook.
336
+ const authorizeField = options.authorizeField as
337
+ | ((
338
+ dataset: AnyDataset,
339
+ key: string,
340
+ access: string,
341
+ ctx: Ctx
342
+ ) => boolean | Promise<boolean>)
343
+ | undefined
344
+ // createAnalytics refuses marked fields without the hook; deny them all regardless.
345
+ if (restricted.length === 0 || !authorizeField) return restricted
346
+
347
+ const mayUse = async (field: RestrictedField) => {
348
+ const answers = await Promise.all(
349
+ field.access.map((access) =>
350
+ authorizeField(dataset, field.key, access, ctx)
351
+ )
352
+ )
353
+ return answers.every(Boolean)
354
+ }
355
+
356
+ const allowed = await Promise.all(restricted.map(mayUse))
357
+ return restricted.filter((_, index) => !allowed[index])
358
+ }
280
359
 
281
- const allowed = await Promise.all(
282
- datasets.map((dataset) => authorize(dataset, ctx))
360
+ /**
361
+ * The datasets `ctx` may query, each without the fields it may not use. A dataset without
362
+ * a tenant column needs `allTenants`, and one without a usable measure is left out.
363
+ */
364
+ const allowedDatasets = async (ctx: Ctx): Promise<AnyDataset[]> => {
365
+ const everyTenant = options.tenant(ctx) === allTenants
366
+ const views = await Promise.all(
367
+ datasets.map(async (dataset) => {
368
+ if (!dataset.tenantColumn && !everyTenant) return null
369
+ if (options.authorize && !(await options.authorize(dataset, ctx)))
370
+ return null
371
+
372
+ const view = withoutFields(dataset, await deniedFields(dataset, ctx))
373
+ return Object.keys(view.measures).length > 0 ? view : null
374
+ })
283
375
  )
284
- return datasets.filter((_, index) => allowed[index])
376
+ return views.filter((view) => view !== null)
285
377
  }
286
378
 
287
379
  const querySchemaForCtx = async (ctx: Ctx) => {
@@ -289,12 +381,12 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
289
381
  if (allowed.length === 0) {
290
382
  throw new AnalyticsError('forbidden', 'No dataset is allowed')
291
383
  }
292
- return querySchemaFor(allowed.map((dataset) => dataset.key))
384
+ return querySchemaFor(allowed)
293
385
  }
294
386
 
295
387
  /** Parse, authorize, and resolve everything that comes from hooks. */
296
388
  const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
297
- const parsed = querySchemaFor(undefined).safeParse(input)
389
+ const parsed = querySchemaFor(datasets).safeParse(input)
298
390
  if (!parsed.success) {
299
391
  throw new AnalyticsError(
300
392
  'invalid_query',
@@ -303,7 +395,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
303
395
  )
304
396
  }
305
397
 
306
- const dataset = datasetOf((parsed.data as ParsedQuery).dataset)
398
+ const parsedQuery = parsed.data as ParsedQuery
399
+ const dataset = datasetOf(parsedQuery.dataset)
307
400
  if (options.authorize && !(await options.authorize(dataset, ctx))) {
308
401
  throw new AnalyticsError(
309
402
  'forbidden',
@@ -311,6 +404,24 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
311
404
  )
312
405
  }
313
406
 
407
+ // Sorting names only asked measures and grouped dimensions, so these cover it.
408
+ const dimensionsUsed = [
409
+ ...(parsedQuery.groupBy ?? []),
410
+ ...(parsedQuery.filters ?? []).map((filter) => filter.dimension)
411
+ ]
412
+ const refused = (await deniedFields(dataset, ctx)).filter((field) =>
413
+ field.kind === 'measure'
414
+ ? parsedQuery.measures.includes(field.key)
415
+ : dimensionsUsed.includes(field.key)
416
+ )
417
+ if (refused.length > 0) {
418
+ const names = refused.map((field) => `${field.kind} ${field.key}`)
419
+ throw new AnalyticsError(
420
+ 'forbidden',
421
+ `Not allowed to use ${names.join(', ')} of ${dataset.key}`
422
+ )
423
+ }
424
+
314
425
  const tenant: unknown = options.tenant(ctx)
315
426
  if (!isTenant(tenant)) {
316
427
  throw new AnalyticsError(
@@ -318,6 +429,13 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
318
429
  'Refusing to query without a tenant'
319
430
  )
320
431
  }
432
+ // Never drop a narrower scope: rows without a tenant are only for every-tenant callers.
433
+ if (!dataset.tenantColumn && tenant !== allTenants) {
434
+ throw new AnalyticsError(
435
+ 'forbidden',
436
+ `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`
437
+ )
438
+ }
321
439
 
322
440
  const timezone = (await options.timezone?.(ctx)) || 'UTC'
323
441
  if (!isValidTimezone(timezone)) {
@@ -327,7 +445,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
327
445
  )
328
446
  }
329
447
 
330
- const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
448
+ const query = normalizeQuery(dataset, parsedQuery)
331
449
  const at = now()
332
450
  let period: ResolvedPeriod | null
333
451
  try {
@@ -478,10 +596,9 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
478
596
  ) =>
479
597
  schemaOptions && 'ctx' in schemaOptions
480
598
  ? querySchemaForCtx(schemaOptions.ctx)
481
- : querySchemaFor(schemaOptions?.datasets)) as Analytics<
482
- DS,
483
- Ctx
484
- >['querySchema'],
599
+ : querySchemaFor(
600
+ schemaOptions?.datasets?.map(datasetOf) ?? datasets
601
+ )) as Analytics<DS, Ctx>['querySchema'],
485
602
  resultSchema: (key) => resultSchemaFor(key) as z.ZodType<ShapedResult>,
486
603
  listDatasets: async (ctx) =>
487
604
  (await allowedDatasets(ctx)).map(
@@ -552,7 +669,7 @@ function resolveSource(
552
669
  function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
553
670
  const now = new Date(0)
554
671
  const context: PlanContext = {
555
- tenant: 'tenant',
672
+ tenant: dataset.tenantColumn ? 'tenant' : allTenants,
556
673
  timezone: 'UTC',
557
674
  period: resolvePeriod({ last: { days: 1 } }, 'UTC', now),
558
675
  now,
@@ -664,6 +781,60 @@ function decodeKey(value: unknown, dialect: Dialect): string | number {
664
781
  return dialect.decodeNumber(value) ?? String(value)
665
782
  }
666
783
 
784
+ /**
785
+ * Every marked field of `dataset` with the access keys it needs: its own, and for a ratio
786
+ * those of the measures it divides, which it would leak otherwise.
787
+ */
788
+ function restrictedFields(dataset: AnyDataset): RestrictedField[] {
789
+ const measures: Record<string, AnyMeasure> = dataset.measures
790
+ // defineDataset refuses ratio cycles.
791
+ const accessOf = (key: string): string[] => {
792
+ const entry = measures[key]
793
+ if (!entry) return []
794
+ const own = entry.access === undefined ? [] : [entry.access]
795
+ return entry.kind === 'ratio'
796
+ ? [...own, ...accessOf(entry.numerator), ...accessOf(entry.denominator)]
797
+ : own
798
+ }
799
+
800
+ return [
801
+ ...Object.keys(measures).map((key) => ({
802
+ kind: 'measure' as const,
803
+ key,
804
+ access: [...new Set(accessOf(key))]
805
+ })),
806
+ ...Object.entries<AnyDimension>(dataset.dimensions).map(([key, entry]) => ({
807
+ kind: 'dimension' as const,
808
+ key,
809
+ access: entry.access === undefined ? [] : [entry.access]
810
+ }))
811
+ ].filter((field) => field.access.length > 0)
812
+ }
813
+
814
+ /** `dataset` without the given fields; the dataset itself when there are none. */
815
+ function withoutFields(
816
+ dataset: AnyDataset,
817
+ fields: readonly RestrictedField[]
818
+ ): AnyDataset {
819
+ if (fields.length === 0) return dataset
820
+
821
+ const keep = <Field>(
822
+ kind: RestrictedField['kind'],
823
+ entries: Record<string, Field>
824
+ ): Record<string, Field> =>
825
+ Object.fromEntries(
826
+ Object.entries(entries).filter(
827
+ ([key]) =>
828
+ !fields.some((field) => field.kind === kind && field.key === key)
829
+ )
830
+ )
831
+ return {
832
+ ...dataset,
833
+ measures: keep('measure', dataset.measures),
834
+ dimensions: keep('dimension', dataset.dimensions)
835
+ }
836
+ }
837
+
667
838
  function catalogEntry(dataset: AnyDataset): DatasetCatalogEntry {
668
839
  return {
669
840
  key: dataset.key,