@querypanel/node-sdk 1.3.8 → 1.3.9

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/README.md CHANGED
@@ -578,6 +578,26 @@ const response = await qp.ask("Show top products", {
578
578
 
579
579
  BigQuery is attached the same way with `attachBigQuery(name, clientFn, options)`. Any other engine can be plugged in with `attachDatabase(name, adapter)` and your own `DatabaseAdapter` implementation.
580
580
 
581
+ ## One schema per tenant
582
+
583
+ If each tenant has its own schema with the same tables (for example `group_4048_indexed`, `group_4049_indexed`), attach with `tenantSchema` instead of `tenantFieldName`. Sync the schema of one tenant; the SQL QueryPanel generates names that schema, and the SDK rewrites it to the caller's schema (`{tenant}` replaced by `tenantId`) before running it:
584
+
585
+ ```ts
586
+ qp.attachRedshift("analytics", createClient, {
587
+ tenantSchema: {
588
+ template: "group_{tenant}_indexed",
589
+ sharedSchemas: ["lookups"], // optional: schemas every tenant may also read
590
+ },
591
+ });
592
+
593
+ await qp.ask("How many members joined last month?", { tenantId: "4048" });
594
+ // runs against group_4048_indexed
595
+ ```
596
+
597
+ Queries that read anything outside the tenant's schema, a shared schema or a CTE are refused, so a tenant cannot reach another tenant's schema. `allowedTables` written for the template schema (`group_4048_indexed.users`) applies to every tenant's schema. `tenantId` must be 1–64 letters, digits or `_`. Postgres and Redshift only. Adding a tenant needs no QueryPanel call: create its schema, then sign JWTs with its `tenantId`.
598
+
599
+ For embedded dashboards, set the same template as **Tenant schema template** on the datasource in QueryPanel and mint the usual JWT; `tenantId` is only the part that fills `{tenant}` (`"4048"`, not `"group_4048_indexed"`). See [Tenant isolation](https://querypanel.io/docs/tenant-isolation#schema-per-tenant).
600
+
581
601
  ## Building locally
582
602
 
583
603
  ```bash
package/dist/index.cjs CHANGED
@@ -39,10 +39,14 @@ __export(index_exports, {
39
39
  QueryPanelSdkAPI: () => QueryPanelSdkAPI,
40
40
  QueryPipelineError: () => QueryPipelineError,
41
41
  SchemaTooLargeError: () => SchemaTooLargeError,
42
+ TenantSchemaError: () => TenantSchemaError,
42
43
  anonymizeResults: () => anonymizeResults,
43
44
  mapGeneratedParams: () => mapGeneratedParams,
44
45
  normalizeSqlParamsInput: () => normalizeSqlParamsInput,
45
- resolveQueryPipeline: () => resolveQueryPipeline
46
+ parseTenantSchemaTemplate: () => parseTenantSchemaTemplate,
47
+ resolveQueryPipeline: () => resolveQueryPipeline,
48
+ resolveTenantSchema: () => resolveTenantSchema,
49
+ rewriteTenantSchema: () => rewriteTenantSchema
46
50
  });
47
51
  module.exports = __toCommonJS(index_exports);
48
52
 
@@ -629,6 +633,248 @@ function toNumber(value) {
629
633
  return Number.isNaN(parsed) ? void 0 : parsed;
630
634
  }
631
635
 
636
+ // src/core/sql-relations.ts
637
+ function tokenize(sql) {
638
+ const tokens = [];
639
+ let i = 0;
640
+ while (i < sql.length) {
641
+ const ch = sql[i];
642
+ const next = sql[i + 1];
643
+ if (/\s/.test(ch)) {
644
+ i++;
645
+ } else if (ch === "-" && next === "-") {
646
+ while (i < sql.length && sql[i] !== "\n") i++;
647
+ } else if (ch === "/" && next === "*") {
648
+ const end = sql.indexOf("*/", i + 2);
649
+ i = end < 0 ? sql.length : end + 2;
650
+ } else if (ch === "'") {
651
+ i++;
652
+ while (i < sql.length) {
653
+ if (sql[i] === "\\") i += 2;
654
+ else if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
655
+ else if (sql[i] === "'") break;
656
+ else i++;
657
+ }
658
+ i++;
659
+ } else if (ch === "$" && /[A-Za-z_$]/.test(next ?? "")) {
660
+ const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i))?.[0];
661
+ if (tag) {
662
+ const end = sql.indexOf(tag, i + tag.length);
663
+ i = end < 0 ? sql.length : end + tag.length;
664
+ } else {
665
+ i++;
666
+ }
667
+ } else if (ch === '"') {
668
+ const start = i;
669
+ let text = "";
670
+ i++;
671
+ while (i < sql.length) {
672
+ if (sql[i] === '"' && sql[i + 1] === '"') {
673
+ text += '"';
674
+ i += 2;
675
+ } else if (sql[i] === '"') break;
676
+ else text += sql[i++];
677
+ }
678
+ i++;
679
+ tokens.push({ kind: "word", text, quoted: true, start, end: i });
680
+ } else if (/[A-Za-z_]/.test(ch)) {
681
+ const start = i;
682
+ while (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i])) i++;
683
+ tokens.push({ kind: "word", text: sql.slice(start, i), quoted: false, start, end: i });
684
+ } else if (/[0-9]/.test(ch)) {
685
+ while (i < sql.length && /[0-9.eE]/.test(sql[i])) i++;
686
+ } else {
687
+ tokens.push({ kind: "punct", text: ch, start: i, end: i + 1 });
688
+ i++;
689
+ }
690
+ }
691
+ return tokens;
692
+ }
693
+ var identifier = (t) => t?.kind === "word" ? t.quoted ? t.text : t.text.toLowerCase() : void 0;
694
+ var isKeyword = (t, word) => t?.kind === "word" && !t.quoted && t.text.toLowerCase() === word;
695
+ var isPunct = (t, p) => t?.kind === "punct" && t.text === p;
696
+ var CLAUSE_WORDS = /* @__PURE__ */ new Set([
697
+ "where",
698
+ "group",
699
+ "order",
700
+ "limit",
701
+ "having",
702
+ "union",
703
+ "intersect",
704
+ "except",
705
+ "join",
706
+ "inner",
707
+ "left",
708
+ "right",
709
+ "full",
710
+ "cross",
711
+ "natural",
712
+ "on",
713
+ "using",
714
+ "window",
715
+ "qualify",
716
+ "offset",
717
+ "fetch",
718
+ "for",
719
+ "lateral",
720
+ "as"
721
+ ]);
722
+ var FROM_CLAUSE_END = /* @__PURE__ */ new Set([
723
+ "where",
724
+ "group",
725
+ "having",
726
+ "order",
727
+ "limit",
728
+ "offset",
729
+ "fetch",
730
+ "union",
731
+ "intersect",
732
+ "except",
733
+ "window",
734
+ "qualify",
735
+ "for",
736
+ "returning"
737
+ ]);
738
+ function cteNames(tokens) {
739
+ const names = /* @__PURE__ */ new Set();
740
+ for (let k = 0; k + 2 < tokens.length; k++) {
741
+ const value = identifier(tokens[k]);
742
+ if (!value) continue;
743
+ if (isKeyword(tokens[k + 1], "as") && isPunct(tokens[k + 2], "(")) names.add(value);
744
+ if (isPunct(tokens[k + 1], "(")) {
745
+ let j = k + 2;
746
+ while (j < tokens.length && !isPunct(tokens[j], ")")) j++;
747
+ if (isKeyword(tokens[j + 1], "as") && isPunct(tokens[j + 2], "(")) names.add(value);
748
+ }
749
+ }
750
+ return names;
751
+ }
752
+ function findRelations(sql) {
753
+ const tokens = tokenize(sql);
754
+ const ctes = cteNames(tokens);
755
+ const relations = [];
756
+ const readItem = (start) => {
757
+ let k = start;
758
+ if (isKeyword(tokens[k], "lateral")) k++;
759
+ const first = identifier(tokens[k]);
760
+ if (!first) return k;
761
+ const parts = [first];
762
+ k++;
763
+ while (isPunct(tokens[k], ".") && identifier(tokens[k + 1])) {
764
+ parts.push(identifier(tokens[k + 1]));
765
+ k += 2;
766
+ }
767
+ if (isPunct(tokens[k], "(")) return k;
768
+ if (!(parts.length === 1 && ctes.has(first))) relations.push({ parts });
769
+ if (isKeyword(tokens[k], "as")) k++;
770
+ const alias = tokens[k];
771
+ if (alias?.kind === "word" && (alias.quoted || !CLAUSE_WORDS.has(alias.text.toLowerCase()))) k++;
772
+ return k;
773
+ };
774
+ const selectAtDepth = [false];
775
+ const inFromAtDepth = [false];
776
+ const top = () => selectAtDepth.length - 1;
777
+ for (let k = 0; k < tokens.length; k++) {
778
+ const token = tokens[k];
779
+ if (isPunct(token, "(")) {
780
+ selectAtDepth.push(false);
781
+ inFromAtDepth.push(false);
782
+ } else if (isPunct(token, ")")) {
783
+ if (selectAtDepth.length > 1) {
784
+ selectAtDepth.pop();
785
+ inFromAtDepth.pop();
786
+ }
787
+ } else if (isKeyword(token, "select")) {
788
+ selectAtDepth[top()] = true;
789
+ inFromAtDepth[top()] = false;
790
+ } else if (isKeyword(token, "join")) {
791
+ k = readItem(k + 1) - 1;
792
+ } else if (isKeyword(token, "from") && selectAtDepth[top()] && !isKeyword(tokens[k - 1], "distinct")) {
793
+ inFromAtDepth[top()] = true;
794
+ k = readItem(k + 1) - 1;
795
+ } else if (isPunct(token, ",") && inFromAtDepth[top()]) {
796
+ k = readItem(k + 1) - 1;
797
+ } else if (token?.kind === "word" && !token.quoted && FROM_CLAUSE_END.has(token.text.toLowerCase())) {
798
+ inFromAtDepth[top()] = false;
799
+ }
800
+ }
801
+ return relations;
802
+ }
803
+
804
+ // src/core/tenant-schema.ts
805
+ var TenantSchemaError = class extends Error {
806
+ constructor(message) {
807
+ super(message);
808
+ this.name = "TenantSchemaError";
809
+ }
810
+ };
811
+ var PLACEHOLDER = "{tenant}";
812
+ var TEMPLATE_PART = /^[a-z0-9_]*$/;
813
+ var TENANT_ID = /^[A-Za-z0-9_]{1,64}$/;
814
+ function parseTenantSchemaTemplate(template) {
815
+ const value = template.trim().toLowerCase();
816
+ const index = value.indexOf(PLACEHOLDER);
817
+ if (index < 0 || value.indexOf(PLACEHOLDER, index + 1) >= 0) {
818
+ throw new TenantSchemaError(
819
+ `Tenant schema template must contain ${PLACEHOLDER} exactly once`
820
+ );
821
+ }
822
+ const prefix = value.slice(0, index);
823
+ const suffix = value.slice(index + PLACEHOLDER.length);
824
+ if (!TEMPLATE_PART.test(prefix) || !TEMPLATE_PART.test(suffix) || !(prefix || suffix)) {
825
+ throw new TenantSchemaError(
826
+ "Tenant schema template may only use a-z, 0-9 and _ around {tenant}"
827
+ );
828
+ }
829
+ return { prefix, suffix };
830
+ }
831
+ function matchesTemplate({ prefix, suffix }, schema) {
832
+ const value = schema.toLowerCase();
833
+ return value.length > prefix.length + suffix.length && value.startsWith(prefix) && value.endsWith(suffix) && /^[a-z0-9_]+$/.test(value.slice(prefix.length, value.length - suffix.length));
834
+ }
835
+ function resolveTenantSchema(template, tenantId) {
836
+ const { prefix, suffix } = parseTenantSchemaTemplate(template);
837
+ const id = tenantId.trim();
838
+ if (!TENANT_ID.test(id)) {
839
+ throw new TenantSchemaError(
840
+ "tenantId must be 1-64 characters of letters, digits or _ for schema-per-tenant isolation"
841
+ );
842
+ }
843
+ return `${prefix}${id.toLowerCase()}${suffix}`;
844
+ }
845
+ function rewriteTenantSchema(sql, template, tenantId) {
846
+ const parsed = parseTenantSchemaTemplate(template);
847
+ const tenantSchema = resolveTenantSchema(template, tenantId);
848
+ const tokens = tokenize(sql);
849
+ let rewritten = "";
850
+ let cursor = 0;
851
+ for (let k = 0; k < tokens.length; k++) {
852
+ const token = tokens[k];
853
+ const value = identifier(token);
854
+ if (!token || !value || !isPunct(tokens[k + 1], ".")) continue;
855
+ if (!matchesTemplate(parsed, value) || value.toLowerCase() === tenantSchema) continue;
856
+ const replacement = token.kind === "word" && token.quoted ? `"${tenantSchema}"` : tenantSchema;
857
+ rewritten += sql.slice(cursor, token.start) + replacement;
858
+ cursor = token.end;
859
+ }
860
+ rewritten += sql.slice(cursor);
861
+ const pattern = new RegExp(`${escapeRegex(parsed.prefix)}[a-z0-9_]+${escapeRegex(parsed.suffix)}`, "gi");
862
+ for (const match of rewritten.matchAll(pattern)) {
863
+ if (match[0].toLowerCase() !== tenantSchema) {
864
+ throw new TenantSchemaError("Query references another tenant's schema");
865
+ }
866
+ }
867
+ return rewritten;
868
+ }
869
+ var TENANT_SCHEMA_KEY = PLACEHOLDER;
870
+ function tenantSchemaKey(template) {
871
+ const parsed = parseTenantSchemaTemplate(template);
872
+ return (schema) => matchesTemplate(parsed, schema) ? TENANT_SCHEMA_KEY : schema.toLowerCase();
873
+ }
874
+ function escapeRegex(value) {
875
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
876
+ }
877
+
632
878
  // src/adapters/postgres.ts
633
879
  var WILDCARD = "*";
634
880
  function maxPostgresPlaceholderIndex(sql) {
@@ -675,6 +921,11 @@ var PostgresAdapter = class {
675
921
  this.defaultSchema
676
922
  );
677
923
  }
924
+ this.allowsRelation = buildRelationAllowlist(
925
+ this.allowedTables,
926
+ options.tenantSchema,
927
+ this.defaultSchema
928
+ );
678
929
  }
679
930
  clientFn;
680
931
  databaseName;
@@ -682,9 +933,11 @@ var PostgresAdapter = class {
682
933
  kind;
683
934
  dialect;
684
935
  allowedTables;
936
+ /** Undefined when queries are unrestricted. */
937
+ allowsRelation;
685
938
  async execute(sql, params) {
686
- if (this.allowedTables) {
687
- this.validateQueryTables(sql);
939
+ if (this.allowsRelation) {
940
+ this.validateQueryTables(sql, this.allowsRelation);
688
941
  }
689
942
  const boundParams = filterParamsForPostgresSql(sql, params);
690
943
  let paramArray;
@@ -695,29 +948,11 @@ var PostgresAdapter = class {
695
948
  const fields = result.fields.map((f) => f.name);
696
949
  return { fields, rows: result.rows };
697
950
  }
698
- validateQueryTables(sql) {
699
- if (!this.allowedTables || this.allowedTables.length === 0) {
700
- return;
701
- }
702
- const allowedSet = new Set(
703
- this.allowedTables.map((t) => tableKey2(t.schema, t.table))
704
- );
705
- const allowedSchemas = new Set(
706
- this.allowedTables.filter((t) => t.table === WILDCARD).map((t) => t.schema)
707
- );
708
- const neutralizedSql = sql.replace(/EXTRACT\s*\([^)]*FROM\s+[^)]+\)/gi, "EXTRACT(/*neutralized*/)").replace(/SUBSTRING\s*\([^)]*FROM\s+[^)]+\)/gi, "SUBSTRING(/*neutralized*/)").replace(/TRIM\s*\([^)]*FROM\s+[^)]+\)/gi, "TRIM(/*neutralized*/)").replace(/POSITION\s*\([^)]*FROM\s+[^)]+\)/gi, "POSITION(/*neutralized*/)");
709
- const tablePattern = /(?:FROM|JOIN)\s+(?:ONLY\s+)?(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?(["']?[a-zA-Z_][a-zA-Z0-9_]*["']?)/gi;
710
- const matches = neutralizedSql.matchAll(tablePattern);
711
- for (const match of matches) {
712
- const schema = match[1] ?? this.defaultSchema;
713
- const table = match[2]?.replace(/['"]/g, "");
714
- if (table) {
715
- const key = tableKey2(schema, table);
716
- if (!allowedSet.has(key) && !allowedSchemas.has(schema)) {
717
- throw new Error(
718
- `Query references table "${schema}.${table}" which is not in the allowed tables list`
719
- );
720
- }
951
+ validateQueryTables(sql, allows) {
952
+ for (const relation of findRelations(sql)) {
953
+ if (!allows(relation)) {
954
+ const name = relation.parts.length === 1 ? `${this.defaultSchema}.${relation.parts[0]}` : relation.parts.join(".");
955
+ throw new Error(`Query references table "${name}" which is not in the allowed tables list`);
721
956
  }
722
957
  }
723
958
  }
@@ -755,6 +990,9 @@ var PostgresAdapter = class {
755
990
  return positionalParams;
756
991
  }
757
992
  async validate(sql, params) {
993
+ if (this.allowsRelation) {
994
+ this.validateQueryTables(sql, this.allowsRelation);
995
+ }
758
996
  const boundParams = filterParamsForPostgresSql(sql, params);
759
997
  let paramArray;
760
998
  if (boundParams) {
@@ -1044,6 +1282,27 @@ function buildListTablesQuery(dialect, options) {
1044
1282
  LIMIT ${limit};`;
1045
1283
  return { sql, params };
1046
1284
  }
1285
+ function buildRelationAllowlist(allowedTables, tenantSchema, defaultSchema) {
1286
+ if (!allowedTables?.length && !tenantSchema) return void 0;
1287
+ const schemaKey = tenantSchema ? tenantSchemaKey(tenantSchema.template) : (schema) => schema.toLowerCase();
1288
+ const entries = [
1289
+ ...allowedTables?.length ? allowedTables : [{ schema: TENANT_SCHEMA_KEY, table: WILDCARD }],
1290
+ ...(tenantSchema?.sharedSchemas ?? []).map((schema) => ({ schema, table: WILDCARD }))
1291
+ ];
1292
+ const tables = /* @__PURE__ */ new Set();
1293
+ const wildcardSchemas = /* @__PURE__ */ new Set();
1294
+ for (const entry of entries) {
1295
+ const schema = entry.schema === TENANT_SCHEMA_KEY ? entry.schema : schemaKey(entry.schema);
1296
+ if (entry.table === WILDCARD) wildcardSchemas.add(schema);
1297
+ else tables.add(tableKey2(schema, entry.table.toLowerCase()));
1298
+ }
1299
+ return ({ parts }) => {
1300
+ if (parts.length > 2) return false;
1301
+ const table = parts[parts.length - 1].toLowerCase();
1302
+ const schema = schemaKey(parts.length === 2 ? parts[0] : defaultSchema);
1303
+ return wildcardSchemas.has(schema) || tables.has(tableKey2(schema, table));
1304
+ };
1305
+ }
1047
1306
  function tableKey2(schema, table) {
1048
1307
  return `${schema}.${table}`;
1049
1308
  }
@@ -1648,7 +1907,9 @@ var QueryEngine = class {
1648
1907
  const adapter = this.getDatabase(databaseName);
1649
1908
  const metadata = this.getDatabaseMetadata(databaseName);
1650
1909
  let finalSql = sql;
1651
- if (metadata) {
1910
+ if (metadata?.tenantSchema) {
1911
+ finalSql = rewriteTenantSchema(sql, metadata.tenantSchema.template, tenantId);
1912
+ } else if (metadata) {
1652
1913
  finalSql = this.ensureTenantIsolation(sql, params, metadata, tenantId);
1653
1914
  }
1654
1915
  await adapter.validate(finalSql, params);
@@ -3397,6 +3658,9 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
3397
3658
  this.queryEngine.attachDatabase(name, adapter, metadata);
3398
3659
  }
3399
3660
  attachPostgres(name, clientFn, options) {
3661
+ if (options?.tenantSchema && options.tenantFieldName) {
3662
+ throw new Error("Use either tenantSchema or tenantFieldName for tenant isolation, not both");
3663
+ }
3400
3664
  const adapter = new PostgresAdapter(clientFn, options);
3401
3665
  const metadata = {
3402
3666
  name,
@@ -3408,7 +3672,8 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
3408
3672
  enforceTenantIsolation: resolveAttachEnforceTenantIsolation(
3409
3673
  options?.tenantFieldName,
3410
3674
  options?.enforceTenantIsolation
3411
- )
3675
+ ),
3676
+ tenantSchema: options?.tenantSchema
3412
3677
  };
3413
3678
  this.queryEngine.attachDatabase(name, adapter, metadata);
3414
3679
  }
@@ -4463,9 +4728,13 @@ var QueryPanelSdkAPI = class _QueryPanelSdkAPI {
4463
4728
  QueryPanelSdkAPI,
4464
4729
  QueryPipelineError,
4465
4730
  SchemaTooLargeError,
4731
+ TenantSchemaError,
4466
4732
  anonymizeResults,
4467
4733
  mapGeneratedParams,
4468
4734
  normalizeSqlParamsInput,
4469
- resolveQueryPipeline
4735
+ parseTenantSchemaTemplate,
4736
+ resolveQueryPipeline,
4737
+ resolveTenantSchema,
4738
+ rewriteTenantSchema
4470
4739
  });
4471
4740
  //# sourceMappingURL=index.cjs.map