diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ec1705445..0d33de318 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9,12 +9,43 @@ import { DataEngineAggregateOptions, DataEngineCountOptions } from '@objectstack/spec/data'; +import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; import { SchemaRegistry } from './registry.js'; export type HookHandler = (context: HookContext) => Promise | void; +/** + * Per-object hook entry with priority support + */ +export interface HookEntry { + handler: HookHandler; + object?: string | string[]; // undefined = global hook + priority: number; +} + +/** + * Operation Context for Middleware Chain + */ +export interface OperationContext { + object: string; + operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate'; + ast?: QueryAST; + data?: any; + options?: any; + context?: ExecutionContext; + result?: any; +} + +/** + * Engine Middleware (Onion model) + */ +export type EngineMiddleware = ( + ctx: OperationContext, + next: () => Promise +) => Promise; + /** * Host Context provided to plugins (Internal ObjectQL Plugin System) */ @@ -38,13 +69,19 @@ export class ObjectQL implements IDataEngine { private defaultDriver: string | null = null; private logger: Logger; - // Hooks Registry - private hooks: Record = { - 'beforeFind': [], 'afterFind': [], - 'beforeInsert': [], 'afterInsert': [], - 'beforeUpdate': [], 'afterUpdate': [], - 'beforeDelete': [], 'afterDelete': [], - }; + // Per-object hooks with priority support + private hooks: Map = new Map([ + ['beforeFind', []], ['afterFind', []], + ['beforeInsert', []], ['afterInsert', []], + ['beforeUpdate', []], ['afterUpdate', []], + ['beforeDelete', []], ['afterDelete', []], + ]); + + // Middleware chain (onion model) + private middlewares: Array<{ + fn: EngineMiddleware; + object?: string; + }> = []; // Host provided context additions (e.g. Server router) private hostContext: Record = {}; @@ -116,30 +153,94 @@ export class ObjectQL implements IDataEngine { * Register a hook * @param event The event name (e.g. 'beforeFind', 'afterInsert') * @param handler The handler function + * @param options Optional: target object(s) and priority */ - registerHook(event: string, handler: HookHandler) { - if (!this.hooks[event]) { - this.hooks[event] = []; + registerHook(event: string, handler: HookHandler, options?: { + object?: string | string[]; + priority?: number; + }) { + if (!this.hooks.has(event)) { + this.hooks.set(event, []); } - this.hooks[event].push(handler); - this.logger.debug('Registered hook', { event, totalHandlers: this.hooks[event].length }); + const entries = this.hooks.get(event)!; + entries.push({ + handler, + object: options?.object, + priority: options?.priority ?? 100, + }); + // Sort by priority (lower runs first) + entries.sort((a, b) => a.priority - b.priority); + this.logger.debug('Registered hook', { event, object: options?.object, priority: options?.priority ?? 100, totalHandlers: entries.length }); } public async triggerHooks(event: string, context: HookContext) { - const handlers = this.hooks[event] || []; + const entries = this.hooks.get(event) || []; - if (handlers.length === 0) { + if (entries.length === 0) { this.logger.debug('No hooks registered for event', { event }); return; } - this.logger.debug('Triggering hooks', { event, count: handlers.length }); + this.logger.debug('Triggering hooks', { event, count: entries.length }); - for (const handler of handlers) { - await handler(context); + for (const entry of entries) { + // Per-object matching + if (entry.object) { + const targets = Array.isArray(entry.object) ? entry.object : [entry.object]; + if (!targets.includes('*') && !targets.includes(context.object)) { + continue; // Skip non-matching hooks + } + } + await entry.handler(context); } } + /** + * Register a middleware function + * Middlewares execute in onion model around every data operation. + * @param fn The middleware function + * @param options Optional: target object filter + */ + registerMiddleware(fn: EngineMiddleware, options?: { object?: string }): void { + this.middlewares.push({ fn, object: options?.object }); + this.logger.debug('Registered middleware', { object: options?.object, total: this.middlewares.length }); + } + + /** + * Execute an operation through the middleware chain + */ + private async executeWithMiddleware(ctx: OperationContext, executor: () => Promise): Promise { + const applicable = this.middlewares.filter(m => + !m.object || m.object === '*' || m.object === ctx.object + ); + + let index = 0; + const next = async (): Promise => { + if (index < applicable.length) { + const mw = applicable[index++]; + await mw.fn(ctx, next); + } else { + ctx.result = await executor(); + } + }; + + await next(); + return ctx.result; + } + + /** + * Build a HookContext.session from ExecutionContext + */ + private buildSession(execCtx?: ExecutionContext): HookContext['session'] { + if (!execCtx) return undefined; + return { + userId: execCtx.userId, + tenantId: execCtx.tenantId, + roles: execCtx.roles, + accessToken: execCtx.accessToken, + }; + } + /** * Register contribution (Manifest) * @@ -523,26 +624,40 @@ export class ObjectQL implements IDataEngine { const driver = this.getDriver(object); const ast = this.toQueryAST(object, query); - const hookContext: HookContext = { - object, - event: 'beforeFind', - input: { ast, options: undefined }, // Should map options? - ql: this + const opCtx: OperationContext = { + object, + operation: 'find', + ast, + options: query, + context: query?.context, }; - await this.triggerHooks('beforeFind', hookContext); - try { - const result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any); - - hookContext.event = 'afterFind'; - hookContext.result = result; - await this.triggerHooks('afterFind', hookContext); - - return hookContext.result as any[]; - } catch (e) { - this.logger.error('Find operation failed', e as Error, { object }); - throw e; - } + await this.executeWithMiddleware(opCtx, async () => { + const hookContext: HookContext = { + object, + event: 'beforeFind', + input: { ast: opCtx.ast, options: opCtx.options }, + session: this.buildSession(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this + }; + await this.triggerHooks('beforeFind', hookContext); + + try { + const result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any); + + hookContext.event = 'afterFind'; + hookContext.result = result; + await this.triggerHooks('afterFind', hookContext); + + return hookContext.result; + } catch (e) { + this.logger.error('Find operation failed', e as Error, { object }); + throw e; + } + }); + + return opCtx.result as any[]; } async findOne(objectName: string, query?: DataEngineQueryOptions): Promise { @@ -552,9 +667,19 @@ export class ObjectQL implements IDataEngine { const ast = this.toQueryAST(objectName, query); ast.limit = 1; - // Reuse find logic or call generic driver.findOne if available - // Assuming driver has findOne - return driver.findOne(objectName, ast); + const opCtx: OperationContext = { + object: objectName, + operation: 'findOne', + ast, + options: query, + context: query?.context, + }; + + await this.executeWithMiddleware(opCtx, async () => { + return driver.findOne(objectName, opCtx.ast as QueryAST); + }); + + return opCtx.result; } async insert(object: string, data: any | any[], options?: DataEngineInsertOptions): Promise { @@ -562,85 +687,107 @@ export class ObjectQL implements IDataEngine { this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) }); const driver = this.getDriver(object); - const hookContext: HookContext = { - object, - event: 'beforeInsert', - input: { data, options }, - ql: this + const opCtx: OperationContext = { + object, + operation: 'insert', + data, + options, + context: options?.context, }; - await this.triggerHooks('beforeInsert', hookContext); - - try { - let result; - if (Array.isArray(hookContext.input.data)) { - // Bulk Create - if (driver.bulkCreate) { - result = await driver.bulkCreate(object, hookContext.input.data as any[], hookContext.input.options as any); + + await this.executeWithMiddleware(opCtx, async () => { + const hookContext: HookContext = { + object, + event: 'beforeInsert', + input: { data: opCtx.data, options: opCtx.options }, + session: this.buildSession(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this + }; + await this.triggerHooks('beforeInsert', hookContext); + + try { + let result; + if (Array.isArray(hookContext.input.data)) { + // Bulk Create + if (driver.bulkCreate) { + result = await driver.bulkCreate(object, hookContext.input.data as any[], hookContext.input.options as any); + } else { + // Fallback loop + result = await Promise.all((hookContext.input.data as any[]).map((item: any) => driver.create(object, item, hookContext.input.options as any))); + } } else { - // Fallback loop - result = await Promise.all((hookContext.input.data as any[]).map((item: any) => driver.create(object, item, hookContext.input.options as any))); + result = await driver.create(object, hookContext.input.data, hookContext.input.options as any); } - } else { - result = await driver.create(object, hookContext.input.data, hookContext.input.options as any); - } - hookContext.event = 'afterInsert'; - hookContext.result = result; - await this.triggerHooks('afterInsert', hookContext); + hookContext.event = 'afterInsert'; + hookContext.result = result; + await this.triggerHooks('afterInsert', hookContext); - return hookContext.result; - } catch (e) { - this.logger.error('Insert operation failed', e as Error, { object }); - throw e; - } + return hookContext.result; + } catch (e) { + this.logger.error('Insert operation failed', e as Error, { object }); + throw e; + } + }); + + return opCtx.result; } async update(object: string, data: any, options?: DataEngineUpdateOptions): Promise { object = this.resolveObjectName(object); - // NOTE: This signature is tricky because Driver expects (obj, id, data) usually. - // DataEngine protocol puts filter in options. this.logger.debug('Update operation starting', { object }); const driver = this.getDriver(object); // 1. Extract ID from data or filter if it's a single update by ID - // This is a simplification. Real implementation needs robust filter handling. let id = data.id || data._id; if (!id && options?.filter) { - // Optimization: If filter is simple ID check, extract it if (typeof options.filter === 'string') id = options.filter; else if (options.filter._id) id = options.filter._id; else if (options.filter.id) id = options.filter.id; } - const hookContext: HookContext = { - object, - event: 'beforeUpdate', - input: { id, data, options }, - ql: this - }; - await this.triggerHooks('beforeUpdate', hookContext); - - try { - let result; - if (hookContext.input.id) { - // Single update by ID - result = await driver.update(object, hookContext.input.id as string, hookContext.input.data, hookContext.input.options as any); - } else if (options?.multi && driver.updateMany) { - // Bulk update by Query - const ast = this.toQueryAST(object, { filter: options.filter }); - result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options as any); - } else { - throw new Error('Update requires an ID or options.multi=true'); - } - - hookContext.event = 'afterUpdate'; - hookContext.result = result; - await this.triggerHooks('afterUpdate', hookContext); - return hookContext.result; - } catch (e) { - this.logger.error('Update operation failed', e as Error, { object }); - throw e; - } + const opCtx: OperationContext = { + object, + operation: 'update', + data, + options, + context: options?.context, + }; + + await this.executeWithMiddleware(opCtx, async () => { + const hookContext: HookContext = { + object, + event: 'beforeUpdate', + input: { id, data: opCtx.data, options: opCtx.options }, + session: this.buildSession(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this + }; + await this.triggerHooks('beforeUpdate', hookContext); + + try { + let result; + if (hookContext.input.id) { + result = await driver.update(object, hookContext.input.id as string, hookContext.input.data, hookContext.input.options as any); + } else if (options?.multi && driver.updateMany) { + const ast = this.toQueryAST(object, { filter: options.filter }); + result = await driver.updateMany(object, ast, hookContext.input.data, hookContext.input.options as any); + } else { + throw new Error('Update requires an ID or options.multi=true'); + } + + hookContext.event = 'afterUpdate'; + hookContext.result = result; + await this.triggerHooks('afterUpdate', hookContext); + return hookContext.result; + } catch (e) { + this.logger.error('Update operation failed', e as Error, { object }); + throw e; + } + }); + + return opCtx.result; } async delete(object: string, options?: DataEngineDeleteOptions): Promise { @@ -656,45 +803,70 @@ export class ObjectQL implements IDataEngine { else if (options.filter.id) id = options.filter.id; } - const hookContext: HookContext = { - object, - event: 'beforeDelete', - input: { id, options }, - ql: this + const opCtx: OperationContext = { + object, + operation: 'delete', + options, + context: options?.context, }; - await this.triggerHooks('beforeDelete', hookContext); - try { - let result; - if (hookContext.input.id) { - result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any); - } else if (options?.multi && driver.deleteMany) { - const ast = this.toQueryAST(object, { filter: options.filter }); - result = await driver.deleteMany(object, ast, hookContext.input.options as any); - } else { - throw new Error('Delete requires an ID or options.multi=true'); - } + await this.executeWithMiddleware(opCtx, async () => { + const hookContext: HookContext = { + object, + event: 'beforeDelete', + input: { id, options: opCtx.options }, + session: this.buildSession(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this + }; + await this.triggerHooks('beforeDelete', hookContext); - hookContext.event = 'afterDelete'; - hookContext.result = result; - await this.triggerHooks('afterDelete', hookContext); - return hookContext.result; - } catch (e) { - this.logger.error('Delete operation failed', e as Error, { object }); - throw e; - } + try { + let result; + if (hookContext.input.id) { + result = await driver.delete(object, hookContext.input.id as string, hookContext.input.options as any); + } else if (options?.multi && driver.deleteMany) { + const ast = this.toQueryAST(object, { filter: options.filter }); + result = await driver.deleteMany(object, ast, hookContext.input.options as any); + } else { + throw new Error('Delete requires an ID or options.multi=true'); + } + + hookContext.event = 'afterDelete'; + hookContext.result = result; + await this.triggerHooks('afterDelete', hookContext); + return hookContext.result; + } catch (e) { + this.logger.error('Delete operation failed', e as Error, { object }); + throw e; + } + }); + + return opCtx.result; } async count(object: string, query?: DataEngineCountOptions): Promise { object = this.resolveObjectName(object); const driver = this.getDriver(object); - if (driver.count) { - const ast = this.toQueryAST(object, { filter: query?.filter }); - return driver.count(object, ast); - } - // Fallback to find().length - const res = await this.find(object, { filter: query?.filter, select: ['_id'] }); - return res.length; + + const opCtx: OperationContext = { + object, + operation: 'count', + options: query, + context: query?.context, + }; + + await this.executeWithMiddleware(opCtx, async () => { + if (driver.count) { + const ast = this.toQueryAST(object, { filter: query?.filter }); + return driver.count(object, ast); + } + // Fallback to find().length + const res = await this.find(object, { filter: query?.filter, select: ['_id'] }); + return res.length; + }); + + return opCtx.result as number; } async aggregate(object: string, query: DataEngineAggregateOptions): Promise { @@ -702,21 +874,29 @@ export class ObjectQL implements IDataEngine { const driver = this.getDriver(object); this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query); - // Build a QueryAST with groupBy and aggregations, delegate to driver.find() - // Drivers that support aggregation (e.g. InMemoryDriver) handle groupBy/aggregations - // in their find() implementation via performAggregation(). - const ast: QueryAST = { - object, - where: query.filter, - groupBy: query.groupBy, - aggregations: query.aggregations?.map(agg => ({ - function: agg.method, - field: agg.field, - alias: agg.alias || `${agg.method}_${agg.field || 'all'}`, - })), + const opCtx: OperationContext = { + object, + operation: 'aggregate', + options: query, + context: query?.context, }; - return driver.find(object, ast); + await this.executeWithMiddleware(opCtx, async () => { + const ast: QueryAST = { + object, + where: query.filter, + groupBy: query.groupBy, + aggregations: query.aggregations?.map(agg => ({ + function: agg.method, + field: agg.field, + alias: agg.alias || `${agg.method}_${agg.field || 'all'}`, + })), + }; + + return driver.find(object, ast); + }); + + return opCtx.result as any[]; } async execute(command: any, options?: Record): Promise { @@ -731,4 +911,98 @@ export class ObjectQL implements IDataEngine { } throw new Error('Execute requires options.object to select driver'); } + + /** + * Create a scoped execution context bound to this engine. + * + * Usage: + * const ctx = engine.createContext({ userId: '...', tenantId: '...' }); + * const users = ctx.object('user'); + * await users.find({ filter: { status: 'active' } }); + */ + createContext(ctx: Partial): ScopedContext { + return new ScopedContext( + ExecutionContextSchema.parse(ctx), + this + ); + } +} + +/** + * Repository scoped to a single object, bound to an execution context. + */ +export class ObjectRepository { + constructor( + private objectName: string, + private context: ExecutionContext, + private engine: IDataEngine + ) {} + + async find(query: any = {}): Promise { + return this.engine.find(this.objectName, { + ...query, + context: this.context, + }); + } + + async findOne(query: any = {}): Promise { + return this.engine.findOne(this.objectName, { + ...query, + context: this.context, + }); + } + + async insert(data: any): Promise { + return this.engine.insert(this.objectName, data, { + context: this.context, + }); + } + + async update(data: any, options: any = {}): Promise { + return this.engine.update(this.objectName, data, { + ...options, + context: this.context, + }); + } + + async delete(options: any = {}): Promise { + return this.engine.delete(this.objectName, { + ...options, + context: this.context, + }); + } + + async count(query: any = {}): Promise { + return this.engine.count(this.objectName, { + ...query, + context: this.context, + }); + } +} + +/** + * Scoped execution context with object() accessor. + */ +export class ScopedContext { + constructor( + private executionContext: ExecutionContext, + private engine: IDataEngine + ) {} + + /** Get a repository scoped to this context */ + object(name: string): ObjectRepository { + return new ObjectRepository(name, this.executionContext, this.engine); + } + + /** Create an elevated (system) context */ + sudo(): ScopedContext { + return new ScopedContext( + { ...this.executionContext, isSystem: true }, + this.engine + ); + } + + get userId() { return this.executionContext.userId; } + get tenantId() { return this.executionContext.tenantId; } + get roles() { return this.executionContext.roles; } } diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index e21c733f0..a9a671977 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -15,10 +15,11 @@ export type { ObjectContributor } from './registry.js'; export { ObjectStackProtocolImplementation } from './protocol.js'; // Export Engine -export { ObjectQL } from './engine.js'; -export type { ObjectQLHostContext, HookHandler } from './engine.js'; +export { ObjectQL, ObjectRepository, ScopedContext } from './engine.js'; +export type { ObjectQLHostContext, HookHandler, HookEntry, OperationContext, EngineMiddleware } from './engine.js'; + +// Export MetadataFacade +export { MetadataFacade } from './metadata-facade.js'; // Export Plugin Shim export { ObjectQLPlugin } from './plugin.js'; - -// Moved logic to engine.ts diff --git a/packages/objectql/src/metadata-facade.ts b/packages/objectql/src/metadata-facade.ts new file mode 100644 index 000000000..d5b198f9a --- /dev/null +++ b/packages/objectql/src/metadata-facade.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { SchemaRegistry } from './registry.js'; + +/** + * MetadataFacade + * + * Provides a clean, injectable interface over SchemaRegistry. + * Registered as the 'metadata' kernel service to eliminate + * downstream packages needing to manually wrap SchemaRegistry. + */ +export class MetadataFacade { + /** + * Register a metadata item + */ + register(type: string, definition: any): void { + if (type === 'object') { + SchemaRegistry.registerItem(type, definition, 'name' as any); + } else { + SchemaRegistry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any); + } + } + + /** + * Get a metadata item by type and name + */ + get(type: string, name: string): any { + const item = SchemaRegistry.getItem(type, name) as any; + return item?.content ?? item; + } + + /** + * Get the raw entry (with metadata wrapper) + */ + getEntry(type: string, name: string): any { + return SchemaRegistry.getItem(type, name); + } + + /** + * List all items of a type + */ + list(type: string): any[] { + const items = SchemaRegistry.listItems(type); + return items.map((item: any) => item?.content ?? item); + } + + /** + * Unregister a metadata item + */ + unregister(type: string, name: string): void { + SchemaRegistry.unregisterItem(type, name); + } + + /** + * Unregister all metadata from a package + */ + unregisterPackage(packageName: string): void { + SchemaRegistry.unregisterObjectsByPackage(packageName); + } + + /** + * Convenience: get object definition + */ + getObject(name: string): any { + return SchemaRegistry.getObject(name); + } + + /** + * Convenience: list all objects + */ + listObjects(): any[] { + return SchemaRegistry.getAllObjects(); + } +} diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 7f63b4c01..040c55cb8 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectQL } from './engine.js'; +import { MetadataFacade } from './metadata-facade.js'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { Plugin, PluginContext } from '@objectstack/core'; @@ -33,7 +34,7 @@ export class ObjectQLPlugin implements Plugin { // Register as provider for Core Kernel Services ctx.registerService('objectql', this.ql); - // Respect existing metadata service (e.g. from MetadataPlugin) + // Register MetadataFacade as metadata service (unless external service exists) let hasMetadata = false; let metadataProvider = 'objectql'; try { @@ -47,8 +48,9 @@ export class ObjectQLPlugin implements Plugin { if (!hasMetadata) { try { - ctx.registerService('metadata', this.ql); - ctx.logger.info('ObjectQL providing metadata service (fallback mode)', { + const metadataFacade = new MetadataFacade(); + ctx.registerService('metadata', metadataFacade); + ctx.logger.info('MetadataFacade registered as metadata service', { mode: 'in-memory', features: ['registry', 'fast-lookup'] }); @@ -88,7 +90,8 @@ export class ObjectQLPlugin implements Plugin { // Check if we should load from external metadata service try { const metadataService = ctx.getService('metadata') as any; - if (metadataService && metadataService !== this.ql && this.ql) { + // Only sync if metadata service is external (not our own MetadataFacade) + if (metadataService && !(metadataService instanceof MetadataFacade) && this.ql) { await this.loadMetadataFromService(metadataService, ctx); } } catch (e: any) { @@ -112,6 +115,12 @@ export class ObjectQLPlugin implements Plugin { } } } + + // Register built-in audit hooks + this.registerAuditHooks(ctx); + + // Register tenant isolation middleware + this.registerTenantMiddleware(ctx); ctx.logger.info('ObjectQL engine started', { driversRegistered: this.ql?.['drivers']?.size || 0, @@ -119,6 +128,106 @@ export class ObjectQLPlugin implements Plugin { }); } + /** + * Register built-in audit hooks for auto-stamping createdBy/modifiedBy + * and fetching previousData for update/delete operations. + */ + private registerAuditHooks(ctx: PluginContext) { + if (!this.ql) return; + + // Auto-stamp createdBy/modifiedBy on insert + this.ql.registerHook('beforeInsert', async (hookCtx) => { + if (hookCtx.session?.userId && hookCtx.input?.data) { + const data = hookCtx.input.data as Record; + if (typeof data === 'object' && data !== null) { + data.created_by = data.created_by ?? hookCtx.session.userId; + data.modified_by = hookCtx.session.userId; + data.created_at = data.created_at ?? new Date().toISOString(); + data.modified_at = new Date().toISOString(); + if (hookCtx.session.tenantId) { + data.space_id = data.space_id ?? hookCtx.session.tenantId; + } + } + } + }, { object: '*', priority: 10 }); + + // Auto-stamp modifiedBy on update + this.ql.registerHook('beforeUpdate', async (hookCtx) => { + if (hookCtx.session?.userId && hookCtx.input?.data) { + const data = hookCtx.input.data as Record; + if (typeof data === 'object' && data !== null) { + data.modified_by = hookCtx.session.userId; + data.modified_at = new Date().toISOString(); + } + } + }, { object: '*', priority: 10 }); + + // Auto-fetch previousData for update hooks + this.ql.registerHook('beforeUpdate', async (hookCtx) => { + if (hookCtx.input?.id && !hookCtx.previous) { + try { + const existing = await this.ql!.findOne(hookCtx.object, { + filter: { _id: hookCtx.input.id } + }); + if (existing) { + hookCtx.previous = existing; + } + } catch (_e) { + // Non-fatal: some objects may not support findOne + } + } + }, { object: '*', priority: 5 }); + + // Auto-fetch previousData for delete hooks + this.ql.registerHook('beforeDelete', async (hookCtx) => { + if (hookCtx.input?.id && !hookCtx.previous) { + try { + const existing = await this.ql!.findOne(hookCtx.object, { + filter: { _id: hookCtx.input.id } + }); + if (existing) { + hookCtx.previous = existing; + } + } catch (_e) { + // Non-fatal + } + } + }, { object: '*', priority: 5 }); + + ctx.logger.debug('Audit hooks registered (createdBy/modifiedBy, previousData)'); + } + + /** + * Register tenant isolation middleware that auto-injects space_id filter + * for multi-tenant operations. + */ + private registerTenantMiddleware(ctx: PluginContext) { + if (!this.ql) return; + + this.ql.registerMiddleware(async (opCtx, next) => { + // Only apply to operations with tenantId that are not system-level + if (!opCtx.context?.tenantId || opCtx.context?.isSystem) { + return next(); + } + + // Read operations: inject space_id filter into AST + if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { + if (opCtx.ast) { + const tenantFilter = { space_id: opCtx.context.tenantId }; + if (opCtx.ast.where) { + opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] }; + } else { + opCtx.ast.where = tenantFilter; + } + } + } + + await next(); + }); + + ctx.logger.debug('Tenant isolation middleware registered'); + } + /** * Load metadata from external metadata service into ObjectQL registry * This enables ObjectQL to use file-based or remote metadata diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index ee909a91f..0b827feed 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -105,6 +105,24 @@ export class AuthPlugin implements Plugin { } } + // Register auth middleware on ObjectQL engine (if available) + try { + const ql = ctx.getService('objectql'); + if (ql && typeof ql.registerMiddleware === 'function') { + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + // If context already has userId or isSystem, skip auth resolution + if (opCtx.context?.userId || opCtx.context?.isSystem) { + return next(); + } + // Future: resolve session from AsyncLocalStorage or request context + await next(); + }); + ctx.logger.info('Auth middleware registered on ObjectQL engine'); + } + } catch (_e) { + ctx.logger.debug('ObjectQL engine not available, skipping auth middleware registration'); + } + ctx.logger.info('Auth Plugin started successfully'); } diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json new file mode 100644 index 000000000..8a0783d94 --- /dev/null +++ b/packages/plugins/plugin-security/package.json @@ -0,0 +1,28 @@ +{ + "name": "@objectstack/plugin-security", + "version": "2.0.4", + "license": "Apache-2.0", + "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.2", + "typescript": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/plugins/plugin-security/src/field-masker.ts b/packages/plugins/plugin-security/src/field-masker.ts new file mode 100644 index 000000000..e532db942 --- /dev/null +++ b/packages/plugins/plugin-security/src/field-masker.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { FieldPermission } from '@objectstack/spec/security'; + +/** + * FieldMasker + * + * Applies field-level security by stripping restricted fields from query results. + */ +export class FieldMasker { + /** + * Mask fields in query results based on field permissions. + * Removes fields that the user does not have read access to. + */ + maskResults( + results: any | any[], + fieldPermissions: Record, + _objectName: string + ): any | any[] { + // If no field permissions defined, return results as-is + if (Object.keys(fieldPermissions).length === 0) return results; + + // Get list of non-readable fields + const hiddenFields = Object.entries(fieldPermissions) + .filter(([, perm]) => !perm.readable) + .map(([field]) => field); + + if (hiddenFields.length === 0) return results; + + if (Array.isArray(results)) { + return results.map(record => this.maskRecord(record, hiddenFields)); + } + + return this.maskRecord(results, hiddenFields); + } + + /** + * Get non-editable fields for use in write operations. + * Returns a list of field names that should be stripped from incoming data. + */ + getNonEditableFields( + fieldPermissions: Record + ): string[] { + return Object.entries(fieldPermissions) + .filter(([, perm]) => !perm.editable) + .map(([field]) => field); + } + + /** + * Strip non-editable fields from write data. + */ + stripNonEditableFields( + data: Record, + fieldPermissions: Record + ): Record { + const nonEditable = this.getNonEditableFields(fieldPermissions); + if (nonEditable.length === 0) return data; + + const result = { ...data }; + for (const field of nonEditable) { + delete result[field]; + } + return result; + } + + private maskRecord(record: any, hiddenFields: string[]): any { + if (!record || typeof record !== 'object') return record; + + const result = { ...record }; + for (const field of hiddenFields) { + delete result[field]; + } + return result; + } +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts new file mode 100644 index 000000000..cca27a626 --- /dev/null +++ b/packages/plugins/plugin-security/src/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/plugin-security + * + * Security Plugin for ObjectStack + * Provides RBAC, Row-Level Security (RLS), and Field-Level Security runtime. + */ + +export { SecurityPlugin } from './security-plugin.js'; +export { PermissionEvaluator } from './permission-evaluator.js'; +export { RLSCompiler } from './rls-compiler.js'; +export { FieldMasker } from './field-masker.js'; diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts new file mode 100644 index 000000000..61228f8ab --- /dev/null +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { PermissionSet, ObjectPermission, FieldPermission } from '@objectstack/spec/security'; + +/** + * Operation type mapping to permission checks + */ +const OPERATION_TO_PERMISSION: Record = { + find: 'allowRead', + findOne: 'allowRead', + count: 'allowRead', + aggregate: 'allowRead', + insert: 'allowCreate', + update: 'allowEdit', + delete: 'allowDelete', +}; + +/** + * PermissionEvaluator + * + * Runtime evaluator for PermissionSet definitions. + * Resolves aggregated permissions from roles to concrete allow/deny decisions. + */ +export class PermissionEvaluator { + /** + * Check if an operation is allowed on an object for the given permission sets. + * Uses "most permissive" merging: if ANY permission set allows, it's allowed. + */ + checkObjectPermission( + operation: string, + objectName: string, + permissionSets: PermissionSet[] + ): boolean { + const permKey = OPERATION_TO_PERMISSION[operation]; + if (!permKey) return true; // Unknown operations are allowed by default + + for (const ps of permissionSets) { + const objPerm = ps.objects?.[objectName]; + if (objPerm) { + // Check if modifyAllRecords is set (super-user bypass for write ops) + if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) { + return true; + } + // Check if viewAllRecords is set (super-user bypass for read ops) + if (permKey === 'allowRead' && (objPerm.viewAllRecords || objPerm.modifyAllRecords)) { + return true; + } + // Check the specific permission + if (objPerm[permKey]) { + return true; + } + } + } + + return false; + } + + /** + * Get the merged field permissions for an object. + * Returns a map of field names to their effective permissions. + * Uses "most permissive" merging. + */ + getFieldPermissions( + objectName: string, + permissionSets: PermissionSet[] + ): Record { + const result: Record = {}; + + for (const ps of permissionSets) { + if (!ps.fields) continue; + + for (const [key, perm] of Object.entries(ps.fields)) { + // Field keys are in format: "object_name.field_name" + if (!key.startsWith(`${objectName}.`)) continue; + const fieldName = key.substring(objectName.length + 1); + + if (!result[fieldName]) { + result[fieldName] = { readable: false, editable: false }; + } + + // Most permissive merge + if (perm.readable) result[fieldName].readable = true; + if (perm.editable) result[fieldName].editable = true; + } + } + + return result; + } + + /** + * Resolve permission sets for a list of role names from metadata. + */ + resolvePermissionSets( + roles: string[], + metadataService: any + ): PermissionSet[] { + const result: PermissionSet[] = []; + + // Get all permission sets from metadata + const allPermSets = metadataService.list?.('permissions') || []; + + for (const ps of allPermSets) { + // A permission set is relevant if it's a profile assigned to any of the user's roles, + // or if the role name matches the permission set name + if (roles.includes(ps.name)) { + result.push(ps); + } + } + + return result; + } +} diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts new file mode 100644 index 000000000..d061fe3bf --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** + * RLS User Context + * Variables available for RLS expression evaluation. + */ +interface RLSUserContext { + id?: string; + tenant_id?: string; + roles?: string[]; + [key: string]: unknown; +} + +/** + * RLSCompiler + * + * Compiles Row-Level Security policy expressions into query filters. + * Converts `using` / `check` expressions into ObjectQL-compatible filter conditions. + */ +export class RLSCompiler { + /** + * Compile RLS policies into a query filter for the given user context. + * Multiple policies for the same object/operation are OR-combined (any match allows access). + */ + compileFilter( + policies: RowLevelSecurityPolicy[], + executionContext?: ExecutionContext + ): Record | null { + if (policies.length === 0) return null; + + const userCtx: RLSUserContext = { + id: executionContext?.userId, + tenant_id: executionContext?.tenantId, + roles: executionContext?.roles, + }; + + const filters: Record[] = []; + + for (const policy of policies) { + if (!policy.using) continue; + const filter = this.compileExpression(policy.using, userCtx); + if (filter) { + filters.push(filter); + } + } + + if (filters.length === 0) return null; + if (filters.length === 1) return filters[0]; + + // Multiple policies: OR-combine (any policy allows access) + return { $or: filters }; + } + + /** + * Compile a single RLS expression into a query filter. + * + * Supports simple expressions like: + * - "field_name = current_user.property" + * - "field_name IN (current_user.array_property)" + * - "field_name = 'literal_value'" + */ + compileExpression( + expression: string, + userCtx: RLSUserContext + ): Record | null { + if (!expression) return null; + + // Handle simple equality: "field = current_user.property" + const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/); + if (eqMatch) { + const [, field, prop] = eqMatch; + const value = userCtx[prop]; + if (value === undefined) return null; + return { [field]: value }; + } + + // Handle literal equality: "field = 'value'" + const litMatch = expression.match(/^\s*(\w+)\s*=\s*'([^']*)'\s*$/); + if (litMatch) { + const [, field, value] = litMatch; + return { [field]: value }; + } + + // Handle IN: "field IN (current_user.array_property)" + const inMatch = expression.match(/^\s*(\w+)\s+IN\s+\(\s*current_user\.(\w+)\s*\)\s*$/i); + if (inMatch) { + const [, field, prop] = inMatch; + const value = userCtx[prop]; + if (!Array.isArray(value)) return null; + return { [field]: { $in: value } }; + } + + // Unsupported expression: return null (no additional RLS filter applied). + // Note: callers should treat absence of RLS policies as "allow all" only when + // no policies are defined. If policies exist but cannot be compiled, the caller + // may want to deny access as a safety measure. + return null; + } + + /** + * Get applicable RLS policies for a given object and operation. + */ + getApplicablePolicies( + objectName: string, + operation: string, + allPolicies: RowLevelSecurityPolicy[] + ): RowLevelSecurityPolicy[] { + // Map engine operation to RLS operation type + const rlsOp = this.mapOperationToRLS(operation); + + return allPolicies.filter(policy => { + // Check object match + if (policy.object !== objectName && policy.object !== '*') return false; + + // Check operation match + if (policy.operation === 'all') return true; + if (policy.operation === rlsOp) return true; + + return false; + }); + } + + private mapOperationToRLS(operation: string): string { + switch (operation) { + case 'find': + case 'findOne': + case 'count': + case 'aggregate': + return 'select'; + case 'insert': + return 'insert'; + case 'update': + return 'update'; + case 'delete': + return 'delete'; + default: + return 'select'; + } + } +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts new file mode 100644 index 000000000..6ae1304eb --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { Plugin, PluginContext } from '@objectstack/core'; +import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { PermissionEvaluator } from './permission-evaluator.js'; +import { RLSCompiler } from './rls-compiler.js'; +import { FieldMasker } from './field-masker.js'; + +/** + * SecurityPlugin + * + * Provides RBAC, Row-Level Security, and Field-Level Security runtime. + * Registers as an engine middleware on the ObjectQL engine. + * + * This plugin is fully optional — without it, the system operates + * without permission checks (same as current behavior). + * + * Dependencies: + * - objectql service (ObjectQL engine with middleware support) + * - metadata service (MetadataFacade for reading permission sets and RLS policies) + */ +export class SecurityPlugin implements Plugin { + name = 'com.objectstack.security'; + type = 'standard'; + version = '1.0.0'; + dependencies = ['com.objectstack.engine.objectql']; + + private permissionEvaluator = new PermissionEvaluator(); + private rlsCompiler = new RLSCompiler(); + private fieldMasker = new FieldMasker(); + + async init(ctx: PluginContext): Promise { + ctx.logger.info('Initializing Security Plugin...'); + + // Register security services + ctx.registerService('security.permissions', this.permissionEvaluator); + ctx.registerService('security.rls', this.rlsCompiler); + ctx.registerService('security.fieldMasker', this.fieldMasker); + + ctx.logger.info('Security Plugin initialized'); + } + + async start(ctx: PluginContext): Promise { + ctx.logger.info('Starting Security Plugin...'); + + // Get required services + let ql: any; + let metadata: any; + + try { + ql = ctx.getService('objectql'); + metadata = ctx.getService('metadata'); + } catch (e) { + ctx.logger.warn('ObjectQL or metadata service not available, security middleware not registered'); + return; + } + + if (!ql || typeof ql.registerMiddleware !== 'function') { + ctx.logger.warn('ObjectQL engine does not support middleware, security middleware not registered'); + return; + } + + // Register security middleware + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + // System operations bypass security + if (opCtx.context?.isSystem) { + return next(); + } + + const roles = opCtx.context?.roles ?? []; + + // Skip security checks if no roles (anonymous/unauthenticated) + // The auth middleware should handle authentication separately + if (roles.length === 0 && !opCtx.context?.userId) { + return next(); + } + + // 1. Resolve permission sets for the user's roles + let permissionSets: PermissionSet[] = []; + try { + permissionSets = this.permissionEvaluator.resolvePermissionSets(roles, metadata); + } catch (e) { + // If metadata service is misconfigured, log and continue without permission checks + // rather than blocking all operations + return next(); + } + + // 2. CRUD permission check + if (permissionSets.length > 0) { + const allowed = this.permissionEvaluator.checkObjectPermission( + opCtx.operation, + opCtx.object, + permissionSets + ); + + if (!allowed) { + throw new Error( + `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` + + `is not permitted for roles [${roles.join(', ')}]` + ); + } + } + + // 3. RLS filter injection + const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation); + if (allRlsPolicies.length > 0 && opCtx.ast) { + const rlsFilter = this.rlsCompiler.compileFilter(allRlsPolicies, opCtx.context); + if (rlsFilter) { + if (opCtx.ast.where) { + opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] }; + } else { + opCtx.ast.where = rlsFilter; + } + } + } + + await next(); + + // 4. Field-level security: mask restricted fields in read results + if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) { + const fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); + if (Object.keys(fieldPerms).length > 0) { + opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object); + } + } + }); + + ctx.logger.info('Security middleware registered on ObjectQL engine'); + } + + async destroy(): Promise { + // No cleanup needed + } + + /** + * Collect all RLS policies from permission sets applicable to the given object/operation. + */ + private collectRLSPolicies( + permissionSets: PermissionSet[], + objectName: string, + operation: string + ): RowLevelSecurityPolicy[] { + const allPolicies: RowLevelSecurityPolicy[] = []; + + for (const ps of permissionSets) { + if (ps.rowLevelSecurity) { + allPolicies.push(...ps.rowLevelSecurity); + } + } + + return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies); + } +} diff --git a/packages/plugins/plugin-security/tsconfig.json b/packages/plugins/plugin-security/tsconfig.json new file mode 100644 index 000000000..ead733427 --- /dev/null +++ b/packages/plugins/plugin-security/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/spec/package.json b/packages/spec/package.json index 5569bca3a..5a24a8809 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -76,6 +76,11 @@ "import": "./dist/permission/index.mjs", "require": "./dist/permission/index.js" }, + "./security": { + "types": "./dist/security/index.d.ts", + "import": "./dist/security/index.mjs", + "require": "./dist/security/index.js" + }, "./studio": { "types": "./dist/studio/index.d.ts", "import": "./dist/studio/index.mjs", diff --git a/packages/spec/src/data/data-engine.test.ts b/packages/spec/src/data/data-engine.test.ts index 2e1088917..9527cdebc 100644 --- a/packages/spec/src/data/data-engine.test.ts +++ b/packages/spec/src/data/data-engine.test.ts @@ -644,6 +644,82 @@ describe('DataEngineRequestSchema', () => { }); describe('Integration Tests', () => { + it('should support context in query options', () => { + const options = DataEngineQueryOptionsSchema.parse({ + filter: { status: 'active' }, + context: { + userId: 'user_123', + tenantId: 'org_456', + roles: ['admin'], + }, + }); + + expect(options.context?.userId).toBe('user_123'); + expect(options.context?.tenantId).toBe('org_456'); + expect(options.context?.roles).toEqual(['admin']); + }); + + it('should support context in insert options', () => { + const options = DataEngineInsertOptionsSchema.parse({ + returning: true, + context: { userId: 'user_1', isSystem: false }, + }); + + expect(options.context?.userId).toBe('user_1'); + }); + + it('should support context in update options', () => { + const options = DataEngineUpdateOptionsSchema.parse({ + filter: { id: '1' }, + context: { userId: 'user_1', tenantId: 'org_1' }, + }); + + expect(options.context?.tenantId).toBe('org_1'); + }); + + it('should support context in delete options', () => { + const options = DataEngineDeleteOptionsSchema.parse({ + filter: { id: '1' }, + context: { isSystem: true }, + }); + + expect(options.context?.isSystem).toBe(true); + }); + + it('should support context in count options', () => { + const options = DataEngineCountOptionsSchema.parse({ + filter: { status: 'active' }, + context: { userId: 'u1' }, + }); + + expect(options.context?.userId).toBe('u1'); + }); + + it('should support context in aggregate options', () => { + const options = DataEngineAggregateOptionsSchema.parse({ + groupBy: ['status'], + context: { userId: 'u1', roles: ['analyst'] }, + }); + + expect(options.context?.roles).toEqual(['analyst']); + }); + + it('should accept options without context (backward compatible)', () => { + const queryOpts = DataEngineQueryOptionsSchema.parse({ filter: { x: 1 } }); + const insertOpts = DataEngineInsertOptionsSchema.parse({ returning: true }); + const updateOpts = DataEngineUpdateOptionsSchema.parse({ upsert: true }); + const deleteOpts = DataEngineDeleteOptionsSchema.parse({ multi: true }); + const countOpts = DataEngineCountOptionsSchema.parse({}); + const aggOpts = DataEngineAggregateOptionsSchema.parse({ groupBy: ['a'] }); + + expect(queryOpts.context).toBeUndefined(); + expect(insertOpts.context).toBeUndefined(); + expect(updateOpts.context).toBeUndefined(); + expect(deleteOpts.context).toBeUndefined(); + expect(countOpts.context).toBeUndefined(); + expect(aggOpts.context).toBeUndefined(); + }); + it('should support complete CRUD workflow', () => { // Create const insertRequest = DataEngineInsertRequestSchema.parse({ diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index d19f8f254..d8ed146e4 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { FilterConditionSchema } from './filter.zod'; import { SortNodeSchema } from './query.zod'; +import { ExecutionContextSchema } from '../kernel/execution-context.zod'; /** * Data Engine Protocol @@ -41,11 +42,26 @@ export const DataEngineSortSchema = z.union([ z.array(SortNodeSchema) ]).describe('Sort order definition'); +// ========================================================================== +// 1b. Base Engine Options (shared context) +// ========================================================================== + +/** + * Base Engine Options + * + * All Data Engine operation options extend this schema to carry + * an optional ExecutionContext for identity, tenant, and transaction propagation. + */ +export const BaseEngineOptionsSchema = z.object({ + /** Execution context (identity, tenant, transaction) */ + context: ExecutionContextSchema.optional(), +}); + // ========================================================================== // 2. method: FIND // ========================================================================== -export const DataEngineQueryOptionsSchema = z.object({ +export const DataEngineQueryOptionsSchema = BaseEngineOptionsSchema.extend({ /** Filter conditions (WHERE) */ filter: DataEngineFilterSchema.optional(), @@ -78,7 +94,7 @@ export const DataEngineQueryOptionsSchema = z.object({ // 3. method: INSERT // ========================================================================== -export const DataEngineInsertOptionsSchema = z.object({ +export const DataEngineInsertOptionsSchema = BaseEngineOptionsSchema.extend({ /** * Return the inserted record(s)? * Some drivers support RETURNING clause for efficiency. @@ -91,7 +107,7 @@ export const DataEngineInsertOptionsSchema = z.object({ // 4. method: UPDATE // ========================================================================== -export const DataEngineUpdateOptionsSchema = z.object({ +export const DataEngineUpdateOptionsSchema = BaseEngineOptionsSchema.extend({ /** Filter conditions to identify records to update */ filter: DataEngineFilterSchema.optional(), @@ -119,7 +135,7 @@ export const DataEngineUpdateOptionsSchema = z.object({ // 5. method: DELETE // ========================================================================== -export const DataEngineDeleteOptionsSchema = z.object({ +export const DataEngineDeleteOptionsSchema = BaseEngineOptionsSchema.extend({ /** Filter conditions to identify records to delete */ filter: DataEngineFilterSchema.optional(), @@ -135,7 +151,7 @@ export const DataEngineDeleteOptionsSchema = z.object({ // 6. method: AGGREGATE // ========================================================================== -export const DataEngineAggregateOptionsSchema = z.object({ +export const DataEngineAggregateOptionsSchema = BaseEngineOptionsSchema.extend({ /** Filter conditions (WHERE) */ filter: DataEngineFilterSchema.optional(), @@ -157,7 +173,7 @@ export const DataEngineAggregateOptionsSchema = z.object({ // 7. method: COUNT // ========================================================================== -export const DataEngineCountOptionsSchema = z.object({ +export const DataEngineCountOptionsSchema = BaseEngineOptionsSchema.extend({ /** Filter conditions */ filter: DataEngineFilterSchema.optional(), }).describe('Options for DataEngine.count operations'); @@ -335,6 +351,7 @@ export const DataEngineRequestSchema = z.discriminatedUnion('method', [ export type DataEngineFilter = z.infer; export type DataEngineSort = z.infer; +export type BaseEngineOptions = z.infer; export type DataEngineQueryOptions = z.infer; export type DataEngineInsertOptions = z.infer; export type DataEngineUpdateOptions = z.infer; diff --git a/packages/spec/src/kernel/execution-context.test.ts b/packages/spec/src/kernel/execution-context.test.ts new file mode 100644 index 000000000..d127d0b98 --- /dev/null +++ b/packages/spec/src/kernel/execution-context.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { ExecutionContextSchema } from './execution-context.zod'; + +describe('ExecutionContextSchema', () => { + it('should accept empty context (all optional)', () => { + const ctx = ExecutionContextSchema.parse({}); + expect(ctx.roles).toEqual([]); + expect(ctx.permissions).toEqual([]); + expect(ctx.isSystem).toBe(false); + }); + + it('should accept full context', () => { + const ctx = ExecutionContextSchema.parse({ + userId: 'user_123', + tenantId: 'org_456', + roles: ['admin', 'editor'], + permissions: ['read:account', 'write:account'], + isSystem: false, + accessToken: 'Bearer abc', + traceId: 'trace-789', + }); + + expect(ctx.userId).toBe('user_123'); + expect(ctx.tenantId).toBe('org_456'); + expect(ctx.roles).toEqual(['admin', 'editor']); + expect(ctx.permissions).toEqual(['read:account', 'write:account']); + expect(ctx.isSystem).toBe(false); + expect(ctx.accessToken).toBe('Bearer abc'); + expect(ctx.traceId).toBe('trace-789'); + }); + + it('should default roles and permissions to empty arrays', () => { + const ctx = ExecutionContextSchema.parse({ userId: 'u1' }); + expect(ctx.roles).toEqual([]); + expect(ctx.permissions).toEqual([]); + }); + + it('should default isSystem to false', () => { + const ctx = ExecutionContextSchema.parse({}); + expect(ctx.isSystem).toBe(false); + }); + + it('should accept system context', () => { + const ctx = ExecutionContextSchema.parse({ isSystem: true }); + expect(ctx.isSystem).toBe(true); + }); + + it('should accept transaction handle', () => { + const mockTx = { id: 'tx1', commit: () => {} }; + const ctx = ExecutionContextSchema.parse({ transaction: mockTx }); + expect(ctx.transaction).toBeDefined(); + }); +}); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts new file mode 100644 index 000000000..8655671db --- /dev/null +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Execution Context Schema + * + * Defines the runtime context that flows from HTTP request → data operations. + * This is the "identity + environment" envelope that every data operation can carry. + * + * Design: + * - All fields are optional for backward compatibility + * - `isSystem` bypasses permission checks (for internal/migration operations) + * - `transaction` carries the database transaction handle for atomicity + * - `traceId` enables distributed tracing across microservices + * + * Usage: + * engine.find('account', { context: { userId: '...', tenantId: '...' } }) + */ +export const ExecutionContextSchema = z.object({ + /** Current user ID (resolved from session) */ + userId: z.string().optional(), + + /** Current organization/tenant ID (resolved from session.activeOrganizationId) */ + tenantId: z.string().optional(), + + /** User role names (resolved from Member + Role) */ + roles: z.array(z.string()).default([]), + + /** Aggregated permission names (resolved from PermissionSet) */ + permissions: z.array(z.string()).default([]), + + /** Whether this is a system-level operation (bypasses permission checks) */ + isSystem: z.boolean().default(false), + + /** Raw access token (for external API call pass-through) */ + accessToken: z.string().optional(), + + /** Database transaction handle */ + transaction: z.unknown().optional(), + + /** Request trace ID (for distributed tracing) */ + traceId: z.string().optional(), +}); + +export type ExecutionContext = z.infer; diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 78d7601a5..b19100af6 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -20,3 +20,4 @@ export * from './service-registry.zod'; export * from './startup-orchestrator.zod'; export * from './plugin-registry.zod'; export * from './plugin-security.zod'; +export * from './execution-context.zod'; diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 58f7b98b0..5ce3cbdd0 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'src/hub/index.ts', 'src/ai/index.ts', 'src/permission/index.ts', + 'src/security/index.ts', 'src/contracts/index.ts', 'src/integration/index.ts', 'src/studio/index.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5fcb3b5c..c0083201e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,6 +741,25 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.2)(happy-dom@20.5.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0) + packages/plugins/plugin-security: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.2.2 + version: 25.2.2 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.2)(happy-dom@20.5.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0) + packages/rest: dependencies: '@objectstack/core':