diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 33dd30733..996f55be6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -80,9 +80,9 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + key: ${{ runner.os }}-node-${{ matrix.node-version }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | - ${{ runner.os }}-pnpm-store- + ${{ runner.os }}-node-${{ matrix.node-version }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 28983e822..913e2a2dc 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -36,6 +36,7 @@ import { isComputedField, isDataFieldReference, isDelegateModel, + isEnumFieldReference, isRelationshipField, mapBuiltinTypeToExpressionType, resolved, @@ -187,6 +188,8 @@ export default class AttributeApplicationValidator implements AstValidator { return createZenStackPromise(async (txClient?: ClientContract) => { + // Per-operation context shared between onQuery and onKyselyQuery hooks. + // onQuery plugins write here; the context executor passes it to onKyselyQuery. + const queryContext = new Map(); + let proceed = async (_args: unknown) => { // prepare args for ext result: strip ext result field names from select/omit, // inject needs fields into select (recursively handles nested relations) @@ -619,7 +623,32 @@ function createModelCrudHandler( ? prepareArgsForExtResult(_args, model, schema, plugins) : _args; - const _handler = txClient ? handler.withClient(txClient) : handler; + // Bind queryContext to the executor so onKyselyQuery hooks can read it. + // Uses txClient's executor (which holds the tx connection) when in a transaction. + const baseClient = txClient ?? client; + const rawExecutor = (baseClient.$qb as any).getExecutor(); + + let contextExecutor: ZenStackQueryExecutor; + if (rawExecutor instanceof ZenStackQueryExecutor) { + contextExecutor = rawExecutor.withQueryContext(queryContext); + } else { + // Kysely wraps the real executor in NotCommittedOrRolledBackAssertingExecutor + // inside sequential transactions — delegate connection to rawExecutor so + // queries run within the transaction. + const rootZenExecutor = (client as unknown as ClientImpl).kyselyProps + .executor as ZenStackQueryExecutor; + contextExecutor = rootZenExecutor + .withConnectionProvider({ + provideConnection: (consumer) => rawExecutor.provideConnection(consumer), + }) + .withQueryContext(queryContext); + } + + const contextClient = (baseClient as unknown as ClientImpl).withExecutor( + contextExecutor, + ) as unknown as ClientContract; + + const _handler = handler.withClient(contextClient); const r = await _handler.handle(operation, processedArgs); if (!r && throwIfNoResult) { throw createNotFoundError(model); @@ -652,6 +681,7 @@ function createModelCrudHandler( operation: nominalOperation, // reflect the latest override if provided args: _args, + queryContext, // ensure inner overrides are propagated to the previous proceed proceed: (nextArgs: unknown) => _proceed(nextArgs), }; diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index a9d4d2fcb..b2644c27c 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -170,9 +170,7 @@ export abstract class BaseCrudDialect { effectiveOrderBy && enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob) ) { - throw createNotSupportedError( - 'cursor pagination cannot be combined with "_fuzzyRelevance" ordering', - ); + throw createNotSupportedError('cursor pagination cannot be combined with "_fuzzyRelevance" ordering'); } result = this.buildCursorFilter( model, @@ -1670,7 +1668,10 @@ export abstract class BaseCrudDialect { 'fuzzy filter must be an object with at least a "search" field', ); const raw = value as Record; - invariant(typeof raw['search'] === 'string' && raw['search'].length > 0, 'fuzzy.search must be a non-empty string'); + invariant( + typeof raw['search'] === 'string' && raw['search'].length > 0, + 'fuzzy.search must be a non-empty string', + ); const mode = raw['mode'] ?? 'simple'; invariant( mode === 'simple' || mode === 'word' || mode === 'strictWord', diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 4c37e0eae..04459acc1 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -51,6 +51,7 @@ import { } from '../../query-utils'; import { getCrudDialect } from '../dialects'; import type { BaseCrudDialect } from '../dialects/base-dialect'; +import { ZenStackQueryExecutor } from '../../executor/zenstack-query-executor'; import { InputValidator } from '../validator'; /** @@ -168,6 +169,16 @@ export const AllReadOperations = [...CoreReadOperations, 'findUniqueOrThrow', 'f */ export type AllReadOperations = (typeof AllReadOperations)[number]; +/** + * List of single-row read operations that throw when no row is found. + */ +export const SingleRowOrThrowOperations = ['findUniqueOrThrow', 'findFirstOrThrow'] as const; + +/** + * List of single-row read operations that throw when no row is found. + */ +export type SingleRowOrThrowOperations = (typeof SingleRowOrThrowOperations)[number]; + /** * List of all write operations - simply an alias of CoreWriteOperations. */ @@ -281,6 +292,7 @@ export abstract class BaseOperationHandler { kysely: AnyKysely, model: string, args: FindArgs, any, true> | undefined, + direct = false, ): Promise { // table let query = this.dialect.buildSelectModel(model, model); @@ -306,20 +318,40 @@ export abstract class BaseOperationHandler { query = query.modifyEnd(this.makeContextComment({ model, operation: 'read' })); - let result: any[] = []; const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); + + let result: any[] = []; try { - const r = await kysely.getExecutor().executeQuery(compiled); - result = r.rows; + if (direct) { + // Bypass onKyselyQuery interceptors (e.g. policy plugin) so read-denied rows + // are still reachable. Uses the outer executor for connection acquisition so + // the query runs within an active transaction when applicable. + const zenExecutor = (this.client as any).kyselyProps.executor as ZenStackQueryExecutor; + const r = await kysely + .getExecutor() + .provideConnection((connection) => zenExecutor.executeQueryDirect(compiled, connection)); + result = r.rows; + } else { + const r = await kysely.getExecutor().executeQuery(compiled); + result = r.rows; + } } catch (err) { + // Re-throw ORMErrors (e.g. policy violations with custom error codes) as-is + // to avoid wrapping them in a generic DBQueryError and losing their type/code. + if (err instanceof ORMError) throw err; throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); } return result; } - protected async readUnique(kysely: AnyKysely, model: string, args: FindArgs, any, true>) { - const result = await this.read(kysely, model, { ...args, take: 1 }); + protected async readUnique( + kysely: AnyKysely, + model: string, + args: FindArgs, any, true>, + direct = false, + ) { + const result = await this.read(kysely, model, { ...args, take: 1 }, direct); return result[0] ?? null; } @@ -1182,11 +1214,16 @@ export abstract class BaseOperationHandler { } } + // For non-RETURNING dialects that require it (e.g. MySQL), the pre-load SELECT must + // bypass the read policy so that read-denied rows are still reachable and the UPDATE + // can run, allowing its own policy error codes to be surfaced. + const bypassReadPolicyForPreload = !this.dialect.supportsReturning && !fromRelation; + // lazily load the entity to be updated let thisEntity: any; const loadThisEntity = async () => { if (thisEntity === undefined) { - thisEntity = (await this.getEntityIds(kysely, model, origWhere)) ?? null; + thisEntity = (await this.getEntityIds(kysely, model, origWhere, bypassReadPolicyForPreload)) ?? null; if (!thisEntity && throwIfNotFound) { throw createNotFoundError(model); } @@ -2517,11 +2554,16 @@ export abstract class BaseOperationHandler { } // Given a unique filter of a model, load the entity and return its id fields - private getEntityIds(kysely: AnyKysely, model: string, uniqueFilter: any) { - return this.readUnique(kysely, model, { - where: uniqueFilter, - select: this.makeIdSelect(model), - }); + private getEntityIds(kysely: AnyKysely, model: string, uniqueFilter: any, direct = false) { + return this.readUnique( + kysely, + model, + { + where: uniqueFilter, + select: this.makeIdSelect(model), + }, + direct, + ); } // Given multiple unique filters, load all matching entities and return their id fields in one query diff --git a/packages/orm/src/client/errors.ts b/packages/orm/src/client/errors.ts index 9908a6b21..aed8066e8 100644 --- a/packages/orm/src/client/errors.ts +++ b/packages/orm/src/client/errors.ts @@ -92,6 +92,16 @@ export class ORMError extends Error { */ public rejectedByPolicyReason?: RejectedByPolicyReason; + /** + * Custom error codes from every policy rule that contributed to this rejection. + * Set via the optional third argument of `@@allow` / `@@deny`. Only available when + * `reason` is `REJECTED_BY_POLICY` and at least one matching rule carries a code. + * Note: surfaced for `create`, `post-update`, `update`, `delete`, and single-row `read` + * violations. For `read`, only `findFirst`/`findUnique`-equivalent queries (LIMIT 1) + * where a denied row exists will throw; `findMany` uses filter-based enforcement. + */ + public policyCodes?: string[]; + /** * The SQL query that was executed. Only available when `reason` is `DB_QUERY_ERROR`. */ diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index ed4f6f6b1..1f946a77b 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -86,6 +86,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { private readonly connectionProvider: ConnectionProvider, plugins: KyselyPlugin[] = [], private suppressMutationHooks: boolean = false, + private readonly queryContext: Map = new Map(), ) { super(compiler, adapter, connectionProvider, plugins); @@ -214,6 +215,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { schema: this.client.$schema, query, proceed: _p, + queryContext: this.queryContext, }); return hookResult; }; @@ -673,6 +675,16 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie } } + /** + * Execute a compiled query on `connection`, bypassing all `onKyselyQuery` plugin interceptors. + */ + async executeQueryDirect( + compiledQuery: CompiledQuery, + connection: DatabaseConnection, + ): Promise> { + return this.internalExecuteQuery(compiledQuery.query, connection, compiledQuery.queryId); + } + private async internalExecuteQuery( query: RootOperationNode, connection: DatabaseConnection, @@ -770,6 +782,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [...this.plugins, plugin], this.suppressMutationHooks, + this.queryContext, ); } @@ -782,6 +795,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [...this.plugins, ...plugins], this.suppressMutationHooks, + this.queryContext, ); } @@ -794,6 +808,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [plugin, ...this.plugins], this.suppressMutationHooks, + this.queryContext, ); } @@ -806,6 +821,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [], this.suppressMutationHooks, + this.queryContext, ); } @@ -818,11 +834,33 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie connectionProvider, this.plugins as KyselyPlugin[], this.suppressMutationHooks, + this.queryContext, ); // replace client with a new one associated with the new executor newExecutor.client = this.client.withExecutor(newExecutor); return newExecutor; } + /** + * Create a new executor carrying the given per-operation query context. + * Called once per top-level ORM operation so that onQuery plugins can write + * values (e.g. `operation`, `fetchPolicyCodes`) that onKyselyQuery plugins read — + * without AsyncLocalStorage. + */ + withQueryContext(queryContext: Map): ZenStackQueryExecutor { + const newExecutor = new ZenStackQueryExecutor( + this.client, + this.driver, + this.compiler, + this.adapter, + this.connectionProvider, + this.plugins as KyselyPlugin[], + this.suppressMutationHooks, + queryContext, + ); + newExecutor.client = this.client.withExecutor(newExecutor); + return newExecutor; + } + // #endregion } diff --git a/packages/orm/src/client/index.ts b/packages/orm/src/client/index.ts index 7414ae1fe..56af38272 100644 --- a/packages/orm/src/client/index.ts +++ b/packages/orm/src/client/index.ts @@ -13,6 +13,7 @@ export { CoreReadOperations, CoreUpdateOperations, CoreWriteOperations, + SingleRowOrThrowOperations, } from './crud/operations/base'; export { InputValidator } from './crud/validator'; export { ORMError, ORMErrorReason, RejectedByPolicyReason } from './errors'; diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 2a2431637..5130c9433 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -280,6 +280,13 @@ type OnQueryHookContext = { * The ZenStack client that is performing the operation. */ client: ClientContract; + + /** + * Per-operation mutable context shared between onQuery and onKyselyQuery hooks. + * Plugins may write values here in onQuery and read them in onKyselyQuery, avoiding + * the need for AsyncLocalStorage to bridge these two decoupled call sites. + */ + queryContext: Map; }; // #endregion @@ -390,6 +397,7 @@ export type OnKyselyQueryArgs = { client: ClientContract; query: RootOperationNode; proceed: ProceedKyselyQueryFunction; + queryContext: Map; }; export type ProceedKyselyQueryFunction = (query: RootOperationNode) => Promise>; diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index d0425fd04..a93b58dd5 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -48,13 +48,15 @@ "dependencies": { "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/orm": "workspace:*", - "ts-pattern": "catalog:" + "ts-pattern": "catalog:", + "zod": "catalog:" }, "peerDependencies": { "kysely": "catalog:" }, "devDependencies": { "@types/better-sqlite3": "catalog:", + "@types/node": "catalog:", "@types/pg": "^8.0.0", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/tsdown-config": "workspace:*", diff --git a/packages/plugins/policy/plugin.zmodel b/packages/plugins/policy/plugin.zmodel index e9f871725..74abe46b3 100644 --- a/packages/plugins/policy/plugin.zmodel +++ b/packages/plugins/policy/plugin.zmodel @@ -3,8 +3,11 @@ * * @param operation: comma-separated list of "create", "read", "update", "post-update", "delete". Use "all" to denote all operations. * @param condition: a boolean expression that controls if the operation should be allowed. + * @param errorCode: an optional code attached to the error thrown when this policy rejects an operation. + * Accepts a string literal or an enum value. Only surfaced for "create" and "post-update" violations + * (other operations use filter-based enforcement). */ -attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean) +attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean, _ errorCode: Any?) /** * Defines an access policy that allows the annotated field to be read or updated. @@ -20,8 +23,11 @@ attribute @allow(_ operation: String @@@completionHint(["'read'", "'update'", "' * * @param operation: comma-separated list of "create", "read", "update", "post-update", "delete". Use "all" to denote all operations. * @param condition: a boolean expression that controls if the operation should be denied. + * @param errorCode: an optional code attached to the error thrown when this policy rejects an operation. + * Accepts a string literal or an enum value. Only surfaced for "create" and "post-update" violations + * (other operations use filter-based enforcement). */ -attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean) +attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean, _ errorCode: Any?) /** * Defines an access policy that denies the annotated field to be read or updated. diff --git a/packages/plugins/policy/src/options.ts b/packages/plugins/policy/src/options.ts index 7858b4e72..b078e7fd3 100644 --- a/packages/plugins/policy/src/options.ts +++ b/packages/plugins/policy/src/options.ts @@ -5,4 +5,11 @@ export type PolicyPluginOptions = { * not inspect or reject them. */ dangerouslyAllowRawSql?: boolean; + + /** + * Whether to run the diagnostic query to determine which policy rule was violated when + * a write is rejected. Defaults to `true`. Set to `false` to skip it globally for + * performance. Can be overridden per-query with the `fetchPolicyCodes` option. + */ + fetchPolicyCodes?: boolean; }; diff --git a/packages/plugins/policy/src/plugin.ts b/packages/plugins/policy/src/plugin.ts index 61bf7b73a..10bb3c71a 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -1,12 +1,22 @@ import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; -import type { PolicyPluginOptions } from './options'; +import { z } from 'zod'; import { check } from './functions'; +import type { PolicyPluginOptions } from './options'; import { PolicyHandler } from './policy-handler'; export type { PolicyPluginOptions } from './options'; -export class PolicyPlugin implements RuntimePlugin { +type PolicyExtQueryArgs = { + $read: Pick; + $create: Pick; + $update: Pick; + $delete: Pick; +}; + +const fetchPolicyCodesSchema = z.object({ fetchPolicyCodes: z.boolean().optional() }); + +export class PolicyPlugin implements RuntimePlugin { constructor(private readonly options: PolicyPluginOptions = {}) {} get id() { @@ -27,8 +37,35 @@ export class PolicyPlugin implements RuntimePlugin { }; } - onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs) { - const handler = new PolicyHandler(client, this.options); + readonly queryArgs = { + $read: fetchPolicyCodesSchema, + $create: fetchPolicyCodesSchema, + $update: fetchPolicyCodesSchema, + $delete: fetchPolicyCodesSchema, + }; + + onQuery(ctx: { + operation: string; + args: Record | undefined; + proceed: (args: Record | undefined) => Promise; + queryContext: Map; + [key: string]: unknown; + }) { + ctx.queryContext.set('policy:operation', ctx.operation); + const fetchPolicyCodes = ctx.args?.['fetchPolicyCodes'] as boolean | undefined; + if (fetchPolicyCodes !== undefined) { + ctx.queryContext.set('policy:fetchPolicyCodes', fetchPolicyCodes); + } + return ctx.proceed(ctx.args); + } + + onKyselyQuery({ query, client, proceed, queryContext }: OnKyselyQueryArgs) { + const fetchPolicyCodes = queryContext.get('policy:fetchPolicyCodes') as boolean | undefined; + const effectiveOptions: PolicyPluginOptions = + fetchPolicyCodes !== undefined + ? { ...this.options, fetchPolicyCodes } + : this.options; + const handler = new PolicyHandler(client, effectiveOptions, queryContext); return handler.handle(query, proceed); } } diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 93c6c334a..89cc5c63b 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -1,6 +1,12 @@ import { invariant } from '@zenstackhq/common-helpers'; import type { BaseCrudDialect, ClientContract, CRUD_EXT, ProceedKyselyQueryFunction } from '@zenstackhq/orm'; -import { getCrudDialect, QueryUtils, RejectedByPolicyReason, SchemaUtils } from '@zenstackhq/orm'; +import { + getCrudDialect, + QueryUtils, + RejectedByPolicyReason, + SchemaUtils, + SingleRowOrThrowOperations, +} from '@zenstackhq/orm'; import { ExpressionUtils, type BuiltinType, @@ -59,6 +65,8 @@ import { trueNode, } from './utils'; +const SINGLE_ROW_OR_THROW_OPERATIONS = new Set(SingleRowOrThrowOperations); + export type CrudQueryNode = SelectQueryNode | InsertQueryNode | UpdateQueryNode | DeleteQueryNode; export type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryNode; @@ -72,6 +80,7 @@ export class PolicyHandler extends OperationNodeTransf constructor( private readonly client: ClientContract, private readonly options: PolicyPluginOptions = {}, + private readonly queryContext: Map = new Map(), ) { super(); this.dialect = getCrudDialect(this.client.$schema, this.client.$options); @@ -93,8 +102,18 @@ export class PolicyHandler extends OperationNodeTransf } if (!this.isMutationQueryNode(node)) { - // transform and proceed with read directly - return proceed(this.transformNode(node)); + const selectNode = node as SelectQueryNode; + const result = await proceed(this.transformNode(node)); + // When 0 rows returned on a throwing single-row read (findFirstOrThrow/findUniqueOrThrow), distinguish "not found" from policy denial + if ( + result.rows.length === 0 && + SINGLE_ROW_OR_THROW_OPERATIONS.has( + (this.queryContext.get('policy:operation') as string | undefined) ?? '', + ) + ) { + await this.postReadZeroRowsCheck(selectNode, proceed); + } + return result; } const { mutationModel } = this.getMutationModel(node); @@ -138,6 +157,16 @@ export class PolicyHandler extends OperationNodeTransf // #region Post mutation work + // When 0 rows affected, distinguish "row not found" from "row denied by policy" + // Use > 0 negation (not === 0) because numAffectedRows is BigInt in some drivers + if (!((result.numAffectedRows ?? 0) > 0)) { + if (DeleteQueryNode.is(node)) { + await this.postZeroRowsCheck(mutationModel, 'delete', node.where?.where, proceed); + } else if (UpdateQueryNode.is(node)) { + await this.postZeroRowsCheck(mutationModel, 'update', node.where?.where, proceed); + } + } + if ((result.numAffectedRows ?? 0) > 0 && needsPostUpdateCheck) { await this.postUpdateCheck(mutationModel, beforeUpdateInfo, result, proceed); } @@ -175,7 +204,16 @@ export class PolicyHandler extends OperationNodeTransf if (constCondition === true) { needCheckPreCreate = false; } else if (constCondition === false) { - throw createRejectedByPolicyError(mutationModel, RejectedByPolicyReason.NO_ACCESS); + const policies = this.getModelPolicies(mutationModel, 'create'); + const constantDenyCodes = policies + .filter((p) => p.kind === 'deny' && this.isTrueExpr(p.condition) && p.code) + .map((p) => p.code!); + throw createRejectedByPolicyError( + mutationModel, + RejectedByPolicyReason.NO_ACCESS, + undefined, + constantDenyCodes, + ); } } @@ -240,6 +278,70 @@ export class PolicyHandler extends OperationNodeTransf } } + private async postReadZeroRowsCheck(node: SelectQueryNode, proceed: ProceedKyselyQueryFunction): Promise { + if (!node.from || node.from.froms.length !== 1) return; + const extractedTable = this.extractTableName(node.from.froms[0]!); + if (!extractedTable) return; + const { model } = extractedTable; + if (!QueryUtils.getModel(this.client.$schema, model)) return; + return this.postZeroRowsCheck(model, 'read', node.where?.where, proceed); + } + + // Checks if any row matching WHERE exists without the policy filter. + // If a row exists but was filtered by policy → throws REJECTED_BY_POLICY with codes. + // If no row matches → returns silently. + private async postZeroRowsCheck( + model: string, + operation: 'read' | 'update' | 'delete', + whereCondition: OperationNode | undefined, + proceed: ProceedKyselyQueryFunction, + ) { + if (this.isManyToManyJoinTable(model)) return; + if (this.tryGetConstantPolicy(model, operation) === true) return; + if (this.options.fetchPolicyCodes === false) return; + const policiesWithCode = this.getModelPolicies(model, operation).filter((p) => p.code); + if (policiesWithCode.length === 0) return; + + // No WHERE clause means "match all rows" — use a literal TRUE so the existence sub-query is valid SQL. + const where = whereCondition ?? trueNode(this.dialect); + + const rowExistsInner = this.eb + .selectFrom(model) + .select(this.eb.lit(1).as('_')) + .where(() => new ExpressionWrapper(where)); + + const codeSelections = policiesWithCode.map((policy, i) => { + const condition = this.compilePolicyCondition(model, undefined, operation, policy); + const violationCondition = policy.kind === 'allow' ? logicalNot(this.dialect, condition) : condition; + const inner = this.eb + .selectFrom(model) + .select(this.eb.lit(1).as('_')) + .where(() => new ExpressionWrapper(conjunction(this.dialect, [where, violationCondition]))); + return SelectionNode.create( + AliasNode.create(this.eb.exists(inner).toOperationNode(), IdentifierNode.create(`$c${i}`)), + ); + }); + + const result = await proceed({ + kind: 'SelectQueryNode', + selections: [ + SelectionNode.create( + AliasNode.create( + this.eb.exists(rowExistsInner).toOperationNode(), + IdentifierNode.create('$exists'), + ), + ), + ...codeSelections, + ], + } satisfies SelectQueryNode); + + const row = result.rows[0] ?? {}; + if (!row.$exists) return; + + const policyCodes = policiesWithCode.filter((_, i) => row[`$c${i}`]).map((p) => p.code!); + throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS, undefined, policyCodes); + } + private async postUpdateCheck( model: string, beforeUpdateInfo: Awaited>, @@ -331,10 +433,15 @@ export class PolicyHandler extends OperationNodeTransf const postUpdateResult = await proceed(postUpdateQuery.toOperationNode()); if (!postUpdateResult.rows[0]?.$condition) { + const policyCodes = + this.options.fetchPolicyCodes !== false + ? await this.findViolatingPostUpdatePolicyCodes(model, idConditions, beforeUpdateInfo, proceed) + : undefined; throw createRejectedByPolicyError( model, RejectedByPolicyReason.NO_ACCESS, 'some or all updated rows failed to pass post-update policy check', + policyCodes, ); } } @@ -950,7 +1057,11 @@ export class PolicyHandler extends OperationNodeTransf } satisfies SelectQueryNode, ); if (!result.rows[0]?.$condition) { - throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS); + const policyCodes = + this.options.fetchPolicyCodes !== false + ? await this.findViolatingCreatePolicyCodes(model, valuesTable, proceed) + : undefined; + throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS, undefined, policyCodes); } } @@ -1047,6 +1158,98 @@ export class PolicyHandler extends OperationNodeTransf return ExpressionUtils.isLiteral(expr) && expr.value === true; } + private async findViolatingCreatePolicyCodes( + model: string, + valuesTable: ReturnType['buildValuesTableSelect']>, + proceed: ProceedKyselyQueryFunction, + ): Promise { + const policiesWithCode = this.getModelPolicies(model, 'create').filter((p) => p.code); + if (policiesWithCode.length === 0) { + return []; + } + + const selections = policiesWithCode.map((policy, i) => { + const condition = this.compilePolicyCondition(model, undefined, 'create', policy); + // For allow rules, negate: EXISTS(NOT condition) = true when any proposed row violates allow. + // For deny rules, keep as-is: EXISTS(condition) = true when deny fires. + const existsCondition = policy.kind === 'allow' ? logicalNot(this.dialect, condition) : condition; + const inner = this.eb + .selectFrom(valuesTable.as(model)) + .select(this.eb.lit(1).as('_')) + .where(() => new ExpressionWrapper(existsCondition)); + return SelectionNode.create( + AliasNode.create(this.eb.exists(inner).toOperationNode(), IdentifierNode.create(`$c${i}`)), + ); + }); + + return this.evaluatePolicyDiagnostics(policiesWithCode, selections, proceed); + } + + private async findViolatingPostUpdatePolicyCodes( + model: string, + idConditions: OperationNode, + beforeUpdateInfo: Awaited>, + proceed: ProceedKyselyQueryFunction, + ): Promise { + const policiesWithCode = this.getModelPolicies(model, 'post-update').filter((p) => p.code); + if (policiesWithCode.length === 0) { + return []; + } + + const needsBeforeUpdateJoin = !!beforeUpdateInfo?.fields; + let beforeUpdateTable: SelectQueryNode | undefined; + if (needsBeforeUpdateJoin) { + const fieldDefs = beforeUpdateInfo!.fields!.map((name) => + QueryUtils.requireField(this.client.$schema, model, name), + ); + const rows = beforeUpdateInfo!.rows.map((r) => beforeUpdateInfo!.fields!.map((f) => r[f])); + beforeUpdateTable = this.dialect.buildValuesTableSelect(fieldDefs, rows).toOperationNode(); + } + + const eb = expressionBuilder(); + + const buildInnerExists = (condition: OperationNode) => { + const inner = eb + .selectFrom(model) + .select(eb.lit(1).as('_')) + .where(() => new ExpressionWrapper(conjunction(this.dialect, [idConditions, condition]))) + .$if(needsBeforeUpdateJoin, (qb) => + qb.leftJoin( + () => new ExpressionWrapper(beforeUpdateTable!).as('$before'), + (join) => { + const idFields = QueryUtils.requireIdFields(this.client.$schema, model); + return idFields.reduce((acc, f) => acc.onRef(`${model}.${f}`, '=', `$before.${f}`), join); + }, + ), + ); + return eb.exists(inner).toOperationNode(); + }; + + const selections = policiesWithCode.map((policy, i) => { + const condition = this.compilePolicyCondition(model, undefined, 'post-update', policy); + // For allow rules, negate: EXISTS(NOT condition) = true when any updated row violates allow. + // For deny rules, keep as-is: EXISTS(condition) = true when deny fires. + const existsCondition = policy.kind === 'allow' ? logicalNot(this.dialect, condition) : condition; + return SelectionNode.create( + AliasNode.create(buildInnerExists(existsCondition), IdentifierNode.create(`$c${i}`)), + ); + }); + + return this.evaluatePolicyDiagnostics(policiesWithCode, selections, proceed); + } + + // Single diagnostic query: one EXISTS column per coded policy. + // EXISTS=true means a violation: deny condition fired, or allow condition wasn't met (negated in caller). + private async evaluatePolicyDiagnostics( + policiesWithCode: Policy[], + selections: SelectionNode[], + proceed: ProceedKyselyQueryFunction, + ): Promise { + const result = await proceed({ kind: 'SelectQueryNode', selections } satisfies SelectQueryNode); + const row = result.rows[0] ?? {}; + return policiesWithCode.filter((_, i) => row[`$c${i}`]).map((p) => p.code!); + } + private async processReadBack(node: CrudQueryNode, result: QueryResult, proceed: ProceedKyselyQueryFunction) { if (result.rows.length === 0) { return result; @@ -1240,14 +1443,18 @@ export class PolicyHandler extends OperationNodeTransf result.push( ...modelDef.attributes .filter((attr) => attr.name === '@@allow' || attr.name === '@@deny') - .map( - (attr) => - ({ - kind: attr.name === '@@allow' ? 'allow' : 'deny', - operations: extractOperations(attr.args![0]!.value), - condition: attr.args![1]!.value, - }) as const, - ) + .map((attr) => { + const codeExpr = attr.args?.[2]?.value; + return { + kind: attr.name === '@@allow' ? 'allow' : 'deny', + operations: extractOperations(attr.args![0]!.value), + condition: attr.args![1]!.value, + code: + ExpressionUtils.isLiteral(codeExpr) && typeof codeExpr.value === 'string' + ? codeExpr.value + : undefined, + } as const; + }) .filter( (policy) => (operation !== 'post-update' && policy.operations.includes('all')) || @@ -1275,14 +1482,18 @@ export class PolicyHandler extends OperationNodeTransf result.push( ...fieldDef.attributes .filter((attr) => attr.name === '@allow' || attr.name === '@deny') - .map( - (attr) => - ({ - kind: attr.name === '@allow' ? 'allow' : 'deny', - operations: extractOperations(attr.args![0]!.value), - condition: attr.args![1]!.value, - }) as const, - ) + .map((attr) => { + const codeExpr = attr.args?.[2]?.value; + return { + kind: attr.name === '@allow' ? 'allow' : 'deny', + operations: extractOperations(attr.args![0]!.value), + condition: attr.args![1]!.value, + code: + ExpressionUtils.isLiteral(codeExpr) && typeof codeExpr.value === 'string' + ? codeExpr.value + : undefined, + } as const; + }) .filter((policy) => policy.operations.includes('all') || policy.operations.includes(operation)), ); } diff --git a/packages/plugins/policy/src/types.ts b/packages/plugins/policy/src/types.ts index 8f2f635bf..0acf6b9b8 100644 --- a/packages/plugins/policy/src/types.ts +++ b/packages/plugins/policy/src/types.ts @@ -18,6 +18,7 @@ export type Policy = { kind: PolicyKind; operations: readonly PolicyOperation[]; condition: Expression; + code?: string; }; /** diff --git a/packages/plugins/policy/src/utils.ts b/packages/plugins/policy/src/utils.ts index 5deef04bd..4a39dd628 100644 --- a/packages/plugins/policy/src/utils.ts +++ b/packages/plugins/policy/src/utils.ts @@ -181,10 +181,12 @@ export function createRejectedByPolicyError( model: string | undefined, reason: RejectedByPolicyReason, message?: string, + policyCodes?: string[], ) { const err = new ORMError(ORMErrorReason.REJECTED_BY_POLICY, message ?? 'operation is rejected by access policies'); err.rejectedByPolicyReason = reason; err.model = model; + err.policyCodes = policyCodes?.length ? policyCodes : undefined; return err; } diff --git a/packages/plugins/policy/tsconfig.json b/packages/plugins/policy/tsconfig.json index 41472d086..e1cce42bb 100644 --- a/packages/plugins/policy/tsconfig.json +++ b/packages/plugins/policy/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "types": ["node"] + }, "include": ["src/**/*"] } diff --git a/packages/testtools/src/types.d.ts b/packages/testtools/src/types.d.ts index 9f58106f0..083fe4ff3 100644 --- a/packages/testtools/src/types.d.ts +++ b/packages/testtools/src/types.d.ts @@ -6,7 +6,7 @@ interface CustomMatchers { toResolveNull: () => Promise; toResolveWithLength: (length: number) => Promise; toBeRejectedNotFound: () => Promise; - toBeRejectedByPolicy: (expectedMessages?: string[]) => Promise; + toBeRejectedByPolicy: (expectedMessages?: string[], expectedCodes?: string[]) => Promise; toBeRejectedByValidation: (expectedMessages?: string[]) => Promise; } diff --git a/packages/testtools/src/vitest-ext.ts b/packages/testtools/src/vitest-ext.ts index 64d5684f6..0277ae618 100644 --- a/packages/testtools/src/vitest-ext.ts +++ b/packages/testtools/src/vitest-ext.ts @@ -88,17 +88,31 @@ expect.extend({ }; }, - async toBeRejectedByPolicy(received: Promise, expectedMessages?: string[]) { + async toBeRejectedByPolicy(received: Promise, expectedMessages?: string[], expectedCodes?: string[]) { if (!isPromise(received)) { return { message: () => 'a promise is expected', pass: false }; } try { await received; } catch (err) { - if (expectedMessages && err instanceof ORMError && err.reason === ORMErrorReason.REJECTED_BY_POLICY) { - const r = expectErrorMessages(expectedMessages, err.message || ''); - if (r) { - return r; + if (err instanceof ORMError && err.reason === ORMErrorReason.REJECTED_BY_POLICY) { + if (expectedMessages) { + const r = expectErrorMessages(expectedMessages, err.message || ''); + if (r) { + return r; + } + } + if (expectedCodes) { + const actualCodes = err.policyCodes ?? []; + const missing = expectedCodes.filter((c) => !actualCodes.includes(c)); + const extra = actualCodes.filter((c) => !expectedCodes.includes(c)); + if (missing.length > 0 || extra.length > 0) { + return { + message: () => + `expected policy codes [${expectedCodes.join(', ')}], got [${actualCodes.join(', ') || '(none)'}]`, + pass: false, + }; + } } } return expectErrorReason(err, ORMErrorReason.REJECTED_BY_POLICY); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 360915de0..ec3510dc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,10 +681,16 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.7.1 + zod: + specifier: 'catalog:' + version: 4.1.12 devDependencies: '@types/better-sqlite3': specifier: 'catalog:' version: 7.6.13 + '@types/node': + specifier: 'catalog:' + version: 20.19.24 '@types/pg': specifier: ^8.0.0 version: 8.11.11 @@ -954,6 +960,9 @@ importers: '@zenstackhq/orm': specifier: workspace:* version: link:../../packages/orm + '@zenstackhq/plugin-policy': + specifier: workspace:* + version: link:../../packages/plugins/policy '@zenstackhq/schema': specifier: workspace:* version: link:../../packages/schema diff --git a/samples/next.js/package.json b/samples/next.js/package.json index 1a58b53c2..a1152bd3a 100644 --- a/samples/next.js/package.json +++ b/samples/next.js/package.json @@ -11,8 +11,9 @@ }, "dependencies": { "@tanstack/react-query": "catalog:", - "@zenstackhq/schema": "workspace:*", "@zenstackhq/orm": "workspace:*", + "@zenstackhq/plugin-policy": "workspace:*", + "@zenstackhq/schema": "workspace:*", "@zenstackhq/server": "workspace:*", "@zenstackhq/tanstack-query": "workspace:*", "better-sqlite3": "catalog:", diff --git a/samples/shared/schema.zmodel b/samples/shared/schema.zmodel index a0d25d8a8..66a65d6fd 100644 --- a/samples/shared/schema.zmodel +++ b/samples/shared/schema.zmodel @@ -3,6 +3,10 @@ datasource db { url = 'file:./dev.db' } +plugin policy { + provider = '@zenstackhq/plugin-policy' +} + /// User model model User { id String @id @default(cuid()) diff --git a/tests/e2e/orm/policy/crud/delete.test.ts b/tests/e2e/orm/policy/crud/delete.test.ts index 1572d521a..a6d85f208 100644 --- a/tests/e2e/orm/policy/crud/delete.test.ts +++ b/tests/e2e/orm/policy/crud/delete.test.ts @@ -48,4 +48,24 @@ model Foo { await expect(db.$qb.deleteFrom('Foo').executeTakeFirst()).resolves.toMatchObject({ numDeletedRows: 1n }); await expect(db.foo.count()).resolves.toBe(1); }); + + it('does not throw for nonexistent row', async () => { + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id + x Int + @@allow('create,read', true) + @@allow('delete', x > 0) +} +`, + ); + await db.foo.create({ data: { id: 1, x: 1 } }); + + // nonexistent row — row does not exist at all, so postModelLevelCheck must NOT throw + await expect(db.$qb.deleteFrom('Foo').where('id', '=', 999).executeTakeFirst()).resolves.toMatchObject({ + numDeletedRows: 0n, + }); + await expect(db.foo.count()).resolves.toBe(1); + }); }); diff --git a/tests/e2e/orm/policy/crud/error-codes.test.ts b/tests/e2e/orm/policy/crud/error-codes.test.ts new file mode 100644 index 000000000..2dfcd19a7 --- /dev/null +++ b/tests/e2e/orm/policy/crud/error-codes.test.ts @@ -0,0 +1,871 @@ +import { ORMError } from '@zenstackhq/orm'; +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Policy error code tests', () => { + // ┌─────────────────────────────────────────┬────────┬─────────────┬────────┬────────┬────────┐ + // │ Scenario │ create │ post-update │ update │ delete │ read │ + // ├─────────────────────────────────────────┼────────┼─────────────┼────────┼────────┼────────┤ + // │ deny rule fires (string code) │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ + // │ allow rule fails (string code) │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ + // │ constant deny (true condition) │ ✓ │ │ │ │ │ + // │ no errorCode on rule │ ✓ │ │ │ │ │ + // │ policyCodes undefined when no codes │ ✓ │ │ │ │ │ + // │ code opt-in: NotFound vs RejByPol. │ │ │ ✓ │ ✓ │ │ + // │ findFirst/findUnique: always null │ │ │ │ │ ✓ │ + // │ findXOrThrow: deny/allow rule fires │ │ │ │ │ ✓ │ + // │ findXOrThrow: NOT_FOUND vs RejByPol. │ │ │ │ │ ✓ │ + // │ findMany not affected (filter-based) │ │ │ │ │ ✓ │ + // │ findMany({ take:1 }) not affected │ │ │ │ │ ✓ │ + // │ multiple deny rules fire │ ✓ │ ✓ │ │ │ │ + // │ deny + allow conflict │ ✓ │ ✓ │ │ │ │ + // │ multiple allow rules all fail │ ✓ │ ✓ │ │ │ │ + // │ batch: some rows pass, some fail │ │ ✓ │ │ │ │ + // │ complex schema (auth(), before()) │ ✓ │ ✓ │ │ │ │ + // │ enum error codes │ ✓ │ ✓ │ │ │ │ + // │ mixed enum + string codes │ ✓ │ │ │ │ │ + // │ fetchPolicyCodes: plugin false │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ + // │ fetchPolicyCodes: query false │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ + // │ fetchPolicyCodes: query overrides plugin│ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ + // └─────────────────────────────────────────┴────────┴─────────────┴────────┴────────┴────────┘ + + // ── create: single rule, single code ───────────────────────────────────── + + it('surfaces code from deny/allow rule on create violation', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', x <= 0, 'NEGATIVE_X') + @@allow('create', y > 0, 'NEED_POSITIVE_Y') + @@allow('read', true) + } + `, + ); + // deny code: x violates deny rule + await expect(db.foo.create({ data: { x: 0, y: 1 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']); + // allow code: y violates allow rule + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']); + await expect(db.foo.create({ data: { x: 1, y: 1 } })).resolves.toMatchObject({ x: 1, y: 1 }); + }); + + it('surfaces code from constant deny rule on create', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('create', true, 'ALWAYS_DENIED') + @@allow('create,read', false) + } + `, + ); + await expect(db.foo.create({ data: { x: 1 } })).toBeRejectedByPolicy(undefined, ['ALWAYS_DENIED']); + }); + + it('no code when policies carry no errorCode', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('create', x <= 0) + @@allow('create,read', true) + } + `, + ); + await expect(db.foo.create({ data: { x: 0 } })).toBeRejectedByPolicy(undefined, []); + }); + + it('policyCodes is undefined (not []) when no error codes are configured', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('create', x <= 0) + @@allow('create,read', true) + } + `, + ); + try { + await db.foo.create({ data: { x: 0 } }); + expect.fail('expected error'); + } catch (err) { + expect(err).toBeInstanceOf(ORMError); + expect((err as ORMError).policyCodes).toBeUndefined(); + } + }); + + // ── opt-in: adding a code changes error type from NotFound to RejectedByPolicy ── + + it('blocked update/delete yields NotFound without code, RejectedByPolicy with code', async () => { + const schema = (withCode: boolean) => ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create,read', true) + @@allow('update', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''}) + @@allow('delete', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''}) +} +`; + // Without error code: the ORM sees 0 affected rows and raises NotFound + const dbNoCode = await createPolicyTestClient(schema(false)); + const noCodeRow = await dbNoCode.foo.create({ data: { x: 0 } }); + await expect(dbNoCode.foo.update({ where: { id: noCodeRow.id }, data: { x: -1 } })).toBeRejectedNotFound(); + await expect(dbNoCode.foo.delete({ where: { id: noCodeRow.id } })).toBeRejectedNotFound(); + + // With error code: the plugin detects the policy block and raises RejectedByPolicy + const dbWithCode = await createPolicyTestClient(schema(true)); + const withCodeRow = await dbWithCode.foo.create({ data: { x: 0 } }); + await expect(dbWithCode.foo.update({ where: { id: withCodeRow.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_X', + ]); + await expect(dbWithCode.foo.delete({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_X']); + }); + + // ── read: single rule, single code ─────────────────────────────────────── + + it('findFirst/findUnique always return null on policy violation, never throw', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create', true) + @@allow('read', x > 0, 'NEED_POSITIVE_X') + } + `, + ); + const blocked = await db.$unuseAll().foo.create({ data: { x: 0 } }); + await expect(db.foo.findFirst({ where: { id: blocked.id } })).resolves.toBeNull(); + await expect(db.foo.findUnique({ where: { id: blocked.id } })).resolves.toBeNull(); + // happy path + const visible = await db.$unuseAll().foo.create({ data: { x: 1 } }); + await expect(db.foo.findFirst({ where: { id: visible.id } })).resolves.toMatchObject({ x: 1 }); + await expect(db.foo.findUnique({ where: { id: visible.id } })).resolves.toMatchObject({ x: 1 }); + }); + + it('surfaces code from deny/allow rule on findFirstOrThrow/findUniqueOrThrow violation', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create', true) + @@deny('read', x <= 0, 'NEGATIVE_X') + @@allow('read', y > 0, 'NEED_POSITIVE_Y') + } + `, + ); + const unprotected = db.$unuseAll(); + const zeroX = await unprotected.foo.create({ data: { x: 0, y: 1 } }); + const zeroY = await unprotected.foo.create({ data: { x: 1, y: 0 } }); + const positiveXY = await unprotected.foo.create({ data: { x: 1, y: 1 } }); + + // deny code: x <= 0 triggers deny rule + await expect(db.foo.findFirstOrThrow({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']); + await expect(db.foo.findUniqueOrThrow({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']); + // allow code: y is not > 0 so allow rule fails + await expect(db.foo.findFirstOrThrow({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']); + await expect(db.foo.findUniqueOrThrow({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']); + // happy path + await expect(db.foo.findFirstOrThrow({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + await expect(db.foo.findUniqueOrThrow({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + }); + + it('blocked findFirstOrThrow/findUniqueOrThrow yields NOT_FOUND without code, REJECTED_BY_POLICY with code', async () => { + const schema = (withCode: boolean) => ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create', true) + @@allow('read', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''}) +} +`; + // Without error code: policy filters the row silently → NOT_FOUND (orThrow always throws) + const dbNoCode = await createPolicyTestClient(schema(false)); + const noCodeRow = await dbNoCode.$unuseAll().foo.create({ data: { x: 0 } }); + await expect(dbNoCode.foo.findUniqueOrThrow({ where: { id: noCodeRow.id } })).toBeRejectedNotFound(); + await expect(dbNoCode.foo.findFirstOrThrow({ where: { id: noCodeRow.id } })).toBeRejectedNotFound(); + + // With error code: the plugin detects the policy block → REJECTED_BY_POLICY + const dbWithCode = await createPolicyTestClient(schema(true)); + const withCodeRow = await dbWithCode.$unuseAll().foo.create({ data: { x: 0 } }); + await expect(dbWithCode.foo.findUniqueOrThrow({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_X', + ]); + await expect(dbWithCode.foo.findFirstOrThrow({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_X', + ]); + }); + + it('findMany is not affected by read policy error codes (filter-based, returns empty array)', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create', true) + @@allow('read', x > 0, 'NEED_POSITIVE_X') + } + `, + ); + const unprotected = db.$unuseAll(); + await unprotected.foo.create({ data: { x: 0 } }); + await unprotected.foo.create({ data: { x: -1 } }); + // findMany still uses filter-based enforcement: denied rows are silently excluded + await expect(db.foo.findMany()).resolves.toEqual([]); + }); + + it('findMany({ take: 1 }) is not affected by read policy error codes (filter-based, returns empty array)', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create', true) + @@allow('read', x > 0, 'NEED_POSITIVE_X') + } + `, + ); + const unprotected = db.$unuseAll(); + await unprotected.foo.create({ data: { x: 0 } }); + // take:1 generates LIMIT 1 SQL but the ORM operation is still 'findMany' + // → filter-based enforcement applies, no diagnostic query, no REJECTED_BY_POLICY + await expect(db.foo.findMany({ take: 1 })).resolves.toEqual([]); + }); + + // ── post-update: single rule, single code ───────────────────────────────── + + it('surfaces code from deny/allow rule on post-update violation', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create,read,update', true) + @@deny('post-update', x <= 0, 'NEGATIVE_AFTER_UPDATE') + @@allow('post-update', y > 0, 'MUST_BE_POSITIVE_AFTER_UPDATE') + } + `, + ); + const positiveXY = await db.foo.create({ data: { x: 1, y: 1 } }); + // deny code: post-update violations carry a distinct message alongside the code + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: -1 } })).toBeRejectedByPolicy( + ['post-update policy check'], + ['NEGATIVE_AFTER_UPDATE'], + ); + // row unchanged after failed update + await expect(db.foo.findUnique({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + // allow code: y violates allow rule + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'MUST_BE_POSITIVE_AFTER_UPDATE', + ]); + // row unchanged after failed update + await expect(db.foo.findUnique({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: 2, y: 2 } })).resolves.toMatchObject({ x: 2, y: 2 }); + }); + + // ── update: single rule, single code ───────────────────────────────────── + + it('surfaces code from deny/allow rule on update violation', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create,read', true) + @@deny('update', x <= 0, 'CANNOT_UPDATE_NEGATIVE_X') + @@allow('update', y > 0, 'NEED_POSITIVE_Y_TO_UPDATE') + } + `, + ); + const negX = await db.foo.create({ data: { x: -1, y: 2 } }); + const zeroY = await db.foo.create({ data: { x: 5, y: 0 } }); + const positiveXY = await db.foo.create({ data: { x: 1, y: 1 } }); + + // deny code: current x violates deny rule + await expect(db.foo.update({ where: { id: negX.id }, data: { y: 3 } })).toBeRejectedByPolicy(undefined, [ + 'CANNOT_UPDATE_NEGATIVE_X', + ]); + // allow code: x=5 passes deny rule, but y=0 fails the allow rule + await expect(db.foo.update({ where: { id: zeroY.id }, data: { y: 1 } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_Y_TO_UPDATE', + ]); + // happy path: x=1 > 0 (deny doesn't fire), y=1 > 0 (allow passes) + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); + }); + + it('surfaces update code on pre-update violation and post-update code on post-update violation (same model)', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int @default(0) + y Int @default(0) + @@allow('create,update,read', true) + @@deny('update', x < 0, 'CANNOT_UPDATE_NEGATIVE_X') + @@deny('post-update', x > 100, 'X_TOO_LARGE_AFTER_UPDATE') + @@deny('update', y < 0, 'CANNOT_UPDATE_NEGATIVE_Y') + @@deny('post-update', y < 100, 'Y_TOO_SMALL_AFTER_UPDATE') + } + `, + ); + const negX = await db.foo.create({ data: { x: -1 } }); + const negY = await db.foo.create({ data: { y: -1 } }); + const positiveXY = await db.foo.create({ data: { x: 10, y: 1000 } }); + + // pre-update policy denies: update check fires before the write + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 50 } })).toBeRejectedByPolicy(undefined, [ + 'CANNOT_UPDATE_NEGATIVE_X', + ]); + + // post-update policy denies: update check passes (x > 0) but result violates post-update + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: 200 } })).toBeRejectedByPolicy( + ['post-update policy check'], + ['X_TOO_LARGE_AFTER_UPDATE'], + ); + + // row unchanged after both failed updates + await expect(db.foo.findUnique({ where: { id: negX.id } })).resolves.toMatchObject({ x: -1 }); + await expect(db.foo.findUnique({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 10 }); + + // happy path: passes both update and post-update policies + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: 50 } })).resolves.toMatchObject({ x: 50 }); + + // update violation fire before post-update policy denies + await expect(db.foo.update({ where: { id: negY.id }, data: { y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'CANNOT_UPDATE_NEGATIVE_Y', + ]); + }); + + // ── delete: single rule, single code ───────────────────────────────────── + + it('surfaces code from deny/allow rule on delete violation', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create,read,update', true) + @@deny('delete', x <= 0, 'CANNOT_DELETE_NEGATIVE_X') + @@allow('delete', y > 0, 'NEED_POSITIVE_Y_TO_DELETE') + } + `, + ); + const negX = await db.foo.create({ data: { x: -1, y: 2 } }); + const zeroY = await db.foo.create({ data: { x: 5, y: 0 } }); + const positiveXY = await db.foo.create({ data: { x: 1, y: 1 } }); + + // deny code: x <= 0 triggers deny rule + await expect(db.foo.delete({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, [ + 'CANNOT_DELETE_NEGATIVE_X', + ]); + // allow code: y is not > 0 so allow rule fails + await expect(db.foo.delete({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_Y_TO_DELETE', + ]); + // happy path + await expect(db.foo.delete({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + }); + + // ── multiple codes simultaneously ───────────────────────────────────────── + + it('returns all codes when multiple deny rules fire simultaneously', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', x < 0, 'NEGATIVE_X') + @@deny('create', y < 0, 'NEGATIVE_Y') + @@allow('create,read,update', true) + @@deny('post-update', x < 0, 'NEGATIVE_X_AFTER_UPDATE') + @@deny('post-update', y < 0, 'NEGATIVE_Y_AFTER_UPDATE') + } + `, + ); + // create: both deny rules fire → both codes + await expect(db.foo.create({ data: { x: -1, y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X', + 'NEGATIVE_Y', + ]); + // create: only one fires → only its code + await expect(db.foo.create({ data: { x: -1, y: 1 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']); + const positiveXY = await db.foo.create({ data: { x: 1, y: 1 } }); + // post-update: both deny rules fire → both codes + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: -1, y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_AFTER_UPDATE', + 'NEGATIVE_Y_AFTER_UPDATE', + ]); + // row unchanged after failed update + await expect(db.foo.findUnique({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 }); + // post-update: only one fires → only its code + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: -1, y: 1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_AFTER_UPDATE', + ]); + await expect(db.foo.update({ where: { id: positiveXY.id }, data: { x: 2, y: 2 } })).resolves.toMatchObject({ + x: 2, + y: 2, + }); + }); + + it('returns codes from both deny and allow rules when they conflict simultaneously', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', x < 0, 'NEGATIVE_X') + @@allow('create', x > 10, 'NEED_LARGE_X') + @@allow('read,update', true) + @@deny('post-update', x < 0, 'NEGATIVE_X_AFTER_UPDATE') + @@allow('post-update', y > 0, 'MUST_BE_POSITIVE_Y_AFTER_UPDATE') + } + `, + ); + // create: deny fires AND allow fails → both codes + await expect(db.foo.create({ data: { x: -1, y: 1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X', + 'NEED_LARGE_X', + ]); + // create: deny doesn't fire but allow still fails → only allow code + await expect(db.foo.create({ data: { x: 5, y: 1 } })).toBeRejectedByPolicy(undefined, ['NEED_LARGE_X']); + const largeX = await db.foo.create({ data: { x: 15, y: 1 } }); + // post-update: deny fires AND allow fails → both codes + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: -1, y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_AFTER_UPDATE', + 'MUST_BE_POSITIVE_Y_AFTER_UPDATE', + ]); + // row unchanged + await expect(db.foo.findUnique({ where: { id: largeX.id } })).resolves.toMatchObject({ x: 15, y: 1 }); + // post-update: deny doesn't fire but allow fails → only allow code + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: 1, y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'MUST_BE_POSITIVE_Y_AFTER_UPDATE', + ]); + // post-update: deny fires but allow passes → only deny code + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: -1, y: 1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_AFTER_UPDATE', + ]); + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: 2, y: 2 } })).resolves.toMatchObject({ + x: 2, + y: 2, + }); + }); + + it('returns all codes when multiple allow rules all fail simultaneously', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create', x > 10, 'NEED_LARGE_X') + @@allow('create', y > 10, 'NEED_LARGE_Y') + @@allow('read,update', true) + @@allow('post-update', x > 0, 'NEED_POSITIVE_X_AFTER_UPDATE') + @@allow('post-update', y > 0, 'NEED_POSITIVE_Y_AFTER_UPDATE') + } + `, + ); + // create: OR semantics — neither condition met → both codes + await expect(db.foo.create({ data: { x: 5, y: 5 } })).toBeRejectedByPolicy(undefined, [ + 'NEED_LARGE_X', + 'NEED_LARGE_Y', + ]); + // create: OR semantics — one condition met → success + const largeX = await db.foo.create({ data: { x: 15, y: 5 } }); + // post-update: OR semantics — neither condition met → both codes + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: -1, y: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEED_POSITIVE_X_AFTER_UPDATE', + 'NEED_POSITIVE_Y_AFTER_UPDATE', + ]); + // row unchanged + await expect(db.foo.findUnique({ where: { id: largeX.id } })).resolves.toMatchObject({ x: 15, y: 5 }); + // post-update: OR semantics — one allow passes → no error + await expect(db.foo.update({ where: { id: largeX.id }, data: { x: 2, y: -1 } })).resolves.toMatchObject({ x: 2 }); + }); + + // ── mixed batch: some rows pass, some fail ──────────────────────────────── + + it('reports allow code on batch update (updateMany) when at least one row violates the allow condition', async () => { + // Verifies that the diagnostic uses EXISTS(WHERE NOT allow_condition) rather than + // NOT EXISTS(WHERE allow_condition): even if some rows pass, any violating row surfaces the code. + const db = await createPolicyTestClient( + ` + model Foo { + id Int @id @default(autoincrement()) + x Int + threshold Int + @@allow('create,read,update', true) + @@allow('post-update', x > threshold, 'NEED_ABOVE_THRESHOLD') + } + `, + ); + await db.foo.create({ data: { x: 5, threshold: 1 } }); // row that will pass after update + await db.foo.create({ data: { x: 5, threshold: 10 } }); // row that will fail after update + // updateMany sets x=3 for all rows: + // - row 1: 3 > 1 = true → passes + // - row 2: 3 > 10 = false → fails + // batch is rejected and the allow code is surfaced despite row 1 passing + await expect(db.foo.updateMany({ data: { x: 3 } })).toBeRejectedByPolicy(undefined, ['NEED_ABOVE_THRESHOLD']); + }); + + // ── realistic scenario: auth() and before() references ─────────────────── + + it('surfaces codes in a complex schema with auth() and before() references', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + creditLimit Int + accountStatus String + @@auth + } + + model Order { + id Int @id @default(autoincrement()) + total Int + stock Int + status String @default("pending") + @@allow('create,read,update', true) + @@deny('create', total > auth().creditLimit, 'CREDIT_EXCEEDED') + @@deny('create', auth().accountStatus == 'suspended', 'ACCOUNT_SUSPENDED') + @@deny('post-update', stock < 0, 'OUT_OF_STOCK') + @@deny('post-update', before().status == 'shipped' && status != 'delivered', 'INVALID_STATUS_TRANSITION') + } + `, + ); + + const activeAuth = { creditLimit: 100, accountStatus: 'active' }; + + // single create deny: total exceeds credit limit + await expect(db.$setAuth(activeAuth).order.create({ data: { total: 200, stock: 10 } })).toBeRejectedByPolicy( + undefined, + ['CREDIT_EXCEEDED'], + ); + + // single create deny: account suspended + await expect( + db.$setAuth({ creditLimit: 1000, accountStatus: 'suspended' }).order.create({ + data: { total: 50, stock: 10 }, + }), + ).toBeRejectedByPolicy(undefined, ['ACCOUNT_SUSPENDED']); + + // both create deny rules fire simultaneously: suspended + over limit + await expect( + db.$setAuth({ creditLimit: 50, accountStatus: 'suspended' }).order.create({ + data: { total: 200, stock: 10 }, + }), + ).toBeRejectedByPolicy(undefined, ['CREDIT_EXCEEDED', 'ACCOUNT_SUSPENDED']); + + // create happy path + const orderPending = await db.$setAuth(activeAuth).order.create({ data: { total: 30, stock: 5 } }); + const orderShipped = await db.$setAuth(activeAuth).order.create({ + data: { total: 30, stock: 5, status: 'shipped' }, + }); + + // single post-update deny: stock goes negative + await expect(db.order.update({ where: { id: orderPending.id }, data: { stock: -1 } })).toBeRejectedByPolicy( + undefined, + ['OUT_OF_STOCK'], + ); + await expect(db.order.findUnique({ where: { id: orderPending.id } })).resolves.toMatchObject({ stock: 5 }); + + // single post-update deny: invalid status transition from 'shipped' + await expect( + db.order.update({ where: { id: orderShipped.id }, data: { status: 'cancelled' } }), + ).toBeRejectedByPolicy(undefined, ['INVALID_STATUS_TRANSITION']); + + // both post-update deny rules fire simultaneously + await expect( + db.order.update({ where: { id: orderShipped.id }, data: { stock: -1, status: 'cancelled' } }), + ).toBeRejectedByPolicy(undefined, ['OUT_OF_STOCK', 'INVALID_STATUS_TRANSITION']); + + // post-update happy path: valid status transition + await expect( + db.order.update({ where: { id: orderShipped.id }, data: { status: 'delivered' } }), + ).resolves.toMatchObject({ status: 'delivered' }); + }); + + // ── enum error codes ────────────────────────────────────────────────────── + + it('surfaces codes from enum values on create violation (@@deny and @@allow)', async () => { + const db = await createPolicyTestClient( + ` + enum PolicyCode { + NEGATIVE_X + NEED_POSITIVE_Y + } + + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', x <= 0, NEGATIVE_X) + @@allow('create', y > 0, NEED_POSITIVE_Y) + @@allow('read', true) + } + `, + ); + // deny code via enum + await expect(db.foo.create({ data: { x: 0, y: 1 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']); + // allow code via enum + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']); + await expect(db.foo.create({ data: { x: 1, y: 1 } })).resolves.toMatchObject({ x: 1, y: 1 }); + }); + + it('surfaces code from enum value on post-update violation', async () => { + const db = await createPolicyTestClient( + ` + enum PolicyCode { + NEGATIVE_AFTER_UPDATE + } + + model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create,read,update', true) + @@deny('post-update', x <= 0, NEGATIVE_AFTER_UPDATE) + } + `, + ); + const positiveX = await db.foo.create({ data: { x: 1 } }); + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_AFTER_UPDATE', + ]); + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); + }); + + it('mixes enum and string literal error codes', async () => { + const db = await createPolicyTestClient( + ` + enum PolicyCode { + ALWAYS_DENIED + } + + model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@allow('create,read', true) + @@deny('create', x <= 0, ALWAYS_DENIED) + @@deny('create', y <= 0, 'NEED_POSITIVE_Y') + } + `, + ); + await expect(db.foo.create({ data: { x: 0, y: 0 } })).toBeRejectedByPolicy(undefined, [ + 'ALWAYS_DENIED', + 'NEED_POSITIVE_Y', + ]); + await expect(db.foo.create({ data: { x: 0, y: 1 } })).toBeRejectedByPolicy(undefined, ['ALWAYS_DENIED']); + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']); + }); + + // ── fetchPolicyCodes opt-out: plugin-level ──────────────────────────────── + + it('plugin-level fetchPolicyCodes:false skips diagnostic query', async () => { + const db = await createTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', y <= 0, 'NEGATIVE_Y_CREATE') + @@allow('create,read', true) + @@deny('read', x <= 0, 'NEGATIVE_X_READ') + @@deny('update', x <= 0, 'NEGATIVE_X_UPDATE') + @@allow('update', x > 0) + @@deny('delete', x <= 0, 'NEGATIVE_X_DELETE') + @@allow('delete', x > 0) + @@deny('post-update', x <= 0, 'NEGATIVE_AFTER_UPDATE') + @@allow('post-update', x > 0) +} +`, + { plugins: [new PolicyPlugin({ fetchPolicyCodes: false })] }, + ); + // create: pre-create check still fires → REJECTED_BY_POLICY, but no codes (y=0 triggers deny) + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, []); + const positiveX = await db.foo.create({ data: { x: 1, y: 1 } }); + const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } }); + // read: diagnostic query skipped entirely — behaves as if no codes → null/NOT_FOUND + await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull(); + await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound(); + await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound(); + // update/delete: diagnostic query skipped entirely — behaves as if no codes → NOT_FOUND + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 } })).toBeRejectedNotFound(); + await expect(db.foo.delete({ where: { id: negX.id } })).toBeRejectedNotFound(); + // post-update: postUpdateCheck fires independently of fetchPolicyCodes → REJECTED_BY_POLICY, no codes + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, []); + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); + }); + + // ── fetchPolicyCodes opt-out: query-level ───────────────────────────────── + + it('query-level fetchPolicyCodes:false skips diagnostic query', async () => { + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', y <= 0, 'NEGATIVE_Y_CREATE') + @@allow('create,read', true) + @@deny('read', x <= 0, 'NEGATIVE_X_READ') + @@deny('update', x <= 0, 'NEGATIVE_X_UPDATE') + @@allow('update', x > 0) + @@deny('delete', x <= 0, 'NEGATIVE_X_DELETE') + @@allow('delete', x > 0) + @@deny('post-update', x <= 0, 'NEGATIVE_AFTER_UPDATE') + @@allow('post-update', x > 0) +} +`, + ); + // create: flag suppresses codes (y=0 triggers deny) + await expect(db.foo.create({ data: { x: 1, y: 0 }, fetchPolicyCodes: false })).toBeRejectedByPolicy( + undefined, + [], + ); + // create: without flag, codes surface + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_Y_CREATE']); + const positiveX = await db.foo.create({ data: { x: 1, y: 1 } }); + const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } }); + // read: flag skips diagnostic query entirely → null/NOT_FOUND (filter-based, same as no codes) + await expect(db.foo.findFirst({ where: { id: negX.id }, fetchPolicyCodes: false })).resolves.toBeNull(); + await expect(db.foo.findFirstOrThrow({ where: { id: negX.id }, fetchPolicyCodes: false })).toBeRejectedNotFound(); + await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id }, fetchPolicyCodes: false })).toBeRejectedNotFound(); + // read: findFirst always returns null; OrThrow variants surface codes + await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull(); + await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_READ']); + await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_READ']); + // update: flag skips diagnostic query entirely → NOT_FOUND (same as no codes defined) + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 }, fetchPolicyCodes: false })).toBeRejectedNotFound(); + // update: without flag, codes surface + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_UPDATE', + ]); + // delete: flag skips diagnostic query entirely → NOT_FOUND + await expect(db.foo.delete({ where: { id: negX.id }, fetchPolicyCodes: false })).toBeRejectedNotFound(); + // delete: without flag, codes surface + await expect(db.foo.delete({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_DELETE']); + // post-update: flag suppresses codes + await expect( + db.foo.update({ where: { id: positiveX.id }, data: { x: -1 }, fetchPolicyCodes: false }), + ).toBeRejectedByPolicy(undefined, []); + // post-update: without flag, codes surface + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_AFTER_UPDATE', + ]); + }); + + it('fetchPolicyCodes:false for update/delete behaves identically to a model without error codes', async () => { + const schema = ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('update', x <= 0, 'NEGATIVE_X_UPDATE') + @@allow('update', x > 0) + @@deny('delete', x <= 0, 'NEGATIVE_X_DELETE') + @@allow('delete', x > 0) + @@allow('create,read', true) +} +`; + const dbWithCodes = await createPolicyTestClient(schema); + const dbOptOut = await createPolicyTestClient(schema); + + const [rowWithCodes, rowOptOut] = await Promise.all([ + dbWithCodes.foo.create({ data: { x: -1 } }), + dbOptOut.foo.create({ data: { x: -1 } }), + ]); + + // With codes: update throws REJECTED_BY_POLICY with the error code + await expect(dbWithCodes.foo.update({ where: { id: rowWithCodes.id }, data: { x: 0 } })).toBeRejectedByPolicy( + undefined, + ['NEGATIVE_X_UPDATE'], + ); + // Opt-out: same update → NOT_FOUND, matching the no-codes baseline + await expect( + dbOptOut.foo.update({ where: { id: rowOptOut.id }, data: { x: 0 }, fetchPolicyCodes: false }), + ).toBeRejectedNotFound(); + + // With codes: delete throws REJECTED_BY_POLICY with the error code + await expect(dbWithCodes.foo.delete({ where: { id: rowWithCodes.id } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_DELETE', + ]); + // Opt-out: same delete → NOT_FOUND, matching the no-codes baseline + await expect( + dbOptOut.foo.delete({ where: { id: rowOptOut.id }, fetchPolicyCodes: false }), + ).toBeRejectedNotFound(); + }); + + it('query-level fetchPolicyCodes:true overrides plugin-level false', async () => { + const db = await createTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', y <= 0, 'NEGATIVE_Y_CREATE') + @@allow('create,read', true) + @@deny('read', x <= 0, 'NEGATIVE_X_READ') + @@deny('update', x <= 0, 'NEGATIVE_X_UPDATE') + @@allow('update', x > 0) + @@deny('delete', x <= 0, 'NEGATIVE_X_DELETE') + @@allow('delete', x > 0) + @@deny('post-update', x <= 0, 'NEGATIVE_AFTER_UPDATE') + @@allow('post-update', x > 0) +} +`, + { plugins: [new PolicyPlugin({ fetchPolicyCodes: false })] }, + ); + // create: query-level true re-enables codes despite plugin false + await expect(db.foo.create({ data: { x: 1, y: 0 }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_Y_CREATE', + ]); + // create: without override, codes are suppressed + await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, []); + const positiveX = await db.foo.create({ data: { x: 1, y: 1 } }); + const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } }); + // read: findFirst always returns null; query-level true re-enables codes for OrThrow variants + await expect(db.foo.findFirst({ where: { id: negX.id }, fetchPolicyCodes: true })).resolves.toBeNull(); + await expect(db.foo.findFirstOrThrow({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_READ', + ]); + await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_READ', + ]); + // read: without override, codes are suppressed → null/NOT_FOUND (filter-based) + await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull(); + await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound(); + await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound(); + // update: query-level true re-enables codes despite plugin false + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 }, fetchPolicyCodes: true })).toBeRejectedByPolicy( + undefined, + ['NEGATIVE_X_UPDATE'], + ); + // update: without override, codes are suppressed + await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 } })).toBeRejectedNotFound(); + // delete: query-level true re-enables codes despite plugin false + await expect(db.foo.delete({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_DELETE', + ]); + // delete: without override, codes are suppressed + await expect(db.foo.delete({ where: { id: negX.id } })).toBeRejectedNotFound(); + // post-update: query-level true re-enables codes despite plugin false + await expect( + db.foo.update({ where: { id: positiveX.id }, data: { x: -1 }, fetchPolicyCodes: true }), + ).toBeRejectedByPolicy(undefined, ['NEGATIVE_AFTER_UPDATE']); + // post-update: without override, codes are suppressed and we get a policy rejection without codes (not NotFound) + await expect(db.foo.update({ where: { id: positiveX.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, []); + }); +}); diff --git a/tests/e2e/orm/policy/crud/update.test.ts b/tests/e2e/orm/policy/crud/update.test.ts index d7f3a8a21..261ca3d63 100644 --- a/tests/e2e/orm/policy/crud/update.test.ts +++ b/tests/e2e/orm/policy/crud/update.test.ts @@ -1208,6 +1208,27 @@ model Foo { await expect(db.foo.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ x: 1 }); }); + it('does not throw for nonexistent row', async () => { + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id + x Int + @@allow('create', true) + @@allow('update', x > 1) + @@allow('read', true) +} +`, + ); + + await db.foo.createMany({ data: [{ id: 1, x: 2 }] }); + + // nonexistent row — row does not exist at all, so postModelLevelCheck must NOT throw + await expect( + db.$qb.updateTable('Foo').set({ x: 5 }).where('id', '=', 999).executeTakeFirst(), + ).resolves.toMatchObject({ numUpdatedRows: 0n }); + }); + it('works with insert on conflict do update', async () => { const db = await createPolicyTestClient( `