Skip to content

Commit 5b6fc1c

Browse files
authored
Merge pull request #594 from objectstack-ai/copilot/update-code-based-on-requirements
2 parents 5116d8a + efe4d14 commit 5b6fc1c

20 files changed

Lines changed: 1382 additions & 155 deletions

packages/objectql/src/engine.ts

Lines changed: 415 additions & 141 deletions
Large diffs are not rendered by default.

packages/objectql/src/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ export type { ObjectContributor } from './registry.js';
1515
export { ObjectStackProtocolImplementation } from './protocol.js';
1616

1717
// Export Engine
18-
export { ObjectQL } from './engine.js';
19-
export type { ObjectQLHostContext, HookHandler } from './engine.js';
18+
export { ObjectQL, ObjectRepository, ScopedContext } from './engine.js';
19+
export type { ObjectQLHostContext, HookHandler, HookEntry, OperationContext, EngineMiddleware } from './engine.js';
20+
21+
// Export MetadataFacade
22+
export { MetadataFacade } from './metadata-facade.js';
2023

2124
// Export Plugin Shim
2225
export { ObjectQLPlugin } from './plugin.js';
23-
24-
// Moved logic to engine.ts
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { SchemaRegistry } from './registry.js';
4+
5+
/**
6+
* MetadataFacade
7+
*
8+
* Provides a clean, injectable interface over SchemaRegistry.
9+
* Registered as the 'metadata' kernel service to eliminate
10+
* downstream packages needing to manually wrap SchemaRegistry.
11+
*/
12+
export class MetadataFacade {
13+
/**
14+
* Register a metadata item
15+
*/
16+
register(type: string, definition: any): void {
17+
if (type === 'object') {
18+
SchemaRegistry.registerItem(type, definition, 'name' as any);
19+
} else {
20+
SchemaRegistry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any);
21+
}
22+
}
23+
24+
/**
25+
* Get a metadata item by type and name
26+
*/
27+
get(type: string, name: string): any {
28+
const item = SchemaRegistry.getItem(type, name) as any;
29+
return item?.content ?? item;
30+
}
31+
32+
/**
33+
* Get the raw entry (with metadata wrapper)
34+
*/
35+
getEntry(type: string, name: string): any {
36+
return SchemaRegistry.getItem(type, name);
37+
}
38+
39+
/**
40+
* List all items of a type
41+
*/
42+
list(type: string): any[] {
43+
const items = SchemaRegistry.listItems(type);
44+
return items.map((item: any) => item?.content ?? item);
45+
}
46+
47+
/**
48+
* Unregister a metadata item
49+
*/
50+
unregister(type: string, name: string): void {
51+
SchemaRegistry.unregisterItem(type, name);
52+
}
53+
54+
/**
55+
* Unregister all metadata from a package
56+
*/
57+
unregisterPackage(packageName: string): void {
58+
SchemaRegistry.unregisterObjectsByPackage(packageName);
59+
}
60+
61+
/**
62+
* Convenience: get object definition
63+
*/
64+
getObject(name: string): any {
65+
return SchemaRegistry.getObject(name);
66+
}
67+
68+
/**
69+
* Convenience: list all objects
70+
*/
71+
listObjects(): any[] {
72+
return SchemaRegistry.getAllObjects();
73+
}
74+
}

packages/objectql/src/plugin.ts

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectQL } from './engine.js';
4+
import { MetadataFacade } from './metadata-facade.js';
45
import { ObjectStackProtocolImplementation } from './protocol.js';
56
import { Plugin, PluginContext } from '@objectstack/core';
67

