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/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 16c7715d5..249761f3b 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -157,7 +157,7 @@ export async function run(options: Options) { } // If a studioAuthKey is provided, create an authDb with the policy plugin - const authDb = db.$use(new PolicyPlugin()) as ClientContract; + const authDb: ClientContract = db.$use(new PolicyPlugin()); if (options.studioAuthKey) { console.log(colors.gray('Access policy plugin enabled for authorization.')); } @@ -423,7 +423,7 @@ function resolveClient( // SuperUser has full access without policy enforcement, so we return the base client directly. return client; } else { - return authDb.$setAuth(claim.data) as ClientContract; + return authDb.$setAuth(claim.data); } } diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index b490e20d4..6c004dfd4 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -37,6 +37,7 @@ import { isComputedField, isDataFieldReference, isDelegateModel, + isEnumFieldReference, isRelationshipField, mapBuiltinTypeToExpressionType, resolved, @@ -188,6 +189,8 @@ export default class AttributeApplicationValidator implements AstValidator { */ abstract get insertIgnoreMethod(): 'onConflict' | 'ignore'; + /** + * Whether the pre-load SELECT (used to resolve entity IDs before a top-level UPDATE on + * non-RETURNING dialects) must bypass the read-policy filter. + * + * MySQL pre-loads entity IDs before running an UPDATE. If the row is read-denied the + * pre-load returns null and the UPDATE never runs, masking update-deny error codes. + * Setting this to true tags the pre-load with a `bypassReadPolicy` query context so + * the policy plugin skips its read filter for it. Other `onKyselyQuery` interceptors + * (e.g. soft-delete) still apply. + */ + get requiresUpdatePreloadBypassReadPolicy(): boolean { + return false; + } + // #endregion // #region value transformation diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 2af95e2cd..095cd2fbc 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -58,6 +58,10 @@ export class MySqlCrudDialect extends LateralJoinDiale return 'ignore' as const; } + override get requiresUpdatePreloadBypassReadPolicy(): boolean { + return true; + } + // #endregion // #region value transformation diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a5ef4d6e7..5f0e85372 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -19,7 +19,7 @@ import { ulid } from 'ulid'; import * as uuid from 'uuid'; import type { AnyKysely } from '../../../utils/kysely-utils'; import { extractFields, fieldsToSelectObject, isEmptyObject } from '../../../utils/object-utils'; -import { NUMERIC_FIELD_TYPES } from '../../constants'; +import { CONTEXT_COMMENT_PREFIX, NUMERIC_FIELD_TYPES } from '../../constants'; import { TransactionIsolationLevel, type ClientContract, type CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, WhereInput } from '../../crud-types'; import { @@ -31,6 +31,7 @@ import { ORMError, ORMErrorReason, } from '../../errors'; +import type { QueryContext } from '../../plugin'; import type { ToKysely } from '../../query-builder'; import { ensureArray, @@ -169,6 +170,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. */ @@ -216,6 +227,23 @@ export abstract class BaseOperationHandler { abstract handle(operation: CoreCrudOperations, args: any): Promise; + /** + * Context of the top-level ORM API call, embedded into generated queries as a + * trailing SQL comment (see {@link makeContextComment}). + */ + protected callContext?: { ormOperation: AllCrudOperations; pluginArgs?: Record }; + + /** + * Returns a clone carrying the given call context, so concurrent invocations + * (e.g. an `onQuery` hook calling `proceed` multiple times in parallel) each + * get their own context. + */ + withCallContext(context: { ormOperation: AllCrudOperations; pluginArgs?: Record }) { + const clone = this.withClient(this.client); + clone.callContext = context; + return clone; + } + withClient(client: ClientContract) { return new (this.constructor as new (...args: any[]) => this)(client, this.model, this.inputValidator); } @@ -313,6 +341,9 @@ export abstract class BaseOperationHandler { 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); } @@ -1186,11 +1217,22 @@ 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 && this.dialect.requiresUpdatePreloadBypassReadPolicy; + // 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 = bypassReadPolicyForPreload + ? await this.readUniqueBypassingReadPolicy(kysely, model, { + where: origWhere, + select: this.makeIdSelect(model), + } as any) + : ((await this.getEntityIds(kysely, model, origWhere)) ?? null); if (!thisEntity && throwIfNotFound) { throw createNotFoundError(model); } @@ -1545,9 +1587,19 @@ export abstract class BaseOperationHandler { return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; } - private makeContextComment(_context: { model: string; operation: CRUD }) { - return sql``; - // return sql.raw(`${CONTEXT_COMMENT_PREFIX}${JSON.stringify(context)}`); + private makeContextComment(context: { model: string; operation: CRUD; bypassReadPolicy?: boolean }) { + if (!this.options.plugins?.some((plugin) => plugin.onKyselyQuery)) { + // the context is only consumed by `onKyselyQuery` hooks, skip the overhead otherwise + return sql``; + } + const payload: QueryContext = { + ...context, + ...(this.callContext?.ormOperation ? { ormOperation: this.callContext.ormOperation } : {}), + ...(this.callContext?.pluginArgs && Object.keys(this.callContext.pluginArgs).length > 0 + ? { pluginArgs: this.callContext.pluginArgs } + : {}), + }; + return sql.raw(`${CONTEXT_COMMENT_PREFIX}${JSON.stringify(payload)}`); } protected async updateMany< @@ -2528,6 +2580,34 @@ export abstract class BaseOperationHandler { }); } + // Like readUnique but tags the query with a `bypassReadPolicy` context so the policy + // plugin skips its read filter. Used for the MySQL update pre-load so read-denied rows + // are still reachable and the UPDATE's own policy error codes surface. Other query + // interceptors (e.g. soft-delete) still apply. + private async readUniqueBypassingReadPolicy( + kysely: AnyKysely, + model: string, + args: FindArgs, any, true>, + ): Promise { + let query = this.dialect.buildSelectModel(model, model); + const argsWithTake = { ...args, take: 1 }; + query = this.dialect.buildFilterSortTake(model, argsWithTake, query, model); + if ('select' in args && args.select) { + query = this.buildFieldSelection(model, query, args.select, model); + } else { + query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit, model); + } + query = query.modifyEnd(this.makeContextComment({ model, operation: 'read', bypassReadPolicy: true })); + const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); + try { + const r = await kysely.getExecutor().executeQuery(compiled); + return r.rows[0] ?? null; + } catch (err) { + if (err instanceof ORMError) throw err; + throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); + } + } + // Given multiple unique filters, load all matching entities and return their id fields in one query private getEntitiesIds(kysely: AnyKysely, model: string, uniqueFilters: any[]) { return this.read(kysely, model, { 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/query-context-utils.ts b/packages/orm/src/client/executor/query-context-utils.ts new file mode 100644 index 000000000..7ca7d9d15 --- /dev/null +++ b/packages/orm/src/client/executor/query-context-utils.ts @@ -0,0 +1,56 @@ +import { RawNode, SelectModifierNode, type OperationNode, type RootOperationNode } from 'kysely'; +import { CONTEXT_COMMENT_PREFIX } from '../constants'; +import type { QueryContext } from '../plugin'; + +// `modifyEnd` appends a plain `RawNode` on insert/update/delete nodes, but wraps it in a +// `SelectModifierNode` on select nodes +function getRawModifier(modifier: OperationNode): RawNode | undefined { + if (RawNode.is(modifier)) { + return modifier; + } + if (SelectModifierNode.is(modifier) && modifier.rawModifier && RawNode.is(modifier.rawModifier)) { + return modifier.rawModifier; + } + return undefined; +} + +/** + * Extracts the ORM query context embedded as a trailing `-- $$context:` comment + * (a `RawNode` in the root node's `endModifiers`), and returns the node with the + * comment stripped so it never reaches plugins or the database driver. + */ +export function extractQueryContext(node: RootOperationNode): { + queryContext?: QueryContext; + strippedNode: RootOperationNode; +} { + const endModifiers = (node as { endModifiers?: readonly OperationNode[] }).endModifiers; + if (!endModifiers?.length) { + return { strippedNode: node }; + } + + let queryContext: QueryContext | undefined; + const remaining = endModifiers.filter((modifier) => { + const rawNode = getRawModifier(modifier); + if (!rawNode || !rawNode.sqlFragments[0]?.startsWith(CONTEXT_COMMENT_PREFIX)) { + return true; + } + try { + queryContext = JSON.parse(rawNode.sqlFragments[0].slice(CONTEXT_COMMENT_PREFIX.length)); + } catch { + // malformed payload: treat as absent + } + return false; + }); + + if (remaining.length === endModifiers.length) { + return { strippedNode: node }; + } + + return { + queryContext, + strippedNode: { + ...node, + endModifiers: remaining.length > 0 ? remaining : undefined, + } as RootOperationNode, + }; +} diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index ed4f6f6b1..ea44d1a8e 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -36,9 +36,10 @@ import { TransactionIsolationLevel, type ClientContract } from '../contract'; import { getCrudDialect } from '../crud/dialects'; import type { BaseCrudDialect } from '../crud/dialects/base-dialect'; import { createDBQueryError, createInternalError, ORMError } from '../errors'; -import type { AfterEntityMutationCallback, OnKyselyQueryCallback } from '../plugin'; +import type { AfterEntityMutationCallback, OnKyselyQueryCallback, QueryContext } from '../plugin'; import { requireIdFields, stripAlias } from '../query-utils'; import { QueryNameMapper } from './name-mapper'; +import { extractQueryContext } from './query-context-utils'; import { TempAliasTransformer } from './temp-alias-transformer'; import type { ZenStackDriver } from './zenstack-driver'; @@ -139,6 +140,10 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { // if the query is a raw query, we need to carry over the parameters const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined; + // extract the ORM context embedded as a trailing SQL comment and strip it so + // neither plugins nor the database driver see it + const { queryContext, strippedNode } = extractQueryContext(compiledQuery.query); + // needs to ensure transaction if we: // - have plugins with Kysely hooks, as they may spawn more queries (check: should creating tx be plugin's responsibility?) // - have entity mutation plugins @@ -149,7 +154,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { try { // mutations are wrapped in tx if not already in one if ( - this.isMutationNode(compiledQuery.query) && + this.isMutationNode(strippedNode) && !this.driver.isTransactionConnection(connection) && needEnsureTx ) { @@ -160,7 +165,8 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { } const result = await this.proceedQueryWithKyselyInterceptors( connection, - compiledQuery.query, + strippedNode, + queryContext, queryParams, compiledQuery.queryId, ); @@ -192,6 +198,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { private async proceedQueryWithKyselyInterceptors( connection: DatabaseConnection, queryNode: RootOperationNode, + queryContext: QueryContext | undefined, parameters: readonly unknown[] | undefined, queryId: QueryId, ) { @@ -213,6 +220,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { client: this.client as unknown as ClientContract, schema: this.client.$schema, query, + queryContext, proceed: _p, }); return hookResult; 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-utils.ts b/packages/orm/src/client/plugin-utils.ts new file mode 100644 index 000000000..9bff6db76 --- /dev/null +++ b/packages/orm/src/client/plugin-utils.ts @@ -0,0 +1,108 @@ +import { invariant } from '@zenstackhq/common-helpers'; +import { ZodObject, type ZodType } from 'zod'; +import { + CoreCreateOperations, + CoreDeleteOperations, + CoreReadOperations, + CoreUpdateOperations, +} from './crud/operations/base'; +import type { AnyPlugin } from './plugin'; + +/** + * Resolves the extended query args schema a plugin declares for the given operation. + * Precedence: exact operation, then grouped `$create/$read/$update/$delete` (both merged + * for `upsert`), then `$all`. + */ +export function getPluginExtQueryArgsSchema(plugin: AnyPlugin, operation: string): ZodObject | undefined { + if (!plugin.queryArgs) { + return undefined; + } + + let result: ZodType | undefined; + + if (operation in plugin.queryArgs && plugin.queryArgs[operation]) { + // most specific operation takes highest precedence + result = plugin.queryArgs[operation]; + } else if (operation === 'upsert') { + // upsert is special: it's in both CoreCreateOperations and CoreUpdateOperations + // so we need to merge both $create and $update schemas to match the type system + const createSchema = + '$create' in plugin.queryArgs && plugin.queryArgs['$create'] ? plugin.queryArgs['$create'] : undefined; + const updateSchema = + '$update' in plugin.queryArgs && plugin.queryArgs['$update'] ? plugin.queryArgs['$update'] : undefined; + + if (createSchema && updateSchema) { + invariant(createSchema instanceof ZodObject, 'Plugin extended query args schema must be a Zod object'); + invariant(updateSchema instanceof ZodObject, 'Plugin extended query args schema must be a Zod object'); + // merge both schemas (combines their properties) + result = createSchema.extend(updateSchema.shape); + } else if (createSchema) { + result = createSchema; + } else if (updateSchema) { + result = updateSchema; + } + } else if ( + // then comes grouped operations: $create, $read, $update, $delete + CoreCreateOperations.includes(operation as CoreCreateOperations) && + '$create' in plugin.queryArgs && + plugin.queryArgs['$create'] + ) { + result = plugin.queryArgs['$create']; + } else if ( + CoreReadOperations.includes(operation as CoreReadOperations) && + '$read' in plugin.queryArgs && + plugin.queryArgs['$read'] + ) { + result = plugin.queryArgs['$read']; + } else if ( + CoreUpdateOperations.includes(operation as CoreUpdateOperations) && + '$update' in plugin.queryArgs && + plugin.queryArgs['$update'] + ) { + result = plugin.queryArgs['$update']; + } else if ( + CoreDeleteOperations.includes(operation as CoreDeleteOperations) && + '$delete' in plugin.queryArgs && + plugin.queryArgs['$delete'] + ) { + result = plugin.queryArgs['$delete']; + } else if ('$all' in plugin.queryArgs && plugin.queryArgs['$all']) { + // finally comes $all + result = plugin.queryArgs['$all']; + } + + invariant( + result === undefined || result instanceof ZodObject, + 'Plugin extended query args schema must be a Zod object', + ); + return result; +} + +/** + * Picks from `args` the keys declared by any plugin's extended query args schema for the + * given operation. Returns undefined if nothing matches. + */ +export function extractPluginQueryArgs( + plugins: AnyPlugin[] | undefined, + operation: string, + args: unknown, +): Record | undefined { + if (!plugins?.length || typeof args !== 'object' || args === null) { + return undefined; + } + + let result: Record | undefined; + for (const plugin of plugins) { + const schema = getPluginExtQueryArgsSchema(plugin, operation); + if (!schema) { + continue; + } + for (const key of Object.keys(schema.shape)) { + if (key in args) { + result ??= {}; + result[key] = (args as Record)[key]; + } + } + } + return result; +} diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 2a2431637..abac17f2a 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,6 +1,7 @@ import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ZodType } from 'zod'; import type { ClientContract, ZModelFunction } from '.'; +import type { CRUD } from './contract'; import type { GetModelFields, GetModels, NonRelationFields, SchemaDef } from '@zenstackhq/schema'; import type { MaybePromise } from '../utils/type-utils'; import type { MapModelFieldType } from './crud-types'; @@ -385,10 +386,52 @@ export type PluginAfterEntityMutationArgs = MutationHo // #region OnKyselyQuery hooks +/** + * ORM-level context attached to generated Kysely queries (serialized as a trailing + * SQL comment at query-build time) and surfaced to `onKyselyQuery` hooks after the + * executor deserializes and strips it. + */ +export type QueryContext = { + /** + * The model the query operates on. + */ + model: string; + + /** + * The CRUD kind of this specific SQL statement. + */ + operation: CRUD; + + /** + * The top-level ORM API method that triggered the query, e.g. 'findUniqueOrThrow'. + */ + ormOperation?: AllCrudOperations; + + /** + * Plugin-extended query args passed to the top-level ORM call, e.g. `{ fetchPolicyCodes: false }`. + * Values must be JSON-serializable to survive the comment round-trip. + */ + pluginArgs?: Record; + + /** + * Set on internal pre-load SELECTs that resolve entity IDs before a mutation on dialects + * without RETURNING (e.g. MySQL). Access-policy plugins should skip read filtering for + * such queries — the mutation that follows enforces its own policy, and filtering here + * would mask its error codes. Other query-filtering plugins (e.g. soft-delete) must + * still apply. + */ + bypassReadPolicy?: boolean; +}; + export type OnKyselyQueryArgs = { schema: SchemaDef; client: ClientContract; query: RootOperationNode; + /** + * ORM-level context of the query, if it originated from a top-level ORM API call. + * Undefined for queries built directly with the query builder or spawned by plugins. + */ + queryContext?: QueryContext; proceed: ProceedKyselyQueryFunction; }; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 42b0b8fb3..606bceabd 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -32,16 +32,11 @@ import type { UpdateManyArgs, UpsertArgs, } from '../crud-types'; -import { - CoreCreateOperations, - CoreDeleteOperations, - CoreReadOperations, - CoreUpdateOperations, - type CoreCrudOperations, -} from '../crud/operations/base'; +import { type CoreCrudOperations } from '../crud/operations/base'; import { createInternalError } from '../errors'; import type { ClientOptions, QueryOptions } from '../options'; import type { AnyPlugin, ExtQueryArgsBase } from '../plugin'; +import { getPluginExtQueryArgsSchema } from '../plugin-utils'; import { fieldHasDefaultValue, getEnum, @@ -2343,7 +2338,7 @@ export class ZodSchemaFactory< let result = schema; for (const plugin of this.plugins ?? []) { if (plugin.queryArgs) { - const pluginSchema = this.getPluginExtQueryArgsSchema(plugin, operation); + const pluginSchema = getPluginExtQueryArgsSchema(plugin, operation); if (pluginSchema) { result = result.extend(pluginSchema.shape); } @@ -2352,71 +2347,6 @@ export class ZodSchemaFactory< return result.strict(); } - private getPluginExtQueryArgsSchema(plugin: AnyPlugin, operation: string): ZodObject | undefined { - if (!plugin.queryArgs) { - return undefined; - } - - let result: ZodType | undefined; - - if (operation in plugin.queryArgs && plugin.queryArgs[operation]) { - // most specific operation takes highest precedence - result = plugin.queryArgs[operation]; - } else if (operation === 'upsert') { - // upsert is special: it's in both CoreCreateOperations and CoreUpdateOperations - // so we need to merge both $create and $update schemas to match the type system - const createSchema = - '$create' in plugin.queryArgs && plugin.queryArgs['$create'] ? plugin.queryArgs['$create'] : undefined; - const updateSchema = - '$update' in plugin.queryArgs && plugin.queryArgs['$update'] ? plugin.queryArgs['$update'] : undefined; - - if (createSchema && updateSchema) { - invariant(createSchema instanceof ZodObject, 'Plugin extended query args schema must be a Zod object'); - invariant(updateSchema instanceof ZodObject, 'Plugin extended query args schema must be a Zod object'); - // merge both schemas (combines their properties) - result = createSchema.extend(updateSchema.shape); - } else if (createSchema) { - result = createSchema; - } else if (updateSchema) { - result = updateSchema; - } - } else if ( - // then comes grouped operations: $create, $read, $update, $delete - CoreCreateOperations.includes(operation as CoreCreateOperations) && - '$create' in plugin.queryArgs && - plugin.queryArgs['$create'] - ) { - result = plugin.queryArgs['$create']; - } else if ( - CoreReadOperations.includes(operation as CoreReadOperations) && - '$read' in plugin.queryArgs && - plugin.queryArgs['$read'] - ) { - result = plugin.queryArgs['$read']; - } else if ( - CoreUpdateOperations.includes(operation as CoreUpdateOperations) && - '$update' in plugin.queryArgs && - plugin.queryArgs['$update'] - ) { - result = plugin.queryArgs['$update']; - } else if ( - CoreDeleteOperations.includes(operation as CoreDeleteOperations) && - '$delete' in plugin.queryArgs && - plugin.queryArgs['$delete'] - ) { - result = plugin.queryArgs['$delete']; - } else if ('$all' in plugin.queryArgs && plugin.queryArgs['$all']) { - // finally comes $all - result = plugin.queryArgs['$all']; - } - - invariant( - result === undefined || result instanceof ZodObject, - 'Plugin extended query args schema must be a Zod object', - ); - return result; - } - // #endregion // #region Helpers diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 2ebafae37..9366a645d 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -48,7 +48,8 @@ "dependencies": { "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/orm": "workspace:*", - "ts-pattern": "catalog:" + "ts-pattern": "catalog:", + "zod": "catalog:" }, "peerDependencies": { "kysely": "catalog:" 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..fb7693984 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -1,12 +1,25 @@ import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; -import type { PolicyPluginOptions } from './options'; +import { SelectQueryNode } from 'kysely'; +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 { +// Keys must stay optional so that a policy-enabled client remains assignable +// to the base `ClientContract` (whose ext query args default to `{}`). +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 +40,25 @@ export class PolicyPlugin implements RuntimePlugin { }; } - onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs) { - const handler = new PolicyHandler(client, this.options); + // The explicit annotation keeps the keys optional so that `$use` infers + // `PolicyExtQueryArgs` with optional keys (see comment above). + readonly queryArgs: { [K in keyof PolicyExtQueryArgs]: z.ZodType } = { + $read: fetchPolicyCodesSchema, + $create: fetchPolicyCodesSchema, + $update: fetchPolicyCodesSchema, + $delete: fetchPolicyCodesSchema, + }; + + onKyselyQuery({ query, client, proceed, queryContext }: OnKyselyQueryArgs) { + if (queryContext?.bypassReadPolicy && SelectQueryNode.is(query)) { + // internal pre-load SELECT for a mutation on a non-RETURNING dialect: skip the + // read filter so the mutation that follows can surface its own policy errors + return proceed(query); + } + const fetchPolicyCodes = queryContext?.pluginArgs?.['fetchPolicyCodes'] as boolean | undefined; + const effectiveOptions: PolicyPluginOptions = + fetchPolicyCodes !== undefined ? { ...this.options, fetchPolicyCodes } : this.options; + const handler = new PolicyHandler(client, effectiveOptions, queryContext?.ormOperation); return handler.handle(query, proceed); } } diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 40c382668..6e5856a96 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 ormOperation?: string, ) { super(); this.dialect = getCrudDialect(this.client.$schema, this.client.$options); @@ -93,8 +102,13 @@ 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.ormOperation ?? '')) { + await this.postReadZeroRowsCheck(selectNode, proceed); + } + return result; } const { mutationModel } = this.getMutationModel(node); @@ -138,6 +152,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 +199,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 +273,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>, @@ -329,12 +426,36 @@ export class PolicyHandler extends OperationNodeTransf ), ); - const postUpdateResult = await proceed(postUpdateQuery.toOperationNode()); - if (!postUpdateResult.rows[0]?.$condition) { + // one EXISTS column per coded policy, evaluated in the same query so a rejection + // doesn't cost an extra diagnostic roundtrip + const policiesWithCode = + this.options.fetchPolicyCodes !== false + ? this.getModelPolicies(model, 'post-update').filter((p) => p.code) + : []; + const codeSelections = this.buildPostUpdateCodeSelections( + policiesWithCode, + model, + idConditions, + beforeUpdateInfo, + ); + + const postUpdateQueryNode = postUpdateQuery.toOperationNode(); + const postUpdateResult = await proceed( + codeSelections.length > 0 + ? { ...postUpdateQueryNode, selections: [...(postUpdateQueryNode.selections ?? []), ...codeSelections] } + : postUpdateQueryNode, + ); + const row = postUpdateResult.rows[0] ?? {}; + if (!row.$condition) { + const policyCodes = + policiesWithCode.length > 0 + ? policiesWithCode.filter((_, i) => row[`$c${i}`]).map((p) => p.code!) + : undefined; throw createRejectedByPolicyError( model, RejectedByPolicyReason.NO_ACCESS, 'some or all updated rows failed to pass post-update policy check', + policyCodes, ); } } @@ -821,22 +942,22 @@ export class PolicyHandler extends OperationNodeTransf const valueRows = node.values ? this.unwrapCreateValueRows(node.values, mutationModel, fields, isManyToManyJoinTable) : [[]]; - for (const values of valueRows) { - if (isManyToManyJoinTable) { + if (isManyToManyJoinTable) { + for (const values of valueRows) { await this.enforcePreCreatePolicyForManyToManyJoinTable( mutationModel, fields, values.map((v) => v.node), proceed, ); - } else { - await this.enforcePreCreatePolicyForOne( - mutationModel, - fields, - values.map((v) => v.node), - proceed, - ); } + } else { + await this.enforcePreCreatePolicyForRows( + mutationModel, + fields, + valueRows.map((values) => values.map((v) => v.node)), + proceed, + ); } } @@ -903,54 +1024,76 @@ export class PolicyHandler extends OperationNodeTransf } } - private async enforcePreCreatePolicyForOne( + private async enforcePreCreatePolicyForRows( model: string, fields: string[], - values: OperationNode[], + valueRows: OperationNode[][], proceed: ProceedKyselyQueryFunction, ) { const allFields = QueryUtils.getModelFields(this.client.$schema, model, { inherited: true }); - const allValues: KyselyExpression[] = []; - - for (const def of allFields) { - const index = fields.indexOf(def.name); - if (index >= 0) { - allValues.push(new ExpressionWrapper(values[index]!)); - } else { - // set non-provided fields to null - allValues.push(this.eb.lit(null)); + const rows = valueRows.map((values) => { + const allValues: KyselyExpression[] = []; + for (const def of allFields) { + const index = fields.indexOf(def.name); + if (index >= 0) { + allValues.push(new ExpressionWrapper(values[index]!)); + } else { + // set non-provided fields to null + allValues.push(this.eb.lit(null)); + } } - } + return allValues; + }); - // create a `SELECT column1 as field1, column2 as field2, ... FROM (VALUES (...))` table for policy evaluation - const valuesTable = this.dialect.buildValuesTableSelect(allFields, [allValues]); + // create a `SELECT column1 as field1, column2 as field2, ... FROM (VALUES (...), ...)` table + // containing all proposed rows for policy evaluation + const valuesTable = this.dialect.buildValuesTableSelect(allFields, rows); const filter = this.buildPolicyFilter(model, undefined, 'create'); - // check if the provided values satisfy the create policy - - // `SELECT 1 FROM (VALUES (...)) AS t(column1, column2, ...) WHERE ` - const preCreateInner = this.eb + // the create passes the policy check iff all proposed rows satisfy the filter: + // `(SELECT COUNT(1) FROM (VALUES (...), ...) AS t WHERE ) = ` + const satisfyingRows = this.eb .selectFrom(valuesTable.as(model)) - .select(this.eb.lit(1).as('_')) + .select(this.eb.fn('COUNT', [this.eb.lit(1)]).as('_')) .where(() => new ExpressionWrapper(filter)); - - const result = await proceed( - // `SELECT EXISTS(preCreateInner) AS $condition` - { - kind: 'SelectQueryNode', - selections: [ - SelectionNode.create( - AliasNode.create( - this.eb.exists(preCreateInner).toOperationNode(), - IdentifierNode.create('$condition'), - ), - ), - ], - } satisfies SelectQueryNode, + const conditionSelection = SelectionNode.create( + AliasNode.create( + this.eb(satisfyingRows, '=', valueRows.length).toOperationNode(), + IdentifierNode.create('$condition'), + ), ); - if (!result.rows[0]?.$condition) { - throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS); + + // one EXISTS column per coded policy, evaluated in the same query so a rejection + // doesn't cost an extra diagnostic roundtrip + const policiesWithCode = + this.options.fetchPolicyCodes !== false ? this.getModelPolicies(model, 'create').filter((p) => p.code) : []; + const codeSelections = 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}`)), + ); + }); + + const result = await proceed({ + kind: 'SelectQueryNode', + selections: [conditionSelection, ...codeSelections], + } satisfies SelectQueryNode); + + const row = result.rows[0] ?? {}; + if (!row.$condition) { + const policyCodes = + this.options.fetchPolicyCodes !== false + ? policiesWithCode.filter((_, i) => row[`$c${i}`]).map((p) => p.code!) + : undefined; + throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS, undefined, policyCodes); } } @@ -1047,6 +1190,60 @@ export class PolicyHandler extends OperationNodeTransf return ExpressionUtils.isLiteral(expr) && expr.value === true; } + // Builds one EXISTS selection per coded post-update policy, meant to be appended to the + // post-update check query so codes are determined in the same roundtrip. + private buildPostUpdateCodeSelections( + policiesWithCode: Policy[], + model: string, + idConditions: OperationNode, + beforeUpdateInfo: Awaited>, + ): SelectionNode[] { + 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 selections; + } + private async processReadBack(node: CrudQueryNode, result: QueryResult, proceed: ProceedKyselyQueryFunction) { if (result.rows.length === 0) { return result; @@ -1240,14 +1437,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 +1476,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/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 dd9b6bbc3..2b6343f31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -727,6 +727,9 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.7.1 + zod: + specifier: 'catalog:' + version: 4.1.12 devDependencies: '@types/better-sqlite3': specifier: 'catalog:' diff --git a/samples/next.js/next.config.ts b/samples/next.js/next.config.ts index b22af960a..4cd8a48c0 100644 --- a/samples/next.js/next.config.ts +++ b/samples/next.js/next.config.ts @@ -1,5 +1,9 @@ import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + // @zenstackhq/orm uses node:async_hooks (AsyncLocalStorage). + // Mark it as server-external so Turbopack never tries to analyze it in any bundle context. + serverExternalPackages: ['@zenstackhq/orm', '@zenstackhq/tanstack-query'], +}; export default nextConfig; diff --git a/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts b/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts index d6e17c04b..8f54ddee5 100644 --- a/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts +++ b/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts @@ -1,7 +1,8 @@ -import { type ClientContract } from '@zenstackhq/orm'; +import { definePlugin, type ClientContract, type QueryContext } from '@zenstackhq/orm'; import { createTestClient } from '@zenstackhq/testtools'; -import { InsertQueryNode, PrimitiveValueListNode, ValuesNode } from 'kysely'; +import { InsertQueryNode, PrimitiveValueListNode, ValuesNode, type OperationNode } from 'kysely'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import z from 'zod'; import { schema } from '../schemas/basic'; describe('On kysely query tests', () => { @@ -224,4 +225,53 @@ describe('On kysely query tests', () => { await expect(called2).toBe(true); await expect(client.user.findFirst()).toResolveNull(); }); + + it('provides query context to hooks', async () => { + const contexts: (QueryContext | undefined)[] = []; + const client = _client.$use( + definePlugin({ + id: 'test-plugin', + queryArgs: { + $read: z.object({ testArg: z.string().optional() }), + }, + onKyselyQuery({ query, queryContext, proceed }) { + contexts.push(queryContext); + // the context comment must be stripped before hooks see the query + const endModifiers = (query as { endModifiers?: readonly OperationNode[] }).endModifiers; + expect(JSON.stringify(endModifiers ?? [])).not.toContain('$$context'); + return proceed(query); + }, + }), + ); + + await client.user.create({ data: { id: '1', email: 'u1@test.com' } }); + expect(contexts).toContainEqual( + expect.objectContaining({ model: 'User', operation: 'create', ormOperation: 'create' }), + ); + + contexts.length = 0; + await client.user.findUniqueOrThrow({ where: { id: '1' }, testArg: 'hello' }); + expect(contexts).toContainEqual( + expect.objectContaining({ + model: 'User', + operation: 'read', + ormOperation: 'findUniqueOrThrow', + pluginArgs: { testArg: 'hello' }, + }), + ); + }); + + it('provides no query context for raw query builder queries', async () => { + const contexts: (QueryContext | undefined)[] = []; + const client = _client.$use({ + id: 'test-plugin', + onKyselyQuery({ query, queryContext, proceed }) { + contexts.push(queryContext); + return proceed(query); + }, + }); + + await client.$qb.selectFrom('User').selectAll().execute(); + expect(contexts).toEqual([undefined]); + }); }); 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..4f81f19be --- /dev/null +++ b/tests/e2e/orm/policy/crud/error-codes.test.ts @@ -0,0 +1,980 @@ +import { ORMError } from '@zenstackhq/orm'; +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; +import { createPolicyTestClient, createTestClient, getTestDbProvider } 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, []); + }); + + // ── rejection determination roundtrips ──────────────────────────────────── + + // The counting plugin is registered before the policy plugin, which makes it sit closer + // to the database in the interceptor chain, so it observes every query the policy + // handler issues (checks, diagnostics, and the mutation itself). + const makeQueryCounter = (kinds: string[]) => ({ + id: 'query-counter', + onKyselyQuery({ query, proceed }: any) { + kinds.push(query.kind); + return proceed(query); + }, + }); + + it('determines create rejection and codes in a single query', async () => { + const kinds: string[] = []; + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('create', x <= 0, 'NEGATIVE_X_CREATE') + @@allow('create,read', true) +} +`, + { plugins: [makeQueryCounter(kinds)] }, + ); + await expect(db.foo.create({ data: { x: 0 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_CREATE']); + // one merged check+diagnostics SELECT, and the INSERT never runs + expect(kinds).toEqual(['SelectQueryNode']); + }); + + it('checks pre-create policy for all rows of a batched create in a single query', async () => { + const kinds: string[] = []; + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@deny('create', x <= 0, 'NEGATIVE_X_CREATE') + @@allow('create,read', true) +} +`, + { plugins: [makeQueryCounter(kinds)] }, + ); + await expect(db.foo.createMany({ data: [{ x: 1 }, { x: 2 }, { x: 3 }] })).resolves.toMatchObject({ count: 3 }); + // one policy check SELECT covering all three rows, then the INSERT + expect(kinds).toEqual(['SelectQueryNode', 'InsertQueryNode']); + }); + + it('returns codes from all violating rows of a rejected batched create', async () => { + const kinds: string[] = []; + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + y Int + @@deny('create', x <= 0, 'NEGATIVE_X_CREATE') + @@deny('create', y <= 0, 'NEGATIVE_Y_CREATE') + @@allow('create,read', true) +} +`, + { plugins: [makeQueryCounter(kinds)] }, + ); + // first row violates the y rule, second row violates the x rule + await expect(db.foo.createMany({ data: [{ x: 1, y: 0 }, { x: 0, y: 1 }] })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_X_CREATE', + 'NEGATIVE_Y_CREATE', + ]); + expect(kinds).toEqual(['SelectQueryNode']); + }); + + it('determines post-update rejection and codes in a single query', async () => { + const kinds: string[] = []; + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id @default(autoincrement()) + x Int + @@allow('create,read,update', true) + @@deny('post-update', x <= 0, 'NEGATIVE_AFTER_UPDATE') + @@allow('post-update', x > 0) +} +`, + { plugins: [makeQueryCounter(kinds)] }, + ); + const row = await db.foo.create({ data: { x: 1 } }); + kinds.length = 0; + await expect(db.foo.update({ where: { id: row.id }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [ + 'NEGATIVE_AFTER_UPDATE', + ]); + if (getTestDbProvider() === 'mysql') { + // Without RETURNING the flow needs extra roundtrips: the ORM pre-loads the entity + // id, the policy handler loads before-update entities to be able to read rows back + // by id, then after the UPDATE reads the updated rows and runs the merged + // post-update check+diagnostics SELECT. + expect(kinds).toEqual([ + 'SelectQueryNode', + 'SelectQueryNode', + 'UpdateQueryNode', + 'SelectQueryNode', + 'SelectQueryNode', + ]); + } else { + // the UPDATE, then one merged post-update check+diagnostics SELECT + expect(kinds).toEqual(['UpdateQueryNode', 'SelectQueryNode']); + } + }); +}); 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( `