Skip to content

Commit a762b39

Browse files
committed
Embed DB addressing in sys_environment; add org/env scoping
Merge physical database mapping into sys_environment and introduce organizationId/environmentId scoping across metadata and control-plane code. sys_environment_database object/table removed; environment rows now store database_url, database_driver, storage_limit_mb and provisioned_at. DatabaseLoader, MetadataManager, plugin and history cleanup updated to use organization_id + env_id (env_id = null = platform-global). sys_metadata/sys_metadata_history schemas and indexes updated to include organization_id/env_id; sys_database_credential now references environment_id and its indexes updated. HTTP dispatcher, provisioning service, migration, and tests adjusted to the new model to simplify lookups, reduce joins, and support per-environment isolation.
1 parent 3d37f2c commit a762b39

22 files changed

Lines changed: 302 additions & 372 deletions

docs/adr/0002-environment-database-isolation.md

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,29 +34,36 @@ We upgrade the multi-tenant architecture from **per-organization database** to *
3434

3535
Registers environments and how to reach them — **never** stores business data:
3636

37-
| Table | Purpose |
38-
|---------------------------|------------------------------------------------------------|
39-
| `sys_environment` | One row per environment — `(organization_id, slug)` UNIQUE |
40-
| `sys_environment_database`| Physical DB addressing (1:1 with `sys_environment`) |
41-
| `sys_database_credential` | Rotatable encrypted secrets (N:1 with `sys_environment_database`) |
42-
| `sys_environment_member` | Per-environment RBAC (`(environment_id, user_id)` UNIQUE) |
37+
| Table | Purpose |
38+
|-----------------------------|----------------------------------------------------------------------------------|
39+
| `sys_environment` | One row per environment — `(organization_id, slug)` UNIQUE. **Includes** physical DB addressing fields (`database_url`, `database_driver`, `storage_limit_mb`, `provisioned_at`). |
40+
| `sys_database_credential` | Rotatable encrypted secrets (N:1 with `sys_environment`) |
41+
| `sys_environment_member` | Per-environment RBAC (`(environment_id, user_id)` UNIQUE) |
42+
| `sys_package_installation` | Which packages are installed in which environment (includes `environment_id`) |
43+
| `sys_metadata` | Schema metadata for all environments (includes `organization_id` + `env_id`) |
44+
45+
> **Design note:** `sys_environment_database` has been merged into `sys_environment`.
46+
> Physical DB addressing is a 1:1 relationship — a dedicated table was unnecessary.
47+
> This reduces joins and simplifies the provisioning flow.
48+
49+
> **Design note:** `sys_metadata` and `sys_package_installation` live in the **Control Plane**,
50+
> not in environment DBs. Metadata is environment-scoped via `env_id` (NULL = platform-global).
51+
> This follows the Power Apps model where the management plane tracks all schema and packages.
4352
4453
### Data Plane (one database per environment)
4554

4655
Each environment owns its own physical database containing:
4756

48-
- All `sys_` data-plane objects — `sys_package_installation`, `sys_solution_history`, …
4957
- All business objects — `account`, `contact`, user tables, …
50-
- **Zero** `environment_id` columns. The environment is **implicit** in the connection.
58+
- **Zero** system tables or `environment_id` columns. The environment is **implicit** in the connection.
5159

5260
### Session → Routing
5361

5462
`better-auth` sessions carry a single `active_environment_id`. The tenant router resolves:
5563

5664
```
5765
session.active_environment_id
58-
→ sys_environment (→ organization_id)
59-
→ sys_environment_database (url, driver, region)
66+
→ sys_environment (url, driver, region — single row lookup)
6067
→ sys_database_credential (active secret, decrypted)
6168
→ data-plane driver
6269
```
@@ -68,8 +75,8 @@ Switching environments ⇒ swapping DB connections. There is no in-process filte
6875
`EnvironmentProvisioningService` (new) exposes:
6976

