11import { lowerCaseFirst } from '@zenstackhq/common-helpers' ;
2- import type { EnumDef , FieldDef , ModelDef , SchemaDef , TypeDefDef } from '@zenstackhq/orm/schema' ;
2+ import type { AttributeApplication , EnumDef , FieldDef , ModelDef , SchemaDef , TypeDefDef } from '@zenstackhq/orm/schema' ;
33import type { OpenAPIV3_1 } from 'openapi-types' ;
44import { PROCEDURE_ROUTE_PREFIXES } from '../common/procedures' ;
55import {
@@ -18,20 +18,29 @@ type SchemaObject = OpenAPIV3_1.SchemaObject;
1818type ReferenceObject = OpenAPIV3_1 . ReferenceObject ;
1919type ParameterObject = OpenAPIV3_1 . ParameterObject ;
2020
21- const ERROR_RESPONSE = {
22- description : 'Error' ,
23- content : {
24- 'application/vnd.api+json' : {
25- schema : { $ref : '#/components/schemas/_errorResponse' } ,
21+ function errorResponse ( description : string ) : OpenAPIV3_1 . ResponseObject {
22+ return {
23+ description,
24+ content : {
25+ 'application/vnd.api+json' : {
26+ schema : { $ref : '#/components/schemas/_errorResponse' } ,
27+ } ,
2628 } ,
27- } ,
28- } ;
29+ } ;
30+ }
31+
32+ const ERROR_400 = errorResponse ( 'Error occurred while processing the request' ) ;
33+ const ERROR_403 = errorResponse ( 'Forbidden: insufficient permissions to perform this operation' ) ;
34+ const ERROR_404 = errorResponse ( 'Resource not found' ) ;
35+ const ERROR_422 = errorResponse ( 'Operation is unprocessable due to validation errors' ) ;
2936
3037const SCALAR_STRING_OPS = [ '$contains' , '$icontains' , '$search' , '$startsWith' , '$endsWith' ] ;
3138const SCALAR_COMPARABLE_OPS = [ '$lt' , '$lte' , '$gt' , '$gte' ] ;
3239const SCALAR_ARRAY_OPS = [ '$has' , '$hasEvery' , '$hasSome' , '$isEmpty' ] ;
3340
3441export class RestApiSpecGenerator < Schema extends SchemaDef = SchemaDef > {
42+ private specOptions ?: OpenApiSpecOptions ;
43+
3544 constructor ( private readonly handlerOptions : RestApiHandlerOptions < Schema > ) { }
3645
3746 private get schema ( ) : SchemaDef {
@@ -53,6 +62,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
5362 }
5463
5564 generateSpec ( options ?: OpenApiSpecOptions ) : OpenAPIV3_1 . Document {
65+ this . specOptions = options ;
5666 return {
5767 openapi : '3.1.0' ,
5868 info : {
@@ -100,7 +110,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
100110 }
101111
102112 // Single resource: GET + PATCH + DELETE
103- const singlePath = this . buildSinglePath ( modelName , tag ) ;
113+ const singlePath = this . buildSinglePath ( modelDef , tag ) ;
104114 if ( Object . keys ( singlePath ) . length > 0 ) {
105115 paths [ `/${ modelPath } /{id}` ] = singlePath ;
106116 }
@@ -124,7 +134,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
124134
125135 // Relationship management path
126136 paths [ `/${ modelPath } /{id}/relationships/${ fieldName } ` ] = this . buildRelationshipPath (
127- modelName ,
137+ modelDef ,
128138 fieldName ,
129139 fieldDef ,
130140 tag ,
@@ -175,7 +185,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
175185 } ,
176186 } ,
177187 } ,
178- '400' : ERROR_RESPONSE ,
188+ '400' : ERROR_400 ,
179189 } ,
180190 } ;
181191
@@ -200,7 +210,9 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
200210 } ,
201211 } ,
202212 } ,
203- '400' : ERROR_RESPONSE ,
213+ '400' : ERROR_400 ,
214+ ...( this . mayDenyAccess ( modelDef , 'create' ) && { '403' : ERROR_403 } ) ,
215+ '422' : ERROR_422 ,
204216 } ,
205217 } ;
206218
@@ -214,7 +226,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
214226 return result ;
215227 }
216228
217- private buildSinglePath ( modelName : string , tag : string ) : Record < string , any > {
229+ private buildSinglePath ( modelDef : ModelDef , tag : string ) : Record < string , any > {
230+ const modelName = modelDef . name ;
218231 const idParam = { $ref : '#/components/parameters/id' } ;
219232 const result : Record < string , any > = { } ;
220233
@@ -233,7 +246,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
233246 } ,
234247 } ,
235248 } ,
236- '404' : ERROR_RESPONSE ,
249+ '404' : ERROR_404 ,
237250 } ,
238251 } ;
239252 }
@@ -261,8 +274,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
261274 } ,
262275 } ,
263276 } ,
264- '400' : ERROR_RESPONSE ,
265- '404' : ERROR_RESPONSE ,
277+ '400' : ERROR_400 ,
278+ ...( this . mayDenyAccess ( modelDef , 'update' ) && { '403' : ERROR_403 } ) ,
279+ '404' : ERROR_404 ,
280+ '422' : ERROR_422 ,
266281 } ,
267282 } ;
268283 }
@@ -275,7 +290,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
275290 parameters : [ idParam ] ,
276291 responses : {
277292 '200' : { description : 'Deleted successfully' } ,
278- '404' : ERROR_RESPONSE ,
293+ ...( this . mayDenyAccess ( modelDef , 'delete' ) && { '403' : ERROR_403 } ) ,
294+ '404' : ERROR_404 ,
279295 } ,
280296 } ;
281297 }
@@ -319,18 +335,19 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
319335 } ,
320336 } ,
321337 } ,
322- '404' : ERROR_RESPONSE ,
338+ '404' : ERROR_404 ,
323339 } ,
324340 } ,
325341 } ;
326342 }
327343
328344 private buildRelationshipPath (
329- _modelName : string ,
345+ modelDef : ModelDef ,
330346 fieldName : string ,
331347 fieldDef : FieldDef ,
332348 tag : string ,
333349 ) : Record < string , any > {
350+ const modelName = modelDef . name ;
334351 const isCollection = ! ! fieldDef . array ;
335352 const idParam = { $ref : '#/components/parameters/id' } ;
336353 const relSchemaRef = isCollection
@@ -341,46 +358,50 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
341358 ? { $ref : '#/components/schemas/_toManyRelationshipRequest' }
342359 : { $ref : '#/components/schemas/_toOneRelationshipRequest' } ;
343360
361+ const mayDeny = this . mayDenyAccess ( modelDef , 'update' ) ;
362+
344363 const pathItem : Record < string , any > = {
345364 get : {
346365 tags : [ tag ] ,
347366 summary : `Fetch ${ fieldName } relationship` ,
348- operationId : `get${ _modelName } _relationships_${ fieldName } ` ,
367+ operationId : `get${ modelName } _relationships_${ fieldName } ` ,
349368 parameters : [ idParam ] ,
350369 responses : {
351370 '200' : {
352371 description : `${ fieldName } relationship` ,
353372 content : { 'application/vnd.api+json' : { schema : relSchemaRef } } ,
354373 } ,
355- '404' : ERROR_RESPONSE ,
374+ '404' : ERROR_404 ,
356375 } ,
357376 } ,
358377 put : {
359378 tags : [ tag ] ,
360379 summary : `Replace ${ fieldName } relationship` ,
361- operationId : `put${ _modelName } _relationships_${ fieldName } ` ,
380+ operationId : `put${ modelName } _relationships_${ fieldName } ` ,
362381 parameters : [ idParam ] ,
363382 requestBody : {
364383 required : true ,
365384 content : { 'application/vnd.api+json' : { schema : relRequestRef } } ,
366385 } ,
367386 responses : {
368387 '200' : { description : 'Relationship updated' } ,
369- '400' : ERROR_RESPONSE ,
388+ '400' : ERROR_400 ,
389+ ...( mayDeny && { '403' : ERROR_403 } ) ,
370390 } ,
371391 } ,
372392 patch : {
373393 tags : [ tag ] ,
374394 summary : `Update ${ fieldName } relationship` ,
375- operationId : `patch${ _modelName } _relationships_${ fieldName } ` ,
395+ operationId : `patch${ modelName } _relationships_${ fieldName } ` ,
376396 parameters : [ idParam ] ,
377397 requestBody : {
378398 required : true ,
379399 content : { 'application/vnd.api+json' : { schema : relRequestRef } } ,
380400 } ,
381401 responses : {
382402 '200' : { description : 'Relationship updated' } ,
383- '400' : ERROR_RESPONSE ,
403+ '400' : ERROR_400 ,
404+ ...( mayDeny && { '403' : ERROR_403 } ) ,
384405 } ,
385406 } ,
386407 } ;
@@ -389,7 +410,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
389410 pathItem [ 'post' ] = {
390411 tags : [ tag ] ,
391412 summary : `Add to ${ fieldName } collection relationship` ,
392- operationId : `post${ _modelName } _relationships_${ fieldName } ` ,
413+ operationId : `post${ modelName } _relationships_${ fieldName } ` ,
393414 parameters : [ idParam ] ,
394415 requestBody : {
395416 required : true ,
@@ -401,7 +422,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
401422 } ,
402423 responses : {
403424 '200' : { description : 'Added to relationship collection' } ,
404- '400' : ERROR_RESPONSE ,
425+ '400' : ERROR_400 ,
426+ ...( mayDeny && { '403' : ERROR_403 } ) ,
405427 } ,
406428 } ;
407429 }
@@ -416,7 +438,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
416438 operationId : `proc_${ procName } ` ,
417439 responses : {
418440 '200' : { description : `Result of ${ procName } ` } ,
419- '400' : ERROR_RESPONSE ,
441+ '400' : ERROR_400 ,
420442 } ,
421443 } ;
422444
@@ -1016,4 +1038,51 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10161038 private getIdFields ( modelDef : ModelDef ) : FieldDef [ ] {
10171039 return modelDef . idFields . map ( ( name ) => modelDef . fields [ name ] ) . filter ( ( f ) : f is FieldDef => f !== undefined ) ;
10181040 }
1041+
1042+ /**
1043+ * Checks if an operation on a model may be denied by access policies.
1044+ * Returns true when `respectAccessPolicies` is enabled and the model's
1045+ * policies for the given operation are NOT a constant allow (i.e., not
1046+ * simply `@@allow('...', true)` with no `@@deny` rules).
1047+ */
1048+ private mayDenyAccess ( modelDef : ModelDef , operation : string ) : boolean {
1049+ if ( ! this . specOptions ?. respectAccessPolicies ) return false ;
1050+
1051+ const policyAttrs = ( modelDef . attributes ?? [ ] ) . filter (
1052+ ( attr ) => attr . name === '@@allow' || attr . name === '@@deny' ,
1053+ ) ;
1054+
1055+ // No policy rules at all means default-deny
1056+ if ( policyAttrs . length === 0 ) return true ;
1057+
1058+ const getArgByName = ( args : AttributeApplication [ 'args' ] , name : string ) =>
1059+ args ?. find ( ( a ) => a . name === name ) ?. value ;
1060+
1061+ const matchesOperation = ( args : AttributeApplication [ 'args' ] ) => {
1062+ const val = getArgByName ( args , 'operation' ) ;
1063+ if ( ! val || val . kind !== 'literal' || typeof val . value !== 'string' ) return false ;
1064+ const ops = val . value . split ( ',' ) . map ( ( s ) => s . trim ( ) ) ;
1065+ return ops . includes ( operation ) || ops . includes ( 'all' ) ;
1066+ } ;
1067+
1068+ const hasEffectiveDeny = policyAttrs . some ( ( attr ) => {
1069+ if ( attr . name !== '@@deny' || ! matchesOperation ( attr . args ) ) return false ;
1070+ const condition = getArgByName ( attr . args , 'condition' ) ;
1071+ // @@deny ('op', false) is a no-op — skip it
1072+ return ! ( condition ?. kind === 'literal' && condition . value === false ) ;
1073+ } ) ;
1074+ if ( hasEffectiveDeny ) return true ;
1075+
1076+ const relevantAllow = policyAttrs . filter (
1077+ ( attr ) => attr . name === '@@allow' && matchesOperation ( attr . args ) ,
1078+ ) ;
1079+
1080+ // If any allow rule has a constant `true` condition (and no deny), access is unconditional
1081+ const hasConstantAllow = relevantAllow . some ( ( attr ) => {
1082+ const condition = getArgByName ( attr . args , 'condition' ) ;
1083+ return condition ?. kind === 'literal' && condition . value === true ;
1084+ } ) ;
1085+
1086+ return ! hasConstantAllow ;
1087+ }
10191088}
0 commit comments