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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
556 changes: 415 additions & 141 deletions packages/objectql/src/engine.ts

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 74 additions & 0 deletions packages/objectql/src/metadata-facade.ts
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();
Comment on lines +14 to +72

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MetadataFacade uses any types 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:

  1. Using generic types for get<T>(type: string, name: string): T | undefined
  2. Creating typed interfaces for common metadata types (ObjectDefinition, PermissionSet, etc.)
  3. At minimum, documenting the expected return types in JSDoc comments

For a facade that wraps SchemaRegistry and will be used throughout the security system, strong typing would help prevent bugs and improve developer experience.

Suggested change
* 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();
* Register a metadata item.
*
* @param type - Metadata type (e.g. 'object', 'permission_set').
* @param definition - Metadata definition, expected to have either an `id` or `name` identifier.
*/
register<T extends { id?: string; name?: string }>(type: string, definition: T): void {
if (type === 'object') {
SchemaRegistry.registerItem(type, definition, 'name');
} else {
SchemaRegistry.registerItem(type, definition, definition.id ? 'id' : 'name');
}
}
/**
* Get a metadata item by type and name.
*
* @typeParam T - The expected shape of the metadata content.
*/
get<T = unknown>(type: string, name: string): T | undefined {
const item = SchemaRegistry.getItem(type, name) as { content?: T } | T | undefined;
if (item == null) {
return undefined;
}
// Many registry entries are wrapped in `{ content }`; fall back to the item itself.
return (item as any).content !== undefined ? (item as any).content as T : (item as T);
}
/**
* Get the raw entry (with metadata wrapper) from the registry.
*
* @typeParam TEntry - The expected shape of the raw registry entry.
*/
getEntry<TEntry = unknown>(type: string, name: string): TEntry | undefined {
return SchemaRegistry.getItem(type, name) as TEntry | undefined;
}
/**
* List all items of a given metadata type.
*
* @typeParam T - The expected shape of each metadata content item.
*/
list<T = unknown>(type: string): T[] {
const items = SchemaRegistry.listItems(type) as Array<{ content?: T } | T>;
return items.map((item) =>
(item as any)?.content !== undefined ? (item as any).content as T : (item as T),
);
}
/**
* 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.
*
* @typeParam TObject - The expected shape of the object definition.
*/
getObject<TObject = unknown>(name: string): TObject | undefined {
return SchemaRegistry.getObject(name) as TObject | undefined;
}
/**
* Convenience: list all objects.
*
* @typeParam TObject - The expected shape of each object definition.
*/
listObjects<TObject = unknown>(): TObject[] {
return SchemaRegistry.getAllObjects() as TObject[];

Copilot uses AI. Check for mistakes.
}
}
117 changes: 113 additions & 4 deletions packages/objectql/src/plugin.ts
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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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']
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

// 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

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Users can insert records with arbitrary space_id values, potentially creating records in other tenants
  2. Users can update/delete records in other tenants by manipulating the filter to bypass space_id checks
  3. The audit hook stamps space_id on 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_id AFTER the audit hook runs
  • Moving tenant enforcement to middleware that runs BEFORE hooks to prevent bypass

Copilot uses AI. Check for mistakes.

/**
* Load metadata from external metadata service into ObjectQL registry
* This enables ObjectQL to use file-based or remote metadata
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ export class AuthPlugin implements Plugin {
}
}

// Register auth middleware on ObjectQL engine (if available)
try {
const ql = ctx.getService<any>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
// 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');
}

Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-security/package.json
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"
}
}
75 changes: 75 additions & 0 deletions packages/plugins/plugin-security/src/field-masker.ts
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;
}
}
13 changes: 13 additions & 0 deletions packages/plugins/plugin-security/src/index.ts
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';
Loading
Loading