Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/server/src/api/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down
125 changes: 97 additions & 28 deletions packages/server/src/api/rest/openapi.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Schema extends SchemaDef = SchemaDef> {
private specOptions?: OpenApiSpecOptions;

constructor(private readonly handlerOptions: RestApiHandlerOptions<Schema>) {}

private get schema(): SchemaDef {
Expand All @@ -53,6 +62,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
}

generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document {
this.specOptions = options;
return {
openapi: '3.1.0',
info: {
Expand Down Expand Up @@ -100,7 +110,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
}

// 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;
}
Expand All @@ -124,7 +134,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {

// Relationship management path
paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath(
modelName,
modelDef,
fieldName,
fieldDef,
tag,
Expand Down Expand Up @@ -175,7 +185,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
},
'400': ERROR_RESPONSE,
'400': ERROR_400,
},
};

Expand All @@ -200,7 +210,9 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
},
'400': ERROR_RESPONSE,
'400': ERROR_400,
...(this.mayDenyAccess(modelDef, 'create') && { '403': ERROR_403 }),
'422': ERROR_422,
},
};

Expand All @@ -214,7 +226,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
return result;
}

private buildSinglePath(modelName: string, tag: string): Record<string, any> {
private buildSinglePath(modelDef: ModelDef, tag: string): Record<string, any> {
const modelName = modelDef.name;
const idParam = { $ref: '#/components/parameters/id' };
const result: Record<string, any> = {};

Expand All @@ -233,7 +246,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
},
'404': ERROR_RESPONSE,
'404': ERROR_404,
},
};
}
Expand Down Expand Up @@ -261,8 +274,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
},
'400': ERROR_RESPONSE,
'404': ERROR_RESPONSE,
'400': ERROR_400,
...(this.mayDenyAccess(modelDef, 'update') && { '403': ERROR_403 }),
'404': ERROR_404,
'422': ERROR_422,
},
};
}
Expand All @@ -275,7 +290,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
parameters: [idParam],
responses: {
'200': { description: 'Deleted successfully' },
'404': ERROR_RESPONSE,
...(this.mayDenyAccess(modelDef, 'delete') && { '403': ERROR_403 }),
'404': ERROR_404,
},
};
}
Expand Down Expand Up @@ -319,18 +335,19 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
},
'404': ERROR_RESPONSE,
'404': ERROR_404,
},
},
};
}

private buildRelationshipPath(
_modelName: string,
modelDef: ModelDef,
fieldName: string,
fieldDef: FieldDef,
tag: string,
): Record<string, any> {
const modelName = modelDef.name;
const isCollection = !!fieldDef.array;
const idParam = { $ref: '#/components/parameters/id' };
const relSchemaRef = isCollection
Expand All @@ -341,46 +358,50 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
? { $ref: '#/components/schemas/_toManyRelationshipRequest' }
: { $ref: '#/components/schemas/_toOneRelationshipRequest' };

const mayDeny = this.mayDenyAccess(modelDef, 'update');

const pathItem: Record<string, any> = {
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,
content: { 'application/vnd.api+json': { schema: relRequestRef } },
},
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,
content: { 'application/vnd.api+json': { schema: relRequestRef } },
},
responses: {
'200': { description: 'Relationship updated' },
'400': ERROR_RESPONSE,
'400': ERROR_400,
...(mayDeny && { '403': ERROR_403 }),
},
},
};
Expand All @@ -389,7 +410,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
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,
Expand All @@ -401,7 +422,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
responses: {
'200': { description: 'Added to relationship collection' },
'400': ERROR_RESPONSE,
'400': ERROR_400,
...(mayDeny && { '403': ERROR_403 }),
},
};
}
Expand All @@ -416,7 +438,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
operationId: `proc_${procName}`,
responses: {
'200': { description: `Result of ${procName}` },
'400': ERROR_RESPONSE,
'400': ERROR_400,
},
};

Expand Down Expand Up @@ -1016,4 +1038,51 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
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 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),
);

// 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;
}
}
Loading
Loading