From 3579df5038f16a02cdf0c74ef39b30f080219454 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:35:46 -0700 Subject: [PATCH 1/2] feat(server): add specific 4xx error responses to REST OpenAPI spec Use distinct error descriptions for 400, 403, 404, and 422 instead of a generic "Error". Add `respectAccessPolicies` option that inspects @@allow/@@deny rules per operation and conditionally includes 403 responses on mutations (create, update, delete, relationship changes) when access is not unconditionally allowed. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/server/src/api/common/types.ts | 6 + packages/server/src/api/rest/openapi.ts | 122 ++++++-- .../test/openapi/baseline/rest.baseline.yaml | 290 +++++++++++------- .../server/test/openapi/rest-openapi.test.ts | 191 ++++++++++++ 4 files changed, 478 insertions(+), 131 deletions(-) diff --git a/packages/server/src/api/common/types.ts b/packages/server/src/api/common/types.ts index 167ca17e5..3542bfc82 100644 --- a/packages/server/src/api/common/types.ts +++ b/packages/server/src/api/common/types.ts @@ -19,6 +19,12 @@ export type OpenApiSpecOptions = { /** Spec summary. */ summary?: string; + + /** + * When true, assumes that the schema includes access policies, and adds + * 403 responses to operations that can potentially be rejected. + */ + respectAccessPolicies?: boolean; }; /** diff --git a/packages/server/src/api/rest/openapi.ts b/packages/server/src/api/rest/openapi.ts index b3429d4dd..b53cb60ff 100644 --- a/packages/server/src/api/rest/openapi.ts +++ b/packages/server/src/api/rest/openapi.ts @@ -1,5 +1,5 @@ import { lowerCaseFirst } from '@zenstackhq/common-helpers'; -import type { EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema'; +import type { AttributeApplication, EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema'; import type { OpenAPIV3_1 } from 'openapi-types'; import { PROCEDURE_ROUTE_PREFIXES } from '../common/procedures'; import { @@ -18,20 +18,29 @@ type SchemaObject = OpenAPIV3_1.SchemaObject; type ReferenceObject = OpenAPIV3_1.ReferenceObject; type ParameterObject = OpenAPIV3_1.ParameterObject; -const ERROR_RESPONSE = { - description: 'Error', - content: { - 'application/vnd.api+json': { - schema: { $ref: '#/components/schemas/_errorResponse' }, +function errorResponse(description: string): OpenAPIV3_1.ResponseObject { + return { + description, + content: { + 'application/vnd.api+json': { + schema: { $ref: '#/components/schemas/_errorResponse' }, + }, }, - }, -}; + }; +} + +const ERROR_400 = errorResponse('Error occurred while processing the request'); +const ERROR_403 = errorResponse('Forbidden: insufficient permissions to perform this operation'); +const ERROR_404 = errorResponse('Resource not found'); +const ERROR_422 = errorResponse('Operation is unprocessable due to validation errors'); const SCALAR_STRING_OPS = ['$contains', '$icontains', '$search', '$startsWith', '$endsWith']; const SCALAR_COMPARABLE_OPS = ['$lt', '$lte', '$gt', '$gte']; const SCALAR_ARRAY_OPS = ['$has', '$hasEvery', '$hasSome', '$isEmpty']; export class RestApiSpecGenerator { + private specOptions?: OpenApiSpecOptions; + constructor(private readonly handlerOptions: RestApiHandlerOptions) {} private get schema(): SchemaDef { @@ -53,6 +62,7 @@ export class RestApiSpecGenerator { } generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document { + this.specOptions = options; return { openapi: '3.1.0', info: { @@ -100,7 +110,7 @@ export class RestApiSpecGenerator { } // Single resource: GET + PATCH + DELETE - const singlePath = this.buildSinglePath(modelName, tag); + const singlePath = this.buildSinglePath(modelDef, tag); if (Object.keys(singlePath).length > 0) { paths[`/${modelPath}/{id}`] = singlePath; } @@ -124,7 +134,7 @@ export class RestApiSpecGenerator { // Relationship management path paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath( - modelName, + modelDef, fieldName, fieldDef, tag, @@ -175,7 +185,7 @@ export class RestApiSpecGenerator { }, }, }, - '400': ERROR_RESPONSE, + '400': ERROR_400, }, }; @@ -200,7 +210,9 @@ export class RestApiSpecGenerator { }, }, }, - '400': ERROR_RESPONSE, + '400': ERROR_400, + ...(this.mayDenyAccess(modelDef, 'create') && { '403': ERROR_403 }), + '422': ERROR_422, }, }; @@ -214,7 +226,8 @@ export class RestApiSpecGenerator { return result; } - private buildSinglePath(modelName: string, tag: string): Record { + private buildSinglePath(modelDef: ModelDef, tag: string): Record { + const modelName = modelDef.name; const idParam = { $ref: '#/components/parameters/id' }; const result: Record = {}; @@ -233,7 +246,7 @@ export class RestApiSpecGenerator { }, }, }, - '404': ERROR_RESPONSE, + '404': ERROR_404, }, }; } @@ -261,8 +274,10 @@ export class RestApiSpecGenerator { }, }, }, - '400': ERROR_RESPONSE, - '404': ERROR_RESPONSE, + '400': ERROR_400, + ...(this.mayDenyAccess(modelDef, 'update') && { '403': ERROR_403 }), + '404': ERROR_404, + '422': ERROR_422, }, }; } @@ -275,7 +290,8 @@ export class RestApiSpecGenerator { parameters: [idParam], responses: { '200': { description: 'Deleted successfully' }, - '404': ERROR_RESPONSE, + ...(this.mayDenyAccess(modelDef, 'delete') && { '403': ERROR_403 }), + '404': ERROR_404, }, }; } @@ -319,18 +335,19 @@ export class RestApiSpecGenerator { }, }, }, - '404': ERROR_RESPONSE, + '404': ERROR_404, }, }, }; } private buildRelationshipPath( - _modelName: string, + modelDef: ModelDef, fieldName: string, fieldDef: FieldDef, tag: string, ): Record { + const modelName = modelDef.name; const isCollection = !!fieldDef.array; const idParam = { $ref: '#/components/parameters/id' }; const relSchemaRef = isCollection @@ -341,24 +358,26 @@ export class RestApiSpecGenerator { ? { $ref: '#/components/schemas/_toManyRelationshipRequest' } : { $ref: '#/components/schemas/_toOneRelationshipRequest' }; + const mayDeny = this.mayDenyAccess(modelDef, 'update'); + const pathItem: Record = { get: { tags: [tag], summary: `Fetch ${fieldName} relationship`, - operationId: `get${_modelName}_relationships_${fieldName}`, + operationId: `get${modelName}_relationships_${fieldName}`, parameters: [idParam], responses: { '200': { description: `${fieldName} relationship`, content: { 'application/vnd.api+json': { schema: relSchemaRef } }, }, - '404': ERROR_RESPONSE, + '404': ERROR_404, }, }, put: { tags: [tag], summary: `Replace ${fieldName} relationship`, - operationId: `put${_modelName}_relationships_${fieldName}`, + operationId: `put${modelName}_relationships_${fieldName}`, parameters: [idParam], requestBody: { required: true, @@ -366,13 +385,14 @@ export class RestApiSpecGenerator { }, responses: { '200': { description: 'Relationship updated' }, - '400': ERROR_RESPONSE, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), }, }, patch: { tags: [tag], summary: `Update ${fieldName} relationship`, - operationId: `patch${_modelName}_relationships_${fieldName}`, + operationId: `patch${modelName}_relationships_${fieldName}`, parameters: [idParam], requestBody: { required: true, @@ -380,7 +400,8 @@ export class RestApiSpecGenerator { }, responses: { '200': { description: 'Relationship updated' }, - '400': ERROR_RESPONSE, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), }, }, }; @@ -389,7 +410,7 @@ export class RestApiSpecGenerator { pathItem['post'] = { tags: [tag], summary: `Add to ${fieldName} collection relationship`, - operationId: `post${_modelName}_relationships_${fieldName}`, + operationId: `post${modelName}_relationships_${fieldName}`, parameters: [idParam], requestBody: { required: true, @@ -401,7 +422,8 @@ export class RestApiSpecGenerator { }, responses: { '200': { description: 'Added to relationship collection' }, - '400': ERROR_RESPONSE, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), }, }; } @@ -416,7 +438,7 @@ export class RestApiSpecGenerator { operationId: `proc_${procName}`, responses: { '200': { description: `Result of ${procName}` }, - '400': ERROR_RESPONSE, + '400': ERROR_400, }, }; @@ -1016,4 +1038,48 @@ export class RestApiSpecGenerator { private getIdFields(modelDef: ModelDef): FieldDef[] { return modelDef.idFields.map((name) => modelDef.fields[name]).filter((f): f is FieldDef => f !== undefined); } + + /** + * Checks if an operation on a model may be denied by access policies. + * Returns true when `respectAccessPolicies` is enabled and the model's + * policies for the given operation are NOT a constant allow (i.e., not + * simply `@@allow('...', true)` with no `@@deny` rules). + */ + private mayDenyAccess(modelDef: ModelDef, operation: string): boolean { + if (!this.specOptions?.respectAccessPolicies) return false; + + const policyAttrs = (modelDef.attributes ?? []).filter( + (attr) => attr.name === '@@allow' || attr.name === '@@deny', + ); + + // No policy rules at all means default-deny + if (policyAttrs.length === 0) return true; + + const getArgByName = (args: AttributeApplication['args'], name: string) => + args?.find((a) => a.name === name)?.value; + + const matchesOperation = (args: AttributeApplication['args']) => { + const val = getArgByName(args, 'operation'); + if (!val || val.kind !== 'literal' || typeof val.value !== 'string') return false; + const ops = val.value.split(',').map((s) => s.trim()); + return ops.includes(operation) || ops.includes('all'); + }; + + const relevantDeny = policyAttrs.filter( + (attr) => attr.name === '@@deny' && matchesOperation(attr.args), + ); + if (relevantDeny.length > 0) return true; + + const relevantAllow = policyAttrs.filter( + (attr) => attr.name === '@@allow' && matchesOperation(attr.args), + ); + + // If any allow rule has a constant `true` condition (and no deny), access is unconditional + const hasConstantAllow = relevantAllow.some((attr) => { + const condition = getArgByName(attr.args, 'condition'); + return condition?.kind === 'literal' && condition.value === true; + }); + + return !hasConstantAllow; + } } diff --git a/packages/server/test/openapi/baseline/rest.baseline.yaml b/packages/server/test/openapi/baseline/rest.baseline.yaml index 0bd8177b0..311a26a30 100644 --- a/packages/server/test/openapi/baseline/rest.baseline.yaml +++ b/packages/server/test/openapi/baseline/rest.baseline.yaml @@ -119,7 +119,7 @@ paths: schema: $ref: "#/components/schemas/UserListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -143,7 +143,13 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -165,7 +171,7 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -191,13 +197,19 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -213,7 +225,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -382,7 +394,7 @@ paths: schema: $ref: "#/components/schemas/PostListResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -403,7 +415,7 @@ paths: schema: $ref: "#/components/schemas/_toManyRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -425,7 +437,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -447,7 +459,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -469,7 +481,7 @@ paths: "200": description: Added to relationship collection "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -504,7 +516,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeListResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -525,7 +537,7 @@ paths: schema: $ref: "#/components/schemas/_toManyRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -547,7 +559,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -569,7 +581,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -591,7 +603,7 @@ paths: "200": description: Added to relationship collection "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -613,7 +625,7 @@ paths: schema: $ref: "#/components/schemas/ProfileResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -634,7 +646,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -656,7 +668,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -678,7 +690,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -757,7 +769,7 @@ paths: schema: $ref: "#/components/schemas/ProfileListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -781,7 +793,13 @@ paths: schema: $ref: "#/components/schemas/ProfileResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -803,7 +821,7 @@ paths: schema: $ref: "#/components/schemas/ProfileResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -829,13 +847,19 @@ paths: schema: $ref: "#/components/schemas/ProfileResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -851,7 +875,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -873,7 +897,7 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -894,7 +918,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -916,7 +940,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -938,7 +962,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1106,7 +1130,7 @@ paths: schema: $ref: "#/components/schemas/PostListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1130,7 +1154,13 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -1152,7 +1182,7 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1178,13 +1208,19 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -1200,7 +1236,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1222,7 +1258,7 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1243,7 +1279,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1265,7 +1301,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1287,7 +1323,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1363,7 +1399,7 @@ paths: schema: $ref: "#/components/schemas/CommentListResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1384,7 +1420,7 @@ paths: schema: $ref: "#/components/schemas/_toManyRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1406,7 +1442,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1428,7 +1464,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1450,7 +1486,7 @@ paths: "200": description: Added to relationship collection "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1485,7 +1521,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeListResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1506,7 +1542,7 @@ paths: schema: $ref: "#/components/schemas/_toManyRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1528,7 +1564,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1550,7 +1586,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1572,7 +1608,7 @@ paths: "200": description: Added to relationship collection "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1594,7 +1630,7 @@ paths: schema: $ref: "#/components/schemas/SettingResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1615,7 +1651,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1637,7 +1673,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1659,7 +1695,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1734,7 +1770,7 @@ paths: schema: $ref: "#/components/schemas/CommentListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1758,7 +1794,13 @@ paths: schema: $ref: "#/components/schemas/CommentResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -1780,7 +1822,7 @@ paths: schema: $ref: "#/components/schemas/CommentResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1806,13 +1848,19 @@ paths: schema: $ref: "#/components/schemas/CommentResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -1828,7 +1876,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1850,7 +1898,7 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1871,7 +1919,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -1893,7 +1941,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1915,7 +1963,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -1986,7 +2034,7 @@ paths: schema: $ref: "#/components/schemas/SettingListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2010,7 +2058,13 @@ paths: schema: $ref: "#/components/schemas/SettingResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2032,7 +2086,7 @@ paths: schema: $ref: "#/components/schemas/SettingResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2058,13 +2112,19 @@ paths: schema: $ref: "#/components/schemas/SettingResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2080,7 +2140,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2102,7 +2162,7 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2123,7 +2183,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2145,7 +2205,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2167,7 +2227,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2201,7 +2261,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2225,7 +2285,13 @@ paths: schema: $ref: "#/components/schemas/PostLikeResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2247,7 +2313,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2273,13 +2339,19 @@ paths: schema: $ref: "#/components/schemas/PostLikeResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2295,7 +2367,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2317,7 +2389,7 @@ paths: schema: $ref: "#/components/schemas/PostResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2338,7 +2410,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2360,7 +2432,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2382,7 +2454,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2404,7 +2476,7 @@ paths: schema: $ref: "#/components/schemas/UserResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2425,7 +2497,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2447,7 +2519,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2469,7 +2541,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2570,7 +2642,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeInfoListResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2591,7 +2663,7 @@ paths: schema: $ref: "#/components/schemas/_toManyRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2613,7 +2685,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2635,7 +2707,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2657,7 +2729,7 @@ paths: "200": description: Added to relationship collection "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2757,7 +2829,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeInfoListResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2781,7 +2853,13 @@ paths: schema: $ref: "#/components/schemas/PostLikeInfoResponse" "400": - description: Error + description: Error occurred while processing the request + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2803,7 +2881,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeInfoResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2829,13 +2907,19 @@ paths: schema: $ref: "#/components/schemas/PostLikeInfoResponse" "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: $ref: "#/components/schemas/_errorResponse" "404": - description: Error + description: Resource not found + content: + application/vnd.api+json: + schema: + $ref: "#/components/schemas/_errorResponse" + "422": + description: Operation is unprocessable due to validation errors content: application/vnd.api+json: schema: @@ -2851,7 +2935,7 @@ paths: "200": description: Deleted successfully "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2873,7 +2957,7 @@ paths: schema: $ref: "#/components/schemas/PostLikeResponse" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2894,7 +2978,7 @@ paths: schema: $ref: "#/components/schemas/_toOneRelationshipWithLinks" "404": - description: Error + description: Resource not found content: application/vnd.api+json: schema: @@ -2916,7 +3000,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: @@ -2938,7 +3022,7 @@ paths: "200": description: Relationship updated "400": - description: Error + description: Error occurred while processing the request content: application/vnd.api+json: schema: diff --git a/packages/server/test/openapi/rest-openapi.test.ts b/packages/server/test/openapi/rest-openapi.test.ts index 87e2c246b..ef1781bd0 100644 --- a/packages/server/test/openapi/rest-openapi.test.ts +++ b/packages/server/test/openapi/rest-openapi.test.ts @@ -662,4 +662,195 @@ enum PostStatus { expect((s.components?.schemas?.['PostStatus'] as any).enum).toContain('DRAFT'); expect((s.components?.schemas?.['PostStatus'] as any).enum).toContain('PUBLISHED'); }); + + describe('respectAccessPolicies', () => { + it('no 403 when respectAccessPolicies is off', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('read', true) + @@allow('create', value > 0) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec(); + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined(); + }); + + it('adds 403 for operations with non-constant-allow policies', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('read', true) + @@allow('create', value > 0) + @@allow('update', value > 0) + @@allow('delete', value > 0) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // create has a non-constant condition → 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined(); + // update has a non-constant condition → 403 + expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined(); + // delete has a non-constant condition → 403 + expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeDefined(); + }); + + it('no 403 for constant-allow operations', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('all', true) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // all operations are constant-allow → no 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined(); + expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeUndefined(); + expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeUndefined(); + }); + + it('403 when deny rule exists even with constant allow', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('create', true) + @@deny('create', value < 0) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // deny rule overrides → 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined(); + }); + + it('403 when no policy rules at all (default-deny)', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // no rules = default deny → 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined(); + expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined(); + expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeDefined(); + }); + + it('per-operation granularity: only non-constant ops get 403', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('create,read', true) + @@allow('update,delete', value > 0) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // create is constant-allow → no 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined(); + // update/delete are non-constant → 403 + expect(s.paths?.['/item/{id}']?.patch?.responses?.['403']).toBeDefined(); + expect(s.paths?.['/item/{id}']?.delete?.responses?.['403']).toBeDefined(); + }); + + it('relationship mutations get 403 when update may be denied', async () => { + const policySchema = ` +model Parent { + id Int @id @default(autoincrement()) + children Child[] + + @@allow('read', true) + @@allow('update', false) +} + +model Child { + id Int @id @default(autoincrement()) + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + + @@allow('all', true) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + const relPath = s.paths?.['/parent/{id}/relationships/children'] as any; + expect(relPath.put.responses['403']).toBeDefined(); + expect(relPath.patch.responses['403']).toBeDefined(); + expect(relPath.post.responses['403']).toBeDefined(); + // GET should not have 403 + expect(relPath.get.responses['403']).toBeUndefined(); + }); + + it('relationship mutations have no 403 when update is constant-allow', async () => { + const policySchema = ` +model Parent { + id Int @id @default(autoincrement()) + children Child[] + + @@allow('all', true) +} + +model Child { + id Int @id @default(autoincrement()) + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + + @@allow('all', true) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + const relPath = s.paths?.['/parent/{id}/relationships/children'] as any; + expect(relPath.put.responses['403']).toBeUndefined(); + expect(relPath.patch.responses['403']).toBeUndefined(); + expect(relPath.post.responses['403']).toBeUndefined(); + }); + }); }); From d2d3615624efdfb07469c403e96d7e09c4b7de52 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:37:40 -0700 Subject: [PATCH 2/2] fix(server): skip @@deny rules with literal false condition in mayDenyAccess A @@deny('op', false) is a no-op that can never trigger, so it should not force a 403 response into the OpenAPI spec. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/server/src/api/rest/openapi.ts | 11 ++++++---- .../server/test/openapi/rest-openapi.test.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/server/src/api/rest/openapi.ts b/packages/server/src/api/rest/openapi.ts index b53cb60ff..9c5bae03f 100644 --- a/packages/server/src/api/rest/openapi.ts +++ b/packages/server/src/api/rest/openapi.ts @@ -1065,10 +1065,13 @@ export class RestApiSpecGenerator { return ops.includes(operation) || ops.includes('all'); }; - const relevantDeny = policyAttrs.filter( - (attr) => attr.name === '@@deny' && matchesOperation(attr.args), - ); - if (relevantDeny.length > 0) return true; + const hasEffectiveDeny = policyAttrs.some((attr) => { + if (attr.name !== '@@deny' || !matchesOperation(attr.args)) return false; + const condition = getArgByName(attr.args, 'condition'); + // @@deny('op', false) is a no-op — skip it + return !(condition?.kind === 'literal' && condition.value === false); + }); + if (hasEffectiveDeny) return true; const relevantAllow = policyAttrs.filter( (attr) => attr.name === '@@allow' && matchesOperation(attr.args), diff --git a/packages/server/test/openapi/rest-openapi.test.ts b/packages/server/test/openapi/rest-openapi.test.ts index ef1781bd0..4f16c3556 100644 --- a/packages/server/test/openapi/rest-openapi.test.ts +++ b/packages/server/test/openapi/rest-openapi.test.ts @@ -750,6 +750,26 @@ model Item { expect(s.paths?.['/item']?.post?.responses?.['403']).toBeDefined(); }); + it('no 403 when deny condition is literal false', async () => { + const policySchema = ` +model Item { + id Int @id @default(autoincrement()) + value Int + + @@allow('create', true) + @@deny('create', false) +} +`; + const client = await createTestClient(policySchema); + const h = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const s = await h.generateSpec({ respectAccessPolicies: true }); + // @@deny('create', false) is a no-op → no 403 + expect(s.paths?.['/item']?.post?.responses?.['403']).toBeUndefined(); + }); + it('403 when no policy rules at all (default-deny)', async () => { const policySchema = ` model Item {