Skip to content

Commit c81d9fe

Browse files
Copilothotlong
andcommitted
feat: add @objectos/telemetry package and multi-tenancy data isolation
Phase N — Enterprise Features (v1.2.0): - N.1: New @objectos/telemetry package with OpenTelemetry-compatible tracing - W3C Trace Context propagation (traceparent/tracestate) - Automatic HTTP request instrumentation - Data operation span creation (CRUD) - Plugin lifecycle tracing - OTLP HTTP export + Console exporter - Probabilistic sampling with configurable rate - 37 passing tests - N.2: Multi-tenancy data isolation in @objectos/permissions - Added organizationId to PermissionContext - Added TenantContext type - Tenant-scoped data filtering in permission hooks - Automatic tenant field stamping on write operations - Configurable tenantIsolation and tenantField Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 6d67e37 commit c81d9fe

File tree

15 files changed

+1987
-1
lines changed

15 files changed

+1987
-1
lines changed

objectstack.config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { NotificationPlugin } from '@objectos/notification';
2020
import { PermissionsPlugin } from '@objectos/permissions';
2121
import { createRealtimePlugin } from '@objectos/realtime';
2222
import { StoragePlugin } from '@objectos/storage';
23+
import { TelemetryPlugin } from '@objectos/telemetry';
2324
import { UIPlugin } from '@objectos/ui';
2425
import { WorkflowPlugin } from '@objectos/workflow';
2526
import { resolve } from 'path';
@@ -64,6 +65,15 @@ export default defineStack({
6465
new MetricsPlugin(),
6566
new CachePlugin(),
6667
new StoragePlugin(),
68+
new TelemetryPlugin({
69+
serviceName: 'objectos',
70+
serviceVersion: '0.1.0',
71+
environment: process.env.NODE_ENV || 'development',
72+
exporter: {
73+
protocol: (process.env.OTEL_EXPORTER_PROTOCOL as any) || 'none',
74+
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
75+
},
76+
}),
6777

6878
// Core
6979
new AuthPlugin({
@@ -76,7 +86,9 @@ export default defineStack({
7686
})(),
7787
baseUrl: process.env.BETTER_AUTH_URL || 'http://localhost:5320',
7888
}),
79-
new PermissionsPlugin(),
89+
new PermissionsPlugin({
90+
tenantIsolation: true,
91+
}),
8092
new AuditLogPlugin(),
8193

8294
// Logic

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"@objectos/permissions": "workspace:*",
9090
"@objectos/realtime": "workspace:*",
9191
"@objectos/storage": "workspace:*",
92+
"@objectos/telemetry": "workspace:*",
9293
"@objectos/ui": "workspace:*",
9394
"@objectos/workflow": "workspace:*",
9495
"@objectql/core": "^4.2.0",

packages/permissions/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export type {
5353
// Runtime
5454
PermissionContext,
5555
PermissionCheckResult,
56+
// Multi-tenancy
57+
TenantContext,
5658
// Plugin Config
5759
PermissionPluginConfig,
5860
} from './types.js';

packages/permissions/src/plugin.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export class PermissionsPlugin implements Plugin {
5656
defaultDeny: true,
5757
permissionsDir: './permissions',
5858
cachePermissions: true,
59+
tenantIsolation: false,
60+
tenantField: '_organizationId',
5961
...config,
6062
};
6163

@@ -236,21 +238,69 @@ export class PermissionsPlugin implements Plugin {
236238
// Hook into data operations for permission checking (PRE-Operation)
237239
context.hook('data.beforeCreate', async (data: any) => {
238240
await this.checkDataPermission(data, 'create');
241+
this.applyTenantToWrite(data);
239242
});
240243

241244
context.hook('data.beforeUpdate', async (data: any) => {
242245
await this.checkDataPermission(data, 'update');
246+
this.applyTenantToWrite(data);
243247
});
244248

245249
context.hook('data.beforeDelete', async (data: any) => {
246250
await this.checkDataPermission(data, 'delete');
251+
this.applyTenantFilter(data);
247252
});
248253

249254
context.hook('data.beforeFind', async (data: any) => {
250255
await this.applyRecordLevelSecurity(data);
256+
this.applyTenantFilter(data);
251257
});
252258

253259
this.context?.logger.info('[Permissions Plugin] Event listeners registered');
260+
261+
if (this.config.tenantIsolation) {
262+
this.context?.logger.info(`[Permissions Plugin] Tenant isolation enabled (field: ${this.config.tenantField})`);
263+
}
264+
}
265+
266+
/**
267+
* Apply tenant ID to write operations (create/update).
268+
* Stamps the tenant field on the record data so it belongs to the user's organization.
269+
*/
270+
private applyTenantToWrite(data: any): void {
271+
if (!this.config.tenantIsolation) return;
272+
273+
const organizationId = data.organizationId || data.metadata?.organizationId;
274+
if (!organizationId) return;
275+
276+
const tenantField = this.config.tenantField || '_organizationId';
277+
278+
// Stamp the tenant field on the record being written
279+
if (data.doc) {
280+
data.doc[tenantField] = organizationId;
281+
}
282+
if (data.record) {
283+
data.record[tenantField] = organizationId;
284+
}
285+
}
286+
287+
/**
288+
* Apply tenant filter to read/delete operations.
289+
* Ensures queries are automatically scoped to the user's organization.
290+
*/
291+
private applyTenantFilter(data: any): void {
292+
if (!this.config.tenantIsolation) return;
293+
294+
const organizationId = data.organizationId || data.metadata?.organizationId;
295+
if (!organizationId) return;
296+
297+
const tenantField = this.config.tenantField || '_organizationId';
298+
299+
// Merge tenant filter into existing query filters
300+
data.filters = {
301+
...data.filters,
302+
[tenantField]: organizationId,
303+
};
254304
}
255305

256306
/**
@@ -266,6 +316,7 @@ export class PermissionsPlugin implements Plugin {
266316

267317
const permissionContext: PermissionContext = {
268318
userId,
319+
organizationId: data.organizationId || data.metadata?.organizationId,
269320
profiles: userProfiles || [],
270321
metadata: data.metadata,
271322
};
@@ -302,6 +353,7 @@ export class PermissionsPlugin implements Plugin {
302353

303354
const permissionContext: PermissionContext = {
304355
userId,
356+
organizationId: data.organizationId || data.metadata?.organizationId,
305357
profiles: userProfiles || [],
306358
metadata: data.metadata,
307359
};

packages/permissions/src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ export interface RLSEvaluationResult {
344344
export interface PermissionContext {
345345
/** User ID */
346346
userId: string;
347+
/** Organization / Tenant ID for multi-tenancy data isolation */
348+
organizationId?: string;
347349
/** User's profile name */
348350
profileName?: string;
349351
/** User's profiles (array of profile names) */
@@ -356,6 +358,21 @@ export interface PermissionContext {
356358
metadata?: Record<string, any>;
357359
}
358360

361+
/**
362+
* Tenant context — extracted from authenticated session for data isolation.
363+
* Used by the tenant middleware to scope all data queries to a specific organization.
364+
*/
365+
export interface TenantContext {
366+
/** Organization / Tenant ID */
367+
organizationId: string;
368+
/** Organization slug (for URL routing) */
369+
slug?: string;
370+
/** User ID within the organization */
371+
userId: string;
372+
/** User's role within the organization (e.g., 'owner', 'admin', 'member') */
373+
role?: string;
374+
}
375+
359376
/**
360377
* Permission check result
361378
*/
@@ -384,6 +401,10 @@ export interface PermissionPluginConfig {
384401
cachePermissions?: boolean;
385402
/** Custom storage implementation */
386403
storage?: any; // PermissionStorage
404+
/** Enable multi-tenancy data isolation — automatically scopes data queries to the user's organization */
405+
tenantIsolation?: boolean;
406+
/** Field name used to store the tenant/organization ID on data records (default: '_organizationId') */
407+
tenantField?: string;
387408
}
388409

389410
// ─── Kernel Compliance Types (from @objectstack/spec) ──────────────────────────

packages/telemetry/jest.config.cjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module.exports = {
2+
preset: 'ts-jest/presets/default-esm',
3+
testEnvironment: 'node',
4+
extensionsToTreatAsEsm: ['.ts'],
5+
moduleNameMapper: {
6+
'^(\\.{1,2}/.*)\\.js$': '$1',
7+
},
8+
transform: {
9+
'^.+\\.ts$': [
10+
'ts-jest',
11+
{
12+
useESM: true,
13+
},
14+
],
15+
},
16+
roots: ['<rootDir>/test'],
17+
testMatch: ['**/*.test.ts'],
18+
collectCoverageFrom: [
19+
'src/**/*.ts',
20+
'!src/**/*.d.ts'
21+
]
22+
};

packages/telemetry/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@objectos/telemetry",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"license": "AGPL-3.0",
6+
"description": "OpenTelemetry integration for ObjectOS — distributed tracing, span collection, and OTLP export",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"scripts": {
10+
"build": "tsup src/index.ts --format esm,cjs --clean && tsc --emitDeclarationOnly --declaration",
11+
"test": "jest --forceExit --passWithNoTests"
12+
},
13+
"dependencies": {
14+
"@objectstack/runtime": "^2.0.7",
15+
"@objectstack/spec": "2.0.7"
16+
},
17+
"devDependencies": {
18+
"@types/jest": "^30.0.0",
19+
"@types/node": "^25.2.0",
20+
"jest": "^30.2.0",
21+
"ts-jest": "^29.4.6",
22+
"tsup": "^8.5.1",
23+
"typescript": "^5.9.3"
24+
}
25+
}

0 commit comments

Comments
 (0)