7077
- `provisionOrganization(req)` — atomically creates the org's **default** environment and its physical DB (replaces `provisionTenant`).
71-
- `provisionEnvironment(req)` — allocates any subsequent `dev` / `test` / `sandbox` / `preview` environment, each with its own DB and credential row.
72-
- `rotateCredential(envDbId, plaintext)` — issues a new `active` credential and revokes the previous one.
78+
- `provisionEnvironment(req)` — allocates any subsequent `dev` / `test` / `sandbox` / `preview` environment, each with its own DB and credential row. DB addressing fields are written directly onto the `sys_environment` row.
79+
- `rotateCredential(environmentId, plaintext)` — issues a new `active` credential and revokes the previous one.
7380

7481
Physical-DB allocation is delegated to pluggable `EnvironmentDatabaseAdapter` implementations (initially `turso`; `libsql` / `sqlite` / `postgres` drop in without core changes).
7582

@@ -117,7 +124,7 @@ The migration is **non-destructive** and **idempotent**: each legacy org's datab
117124
## References
118125

119126
- `packages/spec/src/cloud/environment.zod.ts` — protocol schemas
120-
- `packages/services/service-tenant/src/objects/sys-environment*.object.ts` — control-plane objects
127+
- `packages/services/service-tenant/src/objects/sys-environment.object.ts`merged control-plane environment object (includes DB addressing)
121128
- `packages/services/service-tenant/src/environment-provisioning.ts` — provisioning service
122129
- `packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts` — migration skeleton
123130
- Power Platform environments: <https://learn.microsoft.com/power-platform/admin/environments-overview>

packages/metadata/src/loaders/database-loader.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,26 +342,27 @@ describe('DatabaseLoader', () => {
342342
});
343343

