-
Notifications
You must be signed in to change notification settings - Fork 2
Kernel Upgrade: ExecutionContext propagation, per-object hooks, middleware chain, plugin-security #594
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Kernel Upgrade: ExecutionContext propagation, per-object hooks, middleware chain, plugin-security #594
Changes from all commits
7564b0b
ac89550
013141d
10c152b
efe4d14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,13 +115,119 @@ 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, | ||
| objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0 | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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<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 }); | ||
|
Comment on lines
+139
to
+152
|
||
|
|
||
| // 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<string, any>; | ||
| 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'); | ||
| } | ||
|
Comment on lines
+204
to
+229
|
||
|
|
||
| /** | ||
| * Load metadata from external metadata service into ObjectQL registry | ||
| * This enables ObjectQL to use file-based or remote metadata | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, FieldPermission>, | ||
| _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, FieldPermission> | ||
| ): string[] { | ||
| return Object.entries(fieldPermissions) | ||
| .filter(([, perm]) => !perm.editable) | ||
| .map(([field]) => field); | ||
| } | ||
|
|
||
| /** | ||
| * Strip non-editable fields from write data. | ||
| */ | ||
| stripNonEditableFields( | ||
| data: Record<string, any>, | ||
| fieldPermissions: Record<string, FieldPermission> | ||
| ): Record<string, any> { | ||
| 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The MetadataFacade uses
anytypes throughout all method signatures, losing type safety. This makes it difficult for consumers to know what types are returned and increases the risk of runtime errors.Consider:
get<T>(type: string, name: string): T | undefinedFor a facade that wraps SchemaRegistry and will be used throughout the security system, strong typing would help prevent bugs and improve developer experience.