@@ -33,7 +34,7 @@ export class ObjectQLPlugin implements Plugin {
3334
// Register as provider for Core Kernel Services
3435
ctx.registerService('objectql', this.ql);
3536

36-
// Respect existing metadata service (e.g. from MetadataPlugin)
37+
// Register MetadataFacade as metadata service (unless external service exists)
3738
let hasMetadata = false;
3839
let metadataProvider = 'objectql';
3940
try {
@@ -47,8 +48,9 @@ export class ObjectQLPlugin implements Plugin {
4748

4849
if (!hasMetadata) {
4950
try {
50-
ctx.registerService('metadata', this.ql);
51-
ctx.logger.info('ObjectQL providing metadata service (fallback mode)', {
51+
const metadataFacade = new MetadataFacade();
52+
ctx.registerService('metadata', metadataFacade);
53+
ctx.logger.info('MetadataFacade registered as metadata service', {
5254
mode: 'in-memory',
5355
features: ['registry', 'fast-lookup']
5456
});
@@ -88,7 +90,8 @@ export class ObjectQLPlugin implements Plugin {
8890
// Check if we should load from external metadata service
8991
try {
9092
const metadataService = ctx.getService('metadata') as any;
91-
if (metadataService && metadataService !== this.ql && this.ql) {
93+
// Only sync if metadata service is external (not our own MetadataFacade)
94+
if (metadataService && !(metadataService instanceof MetadataFacade) && this.ql) {
9295
await this.loadMetadataFromService(metadataService, ctx);
9396
}
9497
} catch (e: any) {
@@ -112,13 +115,119 @@ export class ObjectQLPlugin implements Plugin {
112115
}
113116
}
114117
}
118+
119+
// Register built-in audit hooks
120+
this.registerAuditHooks(ctx);
121+
122+
// Register tenant isolation middleware
123+
this.registerTenantMiddleware(ctx);
115124

116125
ctx.logger.info('ObjectQL engine started', {
117126
driversRegistered: this.ql?.['drivers']?.size || 0,
118127
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
119128
});
120129
}
121130

131+
/**
132+
* Register built-in audit hooks for auto-stamping createdBy/modifiedBy
133+
* and fetching previousData for update/delete operations.
134+
*/
135+
private registerAuditHooks(ctx: PluginContext) {
136+
if (!this.ql) return;
137+
138+
// Auto-stamp createdBy/modifiedBy on insert
139+
this.ql.registerHook('beforeInsert', async (hookCtx) => {
140+
if (hookCtx.session?.userId && hookCtx.input?.data) {
141+
const data = hookCtx.input.data as Record<string, any>;
142+
if (typeof data === 'object' && data !== null) {
143+
data.created_by = data.created_by ?? hookCtx.session.userId;
144+
data.modified_by = hookCtx.session.userId;
145+
data.created_at = data.created_at ?? new Date().toISOString();
146+
data.modified_at = new Date().toISOString();
147+
if (hookCtx.session.tenantId) {
148+
data.space_id = data.space_id ?? hookCtx.session.tenantId;
149+
}
150+
}
151+
}
152+
}, { object: '*', priority: 10 });
153+
154+
// Auto-stamp modifiedBy on update
155+
this.ql.registerHook('beforeUpdate', async (hookCtx) => {
156+
if (hookCtx.session?.userId && hookCtx.input?.data) {
157+
const data = hookCtx.input.data as Record<string, any>;
158+
if (typeof data === 'object' && data !== null) {
159+
data.modified_by = hookCtx.session.userId;
160+
data.modified_at = new Date().toISOString();
161+
}
162+
}
163+
}, { object: '*', priority: 10 });
164+
165+
// Auto-fetch previousData for update hooks
166+
this.ql.registerHook('beforeUpdate', async (hookCtx) => {
167+
if (hookCtx.input?.id && !hookCtx.previous) {
168+
try {
169+
const existing = await this.ql!.findOne(hookCtx.object, {
170+
filter: { _id: hookCtx.input.id }
171+
});
172+
if (existing) {
173+
hookCtx.previous = existing;
174+
}
175+
} catch (_e) {
176+
// Non-fatal: some objects may not support findOne
177+
}
178+
}
179+
}, { object: '*', priority: 5 });
180+
181+
// Auto-fetch previousData for delete hooks
182+
this.ql.registerHook('beforeDelete', async (hookCtx) => {
183+
if (hookCtx.input?.id && !hookCtx.previous) {
184+
try {
185+
const existing = await this.ql!.findOne(hookCtx.object, {
186+
filter: { _id: hookCtx.input.id }
187+
});
188+
if (existing) {
189+
hookCtx.previous = existing;
190+
}
191+
} catch (_e) {
192+
// Non-fatal
193+
}
194+
}
195+
}, { object: '*', priority: 5 });
196+
197+
ctx.logger.debug('Audit hooks registered (createdBy/modifiedBy, previousData)');
198+
}
199+
200+
/**
201+
* Register tenant isolation middleware that auto-injects space_id filter
202+
* for multi-tenant operations.
203+
*/
204+
private registerTenantMiddleware(ctx: PluginContext) {
205+
if (!this.ql) return;
206+
207+
this.ql.registerMiddleware(async (opCtx, next) => {
208+
// Only apply to operations with tenantId that are not system-level
209+
if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
210+
return next();
211+
}
212+
213+
// Read operations: inject space_id filter into AST
214+
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
215+
if (opCtx.ast) {
216+
const tenantFilter = { space_id: opCtx.context.tenantId };
217+
if (opCtx.ast.where) {
218+
opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
219+
} else {
220+
opCtx.ast.where = tenantFilter;
221+
}
222+
}
223+
}
224+
225+
await next();
226+
});
227+
228+
ctx.logger.debug('Tenant isolation middleware registered');
229+
}
230+
122231
/**
123232
* Load metadata from external metadata service into ObjectQL registry
124233
* This enables ObjectQL to use file-based or remote metadata

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ export class AuthPlugin implements Plugin {
105105
}
106106
}
107107

108+
// Register auth middleware on ObjectQL engine (if available)
109+
try {
110+
const ql = ctx.getService<any>('objectql');
111+
if (ql && typeof ql.registerMiddleware === 'function') {
112+
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
113+
// If context already has userId or isSystem, skip auth resolution
114+
if (opCtx.context?.userId || opCtx.context?.isSystem) {
115+
return next();
116+
}
117+
// Future: resolve session from AsyncLocalStorage or request context
118+
await next();
119+
});
120+
ctx.logger.info('Auth middleware registered on ObjectQL engine');
121+
}
122+
} catch (_e) {
123+
ctx.logger.debug('ObjectQL engine not available, skipping auth middleware registration');
124+
}
125+
108126
ctx.logger.info('Auth Plugin started successfully');
109127
}
110128

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@objectstack/plugin-security",
3+
"version": "2.0.4",
4+
"license": "Apache-2.0",
5+
"description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.2.2",
25+
"typescript": "^5.0.0",
26+
"vitest": "^4.0.18"
27+
}
28+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { FieldPermission } from '@objectstack/spec/security';
4+
5+
/**
6+
* FieldMasker
7+
*
8+
* Applies field-level security by stripping restricted fields from query results.
9+
*/
10+
export class FieldMasker {
11+
/**
12+
* Mask fields in query results based on field permissions.
13+
* Removes fields that the user does not have read access to.
14+
*/
15+
maskResults(
16+
results: any | any[],
17+
fieldPermissions: Record<string, FieldPermission>,
18+
_objectName: string
19+
): any | any[] {
20+
// If no field permissions defined, return results as-is
21+
if (Object.keys(fieldPermissions).length === 0) return results;
22+
23+
// Get list of non-readable fields
24+
const hiddenFields = Object.entries(fieldPermissions)
25+
.filter(([, perm]) => !perm.readable)
26+
.map(([field]) => field);
27+
28+
if (hiddenFields.length === 0) return results;
29+
30+
if (Array.isArray(results)) {
31+
return results.map(record => this.maskRecord(record, hiddenFields));
32+
}
33+
34+
return this.maskRecord(results, hiddenFields);
35+
}
36+
37+
/**
38+
* Get non-editable fields for use in write operations.
39+
* Returns a list of field names that should be stripped from incoming data.
40+
*/
41+
getNonEditableFields(
42+
fieldPermissions: Record<string, FieldPermission>
43+
): string[] {
44+
return Object.entries(fieldPermissions)
45+
.filter(([, perm]) => !perm.editable)
46+
.map(([field]) => field);
47+
}
48+
49+
/**
50+
* Strip non-editable fields from write data.
51+
*/
52+
stripNonEditableFields(
53+
data: Record<string, any>,
54+
fieldPermissions: Record<string, FieldPermission>
55+
): Record<string, any> {
56+
const nonEditable = this.getNonEditableFields(fieldPermissions);
57+
if (nonEditable.length === 0) return data;
58+
59+
const result = { ...data };
60+
for (const field of nonEditable) {
61+
delete result[field];
62+
}
63+
return result;
64+
}
65+
66+
private maskRecord(record: any, hiddenFields: string[]): any {
67+
if (!record || typeof record !== 'object') return record;
68+
69+
const result = { ...record };
70+
for (const field of hiddenFields) {
71+
delete result[field];
72+
}
73+
return result;
74+
}
75+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/plugin-security
5+
*
6+
* Security Plugin for ObjectStack
7+
* Provides RBAC, Row-Level Security (RLS), and Field-Level Security runtime.
8+
*/
9+
10+
export { SecurityPlugin } from './security-plugin.js';
11+
export { PermissionEvaluator } from './permission-evaluator.js';
12+
export { RLSCompiler } from './rls-compiler.js';
13+
export { FieldMasker } from './field-masker.js';

0 commit comments

Comments
 (0)