344344
describe('multi-tenant isolation', () => {
345-
it('should filter by tenantId when configured', async () => {
345+
it('should filter by organizationId and environmentId when configured', async () => {
346346
const tenantLoader = new DatabaseLoader({
347347
driver: mockDriver,
348-
tenantId: 'tenant-1',
348+
organizationId: 'org-1',
349+
environmentId: 'env-1',
349350
});
350351

351352
await tenantLoader.save('object', 'account', { name: 'account' });
352353

353-
// The create call should include tenant_id
354+
// The create call should include organization_id and env_id
354355
expect(mockDriver.create).toHaveBeenCalledWith(
355356
'sys_metadata',
356-
expect.objectContaining({ tenant_id: 'tenant-1' })
357+
expect.objectContaining({ organization_id: 'org-1', env_id: 'env-1' })
357358
);
358359

359-
// The find calls should filter by tenant_id
360+
// The find calls should filter by organization_id and env_id
360361
await tenantLoader.load('object', 'account');
361362
expect(mockDriver.findOne).toHaveBeenCalledWith(
362363
'sys_metadata',
363364
expect.objectContaining({
364-
where: expect.objectContaining({ tenant_id: 'tenant-1' }),
365+
where: expect.objectContaining({ organization_id: 'org-1', env_id: 'env-1' }),
365366
})
366367
);
367368
});

packages/metadata/src/loaders/database-loader.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ export interface DatabaseLoaderOptions {
4646
/** The table name to store history records (default: 'sys_metadata_history') */
4747
historyTableName?: string;
4848

49-
/** Tenant ID for multi-tenant isolation */
50-
tenantId?: string;
49+
/** Organization ID for multi-tenant isolation */
50+
organizationId?: string;
51+
52+
/** Environment ID — null = platform-global, set = env-scoped */
53+
environmentId?: string;
5154

5255
/** Enable history tracking (default: true) */
5356
trackHistory?: boolean;
@@ -76,7 +79,8 @@ export class DatabaseLoader implements MetadataLoader {
7679
private engine?: IDataEngine;
7780
private tableName: string;
7881
private historyTableName: string;
79-
private tenantId?: string;
82+
private organizationId?: string;
83+
private environmentId?: string;
8084
private trackHistory: boolean;
8185
private schemaReady = false;
8286
private historySchemaReady = false;
@@ -89,7 +93,8 @@ export class DatabaseLoader implements MetadataLoader {
8993
this.engine = options.engine;
9094
this.tableName = options.tableName ?? 'sys_metadata';
9195
this.historyTableName = options.historyTableName ?? 'sys_metadata_history';
92-
this.tenantId = options.tenantId;
96+
this.organizationId = options.organizationId;
97+
this.environmentId = options.environmentId;
9398
this.trackHistory = options.trackHistory !== false; // Default to true
9499
}
95100

@@ -193,16 +198,19 @@ export class DatabaseLoader implements MetadataLoader {
193198

194199
/**
195200
* Build base filter conditions for queries.
196-
* Always includes tenantId when configured.
201+
* Filters by organizationId when configured; env_id when environmentId is set,
202+
* or null (platform-global) when not set.
197203
*/
198204
private baseFilter(type: string, name?: string): Record<string, unknown> {
199205
const filter: Record<string, unknown> = { type };
200206
if (name !== undefined) {
201207
filter.name = name;
202208
}
203-
if (this.tenantId) {
204-
filter.tenant_id = this.tenantId;
209+
if (this.organizationId) {
210+
filter.organization_id = this.organizationId;
205211
}
212+
// When environmentId is set, scope to that env; otherwise query platform-global (env_id = null).
213+
filter.env_id = this.environmentId ?? null;
206214
return filter;
207215
}
208216

@@ -258,7 +266,8 @@ export class DatabaseLoader implements MetadataLoader {
258266
changeNote,
259267
recordedBy,
260268
recordedAt: now,
261-
...(this.tenantId ? { tenantId: this.tenantId } : {}),
269+
...(this.organizationId ? { organizationId: this.organizationId } : {}),
270+
...(this.environmentId !== undefined ? { environmentId: this.environmentId } : {}),
262271
};
263272

264273
try {
@@ -275,7 +284,8 @@ export class DatabaseLoader implements MetadataLoader {
275284
change_note: historyRecord.changeNote,
276285
recorded_by: historyRecord.recordedBy,
277286
recorded_at: historyRecord.recordedAt,
278-
...(this.tenantId ? { tenant_id: this.tenantId } : {}),
287+
...(this.organizationId ? { organization_id: this.organizationId } : {}),
288+
...(this.environmentId !== undefined ? { env_id: this.environmentId } : {}),
279289
});
280290
} catch (error) {
281291
// Log error but don't fail the main operation
@@ -314,7 +324,8 @@ export class DatabaseLoader implements MetadataLoader {
314324
strategy: (row.strategy as MetadataRecord['strategy']) ?? 'merge',
315325
owner: row.owner as string | undefined,
316326
state: (row.state as MetadataRecord['state']) ?? 'active',
317-
tenantId: row.tenant_id as string | undefined,
327+
organizationId: row.organization_id as string | undefined,
328+
environmentId: row.env_id as string | undefined,
318329
version: (row.version as number) ?? 1,
319330
checksum: row.checksum as string | undefined,
320331
source: row.source as MetadataRecord['source'],
@@ -468,9 +479,10 @@ export class DatabaseLoader implements MetadataLoader {
468479
metadata_id: metadataRow.id,
469480
version,
470481
};
471-
if (this.tenantId) {
472-
filter.tenant_id = this.tenantId;
482+
if (this.organizationId) {
483+
filter.organization_id = this.organizationId;
473484
}
485+
filter.env_id = this.environmentId ?? null;
474486

475487
const row = await this._findOne(this.historyTableName, {
476488
where: filter,
@@ -488,7 +500,8 @@ export class DatabaseLoader implements MetadataLoader {
488500
checksum: row.checksum as string,
489501
previousChecksum: row.previous_checksum as string | undefined,
490502
changeNote: row.change_note as string | undefined,
491-
tenantId: row.tenant_id as string | undefined,
503+
organizationId: row.organization_id as string | undefined,
504+
environmentId: row.env_id as string | undefined,
492505
recordedBy: row.recorded_by as string | undefined,
493506
recordedAt: row.recorded_at as string,
494507
};
@@ -520,7 +533,8 @@ export class DatabaseLoader implements MetadataLoader {
520533

521534
// Find the metadata record
522535
const filter: Record<string, unknown> = { type, name };
523-
if (this.tenantId) filter.tenant_id = this.tenantId;
536+
if (this.organizationId) filter.organization_id = this.organizationId;
537+
filter.env_id = this.environmentId ?? null;
524538

525539
const metadataRecord = await this._findOne(this.tableName, { where: filter });
526540
if (!metadataRecord) {
@@ -531,7 +545,8 @@ export class DatabaseLoader implements MetadataLoader {
531545
const historyFilter: Record<string, unknown> = {
532546
metadata_id: metadataRecord.id,
533547
};
534-
if (this.tenantId) historyFilter.tenant_id = this.tenantId;
548+
if (this.organizationId) historyFilter.organization_id = this.organizationId;
549+
historyFilter.env_id = this.environmentId ?? null;
535550
if (options?.operationType) historyFilter.operation_type = options.operationType;
536551
if (options?.since) historyFilter.recorded_at = { $gte: options.since };
537552
if (options?.until) {
@@ -574,7 +589,8 @@ export class DatabaseLoader implements MetadataLoader {
574589
checksum: row.checksum as string,
575590
previousChecksum: row.previous_checksum as string | undefined,
576591
changeNote: row.change_note as string | undefined,
577-
tenantId: row.tenant_id as string | undefined,
592+
organizationId: row.organization_id as string | undefined,
593+
environmentId: row.env_id as string | undefined,
578594
recordedBy: row.recorded_by as string | undefined,
579595
recordedAt: row.recorded_at as string,
580596
};
@@ -711,7 +727,8 @@ export class DatabaseLoader implements MetadataLoader {
711727
state: 'active',
712728
version: 1,
713729
source: 'database',
714-
...(this.tenantId ? { tenant_id: this.tenantId } : {}),
730+
...(this.organizationId ? { organization_id: this.organizationId } : {}),
731+
...(this.environmentId !== undefined ? { env_id: this.environmentId } : { env_id: null }),
715732
created_at: now,
716733
updated_at: now,
717734
});

packages/metadata/src/metadata-manager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,16 @@ export class MetadataManager implements IMetadataService {
133133
* Can be called at any time to enable database storage (e.g. after kernel resolves the driver).
134134
*
135135
* @param driver - An IDataDriver instance for database operations
136+
* @param organizationId - Organization ID for multi-tenant isolation
137+
* @param environmentId - Environment ID (undefined = platform-global)
136138
*/
137-
setDatabaseDriver(driver: IDataDriver): void {
139+
setDatabaseDriver(driver: IDataDriver, organizationId?: string, environmentId?: string): void {
138140
const tableName = this.config.tableName ?? 'sys_metadata';
139141
const dbLoader = new DatabaseLoader({
140142
driver,
141143
tableName,
144+
organizationId,
145+
environmentId,
142146
});
143147
this.registerLoader(dbLoader);
144148
this.logger.info('DatabaseLoader configured', { datasource: this.config.datasource, tableName });
@@ -151,12 +155,16 @@ export class MetadataManager implements IMetadataService {
151155
* No manual driver resolution needed.
152156
*
153157
* @param engine - An IDataEngine instance (typically the ObjectQL service)
158+
* @param organizationId - Organization ID for multi-tenant isolation
159+
* @param environmentId - Environment ID (undefined = platform-global)
154160
*/
155-
setDataEngine(engine: IDataEngine): void {
161+
setDataEngine(engine: IDataEngine, organizationId?: string, environmentId?: string): void {
156162
const tableName = this.config.tableName ?? 'sys_metadata';
157163
const dbLoader = new DatabaseLoader({
158164
engine,
159165
tableName,
166+
organizationId,
167+
environmentId,
160168
});
161169
this.registerLoader(dbLoader);
162170
this.logger.info('DatabaseLoader configured via DataEngine', { tableName });

packages/metadata/src/objects/sys-metadata-history.object.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,22 @@ export const SysMetadataHistoryObject = ObjectSchema.create({
105105
description: 'Description of what changed in this version',
106106
}),
107107

108-
/** Tenant ID for multi-tenant isolation */
109-
tenant_id: Field.text({
110-
label: 'Tenant ID',
108+
/** Organization ID for multi-tenant isolation */
109+
organization_id: Field.text({
110+
label: 'Organization ID',
111111
required: false,
112112
readonly: true,
113113
maxLength: 255,
114+
description: 'Organization identifier for multi-tenant isolation.',
115+
}),
116+
117+
/** Environment ID — null = platform-global, set = env-scoped */
118+
env_id: Field.text({
119+
label: 'Environment ID',
120+
required: false,
121+
readonly: true,
122+
maxLength: 255,
123+
description: 'Scopes this history entry to a specific environment.',
114124
}),
115125

116126
/** User who made this change */
@@ -135,7 +145,8 @@ export const SysMetadataHistoryObject = ObjectSchema.create({
135145
{ fields: ['type', 'name'] },
136146
{ fields: ['recorded_at'] },
137147
{ fields: ['operation_type'] },
138-
{ fields: ['tenant_id'] },
148+
{ fields: ['organization_id'] },
149+
{ fields: ['env_id'] },
139150
],
140151

141152
enable: {

packages/metadata/src/objects/sys-metadata.object.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,20 @@ export const SysMetadataObject = ObjectSchema.create({
110110
defaultValue: 'active',
111111
}),
112112

113-
/** Tenant ID for multi-tenant isolation */
114-
tenant_id: Field.text({
115-
label: 'Tenant ID',
113+
/** Organization ID for multi-tenant isolation */
114+
organization_id: Field.text({
115+
label: 'Organization ID',
116116
required: false,
117117
maxLength: 255,
118+
description: 'Organization identifier for multi-tenant isolation.',
119+
}),
120+
121+
/** Environment ID — null = platform-global, set = env-scoped */
122+
env_id: Field.text({
123+
label: 'Environment ID',
124+
required: false,
125+
maxLength: 255,
126+
description: 'Scopes this metadata to a specific environment. Null = platform-global.',
118127
}),
119128

120129
/** Version number for optimistic concurrency */
@@ -171,9 +180,10 @@ export const SysMetadataObject = ObjectSchema.create({
171180
},
172181

173182
indexes: [
174-
{ fields: ['type', 'name'], unique: true },
183+
{ fields: ['type', 'name', 'env_id'], unique: true },
175184
{ fields: ['type', 'scope'] },
176-
{ fields: ['tenant_id'] },
185+
{ fields: ['organization_id'] },
186+
{ fields: ['env_id'] },
177187
{ fields: ['state'] },
178188
{ fields: ['namespace'] },
179189
],

packages/metadata/src/plugin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface MetadataPluginOptions {
1111
rootDir?: string;
1212
watch?: boolean;
1313
config?: Partial<MetadataPluginConfig>;
14+
/** Organization ID for multi-tenant metadata isolation (passed to DatabaseLoader). */
15+
organizationId?: string;
16+
/** Environment ID — undefined = platform-global, set = env-scoped metadata. */
17+
environmentId?: string;
1418
}
1519

1620
export class MetadataPlugin implements Plugin {
@@ -114,7 +118,7 @@ export class MetadataPlugin implements Plugin {
114118
const ql = ctx.getService<any>('objectql');
115119
if (ql) {
116120
ctx.logger.info('[MetadataPlugin] Bridging ObjectQL engine to MetadataManager');
117-
this.manager.setDataEngine(ql);
121+
this.manager.setDataEngine(ql, this.options.organizationId, this.options.environmentId);
118122
}
119123
} catch {
120124
ctx.logger.debug('[MetadataPlugin] ObjectQL not available — database persistence disabled');

0 commit comments

Comments
 (0)