diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 58bcea2e..02d3f756 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -575,20 +575,34 @@ export class ExpressionTransformer { } const fromModel = context.model; + const relationFieldDef = QueryUtils.requireField(this.schema, fromModel, field); const { keyPairs, ownedByModel } = QueryUtils.getRelationForeignKeyFieldPairs(this.schema, fromModel, field); let condition: OperationNode; if (ownedByModel) { // `fromModel` owns the fk + condition = conjunction( this.dialect, - keyPairs.map(({ fk, pk }) => - BinaryOperationNode.create( - ReferenceNode.create(ColumnNode.create(fk), TableNode.create(context.alias ?? fromModel)), + keyPairs.map(({ fk, pk }) => { + let fkRef: OperationNode = ReferenceNode.create( + ColumnNode.create(fk), + TableNode.create(context.alias ?? fromModel), + ); + if (relationFieldDef.originModel && relationFieldDef.originModel !== fromModel) { + fkRef = this.buildDelegateBaseFieldSelect( + fromModel, + context.alias ?? fromModel, + fk, + relationFieldDef.originModel, + ); + } + return BinaryOperationNode.create( + fkRef, OperatorNode.create('='), ReferenceNode.create(ColumnNode.create(pk), TableNode.create(relationModel)), - ), - ), + ); + }), ); } else { // `relationModel` owns the fk @@ -633,8 +647,47 @@ export class ExpressionTransformer { return relationQuery.toOperationNode(); } - private createColumnRef(column: string, context: ExpressionTransformerContext): ReferenceNode { - return ReferenceNode.create(ColumnNode.create(column), TableNode.create(context.alias ?? context.model)); + private createColumnRef(column: string, context: ExpressionTransformerContext) { + // if field comes from a delegate base model, we need to use the join alias + // of that base model + + const tableName = context.alias ?? context.model; + + // "create" policies evaluate table from "VALUES" node so no join from delegate bases are + // created and thus we should directly use the model table name + if (context.operation === 'create') { + return ReferenceNode.create(ColumnNode.create(column), TableNode.create(tableName)); + } + + const fieldDef = QueryUtils.requireField(this.schema, context.model, column); + if (!fieldDef.originModel || fieldDef.originModel === context.model) { + return ReferenceNode.create(ColumnNode.create(column), TableNode.create(tableName)); + } + + return this.buildDelegateBaseFieldSelect(context.model, tableName, column, fieldDef.originModel); + } + + private buildDelegateBaseFieldSelect(model: string, modelAlias: string, field: string, baseModel: string) { + const idFields = QueryUtils.requireIdFields(this.client.$schema, model); + return { + kind: 'SelectQueryNode', + from: FromNode.create([TableNode.create(baseModel)]), + selections: [ + SelectionNode.create(ReferenceNode.create(ColumnNode.create(field), TableNode.create(baseModel))), + ], + where: WhereNode.create( + conjunction( + this.dialect, + idFields.map((idField) => + BinaryOperationNode.create( + ReferenceNode.create(ColumnNode.create(idField), TableNode.create(baseModel)), + OperatorNode.create('='), + ReferenceNode.create(ColumnNode.create(idField), TableNode.create(modelAlias)), + ), + ), + ), + ), + } satisfies SelectQueryNode; } private isAuthCall(value: unknown): value is CallExpression { diff --git a/packages/plugins/policy/src/functions.ts b/packages/plugins/policy/src/functions.ts index 5d6d6621..d131f933 100644 --- a/packages/plugins/policy/src/functions.ts +++ b/packages/plugins/policy/src/functions.ts @@ -36,18 +36,60 @@ export const check: ZModelFunction = ( invariant(!fieldDef.array, `Field "${fieldName}" is a to-many relation, which is not supported by "check"`); const relationModel = fieldDef.type; - const op = arg2Node ? (arg2Node.value as CRUD) : operation; + // build the join condition between the current model and the related model + const joinConditions: Expression[] = []; + const fkInfo = QueryUtils.getRelationForeignKeyFieldPairs(client.$schema, model, fieldName); + const idFields = QueryUtils.requireIdFields(client.$schema, model); - const policyHandler = new PolicyHandler(client); + // helper to build a base model select for delegate models + const buildBaseSelect = (baseModel: string, field: string): Expression => { + return eb + .selectFrom(baseModel) + .select(field) + .where( + eb.and( + idFields.map((idField) => + eb(eb.ref(`${fieldDef.originModel}.${idField}`), '=', eb.ref(`${modelAlias}.${idField}`)), + ), + ), + ); + }; + + if (fkInfo.ownedByModel) { + // model owns the relation + joinConditions.push( + ...fkInfo.keyPairs.map(({ fk, pk }) => { + let fkRef: Expression; + if (fieldDef.originModel && fieldDef.originModel !== model) { + // relation is actually defined in a delegate base model, select from there + fkRef = buildBaseSelect(fieldDef.originModel, fk); + } else { + fkRef = eb.ref(`${modelAlias}.${fk}`); + } + return eb(fkRef, '=', eb.ref(`${relationModel}.${pk}`)); + }), + ); + } else { + // related model owns the relation + joinConditions.push( + ...fkInfo.keyPairs.map(({ fk, pk }) => { + let pkRef: Expression; + if (fieldDef.originModel && fieldDef.originModel !== model) { + // relation is actually defined in a delegate base model, select from there + pkRef = buildBaseSelect(fieldDef.originModel, pk); + } else { + pkRef = eb.ref(`${modelAlias}.${pk}`); + } + return eb(pkRef, '=', eb.ref(`${relationModel}.${fk}`)); + }), + ); + } - // join with parent model - const joinPairs = QueryUtils.buildJoinPairs(client.$schema, model, modelAlias, fieldName, relationModel); - const joinCondition = - joinPairs.length === 1 - ? eb(eb.ref(joinPairs[0]![0]), '=', eb.ref(joinPairs[0]![1])) - : eb.and(joinPairs.map(([left, right]) => eb(eb.ref(left), '=', eb.ref(right)))); + const joinCondition = joinConditions.length === 1 ? joinConditions[0]! : eb.and(joinConditions); // policy condition of the related model + const policyHandler = new PolicyHandler(client); + const op = arg2Node ? (arg2Node.value as CRUD) : operation; const policyCondition = policyHandler.buildPolicyFilter(relationModel, undefined, op); // build the final nested select that evaluates the policy condition diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 9bc6f664..92f5e74c 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -297,6 +297,10 @@ export class PolicyHandler extends OperationNodeTransf // #region overrides protected override transformSelectQuery(node: SelectQueryNode) { + if (!node.from) { + return super.transformSelectQuery(node); + } + let whereNode = this.transformNode(node.where); // get combined policy filter for all froms, and merge into where clause @@ -327,6 +331,7 @@ export class PolicyHandler extends OperationNodeTransf // build a nested query with policy filter applied const filter = this.buildPolicyFilter(table.model, table.alias, 'read'); + const nestedSelect: SelectQueryNode = { kind: 'SelectQueryNode', from: FromNode.create([node.table]), diff --git a/packages/testtools/package.json b/packages/testtools/package.json index f01ffe20..29d574d9 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -4,7 +4,7 @@ "description": "ZenStack Test Tools", "type": "module", "scripts": { - "build": "tsc --noEmit && tsup-node && copyfiles -f ./src/types.d.ts ./dist", + "build": "tsc --noEmit && tsup-node", "watch": "tsup-node --watch", "lint": "eslint src --ext ts", "pack": "pnpm pack" @@ -53,7 +53,6 @@ "@types/pg": "^8.11.11", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", - "copyfiles": "^2.4.1", "typescript": "catalog:" } } diff --git a/packages/testtools/tsup.config.ts b/packages/testtools/tsup.config.ts index 5a74a9dd..f45bb68e 100644 --- a/packages/testtools/tsup.config.ts +++ b/packages/testtools/tsup.config.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -7,7 +8,9 @@ export default defineConfig({ outDir: 'dist', splitting: false, sourcemap: true, - clean: true, dts: true, format: ['cjs', 'esm'], + async onSuccess() { + fs.cpSync('src/types.d.ts', 'dist/types.d.ts', { force: true }); + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9236661a..eff0e66f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,9 +485,6 @@ importers: '@zenstackhq/typescript-config': specifier: workspace:* version: link:../config/typescript-config - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 typescript: specifier: 'catalog:' version: 5.8.3 @@ -1520,9 +1517,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -1563,13 +1557,6 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - copyfiles@2.4.1: - resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} - hasBin: true - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1674,10 +1661,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1802,9 +1785,6 @@ packages: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} engines: {node: '>=14.14'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1813,10 +1793,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1855,10 +1831,6 @@ packages: engines: {node: 20 || >=22} hasBin: true - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1911,10 +1883,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1945,12 +1913,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2117,11 +2079,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} @@ -2154,9 +2111,6 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - noms@0.0.0: - resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} - nypm@0.6.1: resolution: {integrity: sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==} engines: {node: ^14.16.0 || >=16.10.0} @@ -2213,10 +2167,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2387,9 +2337,6 @@ packages: typescript: optional: true - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -2417,12 +2364,6 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} - readable-stream@1.0.34: - resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2431,10 +2372,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2462,9 +2399,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2531,12 +2465,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2582,9 +2510,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2736,10 +2661,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - untildify@4.0.0: - resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} - engines: {node: '>=8'} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2885,23 +2806,11 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} hasBin: true - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3686,12 +3595,6 @@ snapshots: cli-spinners@2.9.2: {} - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clone@1.0.4: {} color-convert@2.0.1: @@ -3716,18 +3619,6 @@ snapshots: consola@3.4.2: {} - copyfiles@2.4.1: - dependencies: - glob: 7.2.3 - minimatch: 3.1.2 - mkdirp: 1.0.4 - noms: 0.0.0 - through2: 2.0.5 - untildify: 4.0.0 - yargs: 16.2.0 - - core-util-is@1.0.3: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3858,8 +3749,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.5 '@esbuild/win32-x64': 0.25.5 - escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -4008,15 +3897,11 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true function-bind@1.1.2: {} - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4080,15 +3965,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - globals@14.0.0: {} gopd@1.2.0: {} @@ -4129,11 +4005,6 @@ snapshots: imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -4152,10 +4023,6 @@ snapshots: is-unicode-supported@0.1.0: {} - isarray@0.0.1: {} - - isarray@1.0.0: {} - isarray@2.0.5: {} isexe@2.0.0: {} @@ -4312,8 +4179,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mlly@1.7.4: dependencies: acorn: 8.15.0 @@ -4343,11 +4208,6 @@ snapshots: node-fetch-native@1.6.7: {} - noms@0.0.0: - dependencies: - inherits: 2.0.4 - readable-stream: 1.0.34 - nypm@0.6.1: dependencies: citty: 0.1.6 @@ -4411,8 +4271,6 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-scurry@1.11.1: @@ -4565,8 +4423,6 @@ snapshots: transitivePeerDependencies: - magicast - process-nextick-args@2.0.1: {} - pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -4594,23 +4450,6 @@ snapshots: react@19.1.0: {} - readable-stream@1.0.34: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -4619,8 +4458,6 @@ snapshots: readdirp@4.1.2: {} - require-directory@2.1.1: {} - resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -4664,8 +4501,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-buffer@5.1.2: {} - safe-buffer@5.2.1: {} semver@7.7.2: {} @@ -4725,12 +4560,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 - string_decoder@0.10.31: {} - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -4788,11 +4617,6 @@ snapshots: dependencies: any-promise: 1.3.0 - through2@2.0.5: - dependencies: - readable-stream: 2.3.8 - xtend: 4.0.2 - tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4930,8 +4754,6 @@ snapshots: universalify@2.0.1: {} - untildify@4.0.0: {} - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -5079,22 +4901,8 @@ snapshots: xtend@4.0.2: {} - y18n@5.0.8: {} - yaml@2.8.0: {} - yargs-parser@20.2.9: {} - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - yocto-queue@0.1.0: {} zod-validation-error@4.0.1(zod@3.25.76): diff --git a/tests/e2e/orm/policy/delegate.test.ts b/tests/e2e/orm/policy/delegate.test.ts new file mode 100644 index 00000000..5b526a07 --- /dev/null +++ b/tests/e2e/orm/policy/delegate.test.ts @@ -0,0 +1,176 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Delegate interaction tests', () => { + it('inherits policies from delegate base models', async () => { + const db = await createPolicyTestClient( + ` +model A { + id Int @id @default(autoincrement()) + a Int + aType String + @@delegate(aType) + @@allow('all', true) + @@deny('all', a <= 0) +} + +model B extends A { + b Int + bType String + @@delegate(bType) + @@deny('all', b <= 0) +} + +model C extends B { + c Int + @@deny('all', c <= 0) +} +`, + ); + + await expect(db.c.create({ data: { a: 0, b: 1, c: 1 } })).toBeRejectedByPolicy(); + await expect(db.c.create({ data: { a: 1, b: 0, c: 1 } })).toBeRejectedByPolicy(); + await expect(db.c.create({ data: { a: 1, b: 1, c: 0 } })).toBeRejectedByPolicy(); + await expect(db.c.create({ data: { a: 1, b: 1, c: 1 } })).toResolveTruthy(); + + // clean up + await db.c.deleteMany(); + + await db.$unuseAll().c.create({ data: { id: 2, a: 0, b: 0, c: 1 } }); + await expect(db.a.findUnique({ where: { id: 2 } })).toResolveNull(); + await expect(db.b.findUnique({ where: { id: 2 } })).toResolveNull(); + await expect(db.c.findUnique({ where: { id: 2 } })).toResolveNull(); + + await db.$unuseAll().c.update({ where: { id: 2 }, data: { a: 1, b: 1, c: 1 } }); + await expect(db.a.findUnique({ where: { id: 2 } })).toResolveTruthy(); + await expect(db.b.findUnique({ where: { id: 2 } })).toResolveTruthy(); + await expect(db.c.findUnique({ where: { id: 2 } })).toResolveTruthy(); + }); + + it('works with policies referencing base model fields', async () => { + const db = await createPolicyTestClient( + ` +model A { + id Int @id @default(autoincrement()) + a Int + aType String + @@delegate(aType) + @@allow('all', a > 0) +} + +model B extends A { + b Int + c C @relation(fields: [cId], references: [id]) + cId Int +} + +model C { + id Int @id @default(autoincrement()) + bs B[] + @@allow('all', true) +} +`, + ); + + await expect( + db.c.create({ + data: { + bs: { + create: [ + { a: 0, b: 0 }, + { a: 1, b: 1 }, + ], + }, + }, + }), + ).toBeRejectedByPolicy(); + await expect(db.$unuseAll().b.count()).resolves.toBe(0); + + await db.$unuseAll().c.create({ + data: { + bs: { + create: [ + { id: 1, a: 0, b: 0 }, + { id: 2, a: 1, b: 1 }, + ], + }, + }, + }); + + await expect(db.c.findFirst({ include: { bs: true } })).resolves.toMatchObject({ + bs: [{ a: 1 }], + }); + await expect(db.b.update({ where: { id: 1 }, data: { b: 2 } })).toBeRejectedNotFound(); + await expect(db.b.update({ where: { id: 2 }, data: { b: 2 } })).toResolveTruthy(); + }); + + it('works with policies referencing base model relations', async () => { + const db = await createPolicyTestClient( + ` +model A { + id Int @id @default(autoincrement()) + aType String + c C @relation(fields: [cId], references: [id]) + cId Int + @@delegate(aType) + @@allow('all', true) +} + +model C { + id Int @id @default(autoincrement()) + c Int + as A[] + @@allow('all', true) +} + +model B extends A { + b Int + @@deny('update', c.c <= 0) +} +`, + ); + + await db.b.create({ + data: { id: 1, b: 0, c: { create: { c: 0 } } }, + }); + await expect(db.b.update({ where: { id: 1 }, data: { b: 1 } })).toBeRejectedNotFound(); + + await db.b.create({ + data: { id: 2, b: 0, c: { create: { c: 1 } } }, + }); + await expect(db.b.update({ where: { id: 2 }, data: { b: 1 } })).toResolveTruthy(); + }); + + it('works with policies using check on relation fields on delegate base models', async () => { + const db = await createPolicyTestClient( + ` +model A { + id Int @id @default(autoincrement()) + aType String + c C? + @@delegate(aType) + @@allow('all', true) +} + +model B extends A { + b Int + @@deny('read', !check(c)) +} + +model C { + id Int @id @default(autoincrement()) + c Int + a A @relation(fields: [aId], references: [id]) + aId Int @unique + @@allow('read', c > 0) + @@allow('create', true) +} + `, + ); + + await db.$unuseAll().b.create({ data: { id: 1, b: 1, c: { create: { c: 0 } } } }); + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toBeNull(); + await db.$unuseAll().b.create({ data: { id: 2, b: 2, c: { create: { c: 1 } } } }); + await expect(db.b.findUnique({ where: { id: 2 } })).toResolveTruthy(); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1930.test.ts b/tests/regression/test/v2-migrated/issue-1930.test.ts new file mode 100644 index 00000000..08b64e24 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1930.test.ts @@ -0,0 +1,76 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1930', async () => { + const db = await createPolicyTestClient( + ` +model Organization { + id String @id @default(cuid()) + entities Entity[] + + @@allow('all', true) +} + +model Entity { + id String @id @default(cuid()) + org Organization? @relation(fields: [orgId], references: [id]) + orgId String? + contents EntityContent[] + entityType String + isDeleted Boolean @default(false) + + @@delegate(entityType) + + @@allow('all', !isDeleted) +} + +model EntityContent { + id String @id @default(cuid()) + entity Entity @relation(fields: [entityId], references: [id]) + entityId String + + entityContentType String + + @@delegate(entityContentType) + + @@allow('create', true) + @@allow('read', check(entity)) +} + +model Article extends Entity { +} + +model ArticleContent extends EntityContent { + body String? +} + +model OtherContent extends EntityContent { + data Int +} + `, + ); + + const org = await db.$unuseAll().organization.create({ data: {} }); + const article = await db.$unuseAll().article.create({ + data: { org: { connect: { id: org.id } } }, + }); + + // normal create/read + await expect( + db.articleContent.create({ + data: { body: 'abc', entity: { connect: { id: article.id } } }, + }), + ).toResolveTruthy(); + await expect(db.article.findFirst({ include: { contents: true } })).resolves.toMatchObject({ + contents: expect.arrayContaining([expect.objectContaining({ body: 'abc' })]), + }); + + // deleted article's contents are not readable + const deletedArticle = await db.$unuseAll().article.create({ + data: { org: { connect: { id: org.id } }, isDeleted: true }, + }); + const content1 = await db.$unuseAll().articleContent.create({ + data: { body: 'bcd', entity: { connect: { id: deletedArticle.id } } }, + }); + await expect(db.articleContent.findUnique({ where: { id: content1.id } })).toResolveNull(); +});