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
593 changes: 388 additions & 205 deletions packages/orm/src/client/zod/factory.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/orm/src/client/zod/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { createQuerySchemaFactory } from './factory';
export { createQuerySchemaFactory, type ZodSchemaFactory } from './factory';
48 changes: 47 additions & 1 deletion packages/server/src/api/common/spec-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
import type { QueryOptions } from '@zenstackhq/orm';
import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/orm/schema';
import { ExpressionUtils, type AttributeApplication, type ModelDef, type SchemaDef } from '@zenstackhq/orm/schema';

export const DEFAULT_SPEC_TITLE = 'ZenStack Generated API';
export const DEFAULT_SPEC_VERSION = '1.0.0';

/**
* Checks if a model is included based on slicing options.
Expand Down Expand Up @@ -97,6 +100,49 @@ export function isFilterKindIncluded(
return true;
}

/**
* 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.
*/
export function mayDenyAccess(modelDef: ModelDef, operation: string, respectAccessPolicies?: boolean): boolean {
if (!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));

const hasConstantAllow = relevantAllow.some((attr) => {
const condition = getArgByName(attr.args, 'condition');
return condition?.kind === 'literal' && condition.value === true;
});

return !hasConstantAllow;
}

/**
* Extracts a "description" from `@@meta("description", "...")` or `@meta("description", "...")` attributes.
*/
Expand Down
69 changes: 13 additions & 56 deletions packages/server/src/api/rest/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
import type { AttributeApplication, EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
import type { EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
import type { OpenAPIV3_1 } from 'openapi-types';
import { PROCEDURE_ROUTE_PREFIXES } from '../common/procedures';
import {
DEFAULT_SPEC_TITLE,
DEFAULT_SPEC_VERSION,
getIncludedModels,
getMetaDescription,
isFieldOmitted,
isFilterKindIncluded,
isModelIncluded,
isOperationIncluded,
isProcedureIncluded,
mayDenyAccess,
} from '../common/spec-utils';
import type { OpenApiSpecOptions } from '../common/types';
import type { RestApiHandlerOptions } from '.';
Expand Down Expand Up @@ -70,8 +73,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
return {
openapi: '3.1.0',
info: {
title: options?.title ?? 'ZenStack Generated API',
version: options?.version ?? '1.0.0',
title: options?.title ?? DEFAULT_SPEC_TITLE,
version: options?.version ?? DEFAULT_SPEC_VERSION,
...(options?.description && { description: options.description }),
...(options?.summary && { summary: options.summary }),
},
Expand Down Expand Up @@ -229,7 +232,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
'400': ERROR_400,
...(this.mayDenyAccess(modelDef, 'create') && { '403': ERROR_403 }),
...(mayDenyAccess(modelDef, 'create', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
'422': ERROR_422,
},
};
Expand Down Expand Up @@ -293,7 +296,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
'400': ERROR_400,
...(this.mayDenyAccess(modelDef, 'update') && { '403': ERROR_403 }),
...(mayDenyAccess(modelDef, 'update', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
'404': ERROR_404,
'422': ERROR_422,
},
Expand All @@ -308,7 +311,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
parameters: [idParam],
responses: {
'200': { description: 'Deleted successfully' },
...(this.mayDenyAccess(modelDef, 'delete') && { '403': ERROR_403 }),
...(mayDenyAccess(modelDef, 'delete', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
'404': ERROR_404,
},
};
Expand Down Expand Up @@ -359,7 +362,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
};

if (this.nestedRoutes && relModelDef) {
const mayDeny = this.mayDenyAccess(relModelDef, isCollection ? 'create' : 'update');
const mayDeny = mayDenyAccess(relModelDef, isCollection ? 'create' : 'update', this.specOptions?.respectAccessPolicies);
if (isCollection && isOperationIncluded(fieldDef.type, 'create', this.queryOptions)) {
// POST /{model}/{id}/{field} — nested create
pathItem['post'] = {
Expand Down Expand Up @@ -434,8 +437,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
): Record<string, any> {
const childIdParam = { name: 'childId', in: 'path', required: true, schema: { type: 'string' } };
const idParam = { $ref: '#/components/parameters/id' };
const mayDenyUpdate = this.mayDenyAccess(relModelDef, 'update');
const mayDenyDelete = this.mayDenyAccess(relModelDef, 'delete');
const mayDenyUpdate = mayDenyAccess(relModelDef, 'update', this.specOptions?.respectAccessPolicies);
const mayDenyDelete = mayDenyAccess(relModelDef, 'delete', this.specOptions?.respectAccessPolicies);
const result: Record<string, any> = {};

if (isOperationIncluded(fieldDef.type, 'findUnique', this.queryOptions)) {
Expand Down Expand Up @@ -523,7 +526,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
? { $ref: '#/components/schemas/_toManyRelationshipRequest' }
: { $ref: '#/components/schemas/_toOneRelationshipRequest' };

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

const pathItem: Record<string, any> = {
get: {
Expand Down Expand Up @@ -1204,50 +1207,4 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
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;
}
}
10 changes: 8 additions & 2 deletions packages/server/src/api/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fromError } from 'zod-validation-error/v4';
import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types';
import { getProcedureDef, mapProcedureArgs, PROCEDURE_ROUTE_PREFIXES } from '../common/procedures';
import { loggerSchema, queryOptionsSchema } from '../common/schemas';
import type { CommonHandlerOptions } from '../common/types';
import type { CommonHandlerOptions, OpenApiSpecGenerator, OpenApiSpecOptions } from '../common/types';
import { processSuperJsonRequestPayload, unmarshalQ } from '../common/utils';
import { log, registerCustomSerializers } from '../utils';

Expand All @@ -35,7 +35,7 @@ export type RPCApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
/**
* RPC style API request handler that mirrors the ZenStackClient API
*/
export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema> {
export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema>, OpenApiSpecGenerator {
constructor(private readonly options: RPCApiHandlerOptions<Schema>) {
this.validateOptions(options);
}
Expand Down Expand Up @@ -434,6 +434,12 @@ export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiH
return resp;
}

async generateSpec(options?: OpenApiSpecOptions) {
const { RPCApiSpecGenerator } = await import('./openapi');
const generator = new RPCApiSpecGenerator(this.options);
return generator.generateSpec(options);
}

private async processRequestPayload(args: any) {
const { meta, ...rest } = args ?? {};
if (meta?.serialization) {
Expand Down
Loading
Loading