Kernel Upgrade: ExecutionContext propagation, per-object hooks, middleware chain, plugin-security#594
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ine options Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ry, audit hooks, tenant middleware Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…field masker Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
… error handling Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive kernel upgrade that introduces execution context propagation, per-object hooks with priority, middleware chain (onion model), and a new security plugin. It bridges the authentication layer (plugin-auth) with data operations by flowing user identity, tenant context, and permissions through the entire stack.
Changes:
- Adds
ExecutionContextprotocol to spec package with userId, tenantId, roles, permissions, and transaction support - Refactors ObjectQL engine to support per-object hooks with priority and middleware chain wrapping all CRUD operations
- Introduces
MetadataFacadeas injectable wrapper over SchemaRegistry - Adds
ObjectRepositoryandScopedContextfor ergonomic context-bound data access - Implements
SecurityPluginwith RBAC evaluation, RLS filter compilation, and field-level security - Adds built-in audit hooks for auto-stamping created_by/modified_by and tenant isolation middleware
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
packages/spec/src/kernel/execution-context.zod.ts |
Defines ExecutionContext schema with user/tenant/permissions/transaction fields |
packages/spec/src/data/data-engine.zod.ts |
Adds BaseEngineOptions with optional context to all 6 CRUD operation schemas |
packages/spec/package.json |
Exports new ./security subpath for permission/RLS schemas |
packages/objectql/src/engine.ts |
Refactors hooks to Map-based with priority, adds middleware chain, propagates context through all operations, adds ObjectRepository/ScopedContext |
packages/objectql/src/metadata-facade.ts |
Provides injectable interface over SchemaRegistry for metadata service |
packages/objectql/src/plugin.ts |
Registers MetadataFacade, adds audit hooks and tenant isolation middleware |
packages/plugins/plugin-auth/src/auth-plugin.ts |
Registers placeholder auth middleware on ObjectQL engine |
packages/plugins/plugin-security/* |
New plugin with PermissionEvaluator, RLSCompiler, FieldMasker, and SecurityPlugin |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| this.ql.registerHook('beforeInsert', async (hookCtx) => { | ||
| if (hookCtx.session?.userId && hookCtx.input?.data) { | ||
| const data = hookCtx.input.data as Record<string, any>; | ||
| 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 }); |
There was a problem hiding this comment.
The audit hook for beforeInsert does not handle array data correctly. When hookCtx.input.data is an array (bulk insert), the code treats it as a single object and tries to stamp audit fields on the array itself rather than on each element.
This will fail to add audit fields (created_by, modified_by, etc.) to bulk insert operations. The hook should check if data is an array and iterate over each element to apply the audit stamps.
| compileExpression( | ||
| expression: string, | ||
| userCtx: RLSUserContext | ||
| ): Record<string, unknown> | 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; | ||
| } |
There was a problem hiding this comment.
The RLS compiler's expression parsing is overly simplistic and could lead to security vulnerabilities or incorrect access control.
Issues:
- Only supports basic patterns (
field = current_user.property,field IN (...),field = 'literal') - No validation that the expression is safe (could contain malicious SQL if extended)
- Returns
nullfor unsupported expressions without logging or error - this could silently bypass security policies
Consider:
- Adding validation to reject potentially unsafe expressions
- Logging warnings when policies cannot be compiled
- Documenting the subset of RLS expressions currently supported
- Adding comprehensive test coverage for expression parsing edge cases
| 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'); | ||
| } |
There was a problem hiding this comment.
The tenant isolation middleware only applies filters to read operations (find, findOne, count, aggregate) but does NOT enforce tenant isolation on write operations (insert, update, delete).
This is a critical multi-tenant security gap:
- Users can insert records with arbitrary
space_idvalues, potentially creating records in other tenants - Users can update/delete records in other tenants by manipulating the filter to bypass
space_idchecks - The audit hook stamps
space_idon insert (line 148), but this occurs AFTER middleware, so malicious users could override it
Consider:
- Adding tenant validation to update/delete operations to prevent cross-tenant modifications
- Validating that inserted records have the correct
space_idAFTER the audit hook runs - Moving tenant enforcement to middleware that runs BEFORE hooks to prevent bypass
| export class ObjectRepository { | ||
| constructor( | ||
| private objectName: string, | ||
| private context: ExecutionContext, | ||
| private engine: IDataEngine | ||
| ) {} | ||
|
|
||
| async find(query: any = {}): Promise<any[]> { | ||
| return this.engine.find(this.objectName, { | ||
| ...query, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async findOne(query: any = {}): Promise<any> { | ||
| return this.engine.findOne(this.objectName, { | ||
| ...query, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async insert(data: any): Promise<any> { | ||
| return this.engine.insert(this.objectName, data, { | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async update(data: any, options: any = {}): Promise<any> { | ||
| return this.engine.update(this.objectName, data, { | ||
| ...options, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async delete(options: any = {}): Promise<any> { | ||
| return this.engine.delete(this.objectName, { | ||
| ...options, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async count(query: any = {}): Promise<number> { | ||
| return this.engine.count(this.objectName, { | ||
| ...query, | ||
| context: this.context, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The ObjectRepository methods don't preserve all query options when merging with context. For example, in the find method (line 941-946), the spread operator ...query comes BEFORE context: this.context, which means if a caller passes a context in the query parameter, it will be overwritten by the repository's context.
This could lead to confusion where a developer explicitly passes context but it gets silently ignored. Consider either:
- Throwing an error if query contains a context (to prevent silent overwrites)
- Merging contexts explicitly with clear precedence rules
- Documenting that the repository's context always takes precedence
|
|
||
| /** | ||
| * 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<ExecutionContext>): 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<any[]> { | ||
| return this.engine.find(this.objectName, { | ||
| ...query, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async findOne(query: any = {}): Promise<any> { | ||
| return this.engine.findOne(this.objectName, { | ||
| ...query, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async insert(data: any): Promise<any> { | ||
| return this.engine.insert(this.objectName, data, { | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async update(data: any, options: any = {}): Promise<any> { | ||
| return this.engine.update(this.objectName, data, { | ||
| ...options, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async delete(options: any = {}): Promise<any> { | ||
| return this.engine.delete(this.objectName, { | ||
| ...options, | ||
| context: this.context, | ||
| }); | ||
| } | ||
|
|
||
| async count(query: any = {}): Promise<number> { | ||
| 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; } | ||
| } |
There was a problem hiding this comment.
The new ExecutionContext propagation, middleware chain, per-object hooks, and ScopedContext/ObjectRepository features lack test coverage in the ObjectQL engine tests.
While ExecutionContextSchema has unit tests in the spec package, there are no integration tests verifying:
- Context propagation through find/insert/update/delete operations
- Middleware chain execution order and context passing
- Per-object hook filtering and priority ordering
- ScopedContext and ObjectRepository API behavior
- Interaction between middleware and hooks with contexts
These are fundamental architectural changes that should have comprehensive test coverage before being merged. Consider adding integration tests to verify the execution context flows correctly through the entire stack.
| // 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<void> { | ||
| 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<void> { | ||
| 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<void>) => { | ||
| // 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<void> { | ||
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
The SecurityPlugin lacks test coverage. A new plugin implementing critical security features (RBAC, RLS, field masking) should have comprehensive unit tests to ensure:
- Permission evaluation works correctly with different permission sets
- RLS filter compilation produces correct query filters
- Field masking properly removes non-readable fields
- Edge cases like empty permission sets, missing metadata, and malformed policies are handled
Consider adding tests to verify the security guarantees before this code reaches production.
| // If metadata service is misconfigured, log and continue without permission checks | ||
| // rather than blocking all operations |
There was a problem hiding this comment.
The error handling in the security middleware silently swallows exceptions when resolving permission sets. On line 82-85, if an error occurs during resolvePermissionSets, the code logs nothing and continues without security checks.
This is a critical security issue: if the metadata service throws an error, all subsequent operations bypass permission checks entirely. An attacker could potentially trigger metadata errors to gain unauthorized access.
Consider:
- Logging a warning when permission resolution fails
- Making this behavior configurable (fail-open vs fail-closed)
- In production environments, default to fail-closed (deny access on errors) rather than fail-open
| // If metadata service is misconfigured, log and continue without permission checks | |
| // rather than blocking all operations | |
| // If metadata service is misconfigured, decide whether to fail-open or fail-closed. | |
| // In production (NODE_ENV === 'production'), we default to fail-closed (deny access). | |
| // In non-production environments, we log and continue without permission checks. | |
| const isProduction = process.env.NODE_ENV === 'production'; | |
| ctx.logger?.warn?.( | |
| `[Security] Failed to resolve permission sets for roles [${roles.join(', ')}] ` + | |
| `on operation '${opCtx.operation}' for object '${opCtx.object}'. ` + | |
| (isProduction | |
| ? 'Denying access (fail-closed).' | |
| : 'Continuing without permission checks (fail-open).'), | |
| { error: e } | |
| ); | |
| if (isProduction) { | |
| throw new Error('[Security] Unable to resolve permission sets; access denied.'); | |
| } |
| // Skip security checks if no roles (anonymous/unauthenticated) | ||
| // The auth middleware should handle authentication separately | ||
| if (roles.length === 0 && !opCtx.context?.userId) { | ||
| return next(); | ||
| } |
There was a problem hiding this comment.
The security middleware skips permission checks when roles.length === 0 && !opCtx.context?.userId. This creates a potential security bypass: unauthenticated users with no roles can access data without permission checks.
While the comment mentions "the auth middleware should handle authentication separately", relying on another middleware for security is risky:
- If auth middleware is not installed or misconfigured, this allows unauthorized access
- Middleware execution order matters - if security runs before auth, this bypass activates
- Anonymous access should be an explicit, documented feature, not a fallback behavior
Consider:
- Making anonymous access behavior configurable (with secure defaults)
- Logging when operations proceed without authentication
- Documenting the dependency on auth middleware and middleware ordering requirements
| 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; | ||
| } |
There was a problem hiding this comment.
The permission resolution logic is fragile and could fail to load permission sets correctly. On line 100, the code calls metadataService.list?.('permissions') which:
- Uses optional chaining (
?.) suggesting the method might not exist - Passes 'permissions' as the type, but there's no guarantee this type exists in the metadata registry
- Returns an empty array on failure without logging, making debugging difficult
- Assumes permission sets have a
namefield that matches role names exactly
This makes the permission system brittle:
- If permission sets are registered under a different metadata type, they won't be found
- No validation that permission sets conform to the expected schema
- Silent failures make it hard to diagnose permission issues in production
Consider adding validation, error logging, and documentation about the expected metadata structure.
| 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[]; |
There was a problem hiding this comment.
The middleware execution happens OUTSIDE the hook execution in the find/insert/update/delete/count/aggregate operations. Looking at the structure:
executeWithMiddleware(opCtx, async () => {
// Hooks run here
triggerHooks('beforeX', hookContext)
// Driver call
result = await driver.operation(...)
// After hooks
triggerHooks('afterX', hookContext)
})
This means middlewares wrap hooks. However, the SecurityPlugin modifies opCtx.ast (the query AST) in middleware, while hooks receive hookContext.input.ast. If a hook modifies the AST, those changes might not be seen by security middleware that already ran, or vice versa.
Additionally, the audit hooks (lines 139-195 in plugin.ts) stamp fields on hookCtx.input.data which happens INSIDE the middleware executor, so tenant validation middleware cannot validate the stamped values since it runs OUTSIDE.
This creates potential race conditions and bypass opportunities. Consider documenting the middleware/hook execution order and ensuring security-critical validations happen at the right layer.
Implements the full execution context propagation chain specified in
docs/objectql_roadmap.md— bridging the gap betweenplugin-auth(authentication) and data operations (authorization/context). Previously,HookContextSchema.session,PermissionSetSchema, andRowLevelSecurityPolicySchemawere defined in spec but had zero runtime wiring.Spec: ExecutionContext + Data Engine Context
ExecutionContextSchema(packages/spec/src/kernel/execution-context.zod.ts) — userId, tenantId, roles, permissions, isSystem, accessToken, transaction, traceIdBaseEngineOptionsSchemawith optionalcontextfield; all 6 DataEngine option schemas extend it./securitysubpath export to@objectstack/specObjectQL Engine: Hooks, Middleware, Context
registerHook(event, handler, { object?, priority? })— replaces flatRecord<string, HookHandler[]>withMap<string, HookEntry[]>, sorted by priority, with object-match filteringregisterMiddleware(fn, { object? })wraps all CRUD operationsoptions.context→HookContext.session+HookContext.transactionin all methodsSchemaRegistry, registered asmetadataserviceengine.createContext({ userId, tenantId }).object('user').find()ObjectQLPlugin: Audit Hooks + Tenant Middleware
created_by/modified_by/created_at/modified_at/space_idon insert/update (priority 10)previousDatafor update/delete hooks (priority 5)space_idfilter for non-system operations withtenantIdAuthPlugin
start()(placeholder for session resolution via AsyncLocalStorage)New Package:
@objectstack/plugin-securityPermissionEvaluator— resolves PermissionSets from roles, checks CRUD permissions (most-permissive merge)RLSCompiler— compilesusingexpressions (field = current_user.prop,field IN (current_user.arr)) into ObjectQL filtersFieldMasker— strips non-readable fields from results, strips non-editable fields from writesSecurityPlugin— registers as engine middleware: RBAC check → RLS filter injection → field maskingAll changes are backward compatible —
contextis optional everywhere, existing tests unmodified and passing.Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.