From 0c27ea5802f0f380e9f593a3fb89923cca7dda8f Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 19 Dec 2024 23:06:26 -0300 Subject: [PATCH 01/10] feat: add `list` rule --- .../runtime/src/enhancements/node/policy/policy-utils.ts | 1 + packages/runtime/src/enhancements/node/types.ts | 6 ++++++ packages/runtime/src/types.ts | 2 +- packages/schema/src/res/stdlib.zmodel | 4 ++-- packages/sdk/src/policy.ts | 2 ++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index b39ac5b00..6ed2ecffe 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -277,6 +277,7 @@ export class PolicyUtil extends QueryUtils { create: { guard: true, inputChecker: true }, update: { guard: true }, delete: { guard: true }, + list: { guard: true }, postUpdate: { guard: true }, }, }; diff --git a/packages/runtime/src/enhancements/node/types.ts b/packages/runtime/src/enhancements/node/types.ts index c9a90baa8..31caba26d 100644 --- a/packages/runtime/src/enhancements/node/types.ts +++ b/packages/runtime/src/enhancements/node/types.ts @@ -140,6 +140,7 @@ export type ModelCrudDef = { create: ModelCreateDef; update: ModelUpdateDef; delete: ModelDeleteDef; + list: ModelListDef; postUpdate: ModelPostUpdateDef; }; @@ -207,6 +208,11 @@ type ModelUpdateDef = ModelCrudCommon; */ type ModelDeleteDef = ModelCrudCommon; +/** + * Policy definition for listing a model + */ +type ModelListDef = ModelCrudCommon; + /** * Policy definition for post-update checking a model */ diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index fe31a5058..7391d2aa1 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -36,7 +36,7 @@ export interface DbOperations { */ export type PolicyKind = 'allow' | 'deny'; -export type PolicyCrudKind = 'read' | 'create' | 'update' | 'delete'; +export type PolicyCrudKind = 'read' | 'create' | 'update' | 'delete' | 'list'; /** * Kinds of operations controlled by access policies diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index a0a0a41f8..824661c6a 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -527,7 +527,7 @@ attribute @@schema(_ name: String) @@@prisma * @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations. * @param condition: a boolean expression that controls if the operation should be allowed. */ -attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean) +attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'list'", "'all'"]), _ condition: Boolean) /** * Defines an access policy that allows the annotated field to be read or updated. @@ -545,7 +545,7 @@ attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "' * @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations. * @param condition: a boolean expression that controls if the operation should be denied. */ -attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean) +attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'list'", "'all'"]), _ condition: Boolean) /** * Defines an access policy that denies the annotated field to be read or updated. diff --git a/packages/sdk/src/policy.ts b/packages/sdk/src/policy.ts index c9eea9865..d3da674fd 100644 --- a/packages/sdk/src/policy.ts +++ b/packages/sdk/src/policy.ts @@ -12,6 +12,7 @@ export function analyzePolicies(dataModel: DataModel) { const read = toStaticPolicy('read', allows, denies); const update = toStaticPolicy('update', allows, denies); const del = toStaticPolicy('delete', allows, denies); + const list = toStaticPolicy('list', allows, denies); const hasFieldValidation = hasValidationAttributes(dataModel); return { @@ -21,6 +22,7 @@ export function analyzePolicies(dataModel: DataModel) { read, update, delete: del, + list, allowAll: create === true && read === true && update === true && del === true, denyAll: create === false && read === false && update === false && del === false, hasFieldValidation, From 252de57ae085663e06cfb5312ba4af58ab0c8e90 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 20 Dec 2024 14:57:08 -0300 Subject: [PATCH 02/10] chore: add test --- .../with-policy/toplevel-operations.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts index 3543dd7b5..0773d61d5 100644 --- a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts +++ b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts @@ -271,4 +271,52 @@ describe('With Policy: toplevel operations', () => { expect(await db.model.deleteMany()).toEqual(expect.objectContaining({ count: 0 })); }); + + it('list tests', async () => { + const { enhance, prisma } = await loadSchema( + ` + model Model { + id String @id @default(uuid()) + + @@allow('create', true) + @@allow('list', true) + } + ` + ); + + const db = enhance(); + + await expect( + db.model.create({ + data: { + id: '1', + value: 1, + }, + }) + ).toBeRejectedByPolicy(); + const fromPrisma = await prisma.model.findUnique({ + where: { id: '1' }, + }); + expect(fromPrisma).toBeTruthy(); + + expect(await db.model.findMany()).toHaveLength(0); + expect(await db.model.findUnique({ where: { id: '1' } })).toBeNull(); + expect(await db.model.findFirst({ where: { id: '1' } })).toBeNull(); + await expect(db.model.findUniqueOrThrow({ where: { id: '1' } })).toBeNotFound(); + await expect(db.model.findFirstOrThrow({ where: { id: '1' } })).toBeNotFound(); + + const item2 = { + id: '2', + value: 2, + }; + const r1 = await db.model.create({ + data: item2, + }); + expect(r1).toBeTruthy(); + expect(await db.model.findMany()).toHaveLength(1); + expect(await db.model.findUnique({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findFirst({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findUniqueOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + expect(await db.model.findFirstOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + }); }); From 60f8f42c33077fbafd2ca09db1bd53c8aea8e9b7 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 1 Jan 2025 21:28:37 -0500 Subject: [PATCH 03/10] fix(types): add 'list' operation context to Options type --- packages/sdk/src/typescript-expression-transformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/typescript-expression-transformer.ts b/packages/sdk/src/typescript-expression-transformer.ts index 801db4d4f..9d20076c5 100644 --- a/packages/sdk/src/typescript-expression-transformer.ts +++ b/packages/sdk/src/typescript-expression-transformer.ts @@ -38,7 +38,7 @@ type Options = { thisExprContext?: string; futureRefContext?: string; context: ExpressionContext; - operationContext?: 'read' | 'create' | 'update' | 'postUpdate' | 'delete'; + operationContext?: 'read' | 'create' | 'update' | 'postUpdate' | 'delete' | 'list'; }; type Casing = 'original' | 'upper' | 'lower' | 'capitalize' | 'uncapitalize'; From d5af457f603f736c85ecb6ca2882b03015abfe64 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 1 Jan 2025 23:05:25 -0500 Subject: [PATCH 04/10] fix(policy): add 'list' operation to policy validation and update tests --- .../attribute-application-validator.ts | 2 +- .../with-policy/toplevel-operations.test.ts | 44 ++++++------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/schema/src/language-server/validator/attribute-application-validator.ts b/packages/schema/src/language-server/validator/attribute-application-validator.ts index 0e1d8e885..a06816739 100644 --- a/packages/schema/src/language-server/validator/attribute-application-validator.ts +++ b/packages/schema/src/language-server/validator/attribute-application-validator.ts @@ -137,7 +137,7 @@ export default class AttributeApplicationValidator implements AstValidator { ` model Model { id String @id @default(uuid()) + value Int @@allow('create', true) - @@allow('list', true) + @@allow('read', true) + @@allow('list', false) } ` ); const db = enhance(); - await expect( - db.model.create({ - data: { - id: '1', - value: 1, - }, - }) - ).toBeRejectedByPolicy(); - const fromPrisma = await prisma.model.findUnique({ - where: { id: '1' }, + // Create some items + await db.model.createMany({ + data: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }], }); - expect(fromPrisma).toBeTruthy(); - expect(await db.model.findMany()).toHaveLength(0); - expect(await db.model.findUnique({ where: { id: '1' } })).toBeNull(); - expect(await db.model.findFirst({ where: { id: '1' } })).toBeNull(); - await expect(db.model.findUniqueOrThrow({ where: { id: '1' } })).toBeNotFound(); - await expect(db.model.findFirstOrThrow({ where: { id: '1' } })).toBeNotFound(); + const fromPrisma = await prisma.model.findMany(); + expect(fromPrisma).toHaveLength(4); - const item2 = { - id: '2', - value: 2, - }; - const r1 = await db.model.create({ - data: item2, - }); - expect(r1).toBeTruthy(); - expect(await db.model.findMany()).toHaveLength(1); - expect(await db.model.findUnique({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); - expect(await db.model.findFirst({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); - expect(await db.model.findUniqueOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); - expect(await db.model.findFirstOrThrow({ where: { id: '2' } })).toEqual(expect.objectContaining(item2)); + const firstItem = await db.model.findFirst(); + + expect(firstItem).toBeTruthy(); + + // listing denied + // expect(fromDb).toHaveLength(0); }); }); From 48e3eb6bf0e08a44f90a1e7f5ceac3a860fcc4fe Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 7 Jan 2025 09:03:45 -0500 Subject: [PATCH 05/10] chore(policy): add 'list' model definition to policy generator --- .../enhancer/policy/policy-guard-generator.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts index 9ffe41dcb..468643573 100644 --- a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts @@ -154,6 +154,7 @@ export class PolicyGenerator { this.writeModelUpdateDef(model, policies, writer, sourceFile); this.writeModelPostUpdateDef(model, policies, writer, sourceFile); this.writeModelDeleteDef(model, policies, writer, sourceFile); + this.writeModelListDef(model, policies, writer, sourceFile); }); writer.writeLine(','); } @@ -347,6 +348,21 @@ export class PolicyGenerator { writer.inlineBlock(() => { this.writeCommonModelDef(model, 'delete', policies, writer, sourceFile); }); + writer.writeLine(','); + } + + // writes `list: ...` for a given model + private writeModelListDef( + model: DataModel, + policies: PolicyAnalysisResult, + writer: CodeBlockWriter, + sourceFile: SourceFile + ) { + writer.write(`list:`); + writer.inlineBlock(() => { + this.writeCommonModelDef(model, 'list', policies, writer, sourceFile); + }); + writer.writeLine(','); } // writes `[kind]: ...` for a given model From 2244bc27bdc42f574bd33974b6404b0086753e24 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 7 Jan 2025 09:04:10 -0500 Subject: [PATCH 06/10] chore(policy): inject auth guard for 'list' operation in policy utils --- .../runtime/src/enhancements/node/policy/policy-utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index 6ed2ecffe..04d22c894 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -612,6 +612,11 @@ export class PolicyUtil extends QueryUtils { return false; } + if (!this.injectAuthGuardAsWhere(db, injected, model, 'list')) { + args.where = this.makeFalse(); + return false; + } + if (args.where) { // inject into fields: // to-many: some/none/every From 3d1e12799470a2df222634833760db68a16beddf Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 7 Jan 2025 09:04:29 -0500 Subject: [PATCH 07/10] fix(policy): update doFind method to support list operation in PolicyProxyHandler --- packages/runtime/src/enhancements/node/policy/handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/enhancements/node/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts index 91ab24c07..44a7ad667 100644 --- a/packages/runtime/src/enhancements/node/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -116,7 +116,7 @@ export class PolicyProxyHandler implements Pr } findMany(args?: any) { - return createDeferredPromise(() => this.doFind(args, 'findMany', () => [])); + return createDeferredPromise(() => this.doFind(args, 'findMany', () => [], true)); } // make a find query promise with fluent API call stubs installed @@ -130,7 +130,7 @@ export class PolicyProxyHandler implements Pr ); } - private async doFind(args: any, actionName: FindOperations, handleRejection: () => any) { + private async doFind(args: any, actionName: FindOperations, handleRejection: () => any, isList: boolean = false) { const origArgs = args; const _args = this.policyUtils.safeClone(args); if (!this.policyUtils.injectForRead(this.prisma, this.model, _args)) { From 1ccc6ac726b421cccb9fee34651099fe36066910 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 7 Jan 2025 09:04:39 -0500 Subject: [PATCH 08/10] test(policy): update toplevel operations test to use findMany for database retrieval --- .../enhancements/with-policy/toplevel-operations.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts index bf8d7a13e..f7f3c63dc 100644 --- a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts +++ b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts @@ -296,9 +296,11 @@ describe('With Policy: toplevel operations', () => { const fromPrisma = await prisma.model.findMany(); expect(fromPrisma).toHaveLength(4); - const firstItem = await db.model.findFirst(); + const fromDb = await db.model.findMany(); + console.log(fromDb); + // const firstItem = await db.model.findFirst(); - expect(firstItem).toBeTruthy(); + // expect(firstItem).toBeTruthy(); // listing denied // expect(fromDb).toHaveLength(0); From 687954885fd7934a5e16aca3242455e4911b8ab8 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 7 Jan 2025 09:10:29 -0500 Subject: [PATCH 09/10] chore(policy): add auth guard injection for list operations in PolicyProxyHandler and PolicyUtil --- packages/runtime/src/enhancements/node/policy/handler.ts | 8 ++++++++ .../runtime/src/enhancements/node/policy/policy-utils.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/runtime/src/enhancements/node/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts index 44a7ad667..6af9a1ca7 100644 --- a/packages/runtime/src/enhancements/node/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -140,6 +140,14 @@ export class PolicyProxyHandler implements Pr return handleRejection(); } + if (isList && !this.policyUtils.injectForList(this.prisma, this.model, _args)) { + if (this.shouldLogQuery) { + this.logger.info(`[policy] \`${actionName}\` ${this.model}: unconditionally denied`); + } + + return handleRejection(); + } + this.policyUtils.injectReadCheckSelect(this.model, _args); if (this.shouldLogQuery) { diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index 04d22c894..ce405addd 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -652,6 +652,14 @@ export class PolicyUtil extends QueryUtils { return true; } + /** + * Injects auth guard for read operations. + */ + injectForList(_db: CrudContract, _model: string, _args: any) { + // make select and include visible to the injection + return true; + } + //#endregion //#region Checker From 27bb3bfcb6d3732167de3c79fe0823eb1e46a7ba Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Sun, 12 Jan 2025 13:33:55 -0500 Subject: [PATCH 10/10] chore: expand `injectForRead` to include `isList` --- .../runtime/src/enhancements/node/policy/handler.ts | 12 ++---------- .../runtime/src/enhancements/node/policy/index.ts | 2 +- .../src/enhancements/node/policy/policy-utils.ts | 12 ++---------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/runtime/src/enhancements/node/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts index 6af9a1ca7..d88baa222 100644 --- a/packages/runtime/src/enhancements/node/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -133,21 +133,13 @@ export class PolicyProxyHandler implements Pr private async doFind(args: any, actionName: FindOperations, handleRejection: () => any, isList: boolean = false) { const origArgs = args; const _args = this.policyUtils.safeClone(args); - if (!this.policyUtils.injectForRead(this.prisma, this.model, _args)) { + if (!this.policyUtils.injectForReadOrList(this.prisma, this.model, _args, isList)) { if (this.shouldLogQuery) { this.logger.info(`[policy] \`${actionName}\` ${this.model}: unconditionally denied`); } return handleRejection(); } - if (isList && !this.policyUtils.injectForList(this.prisma, this.model, _args)) { - if (this.shouldLogQuery) { - this.logger.info(`[policy] \`${actionName}\` ${this.model}: unconditionally denied`); - } - - return handleRejection(); - } - this.policyUtils.injectReadCheckSelect(this.model, _args); if (this.shouldLogQuery) { @@ -1617,7 +1609,7 @@ export class PolicyProxyHandler implements Pr // "update" has an extra layer of "after" const payload = key === 'update' ? args[key].after : args[key]; const toInject = { where: payload }; - this.policyUtils.injectForRead(this.prisma, this.model, toInject); + this.policyUtils.injectForReadOrList(this.prisma, this.model, toInject, false); if (key === 'update') { // "update" has an extra layer of "after" args[key].after = toInject.where; diff --git a/packages/runtime/src/enhancements/node/policy/index.ts b/packages/runtime/src/enhancements/node/policy/index.ts index d5523e31b..a68385237 100644 --- a/packages/runtime/src/enhancements/node/policy/index.ts +++ b/packages/runtime/src/enhancements/node/policy/index.ts @@ -75,6 +75,6 @@ export async function policyProcessIncludeRelationPayload( context: EnhancementContext | undefined ) { const utils = new PolicyUtil(prisma, options, context); - await utils.injectForRead(prisma, model, payload); + await utils.injectForReadOrList(prisma, model, payload, false); await utils.injectReadCheckSelect(model, payload); } diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index ce405addd..f9d26aa99 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -604,7 +604,7 @@ export class PolicyUtil extends QueryUtils { /** * Injects auth guard for read operations. */ - injectForRead(db: CrudContract, model: string, args: any) { + injectForReadOrList(db: CrudContract, model: string, args: any, isList: boolean) { // make select and include visible to the injection const injected: any = { select: args.select, include: args.include }; if (!this.injectAuthGuardAsWhere(db, injected, model, 'read')) { @@ -652,14 +652,6 @@ export class PolicyUtil extends QueryUtils { return true; } - /** - * Injects auth guard for read operations. - */ - injectForList(_db: CrudContract, _model: string, _args: any) { - // make select and include visible to the injection - return true; - } - //#endregion //#region Checker @@ -1148,7 +1140,7 @@ export class PolicyUtil extends QueryUtils { CrudFailureReason.RESULT_NOT_READABLE ); - const injectResult = this.injectForRead(db, model, readArgs); + const injectResult = this.injectForReadOrList(db, model, readArgs, false); if (!injectResult) { return { error, result: undefined }; }