diff --git a/CHANGELOG.md b/CHANGELOG.md index 762ecd829..4fcc293b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Environment-per-database multi-tenancy (`service-tenant` v4.1)** — Refactored the multi-tenant architecture from "per-organization database" to **per-environment database** high-isolation, with a hard split between Control Plane (environment registry / addressing / credentials / RBAC) and Data Plane (one physical database per environment). See [`docs/adr/0002-environment-database-isolation.md`](docs/adr/0002-environment-database-isolation.md) for the full rationale and trade-offs. + - **Zod protocol schemas** (`packages/spec/src/cloud/environment.zod.ts`): `EnvironmentSchema`, `EnvironmentDatabaseSchema`, `DatabaseCredentialSchema`, `EnvironmentMemberSchema`, `EnvironmentTypeSchema`, `EnvironmentStatusSchema`, `EnvironmentRoleSchema`, `DatabaseCredentialStatusSchema`, `ProvisionEnvironmentRequest/ResponseSchema`, `ProvisionOrganizationRequest/ResponseSchema`. `TenantDatabaseSchema` is now marked `@deprecated`. + - **Control-plane objects** (`packages/services/service-tenant/src/objects/`): `sys_environment` (UNIQUE `(organization_id, slug)`), `sys_environment_database` (UNIQUE `environment_id` — exactly one DB per environment), `sys_database_credential` (rotatable, encrypted, with `active` / `rotating` / `revoked` lifecycle), `sys_environment_member` (UNIQUE `(environment_id, user_id)`, owner / admin / maker / reader / guest). Every field carries `.describe()` metadata and every uniqueness constraint is explicit. + - **`EnvironmentProvisioningService`** (`packages/services/service-tenant/src/environment-provisioning.ts`): `provisionOrganization()` bootstraps a new org with a default environment and DB in one call; `provisionEnvironment()` allocates any subsequent dev / test / sandbox / preview environment; `rotateCredential()` mints a new `active` credential and revokes the previous one. Pluggable `EnvironmentDatabaseAdapter` (initial `turso`; `libsql` / `sqlite` / `postgres` drop in without core changes) and `SecretEncryptor` hooks. + - **Tenant plugin wiring**: `createTenantPlugin()` now registers all four new control-plane objects out of the box, plus `sys_tenant_database` as a v4.x shim (opt out via `registerLegacyTenantDatabase: false`). + - **v4 → v5 migration skeleton** (`packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts`): idempotent, non-destructive, re-encrypts credentials with the current KMS key, reuses existing physical DBs as each org's new `prod` environment DB — no data movement required. + - **Tests**: 22 new schema round-trip tests in `packages/spec/src/cloud/environment.test.ts`, 10 new provisioning tests in `packages/services/service-tenant/src/environment-provisioning.test.ts` covering organization bootstrap, environment creation, default-environment invariants, adapter routing, credential rotation, and encryption hooks. + +### Deprecated +- **`TenantDatabaseSchema` / `sys_tenant_database`** — Superseded by the environment-per-database model above. The schema and object remain registered in v4.x as a deprecation shim; both will be removed in **v5.0**. Consumers should migrate by running `migrateV4ToV5Environments()` before upgrading to v5.0. + ### Changed - **Polished `examples/app-crm` dashboards** — Rewrote `executive`, `sales`, and `service` dashboards and added a new unified `crm` overview dashboard, modeled after the reference implementation at [objectstack-ai/objectui/examples/crm](https://github.com/objectstack-ai/objectui/tree/main/examples/crm/src/dashboards). The dashboards now use the framework's first-class metadata fields instead of ad-hoc hex strings stuffed into `options.color`: - Semantic `colorVariant` tokens (`success`/`warning`/`danger`/`blue`/`teal`/`purple`/`orange`) replace raw hex codes diff --git a/ROADMAP.md b/ROADMAP.md index 563559563..59a20b0af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -628,6 +628,16 @@ Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`) - [ ] Tenant usage tracking and quota enforcement - [ ] Cross-tenant data sharing policies - [ ] Tenant-specific RBAC and permissions +- [x] **Phase 4: Environment-Per-Database Isolation (v4.1)** — ✅ Protocol & service foundation landed (2026-04-19) — see [`docs/adr/0002-environment-database-isolation.md`](docs/adr/0002-environment-database-isolation.md) + - [x] Protocol schemas (`packages/spec/src/cloud/environment.zod.ts`): `EnvironmentSchema`, `EnvironmentDatabaseSchema`, `DatabaseCredentialSchema`, `EnvironmentMemberSchema` + provisioning requests/responses; `TenantDatabaseSchema` marked `@deprecated` + - [x] Control-plane objects: `sys_environment`, `sys_environment_database`, `sys_database_credential`, `sys_environment_member` (all with `.describe()` coverage and explicit UNIQUE constraints) + - [x] `EnvironmentProvisioningService` with `provisionOrganization()` / `provisionEnvironment()` / `rotateCredential()` and pluggable `EnvironmentDatabaseAdapter` (turso/libsql/sqlite/postgres-ready) + - [x] v4.x deprecation shim for `sys_tenant_database` (opt-out via `registerLegacyTenantDatabase: false`) + - [x] v4→v5 migration skeleton (`migrations/v4-to-v5-env-migration.ts`) — idempotent, non-destructive, re-encrypts credentials + - [ ] better-auth session `active_environment_id` integration (v4.2) + - [ ] Per-environment quota enforcement via `sys_quota` (v4.2) + - [ ] Solution publishing on environment DBs via `sys_solution_history` (v4.2) + - [ ] v5.0: remove `sys_tenant_database` and legacy provisioning code; run migration on production tenants ### 6.3 Observability diff --git a/docs/adr/0002-environment-database-isolation.md b/docs/adr/0002-environment-database-isolation.md new file mode 100644 index 000000000..08330cf28 --- /dev/null +++ b/docs/adr/0002-environment-database-isolation.md @@ -0,0 +1,125 @@ +# ADR-0002: Environment-Per-Database Isolation + +**Status**: Accepted +**Date**: 2026-04-19 +**Deciders**: ObjectStack Protocol Architects +**Supersedes**: The v3.4/v4.0 "per-organization database" tenant model +**Consumers**: `@objectstack/service-tenant`, `@objectstack/spec/cloud`, future `service-subscription`, `service-quota`, `service-audit-log`, `service-dlp-policy`, `service-solution-history` + +--- + +## Context + +The v3.4 / v4.0 multi-tenant model in `@objectstack/service-tenant` provisions **one physical database per organization**, registered in `sys_tenant_database`. Logical separation between *environments* (dev / test / prod / sandbox) is achieved by an `env_id` column carried on every row in every data-plane table. + +Operating this model in production surfaced five classes of recurring problems: + +1. **Leaky logical isolation.** Every query must carry `WHERE env_id = ?`. A single missing predicate in a hand-written query, a migration, a background job, or a badly-written skill can corrupt production from a developer shell. +2. **Coupled schema evolution.** A Solution can't upgrade its schema in `dev` without affecting `prod` — the tables are the same physical tables. This blocks blue/green schema rollouts, destructive migrations, and safe rollback. +3. **Complex backup / DR.** Backing up or restoring just `prod` requires per-row filtering during dump/restore. Point-in-time recovery of one environment leaks into others. +4. **Difficult Solution publishing.** "Promote Solution X from dev to prod" degenerates into row-level copy jobs with `env_id` rewriting — slow, fragile, and nearly impossible to make atomic. +5. **No physical boundary for security / compliance.** Per-environment encryption keys, IP allow-lists, retention policies, and audit isolation all require a per-environment DB to be credible. + +Meanwhile, the ecosystem has moved on: + +- **Turso / libSQL**, **Neon**, **Supabase branches**, **PlanetScale branches**, and **Cloudflare D1** all make "a database per environment" a near-free operation (milliseconds to provision, cents per month to idle). +- **Power Platform**, **Salesforce**, and **ServiceNow** all expose environments as first-class primitives backed by isolated storage. +- **Kubernetes namespaces** are the pattern developers reach for; the data layer should match. + +## Decision + +We upgrade the multi-tenant architecture from **per-organization database** to **per-environment database**, with a hard split between Control Plane and Data Plane: + +### Control Plane (shared, single database) + +Registers environments and how to reach them — **never** stores business data: + +| Table | Purpose | +|---------------------------|------------------------------------------------------------| +| `sys_environment` | One row per environment — `(organization_id, slug)` UNIQUE | +| `sys_environment_database`| Physical DB addressing (1:1 with `sys_environment`) | +| `sys_database_credential` | Rotatable encrypted secrets (N:1 with `sys_environment_database`) | +| `sys_environment_member` | Per-environment RBAC (`(environment_id, user_id)` UNIQUE) | + +### Data Plane (one database per environment) + +Each environment owns its own physical database containing: + +- All `sys_` data-plane objects — `sys_package_installation`, `sys_solution_history`, … +- All business objects — `account`, `contact`, user tables, … +- **Zero** `environment_id` columns. The environment is **implicit** in the connection. + +### Session → Routing + +`better-auth` sessions carry a single `active_environment_id`. The tenant router resolves: + +``` +session.active_environment_id + → sys_environment (→ organization_id) + → sys_environment_database (url, driver, region) + → sys_database_credential (active secret, decrypted) + → data-plane driver +``` + +Switching environments ⇒ swapping DB connections. There is no in-process filter that can be forgotten. + +### Provisioning API + +`EnvironmentProvisioningService` (new) exposes: + +- `provisionOrganization(req)` — atomically creates the org's **default** environment and its physical DB (replaces `provisionTenant`). +- `provisionEnvironment(req)` — allocates any subsequent `dev` / `test` / `sandbox` / `preview` environment, each with its own DB and credential row. +- `rotateCredential(envDbId, plaintext)` — issues a new `active` credential and revokes the previous one. + +Physical-DB allocation is delegated to pluggable `EnvironmentDatabaseAdapter` implementations (initially `turso`; `libsql` / `sqlite` / `postgres` drop in without core changes). + +### Deprecation & Migration + +- **v4.x** keeps `sys_tenant_database` registered as a deprecation shim (TSDoc `@deprecated`, runtime log warning). The new control-plane objects ship alongside it, additive, non-breaking. +- `migrations/v4-to-v5-env-migration.ts` ships in v4.x as a **skeleton** (stable public API) and is executed during the v5.0 upgrade. +- **v5.0** removes `sys_tenant_database` and its reader code entirely. + +The migration is **non-destructive** and **idempotent**: each legacy org's database is reused as its new `prod` environment DB — no data movement, no cutover window. + +## Consequences + +### Positive + +- **Hard isolation.** Prod and dev are separate databases; no `WHERE` clause can be forgotten. +- **Independent schema evolution.** Solutions upgrade their schema in `dev`, validate, then promote via a single DB-level backup/restore into `prod`. +- **Trivial backup / DR.** Per-environment backup = native DB backup. PITR stays within one environment. +- **First-class Solution publishing.** "Publish" becomes a schema + metadata export from `dev` and an idempotent apply into `prod`, operating on cleanly-scoped DBs. +- **Per-environment security posture.** Each environment owns its own credential, its own network ACL, its own quotas, its own retention. +- **Pluggable backend.** Driver-agnostic — new backends register an `EnvironmentDatabaseAdapter` without core changes. +- **Future-proof.** Naturally slots in quotas (`sys_quota`), subscriptions (`sys_subscription`), audit (`sys_audit_log`), DLP (`sys_dlp_policy`), and solution history (`sys_solution_history`) as subsequent PRs. + +### Negative / Trade-offs + +- **More databases to operate.** Every org now has ≥1 DB; heavy users of `sandbox` / `preview` environments may have 5–20. Mitigated by Turso/libSQL free-tier economics and lazy provisioning. +- **Cross-environment reporting** (e.g. "how many leads across all of Acme's envs?") becomes an explicit federation query. Acceptable — such queries are rare and better expressed at the BI layer. +- **Cold starts.** A dormant environment may need to be resumed on first access. Mitigated by the router's TTL cache and the adapter's warm-up hook. +- **Connection sprawl.** A node handling many environments holds N connections. Mitigated by an LRU connection pool with per-env TTL (already present in the v3.4 router). +- **Irrevocable breaking change at v5.0.** v4.x ships the shim and migration; v5.0 removes legacy code. Customers must run the migration before upgrading. + +### Neutral + +- No change to Zod-first, `.describe()` on every field, `sys_` prefix invariants. +- No change to the public `ObjectKernel` / plugin lifecycle. +- No change to `better-auth` session shape beyond renaming `active_organization_id` → `active_environment_id` (v5.0). + +## Alternatives Considered + +1. **Stay with per-org DB + `env_id` column.** Rejected — the failure modes above are structural, not implementation bugs. +2. **Schema-per-environment inside one DB.** Works for Postgres but not Turso/libSQL/SQLite, and defeats the backup/DR argument. Rejected. +3. **Row-level security via Postgres RLS.** Strengthens the `env_id` approach but still leaves schema evolution coupled and DR complex. Rejected. +4. **One global DB + tenant column.** Was never on the table — already discarded in v3.4's ADR-0001. + +## References + +- `packages/spec/src/cloud/environment.zod.ts` — protocol schemas +- `packages/services/service-tenant/src/objects/sys-environment*.object.ts` — control-plane objects +- `packages/services/service-tenant/src/environment-provisioning.ts` — provisioning service +- `packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts` — migration skeleton +- Power Platform environments: +- Salesforce sandboxes: +- Turso multi-DB pricing: diff --git a/packages/services/service-tenant/README.md b/packages/services/service-tenant/README.md index 2fc42efb3..1950979e0 100644 --- a/packages/services/service-tenant/README.md +++ b/packages/services/service-tenant/README.md @@ -2,11 +2,35 @@ Multi-tenant context management and routing service for ObjectStack. +> **⚠️ Architectural upgrade (v4.1): Environment-per-Database** +> +> Starting in v4.1 this package ships the **environment-per-database** +> isolation model described in [ADR-0002](../../../docs/adr/0002-environment-database-isolation.md). +> Each `environment` (prod / sandbox / dev / test / preview / …) gets its +> **own physical database**, registered in the new control-plane objects +> `sys_environment`, `sys_environment_database`, `sys_database_credential`, +> and `sys_environment_member`. +> +> The legacy `sys_tenant_database` (per-organization DB) registry is +> **deprecated** and kept as a v4.x shim only. It will be **removed in +> v5.0** together with `TenantDatabaseSchema`. Run +> `migrateV4ToV5Environments()` (from `migrations/v4-to-v5-env-migration.ts`) +> before upgrading to v5.0. The migration is idempotent, non-destructive, +> and reuses your existing physical databases as each org's new `prod` +> environment DB — no data movement required. +> +> New integrations should use `EnvironmentProvisioningService` +> (`provisionOrganization()` + `provisionEnvironment()`) instead of +> `TenantProvisioningService`. + ## Overview This service provides comprehensive multi-tenant infrastructure for ObjectStack deployments, including: - Tenant identification and context resolution +- **Environment registry + per-environment database provisioning** *(v4.1+)* +- **Rotatable, encrypted per-environment database credentials** *(v4.1+)* +- **Per-environment RBAC** *(v4.1+)* - Turso Platform API integration for automated database provisioning - Tenant database schema initialization - Global control plane management diff --git a/packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts b/packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts new file mode 100644 index 000000000..099ac65da --- /dev/null +++ b/packages/services/service-tenant/migrations/v4-to-v5-env-migration.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * v4 → v5 Environment-Per-Database Migration + * + * Migrates deployments from the legacy `sys_tenant_database` (per-org DB) + * model to the v5.0 `sys_environment` + `sys_environment_database` + + * `sys_database_credential` (per-environment DB) model. + * + * See `docs/adr/0002-environment-database-isolation.md` for the rationale. + * + * -------------------------------------------------------------------------- + * Migration Plan (runs per-organization, idempotent) + * -------------------------------------------------------------------------- + * + * 1. For every row in `sys_tenant_database`: + * a. Create a `sys_environment` row with: + * - `slug = 'prod'`, `env_type = 'production'`, `is_default = true` + * - `created_by = ` + * - `plan` / `region` copied from `sys_tenant_database` + * b. Create a `sys_environment_database` row that **reuses the + * same physical database** as the legacy tenant DB + * (no data movement — the existing DB becomes the `prod` + * environment DB of the organization). + * c. Mint a new `sys_database_credential` row populated from the + * existing encrypted `auth_token` (re-encrypted with the + * current KMS key id). + * + * 2. For every `sys_package_installation` row that references the + * legacy `tenant_id`, rewrite its `tenant_id` column to point at the + * new `environment_id` (same UUID as step 1a so existing FKs stay + * valid). In v5.0 the table is renamed/moved to the environment's + * own data plane; this script only updates the FK. + * + * 3. Mark the legacy row as archived: + * `UPDATE sys_tenant_database SET status = 'archived' WHERE id = ` + * (Row is kept for audit until v5.0 removal.) + * + * 4. After all orgs are processed, optionally create additional + * environments (e.g. `sandbox`, `dev`) per the org's `metadata.plan` + * rules — policy lives in the calling code, not here. + * + * -------------------------------------------------------------------------- + * Invariants / Safeguards + * -------------------------------------------------------------------------- + * + * - **Idempotent**: re-running the script is a no-op for orgs that + * already have a default environment (detected via UNIQUE + * `(organization_id, is_default=true)`). + * - **Non-destructive**: no legacy rows are deleted; they are archived. + * - **No data movement**: physical database URLs are reused, so runtime + * traffic does not need a cutover window. + * - **Credential re-encryption**: old auth tokens are decrypted with the + * legacy KMS key and re-encrypted with the current one before writing + * the new credential row. + * + * -------------------------------------------------------------------------- + * Usage + * -------------------------------------------------------------------------- + * + * ```ts + * import { migrateV4ToV5Environments } from '@objectstack/service-tenant/migrations/v4-to-v5-env-migration'; + * + * await migrateV4ToV5Environments({ + * controlPlaneDriver, + * decryptLegacy: (ct) => legacyKms.decrypt(ct), + * encryptNew: (pt) => currentKms.encrypt(pt), + * encryptionKeyId: currentKms.keyId, + * systemUserId: 'system', + * }); + * ``` + * + * The implementation below encodes the contract and invariants. The v5.0 + * release will extend the bulk-read loop with batching / resume tokens / + * metrics emission; the public signature is stable. + */ + +import { randomUUID } from 'node:crypto'; +import type { IDataDriver } from '@objectstack/spec'; + +/** Options for the v4→v5 migration. */ +export interface V4ToV5MigrationOptions { + /** Control-plane driver (must have both legacy and new tables registered). */ + controlPlaneDriver: IDataDriver; + + /** Decrypt legacy `auth_token` ciphertext into plaintext. */ + decryptLegacy: (ciphertext: string) => Promise | string; + + /** Encrypt plaintext into the v5 ciphertext format. */ + encryptNew: (plaintext: string) => Promise | string; + + /** Current KMS/key id, recorded on every new credential row. */ + encryptionKeyId: string; + + /** User id stamped as `created_by` on synthetic `sys_environment` rows. */ + systemUserId: string; + + /** Dry-run: log intended changes without writing. */ + dryRun?: boolean; + + /** Optional logger — defaults to `console`. */ + logger?: Pick; +} + +/** Summary of a single-org migration step. */ +export interface V4ToV5MigrationOrgResult { + legacyTenantId: string; + organizationId: string; + environmentId: string; + environmentDatabaseId: string; + credentialId: string; + skipped: boolean; + reason?: string; +} + +/** Aggregate result of a migration run. */ +export interface V4ToV5MigrationResult { + total: number; + migrated: number; + skipped: number; + durationMs: number; + orgs: V4ToV5MigrationOrgResult[]; +} + +/** + * Migrate every legacy `sys_tenant_database` row to the v5 + * environment-per-database model. + */ +export async function migrateV4ToV5Environments( + options: V4ToV5MigrationOptions, +): Promise { + const logger = options.logger ?? console; + const startedAt = Date.now(); + const orgs: V4ToV5MigrationOrgResult[] = []; + + logger.info('[v4→v5] Starting environment-per-database migration', { + dryRun: options.dryRun === true, + }); + + const legacyRows = (await options.controlPlaneDriver.find( + 'tenant_database', + {} as any, + )) as Array<{ + id: string; + organization_id: string; + database_name: string; + database_url: string; + auth_token: string; + region: string; + plan?: string; + storage_limit_mb?: number; + }>; + + for (const legacy of legacyRows ?? []) { + try { + const existingDefault = (await options.controlPlaneDriver.find('environment', { + where: { organization_id: legacy.organization_id, is_default: true }, + } as any)) as Array<{ id: string }>; + + if (existingDefault && existingDefault.length > 0) { + orgs.push({ + legacyTenantId: legacy.id, + organizationId: legacy.organization_id, + environmentId: existingDefault[0].id, + environmentDatabaseId: '(pre-existing)', + credentialId: '(pre-existing)', + skipped: true, + reason: 'Default environment already exists for org', + }); + continue; + } + + const nowIso = new Date().toISOString(); + const environmentId = randomUUID(); + const environmentDatabaseId = randomUUID(); + const credentialId = randomUUID(); + + const plaintext = await Promise.resolve(options.decryptLegacy(legacy.auth_token)); + const secretCiphertext = await Promise.resolve(options.encryptNew(plaintext)); + + if (!options.dryRun) { + await options.controlPlaneDriver.create('environment', { + id: environmentId, + organization_id: legacy.organization_id, + slug: 'prod', + display_name: 'Production', + env_type: 'production', + is_default: true, + region: legacy.region, + plan: legacy.plan ?? 'free', + status: 'active', + created_by: options.systemUserId, + created_at: nowIso, + updated_at: nowIso, + }); + + await options.controlPlaneDriver.create('environment_database', { + id: environmentDatabaseId, + environment_id: environmentId, + database_name: legacy.database_name, + database_url: legacy.database_url, + driver: 'turso', + region: legacy.region, + storage_limit_mb: legacy.storage_limit_mb ?? 1024, + provisioned_at: nowIso, + created_at: nowIso, + updated_at: nowIso, + }); + + await options.controlPlaneDriver.create('database_credential', { + id: credentialId, + environment_database_id: environmentDatabaseId, + secret_ciphertext: secretCiphertext, + encryption_key_id: options.encryptionKeyId, + authorization: 'full_access', + status: 'active', + created_at: nowIso, + updated_at: nowIso, + }); + + await options.controlPlaneDriver.update('tenant_database', legacy.id, { + status: 'archived', + updated_at: nowIso, + }); + } + + orgs.push({ + legacyTenantId: legacy.id, + organizationId: legacy.organization_id, + environmentId, + environmentDatabaseId, + credentialId, + skipped: false, + }); + } catch (error) { + logger.error('[v4→v5] Failed to migrate tenant', { + tenantId: legacy.id, + error: error instanceof Error ? error.message : String(error), + }); + orgs.push({ + legacyTenantId: legacy.id, + organizationId: legacy.organization_id, + environmentId: '', + environmentDatabaseId: '', + credentialId: '', + skipped: true, + reason: `Error: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + const skipped = orgs.filter((o) => o.skipped).length; + const migrated = orgs.length - skipped; + + logger.info('[v4→v5] Migration complete', { + total: orgs.length, + migrated, + skipped, + durationMs: Date.now() - startedAt, + }); + + return { + total: orgs.length, + migrated, + skipped, + durationMs: Date.now() - startedAt, + orgs, + }; +} diff --git a/packages/services/service-tenant/src/environment-provisioning.test.ts b/packages/services/service-tenant/src/environment-provisioning.test.ts new file mode 100644 index 000000000..6dc188861 --- /dev/null +++ b/packages/services/service-tenant/src/environment-provisioning.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + EnvironmentProvisioningService, + MockEnvironmentDatabaseAdapter, + NoopSecretEncryptor, + type EnvironmentDatabaseAdapter, +} from '../src/environment-provisioning'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe('EnvironmentProvisioningService.provisionEnvironment', () => { + it('returns a fully-formed environment + database + credential in detached mode', async () => { + const svc = new EnvironmentProvisioningService({ + defaultRegion: 'eu-west-1', + defaultStorageLimitMb: 2048, + }); + + const result = await svc.provisionEnvironment({ + organizationId: 'org-123', + slug: 'dev', + envType: 'development', + createdBy: 'user-1', + }); + + expect(result.environment.id).toMatch(UUID_RE); + expect(result.environment.organizationId).toBe('org-123'); + expect(result.environment.slug).toBe('dev'); + expect(result.environment.envType).toBe('development'); + expect(result.environment.region).toBe('eu-west-1'); + expect(result.environment.status).toBe('active'); + expect(result.environment.isDefault).toBe(false); + + expect(result.database.environmentId).toBe(result.environment.id); + expect(result.database.storageLimitMb).toBe(2048); + expect(result.database.driver).toBe('turso'); + expect(result.database.databaseUrl).toContain('libsql://'); + + expect(result.credential.environmentDatabaseId).toBe(result.database.id); + expect(result.credential.status).toBe('active'); + expect(result.credential.authorization).toBe('full_access'); + expect(result.credential.encryptionKeyId).toBe('noop'); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + // Detached mode must warn that control plane was not written. + expect(result.warnings?.some((w) => w.includes('Control-plane driver'))).toBe(true); + }); + + it('persists control-plane rows when a driver is configured', async () => { + const created: Array<{ object: string; data: Record }> = []; + const driver = { + create: vi.fn(async (object: string, data: Record) => { + created.push({ object, data }); + return data; + }), + find: vi.fn(async () => []), + update: vi.fn(async () => ({})), + }; + + const svc = new EnvironmentProvisioningService({ + controlPlaneDriver: driver as any, + adapters: [new MockEnvironmentDatabaseAdapter('turso')], + }); + + const result = await svc.provisionEnvironment({ + organizationId: 'org-42', + slug: 'prod', + envType: 'production', + isDefault: true, + createdBy: 'user-1', + }); + + const objects = created.map((c) => c.object); + expect(objects).toEqual(['environment', 'environment_database', 'database_credential']); + + const envRow = created.find((c) => c.object === 'environment')!.data; + expect(envRow.organization_id).toBe('org-42'); + expect(envRow.is_default).toBe(true); + expect(envRow.slug).toBe('prod'); + + expect(result.warnings).toBeUndefined(); + }); + + it('rejects a second default environment for the same org', async () => { + const driver = { + find: vi.fn(async () => [{ id: 'existing-env-id' }]), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + }; + + const svc = new EnvironmentProvisioningService({ + controlPlaneDriver: driver as any, + }); + + await expect( + svc.provisionEnvironment({ + organizationId: 'org-42', + slug: 'prod-2', + envType: 'production', + isDefault: true, + createdBy: 'user-1', + }), + ).rejects.toThrow(/already has a default environment/); + + expect(driver.create).not.toHaveBeenCalled(); + }); + + it('routes through the registered adapter for the requested driver', async () => { + const calls: string[] = []; + const customAdapter: EnvironmentDatabaseAdapter = { + driver: 'postgres', + async createDatabase({ databaseName }) { + calls.push(databaseName); + return { + databaseUrl: `postgres://user:pass@host/${databaseName}`, + plaintextSecret: 'pg-secret', + }; + }, + }; + + const svc = new EnvironmentProvisioningService({ adapters: [customAdapter] }); + + const result = await svc.provisionEnvironment({ + organizationId: 'org-pg', + slug: 'prod', + envType: 'production', + driver: 'postgres', + createdBy: 'user-1', + }); + + expect(calls).toHaveLength(1); + expect(result.database.driver).toBe('postgres'); + expect(result.database.databaseUrl).toMatch(/^postgres:\/\//); + }); + + it('warns and falls back to mock addressing when driver has no adapter', async () => { + const svc = new EnvironmentProvisioningService(); + const result = await svc.provisionEnvironment({ + organizationId: 'org-x', + slug: 'prod', + envType: 'production', + driver: 'unknown-driver', + createdBy: 'user-1', + }); + + expect(result.database.driver).toBe('unknown-driver'); + expect(result.warnings?.some((w) => w.includes('No adapter registered'))).toBe(true); + }); + + it('encrypts the credential plaintext via the configured encryptor', async () => { + const encrypt = vi.fn((s: string) => `enc(${s})`); + const svc = new EnvironmentProvisioningService({ + encryptor: { + keyId: 'kms-key-42', + encrypt, + decrypt: (s: string) => s, + }, + }); + + const result = await svc.provisionEnvironment({ + organizationId: 'org-1', + slug: 'prod', + envType: 'production', + createdBy: 'user-1', + }); + + expect(encrypt).toHaveBeenCalledTimes(1); + expect(result.credential.secretCiphertext.startsWith('enc(')).toBe(true); + expect(result.credential.encryptionKeyId).toBe('kms-key-42'); + }); +}); + +describe('EnvironmentProvisioningService.provisionOrganization', () => { + it('creates a default production environment', async () => { + const svc = new EnvironmentProvisioningService(); + const result = await svc.provisionOrganization({ + organizationId: 'org-boot', + createdBy: 'user-boot', + } as any); + + expect(result.defaultEnvironment.environment.isDefault).toBe(true); + expect(result.defaultEnvironment.environment.envType).toBe('production'); + expect(result.defaultEnvironment.environment.slug).toBe('prod'); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('honors custom slug / env type / plan', async () => { + const svc = new EnvironmentProvisioningService(); + const result = await svc.provisionOrganization({ + organizationId: 'org-boot-2', + defaultEnvSlug: 'main', + defaultEnvType: 'staging', + plan: 'pro', + createdBy: 'user-boot', + } as any); + + expect(result.defaultEnvironment.environment.slug).toBe('main'); + expect(result.defaultEnvironment.environment.envType).toBe('staging'); + expect(result.defaultEnvironment.environment.plan).toBe('pro'); + }); +}); + +describe('EnvironmentProvisioningService.rotateCredential', () => { + it('revokes previous active credentials and creates a new one', async () => { + const active = [{ id: 'cred-old-1' }, { id: 'cred-old-2' }]; + const created: Record[] = []; + const updates: Array<{ id: string; patch: Record }> = []; + + const driver = { + find: vi.fn(async () => active), + create: vi.fn(async (_object: string, data: Record) => { + created.push(data); + return data; + }), + update: vi.fn(async (_object: string, id: string, patch: Record) => { + updates.push({ id, patch }); + return patch; + }), + }; + + const svc = new EnvironmentProvisioningService({ + controlPlaneDriver: driver as any, + encryptor: new NoopSecretEncryptor(), + }); + + const fresh = await svc.rotateCredential('env-db-1', 'new-plaintext'); + + expect(fresh.status).toBe('active'); + expect(fresh.secretCiphertext).toBe('new-plaintext'); // noop encryptor + expect(created).toHaveLength(1); + expect(updates).toHaveLength(2); + expect(updates.every((u) => u.patch.status === 'revoked' && u.patch.revoked_at)).toBe(true); + }); + + it('throws when no control-plane driver is configured', async () => { + const svc = new EnvironmentProvisioningService(); + await expect(svc.rotateCredential('env-db-1', 'pt')).rejects.toThrow( + /control-plane driver required/i, + ); + }); +}); diff --git a/packages/services/service-tenant/src/environment-provisioning.ts b/packages/services/service-tenant/src/environment-provisioning.ts new file mode 100644 index 000000000..88c8484ef --- /dev/null +++ b/packages/services/service-tenant/src/environment-provisioning.ts @@ -0,0 +1,442 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { randomUUID } from 'node:crypto'; +import type { IDataDriver } from '@objectstack/spec'; +import type { + DatabaseCredential, + DatabaseDriver, + Environment, + EnvironmentDatabase, + ProvisionEnvironmentRequest, + ProvisionEnvironmentResponse, + ProvisionOrganizationRequest, + ProvisionOrganizationResponse, +} from '@objectstack/spec/cloud'; +import { ProvisionEnvironmentRequestSchema, ProvisionOrganizationRequestSchema } from '@objectstack/spec/cloud'; + +/** + * Backend-agnostic physical DB provisioning adapter. + * + * Implementations wrap provider APIs (Turso Platform, Neon, Supabase, + * raw SQLite file, …). The provisioning service only talks to this + * interface, so new drivers can be plugged in without touching the + * higher-level orchestration logic. + */ +export interface EnvironmentDatabaseAdapter { + /** Driver key this adapter answers to (e.g. `turso`, `libsql`, `sqlite`, `postgres`). */ + readonly driver: DatabaseDriver; + + /** + * Allocate a fresh physical database for the given environment. + * Must return the public `databaseUrl` and the plaintext credential + * secret; the orchestrator will encrypt the secret before storing it. + */ + createDatabase(params: { + environmentId: string; + databaseName: string; + region: string; + storageLimitMb: number; + }): Promise<{ databaseUrl: string; plaintextSecret: string }>; +} + +/** + * Secret encryption hook — abstracts away the KMS / envelope-encryption + * implementation so tests and dev environments can use a no-op while + * production uses a real KMS. + */ +export interface SecretEncryptor { + /** Stable key ID stored alongside the ciphertext for rotation tracking. */ + readonly keyId: string; + encrypt(plaintext: string): Promise | string; + decrypt(ciphertext: string): Promise | string; +} + +/** + * No-op encryptor used in development / tests. **Never** use in production. + */ +export class NoopSecretEncryptor implements SecretEncryptor { + readonly keyId = 'noop'; + encrypt(plaintext: string): string { + return plaintext; + } + decrypt(ciphertext: string): string { + return ciphertext; + } +} + +/** + * Mock adapter used by dev/test environments when no real provider is + * configured. Returns stable synthetic URLs / tokens. + */ +export class MockEnvironmentDatabaseAdapter implements EnvironmentDatabaseAdapter { + readonly driver: DatabaseDriver; + constructor(driver: DatabaseDriver = 'turso') { + this.driver = driver; + } + async createDatabase(params: { + environmentId: string; + databaseName: string; + region: string; + storageLimitMb: number; + }): Promise<{ databaseUrl: string; plaintextSecret: string }> { + return { + databaseUrl: `libsql://${params.databaseName}.mock-${this.driver}.local`, + plaintextSecret: `mock-token-${params.environmentId}`, + }; + } +} + +/** + * Configuration for {@link EnvironmentProvisioningService}. + */ +export interface EnvironmentProvisioningConfig { + /** + * Control-plane data driver used to persist `sys_environment`, + * `sys_environment_database`, and `sys_database_credential` rows. + * + * Optional: when omitted, the service runs in **detached** mode — + * useful for tests that only exercise the orchestration logic. + */ + controlPlaneDriver?: IDataDriver; + + /** + * Registered physical-DB adapters keyed by driver name. The service + * picks an adapter by matching `request.driver` (falling back to + * `defaultDriver`). + */ + adapters?: EnvironmentDatabaseAdapter[]; + + /** Driver used when the request does not specify one. Default `turso`. */ + defaultDriver?: DatabaseDriver; + + /** Default region when the request does not specify one. */ + defaultRegion?: string; + + /** Default storage quota in MB when not specified. */ + defaultStorageLimitMb?: number; + + /** Secret encryptor. Defaults to {@link NoopSecretEncryptor} (**dev-only**). */ + encryptor?: SecretEncryptor; +} + +/** + * Environment Provisioning Service + * + * Orchestrates the v4.1+ "environment per database" model: + * + * 1. `provisionOrganization(req)` + * → creates the organization's default environment + its dedicated DB. + * 2. `provisionEnvironment(req)` + * → allocates a new environment (prod / test / dev / sandbox / …) + * with its own physical DB and credential row. + * + * The Control Plane's responsibilities end at "environment connection + * metadata and authentication". It never touches data-plane rows. + * + * See ADR-0002 (`docs/adr/0002-environment-database-isolation.md`). + */ +export class EnvironmentProvisioningService { + private readonly config: Required< + Pick + > & + Omit; + + private readonly adapters = new Map(); + + private readonly encryptor: SecretEncryptor; + + constructor(config: EnvironmentProvisioningConfig = {}) { + this.config = { + controlPlaneDriver: config.controlPlaneDriver, + adapters: config.adapters, + encryptor: config.encryptor, + defaultDriver: config.defaultDriver ?? 'turso', + defaultRegion: config.defaultRegion ?? 'us-east-1', + defaultStorageLimitMb: config.defaultStorageLimitMb ?? 1024, + }; + + for (const adapter of config.adapters ?? []) { + this.adapters.set(adapter.driver, adapter); + } + + this.encryptor = config.encryptor ?? new NoopSecretEncryptor(); + } + + /** + * Bootstrap a brand-new organization — creates the default environment + * (by default `prod`) and its physical database in one atomic call. + */ + async provisionOrganization( + request: ProvisionOrganizationRequest, + ): Promise { + const parsed = ProvisionOrganizationRequestSchema.parse(request); + const startedAt = Date.now(); + + const defaultEnv = await this.provisionEnvironment({ + organizationId: parsed.organizationId, + slug: parsed.defaultEnvSlug, + displayName: parsed.defaultEnvSlug === 'prod' ? 'Production' : parsed.defaultEnvSlug, + envType: parsed.defaultEnvType, + region: parsed.region, + driver: parsed.driver, + plan: parsed.plan, + storageLimitMb: parsed.storageLimitMb, + isDefault: true, + createdBy: parsed.createdBy, + metadata: parsed.metadata, + }); + + return { + defaultEnvironment: defaultEnv, + durationMs: Date.now() - startedAt, + warnings: defaultEnv.warnings, + }; + } + + /** + * Provision a new environment (dev/test/prod/sandbox/…) for an + * existing organization. Allocates a fresh physical database and mints + * an encrypted credential row. + */ + async provisionEnvironment( + request: ProvisionEnvironmentRequest, + ): Promise { + const parsed = ProvisionEnvironmentRequestSchema.parse(request); + const startedAt = Date.now(); + const warnings: string[] = []; + + const environmentId = randomUUID(); + const environmentDatabaseId = randomUUID(); + const credentialId = randomUUID(); + + const driver: DatabaseDriver = parsed.driver ?? this.config.defaultDriver; + const region = parsed.region ?? this.config.defaultRegion; + const storageLimitMb = parsed.storageLimitMb ?? this.config.defaultStorageLimitMb; + const databaseName = `env-${environmentId}`; + + // 1. Enforce the "exactly one default environment per org" invariant + // before minting the new row. If the caller asked for isDefault:true + // and one already exists, fail fast — the caller should demote the + // existing default first (out of scope for v4.1 API). + if (parsed.isDefault && this.config.controlPlaneDriver) { + const existingDefault = await this.findDefaultEnvironment(parsed.organizationId); + if (existingDefault) { + throw new Error( + `Organization ${parsed.organizationId} already has a default environment (${existingDefault.id}). Demote it first.`, + ); + } + } + + // 2. Allocate physical DB via the adapter. + const adapter = this.resolveAdapter(driver); + let databaseUrl: string; + let plaintextSecret: string; + if (adapter) { + try { + const created = await adapter.createDatabase({ + environmentId, + databaseName, + region, + storageLimitMb, + }); + databaseUrl = created.databaseUrl; + plaintextSecret = created.plaintextSecret; + } catch (error) { + throw new Error( + `Failed to provision physical database for environment ${environmentId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } else { + const fallback = await new MockEnvironmentDatabaseAdapter(driver).createDatabase({ + environmentId, + databaseName, + region, + storageLimitMb, + }); + databaseUrl = fallback.databaseUrl; + plaintextSecret = fallback.plaintextSecret; + warnings.push( + `No adapter registered for driver "${driver}"; environment provisioned with mock addressing.`, + ); + } + + const nowIso = new Date().toISOString(); + const environment: Environment = { + id: environmentId, + organizationId: parsed.organizationId, + slug: parsed.slug, + displayName: parsed.displayName ?? parsed.slug, + envType: parsed.envType, + isDefault: parsed.isDefault ?? false, + region, + plan: parsed.plan ?? 'free', + status: 'active', + createdBy: parsed.createdBy, + createdAt: nowIso, + updatedAt: nowIso, + metadata: parsed.metadata, + }; + + const database: EnvironmentDatabase = { + id: environmentDatabaseId, + environmentId, + databaseName, + databaseUrl, + driver, + region, + storageLimitMb, + provisionedAt: nowIso, + metadata: undefined, + }; + + const credential: DatabaseCredential = { + id: credentialId, + environmentDatabaseId, + secretCiphertext: await Promise.resolve(this.encryptor.encrypt(plaintextSecret)), + encryptionKeyId: this.encryptor.keyId, + authorization: 'full_access', + status: 'active', + createdAt: nowIso, + }; + + // 3. Persist to the control plane (best-effort — callers in detached + // mode just get the in-memory payload). + if (this.config.controlPlaneDriver) { + try { + await this.config.controlPlaneDriver.create('environment', { + id: environment.id, + organization_id: environment.organizationId, + slug: environment.slug, + display_name: environment.displayName, + env_type: environment.envType, + is_default: environment.isDefault, + region: environment.region, + plan: environment.plan, + status: environment.status, + created_by: environment.createdBy, + created_at: environment.createdAt, + updated_at: environment.updatedAt, + metadata: environment.metadata ? JSON.stringify(environment.metadata) : null, + }); + + await this.config.controlPlaneDriver.create('environment_database', { + id: database.id, + environment_id: database.environmentId, + database_name: database.databaseName, + database_url: database.databaseUrl, + driver: database.driver, + region: database.region, + storage_limit_mb: database.storageLimitMb, + provisioned_at: database.provisionedAt, + created_at: nowIso, + updated_at: nowIso, + }); + + await this.config.controlPlaneDriver.create('database_credential', { + id: credential.id, + environment_database_id: credential.environmentDatabaseId, + secret_ciphertext: credential.secretCiphertext, + encryption_key_id: credential.encryptionKeyId, + authorization: credential.authorization, + status: credential.status, + created_at: credential.createdAt, + updated_at: nowIso, + }); + } catch (error) { + warnings.push( + `Failed to persist control-plane rows: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } else { + warnings.push('Control-plane driver not configured — environment records not persisted.'); + } + + return { + environment, + database, + credential, + durationMs: Date.now() - startedAt, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + + /** + * Rotate the credential for an environment's database. Creates a new + * `active` credential row and flips the previous one to `revoked`. + */ + async rotateCredential( + environmentDatabaseId: string, + plaintextSecret: string, + ): Promise { + if (!this.config.controlPlaneDriver) { + throw new Error('Control-plane driver required for credential rotation.'); + } + + const nowIso = new Date().toISOString(); + const newCredentialId = randomUUID(); + + const credential: DatabaseCredential = { + id: newCredentialId, + environmentDatabaseId, + secretCiphertext: await Promise.resolve(this.encryptor.encrypt(plaintextSecret)), + encryptionKeyId: this.encryptor.keyId, + authorization: 'full_access', + status: 'active', + createdAt: nowIso, + }; + + // Find existing active credential(s) and revoke them. + const existing = (await this.config.controlPlaneDriver.find('database_credential', { + where: { environment_database_id: environmentDatabaseId, status: 'active' }, + } as any)) as Array<{ id: string }>; + + for (const row of existing ?? []) { + await this.config.controlPlaneDriver.update('database_credential', row.id, { + status: 'revoked', + revoked_at: nowIso, + updated_at: nowIso, + }); + } + + await this.config.controlPlaneDriver.create('database_credential', { + id: credential.id, + environment_database_id: credential.environmentDatabaseId, + secret_ciphertext: credential.secretCiphertext, + encryption_key_id: credential.encryptionKeyId, + authorization: credential.authorization, + status: credential.status, + created_at: credential.createdAt, + updated_at: nowIso, + }); + + return credential; + } + + /** + * Register/replace a physical-DB adapter at runtime (useful for tests + * and for plugins that ship their own driver). + */ + registerAdapter(adapter: EnvironmentDatabaseAdapter): void { + this.adapters.set(adapter.driver, adapter); + } + + private resolveAdapter(driver: DatabaseDriver): EnvironmentDatabaseAdapter | undefined { + return this.adapters.get(driver); + } + + private async findDefaultEnvironment(organizationId: string): Promise<{ id: string } | null> { + if (!this.config.controlPlaneDriver) return null; + try { + const rows = (await this.config.controlPlaneDriver.find('environment', { + where: { organization_id: organizationId, is_default: true }, + } as any)) as Array<{ id: string }>; + return rows && rows.length > 0 ? rows[0] : null; + } catch { + return null; + } + } +} diff --git a/packages/services/service-tenant/src/index.ts b/packages/services/service-tenant/src/index.ts index 793ce7b2e..e9a9c4d57 100644 --- a/packages/services/service-tenant/src/index.ts +++ b/packages/services/service-tenant/src/index.ts @@ -3,6 +3,7 @@ export * from './tenant-context.js'; export * from './tenant-plugin.js'; export * from './tenant-provisioning.js'; +export * from './environment-provisioning.js'; export * from './turso-platform-client.js'; export * from './tenant-schema-initializer.js'; export * from './objects/index.js'; diff --git a/packages/services/service-tenant/src/objects/environment-objects.test.ts b/packages/services/service-tenant/src/objects/environment-objects.test.ts new file mode 100644 index 000000000..aff2bdee3 --- /dev/null +++ b/packages/services/service-tenant/src/objects/environment-objects.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + SysEnvironment, + SysEnvironmentDatabase, + SysDatabaseCredential, + SysEnvironmentMember, +} from './index'; + +describe('control-plane environment objects', () => { + it('registers all four sys_ objects with correct namespaced names', () => { + expect(`${SysEnvironment.namespace}_${SysEnvironment.name}`).toBe('sys_environment'); + expect(`${SysEnvironmentDatabase.namespace}_${SysEnvironmentDatabase.name}`).toBe( + 'sys_environment_database', + ); + expect(`${SysDatabaseCredential.namespace}_${SysDatabaseCredential.name}`).toBe( + 'sys_database_credential', + ); + expect(`${SysEnvironmentMember.namespace}_${SysEnvironmentMember.name}`).toBe( + 'sys_environment_member', + ); + }); + + it('declares UNIQUE (organization_id, slug) on sys_environment', () => { + const idx = SysEnvironment.indexes ?? []; + expect( + idx.some((i: any) => i.unique && i.fields.join(',') === 'organization_id,slug'), + ).toBe(true); + }); + + it('declares UNIQUE environment_id on sys_environment_database (1:1)', () => { + const idx = SysEnvironmentDatabase.indexes ?? []; + expect(idx.some((i: any) => i.unique && i.fields.join(',') === 'environment_id')).toBe(true); + expect(idx.some((i: any) => i.unique && i.fields.join(',') === 'database_name')).toBe(true); + }); + + it('declares UNIQUE (environment_id, user_id) on sys_environment_member', () => { + const idx = SysEnvironmentMember.indexes ?? []; + expect( + idx.some((i: any) => i.unique && i.fields.join(',') === 'environment_id,user_id'), + ).toBe(true); + }); + + it('gives every field on sys_environment a .description', () => { + for (const [name, field] of Object.entries(SysEnvironment.fields)) { + expect((field as any).description, `field ${name} missing description`).toBeTruthy(); + } + }); + + it('marks sys_environment and sys_environment_database as system objects', () => { + expect(SysEnvironment.isSystem).toBe(true); + expect(SysEnvironmentDatabase.isSystem).toBe(true); + expect(SysDatabaseCredential.isSystem).toBe(true); + expect(SysEnvironmentMember.isSystem).toBe(true); + }); +}); diff --git a/packages/services/service-tenant/src/objects/index.ts b/packages/services/service-tenant/src/objects/index.ts index 7850ddcbd..748a3e301 100644 --- a/packages/services/service-tenant/src/objects/index.ts +++ b/packages/services/service-tenant/src/objects/index.ts @@ -1,4 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// v4.1+ canonical Control-Plane objects (environment-per-database model) +export * from './sys-environment.object'; +export * from './sys-environment-database.object'; +export * from './sys-database-credential.object'; +export * from './sys-environment-member.object'; + +// v4.x deprecation shim — removed in v5.0. See +// docs/adr/0002-environment-database-isolation.md for the migration path. export * from './sys-tenant-database.object'; + +// Package installation registry (lives in control plane for now; will move +// into each environment's data plane in v5.0 per ADR-0002). export * from './sys-package-installation.object'; diff --git a/packages/services/service-tenant/src/objects/sys-database-credential.object.ts b/packages/services/service-tenant/src/objects/sys-database-credential.object.ts new file mode 100644 index 000000000..490acecc3 --- /dev/null +++ b/packages/services/service-tenant/src/objects/sys-database-credential.object.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_database_credential — Rotatable Database Credentials + * + * Stores encrypted credentials for environment databases separately from + * the addressing record (`sys_environment_database`) so that secrets can + * be rotated, revoked, and audited independently. + * + * During rotation, multiple rows can exist per `environment_database_id`: + * the previous credential stays `active` until the new one has been + * propagated to all runtimes, then flips to `revoked`. + * + * @namespace sys + */ +export const SysDatabaseCredential = ObjectSchema.create({ + namespace: 'sys', + name: 'database_credential', + label: 'Database Credential', + pluralLabel: 'Database Credentials', + icon: 'key', + isSystem: true, + description: 'Rotatable encrypted credentials for environment databases.', + titleFormat: '{id}', + compactLayout: ['environment_database_id', 'status', 'authorization', 'expires_at'], + + fields: { + id: Field.text({ + label: 'Credential ID', + required: true, + readonly: true, + description: 'UUID of the credential.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + description: 'Creation timestamp.', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + description: 'Last update timestamp.', + }), + + environment_database_id: Field.text({ + label: 'Environment Database ID', + required: true, + description: 'Foreign key to sys_environment_database.', + }), + + secret_ciphertext: Field.textarea({ + label: 'Secret Ciphertext', + required: true, + description: 'Encrypted auth token or secret (never store plaintext).', + }), + + encryption_key_id: Field.text({ + label: 'Encryption Key ID', + required: true, + maxLength: 255, + description: 'KMS/encryption key ID that produced the ciphertext.', + }), + + authorization: Field.select({ + label: 'Authorization', + required: true, + defaultValue: 'full_access', + description: 'Authorization scope for this credential.', + options: [ + { value: 'full_access', label: 'Full Access' }, + { value: 'read_only', label: 'Read Only' }, + ], + }), + + status: Field.select({ + label: 'Status', + required: true, + defaultValue: 'active', + description: 'Credential lifecycle status.', + options: [ + { value: 'active', label: 'Active' }, + { value: 'rotating', label: 'Rotating' }, + { value: 'revoked', label: 'Revoked' }, + ], + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: false, + description: 'Optional expiry — after this timestamp the credential must be rotated.', + }), + + revoked_at: Field.datetime({ + label: 'Revoked At', + required: false, + description: 'Timestamp when the credential was revoked (null while active).', + }), + }, + + indexes: [ + { fields: ['environment_database_id'] }, + { fields: ['environment_database_id', 'status'] }, + { fields: ['status'] }, + { fields: ['expires_at'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update'], + trash: false, + mru: false, + }, +}); diff --git a/packages/services/service-tenant/src/objects/sys-environment-database.object.ts b/packages/services/service-tenant/src/objects/sys-environment-database.object.ts new file mode 100644 index 000000000..d1cead07b --- /dev/null +++ b/packages/services/service-tenant/src/objects/sys-environment-database.object.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_environment_database — Control Plane Physical Database Mapping + * + * One row per environment, mapping an environment to the physical + * database (Turso/libSQL/Postgres/SQLite/…) that backs it. + * + * The `environment_id` is UNIQUE — there is always **exactly one** + * database per environment. + * + * Credentials live in the separate `sys_database_credential` table so + * they can be rotated without touching the addressing record. + * + * @namespace sys + */ +export const SysEnvironmentDatabase = ObjectSchema.create({ + namespace: 'sys', + name: 'environment_database', + label: 'Environment Database', + pluralLabel: 'Environment Databases', + icon: 'database', + isSystem: true, + description: 'Physical database mapping for each environment (one-to-one with sys_environment).', + titleFormat: '{database_name}', + compactLayout: ['database_name', 'environment_id', 'driver', 'region'], + + fields: { + id: Field.text({ + label: 'Mapping ID', + required: true, + readonly: true, + description: 'UUID of the environment-database mapping.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + description: 'Creation timestamp.', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + description: 'Last update timestamp.', + }), + + environment_id: Field.text({ + label: 'Environment ID', + required: true, + description: 'Foreign key to sys_environment (UNIQUE — one DB per environment).', + }), + + database_name: Field.text({ + label: 'Database Name', + required: true, + maxLength: 255, + description: 'Physical database name (immutable once provisioned).', + }), + + database_url: Field.url({ + label: 'Database URL', + required: true, + description: 'Full connection URL (e.g. libsql://env-{uuid}.turso.io, postgres://…).', + }), + + driver: Field.text({ + label: 'Driver', + required: true, + maxLength: 50, + description: 'Data-plane driver key (turso, libsql, sqlite, postgres, …).', + }), + + region: Field.text({ + label: 'Region', + required: true, + maxLength: 100, + description: 'Region of the physical database (used for latency-aware routing).', + }), + + storage_limit_mb: Field.number({ + label: 'Storage Limit (MB)', + required: true, + defaultValue: 1024, + description: 'Storage quota in megabytes.', + }), + + provisioned_at: Field.datetime({ + label: 'Provisioned At', + required: true, + defaultValue: 'NOW()', + description: 'When the physical database was provisioned.', + }), + + last_accessed_at: Field.datetime({ + label: 'Last Accessed At', + required: false, + description: 'Last successful access (populated by the router for cache invalidation).', + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON-serialized free-form metadata (replica topology, group, backup policy, …).', + }), + }, + + indexes: [ + { fields: ['environment_id'], unique: true }, + { fields: ['database_name'], unique: true }, + { fields: ['driver'] }, + { fields: ['region'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update'], + trash: false, + mru: true, + }, +}); diff --git a/packages/services/service-tenant/src/objects/sys-environment-member.object.ts b/packages/services/service-tenant/src/objects/sys-environment-member.object.ts new file mode 100644 index 000000000..8d62007c6 --- /dev/null +++ b/packages/services/service-tenant/src/objects/sys-environment-member.object.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_environment_member — Per-Environment RBAC + * + * Grants a user access to a specific environment with a specific role. + * Scoped narrowly to environment boundaries so that `prod` can have a + * different cast of admins than `dev`. + * + * A user may be a member of multiple environments within the same + * organization with different roles. Uniqueness is + * `(environment_id, user_id)`. + * + * @namespace sys + */ +export const SysEnvironmentMember = ObjectSchema.create({ + namespace: 'sys', + name: 'environment_member', + label: 'Environment Member', + pluralLabel: 'Environment Members', + icon: 'users', + isSystem: true, + description: 'Per-environment user/role assignments.', + titleFormat: '{user_id} @ {environment_id}', + compactLayout: ['user_id', 'environment_id', 'role'], + + fields: { + id: Field.text({ + label: 'Membership ID', + required: true, + readonly: true, + description: 'UUID of the membership.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + description: 'Creation timestamp.', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + description: 'Last update timestamp.', + }), + + environment_id: Field.text({ + label: 'Environment ID', + required: true, + description: 'Foreign key to sys_environment.', + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + description: 'Foreign key to sys_user.', + }), + + role: Field.select({ + label: 'Role', + required: true, + description: 'Per-environment role (owner/admin/maker/reader/guest).', + options: [ + { value: 'owner', label: 'Owner' }, + { value: 'admin', label: 'Administrator' }, + { value: 'maker', label: 'Maker / Developer' }, + { value: 'reader', label: 'Reader' }, + { value: 'guest', label: 'Guest' }, + ], + }), + + invited_by: Field.text({ + label: 'Invited By', + required: true, + description: 'User ID that granted this membership.', + }), + }, + + indexes: [ + { fields: ['environment_id', 'user_id'], unique: true }, + { fields: ['environment_id'] }, + { fields: ['user_id'] }, + { fields: ['role'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: false, + }, +}); diff --git a/packages/services/service-tenant/src/objects/sys-environment.object.ts b/packages/services/service-tenant/src/objects/sys-environment.object.ts new file mode 100644 index 000000000..d8142e570 --- /dev/null +++ b/packages/services/service-tenant/src/objects/sys-environment.object.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_environment — Control Plane Environment Registry + * + * One row per environment. An organization owns N environments + * (dev/test/prod/sandbox/preview/…) and each environment is physically + * backed by its own database — see `sys_environment_database`. + * + * **This table lives in the Control Plane only.** Data-plane (per-env) + * business tables MUST NOT carry an `environment_id` column; the + * environment is implied by the database connection at runtime. + * + * @namespace sys + */ +export const SysEnvironment = ObjectSchema.create({ + namespace: 'sys', + name: 'environment', + label: 'Environment', + pluralLabel: 'Environments', + icon: 'layers', + isSystem: true, + description: 'Control-plane registry of tenant environments (prod/test/dev/sandbox).', + titleFormat: '{display_name}', + compactLayout: ['display_name', 'slug', 'env_type', 'status', 'is_default'], + + fields: { + id: Field.text({ + label: 'Environment ID', + required: true, + readonly: true, + description: 'UUID of the environment (stable, never reused).', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + description: 'Creation timestamp.', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + description: 'Last update timestamp.', + }), + + organization_id: Field.text({ + label: 'Organization ID', + required: true, + description: 'Foreign key to sys_organization.', + }), + + slug: Field.text({ + label: 'Slug', + required: true, + maxLength: 63, + description: 'Slug unique per organization (e.g. `prod`, `qa-2`). snake_case/kebab-case allowed.', + }), + + display_name: Field.text({ + label: 'Display Name', + required: true, + maxLength: 255, + description: 'Display name shown in Studio and APIs.', + }), + + env_type: Field.select({ + label: 'Environment Type', + required: true, + description: 'Environment classification (prod/sandbox/dev/test/staging/preview/trial).', + options: [ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + { value: 'development', label: 'Development' }, + { value: 'test', label: 'Test' }, + { value: 'staging', label: 'Staging' }, + { value: 'preview', label: 'Preview' }, + { value: 'trial', label: 'Trial' }, + ], + }), + + is_default: Field.boolean({ + label: 'Is Default', + required: true, + defaultValue: false, + description: 'Whether this is the default environment for the organization. Exactly one per org.', + }), + + region: Field.text({ + label: 'Region', + required: false, + maxLength: 100, + description: 'Preferred region (informational; actual placement lives on sys_environment_database).', + }), + + plan: Field.select({ + label: 'Plan', + required: true, + defaultValue: 'free', + description: 'Plan tier applied to this environment for quota and billing.', + options: [ + { value: 'free', label: 'Free' }, + { value: 'starter', label: 'Starter' }, + { value: 'pro', label: 'Pro' }, + { value: 'enterprise', label: 'Enterprise' }, + { value: 'custom', label: 'Custom' }, + ], + }), + + status: Field.select({ + label: 'Status', + required: true, + defaultValue: 'provisioning', + description: 'Environment lifecycle status.', + options: [ + { value: 'provisioning', label: 'Provisioning' }, + { value: 'active', label: 'Active' }, + { value: 'suspended', label: 'Suspended' }, + { value: 'archived', label: 'Archived' }, + { value: 'failed', label: 'Failed' }, + { value: 'migrating', label: 'Migrating' }, + ], + }), + + created_by: Field.text({ + label: 'Created By', + required: true, + description: 'User ID that created the environment.', + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON-serialized free-form metadata (feature flags, tags, …).', + }), + }, + + indexes: [ + { fields: ['organization_id', 'slug'], unique: true }, + { fields: ['organization_id'] }, + { fields: ['organization_id', 'is_default'] }, + { fields: ['status'] }, + { fields: ['env_type'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update'], + trash: false, + mru: true, + }, +}); diff --git a/packages/services/service-tenant/src/objects/sys-package-installation.object.ts b/packages/services/service-tenant/src/objects/sys-package-installation.object.ts index d043ddb3a..1f84629c6 100644 --- a/packages/services/service-tenant/src/objects/sys-package-installation.object.ts +++ b/packages/services/service-tenant/src/objects/sys-package-installation.object.ts @@ -61,7 +61,7 @@ export const SysPackageInstallation = ObjectSchema.create({ description: 'Installed package version (semver)', }), - status: Field.picklist({ + status: Field.select({ label: 'Status', required: true, options: [ diff --git a/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts index 535e7dd38..01a129c6c 100644 --- a/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts +++ b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts @@ -5,6 +5,14 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * sys_tenant_database — Global Tenant Registry Object * + * @deprecated v4.x deprecation shim — superseded by the + * environment-per-database isolation model. New deployments should use + * `sys_environment` + `sys_environment_database` + `sys_database_credential`. + * This object is kept for backwards compatibility with existing v4.x + * tenants and will be **removed in v5.0** together with the associated + * migration in `migrations/v4-to-v5-env-migration.ts`. See + * `docs/adr/0002-environment-database-isolation.md`. + * * Stores tenant database information in the global control plane. * Each tenant has its own isolated Turso database with UUID-based naming. * @@ -67,7 +75,7 @@ export const SysTenantDatabase = ObjectSchema.create({ description: 'Encrypted database-specific auth token', }), - status: Field.picklist({ + status: Field.select({ label: 'Status', required: true, options: [ @@ -87,7 +95,7 @@ export const SysTenantDatabase = ObjectSchema.create({ description: 'Deployment region (e.g., us-east-1, eu-west-1)', }), - plan: Field.picklist({ + plan: Field.select({ label: 'Plan', required: true, options: [ diff --git a/packages/services/service-tenant/src/tenant-plugin.ts b/packages/services/service-tenant/src/tenant-plugin.ts index 5865abb59..5f13d9e1a 100644 --- a/packages/services/service-tenant/src/tenant-plugin.ts +++ b/packages/services/service-tenant/src/tenant-plugin.ts @@ -3,7 +3,14 @@ import type { Plugin, PluginContext } from '@objectstack/spec'; import type { TenantRoutingConfig } from '@objectstack/spec/cloud'; import { TenantContextService } from './tenant-context'; -import { SysTenantDatabase, SysPackageInstallation } from './objects'; +import { + SysTenantDatabase, + SysPackageInstallation, + SysEnvironment, + SysEnvironmentDatabase, + SysDatabaseCredential, + SysEnvironmentMember, +} from './objects'; /** * Tenant Plugin Configuration @@ -19,6 +26,18 @@ export interface TenantPluginConfig { * Default: true */ registerSystemObjects?: boolean; + + /** + * Register the v4.x deprecated `sys_tenant_database` shim alongside the + * v4.1+ environment objects. Default: true (for backwards compatibility). + * + * Set to false in greenfield deployments that never stored data under + * the legacy per-organization model. Will default to `false` in v5.0 + * and be removed entirely thereafter. + * + * @see docs/adr/0002-environment-database-isolation.md + */ + registerLegacyTenantDatabase?: boolean; } /** @@ -36,7 +55,16 @@ export function createTenantPlugin(config: TenantPluginConfig = {}): Plugin { version: '0.2.0', objects: config.registerSystemObjects !== false - ? [SysTenantDatabase, SysPackageInstallation] + ? [ + // v4.1+ canonical control-plane objects (environment-per-database model). + SysEnvironment, + SysEnvironmentDatabase, + SysDatabaseCredential, + SysEnvironmentMember, + SysPackageInstallation, + // v4.x deprecation shim — opt out via `registerLegacyTenantDatabase: false`. + ...(config.registerLegacyTenantDatabase !== false ? [SysTenantDatabase] : []), + ] : [], async init(ctx: PluginContext) { @@ -57,9 +85,17 @@ export function createTenantPlugin(config: TenantPluginConfig = {}): Plugin { // Register system objects if enabled if (config.registerSystemObjects !== false) { - ctx.logger.info('[TenantPlugin] System objects registered', { - objects: ['sys_tenant_database', 'sys_package_installation'], - }); + const registered = [ + 'sys_environment', + 'sys_environment_database', + 'sys_database_credential', + 'sys_environment_member', + 'sys_package_installation', + ]; + if (config.registerLegacyTenantDatabase !== false) { + registered.push('sys_tenant_database (deprecated)'); + } + ctx.logger.info('[TenantPlugin] System objects registered', { objects: registered }); } }, diff --git a/packages/services/service-tenant/vitest.config.ts b/packages/services/service-tenant/vitest.config.ts index 8e730d505..a033c31f7 100644 --- a/packages/services/service-tenant/vitest.config.ts +++ b/packages/services/service-tenant/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ + resolve: { + alias: { + '@objectstack/spec/cloud': path.resolve(__dirname, '../../spec/src/cloud/index.ts'), + '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), + '@objectstack/spec': path.resolve(__dirname, '../../spec/src/index.ts'), + }, + }, test: { globals: true, environment: 'node', diff --git a/packages/spec/src/cloud/environment.test.ts b/packages/spec/src/cloud/environment.test.ts new file mode 100644 index 000000000..925166e80 --- /dev/null +++ b/packages/spec/src/cloud/environment.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + EnvironmentSchema, + EnvironmentTypeSchema, + EnvironmentStatusSchema, + EnvironmentDatabaseSchema, + DatabaseCredentialSchema, + DatabaseCredentialStatusSchema, + EnvironmentMemberSchema, + EnvironmentRoleSchema, + ProvisionEnvironmentRequestSchema, + ProvisionOrganizationRequestSchema, +} from './environment.zod'; + +describe('EnvironmentTypeSchema', () => { + it('accepts all canonical environment types', () => { + for (const t of [ + 'production', + 'sandbox', + 'development', + 'test', + 'staging', + 'preview', + 'trial', + ]) { + expect(() => EnvironmentTypeSchema.parse(t)).not.toThrow(); + } + }); + + it('rejects unknown types', () => { + expect(() => EnvironmentTypeSchema.parse('prod')).toThrow(); + }); +}); + +describe('EnvironmentStatusSchema', () => { + it('accepts lifecycle statuses including migrating', () => { + for (const s of [ + 'provisioning', + 'active', + 'suspended', + 'archived', + 'failed', + 'migrating', + ]) { + expect(() => EnvironmentStatusSchema.parse(s)).not.toThrow(); + } + }); +}); + +describe('EnvironmentSchema', () => { + const base = { + id: '550e8400-e29b-41d4-a716-446655440000', + organizationId: 'org_1', + slug: 'prod', + displayName: 'Production', + envType: 'production' as const, + isDefault: true, + plan: 'pro' as const, + status: 'active' as const, + createdBy: 'user_1', + createdAt: '2026-04-19T00:00:00.000Z', + updatedAt: '2026-04-19T00:00:00.000Z', + }; + + it('parses a valid environment', () => { + expect(() => EnvironmentSchema.parse(base)).not.toThrow(); + }); + + it('rejects an invalid slug (uppercase)', () => { + expect(() => EnvironmentSchema.parse({ ...base, slug: 'PROD' })).toThrow(); + }); + + it('rejects a slug longer than 63 characters', () => { + expect(() => EnvironmentSchema.parse({ ...base, slug: 'a'.repeat(64) })).toThrow(); + }); + + it('rejects a non-UUID id', () => { + expect(() => EnvironmentSchema.parse({ ...base, id: 'not-a-uuid' })).toThrow(); + }); + + it('defaults isDefault to false when omitted', () => { + const { isDefault: _d, ...rest } = base; + const parsed = EnvironmentSchema.parse(rest); + expect(parsed.isDefault).toBe(false); + }); +}); + +describe('EnvironmentDatabaseSchema', () => { + it('accepts turso URL', () => { + expect(() => + EnvironmentDatabaseSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + environmentId: '550e8400-e29b-41d4-a716-446655440001', + databaseName: 'env-abc', + databaseUrl: 'https://env-abc.turso.io', + driver: 'turso', + region: 'us-east-1', + storageLimitMb: 1024, + provisionedAt: '2026-04-19T00:00:00.000Z', + }), + ).not.toThrow(); + }); + + it('rejects a non-positive storage limit', () => { + expect(() => + EnvironmentDatabaseSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + environmentId: '550e8400-e29b-41d4-a716-446655440001', + databaseName: 'env-abc', + databaseUrl: 'https://env-abc.turso.io', + driver: 'turso', + region: 'us-east-1', + storageLimitMb: 0, + provisionedAt: '2026-04-19T00:00:00.000Z', + }), + ).toThrow(); + }); +}); + +describe('DatabaseCredentialSchema', () => { + it('parses a valid active credential', () => { + const cred = DatabaseCredentialSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + environmentDatabaseId: '550e8400-e29b-41d4-a716-446655440001', + secretCiphertext: 'ciphertext', + encryptionKeyId: 'kms-key-1', + createdAt: '2026-04-19T00:00:00.000Z', + }); + expect(cred.status).toBe('active'); + expect(cred.authorization).toBe('full_access'); + }); + + it('rejects unknown status', () => { + expect(() => + DatabaseCredentialSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + environmentDatabaseId: '550e8400-e29b-41d4-a716-446655440001', + secretCiphertext: 'ciphertext', + encryptionKeyId: 'kms-key-1', + createdAt: '2026-04-19T00:00:00.000Z', + status: 'bogus', + }), + ).toThrow(); + }); + + it('accepts the full rotation status set', () => { + for (const s of ['active', 'rotating', 'revoked']) { + expect(() => DatabaseCredentialStatusSchema.parse(s)).not.toThrow(); + } + }); +}); + +describe('EnvironmentMemberSchema', () => { + it('accepts canonical roles', () => { + for (const r of ['owner', 'admin', 'maker', 'reader', 'guest']) { + expect(() => EnvironmentRoleSchema.parse(r)).not.toThrow(); + } + }); + + it('parses a valid member row', () => { + expect(() => + EnvironmentMemberSchema.parse({ + id: '550e8400-e29b-41d4-a716-446655440000', + environmentId: '550e8400-e29b-41d4-a716-446655440001', + userId: 'user_1', + role: 'admin', + invitedBy: 'user_0', + createdAt: '2026-04-19T00:00:00.000Z', + updatedAt: '2026-04-19T00:00:00.000Z', + }), + ).not.toThrow(); + }); +}); + +describe('ProvisionEnvironmentRequestSchema', () => { + it('accepts a minimal request', () => { + expect(() => + ProvisionEnvironmentRequestSchema.parse({ + organizationId: 'org_1', + slug: 'dev', + envType: 'development', + createdBy: 'user_1', + }), + ).not.toThrow(); + }); + + it('rejects a slug with invalid characters', () => { + expect(() => + ProvisionEnvironmentRequestSchema.parse({ + organizationId: 'org_1', + slug: 'DEV!', + envType: 'development', + createdBy: 'user_1', + }), + ).toThrow(); + }); +}); + +describe('ProvisionOrganizationRequestSchema', () => { + it('applies defaults for envType and slug', () => { + const parsed = ProvisionOrganizationRequestSchema.parse({ + organizationId: 'org_1', + createdBy: 'user_1', + }); + expect(parsed.defaultEnvType).toBe('production'); + expect(parsed.defaultEnvSlug).toBe('prod'); + }); +}); diff --git a/packages/spec/src/cloud/environment.zod.ts b/packages/spec/src/cloud/environment.zod.ts new file mode 100644 index 000000000..e002ce585 --- /dev/null +++ b/packages/spec/src/cloud/environment.zod.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { TenantPlanSchema } from './tenant.zod'; + +/** + * Environment-Per-Database Isolation Protocol + * + * Defines the schema for the v4.x → v5.0 multi-tenant architecture upgrade, + * where each **environment** (dev/test/prod/sandbox) owns a physically isolated + * database, rather than each organization owning a single database shared + * across environments. + * + * Split of concerns: + * - **Control Plane**: `sys_environment`, `sys_environment_database`, + * `sys_database_credential`, `sys_environment_member` — stores the + * environment registry, physical addressing, credentials (rotatable), + * and per-environment RBAC. + * - **Data Plane**: each environment has its own DB containing `sys_` + * objects (`sys_package_installation`, `sys_solution_history`, …) and + * business objects. No `environment_id` column is ever required on + * business rows — environment is implicit in the connection. + * + * See `docs/adr/0002-environment-database-isolation.md` for the full + * rationale. + */ + +// --------------------------------------------------------------------------- +// Environment registry +// --------------------------------------------------------------------------- + +/** + * Environment type — canonical buckets per industry convention + * (Salesforce, Power Platform, ServiceNow all use this taxonomy). + */ +export const EnvironmentTypeSchema = z + .enum(['production', 'sandbox', 'development', 'test', 'staging', 'preview', 'trial']) + .describe('Environment type (prod/sandbox/dev/test/…)'); + +export type EnvironmentType = z.infer; + +/** + * Environment lifecycle status + */ +export const EnvironmentStatusSchema = z + .enum(['provisioning', 'active', 'suspended', 'archived', 'failed', 'migrating']) + .describe('Environment lifecycle status'); + +export type EnvironmentStatus = z.infer; + +/** + * Environment — one logical runtime of an organization's data. + * + * An organization may own many environments (e.g. `prod`, `staging`, + * `dev-alice`, `sandbox-demo`). Each environment is physically backed by a + * distinct database (see {@link EnvironmentDatabaseSchema}). Environments + * are addressable by `(organizationId, slug)`. + */ +export const EnvironmentSchema = z.object({ + /** UUID of the environment (stable, never reused). */ + id: z.string().uuid().describe('UUID of the environment (stable, never reused)'), + + /** Organization that owns this environment. */ + organizationId: z.string().describe('Organization that owns this environment'), + + /** Human-friendly slug, unique within the organization (e.g. `prod`, `qa-2`). */ + slug: z + .string() + .regex(/^[a-z0-9][a-z0-9-]{0,62}$/) + .describe('Slug unique per organization (snake_case/kebab-case allowed)'), + + /** Display name shown in Studio and APIs. */ + displayName: z.string().describe('Display name shown in Studio and APIs'), + + /** Environment classification used for routing, quotas, and RBAC defaults. */ + envType: EnvironmentTypeSchema.describe('Environment classification'), + + /** Whether this is the organization's **default** environment. Exactly one per org. */ + isDefault: z.boolean().default(false).describe('Whether this is the default environment for the organization'), + + /** Preferred region (informational; actual placement lives on the database row). */ + region: z.string().optional().describe('Preferred region (informational)'), + + /** Plan tier applied to this environment for quota/billing enforcement. */ + plan: TenantPlanSchema.default('free').describe('Plan tier for this environment'), + + /** Environment lifecycle status. */ + status: EnvironmentStatusSchema.default('provisioning').describe('Environment lifecycle status'), + + /** User ID that created the environment. */ + createdBy: z.string().describe('User ID that created the environment'), + + /** Creation timestamp (ISO-8601). */ + createdAt: z.string().datetime().describe('Creation timestamp (ISO-8601)'), + + /** Last update timestamp (ISO-8601). */ + updatedAt: z.string().datetime().describe('Last update timestamp (ISO-8601)'), + + /** Free-form metadata (feature flags, tags, …). */ + metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), +}); + +export type Environment = z.infer; + +// --------------------------------------------------------------------------- +// Environment → Database mapping +// --------------------------------------------------------------------------- + +/** + * Backend driver registry — keys used by the data-plane driver factory. + * Kept open-ended (`z.string()`) so third-party drivers can register new + * backends without a core release. + */ +export const DatabaseDriverSchema = z + .string() + .min(1) + .describe('Data-plane driver key (e.g. `turso`, `libsql`, `sqlite`, `postgres`)'); + +export type DatabaseDriver = z.infer; + +/** + * Physical database backing a single environment. + * + * The `environmentId` is **unique** — there is always exactly one DB per + * environment. Credentials live in a separate {@link DatabaseCredentialSchema} + * row so they can be rotated without touching the addressing record. + */ +export const EnvironmentDatabaseSchema = z.object({ + /** UUID of the mapping. */ + id: z.string().uuid().describe('UUID of the environment-database mapping'), + + /** Environment this database backs (UNIQUE). */ + environmentId: z.string().uuid().describe('Environment this database backs (UNIQUE)'), + + /** Physical database name (e.g. `env-`). Immutable once provisioned. */ + databaseName: z.string().describe('Physical database name (immutable)'), + + /** Full connection URL (e.g. `libsql://env-.turso.io`, `postgres://…`). */ + databaseUrl: z.string().url().describe('Full connection URL'), + + /** Data-plane driver key. */ + driver: DatabaseDriverSchema, + + /** Region of the physical database (used for latency-aware routing). */ + region: z.string().describe('Region of the physical database'), + + /** Storage quota in megabytes. */ + storageLimitMb: z.number().int().positive().describe('Storage quota in megabytes'), + + /** When the physical database was provisioned. */ + provisionedAt: z.string().datetime().describe('Provisioning timestamp (ISO-8601)'), + + /** Last successful access (populated by the router for cache invalidation). */ + lastAccessedAt: z.string().datetime().optional().describe('Last successful access timestamp'), + + /** Free-form metadata (replica topology, group, backup policy, …). */ + metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), +}); + +export type EnvironmentDatabase = z.infer; + +// --------------------------------------------------------------------------- +// Database credentials (rotatable) +// --------------------------------------------------------------------------- + +/** + * Credential lifecycle status — used during rotation: + * the previous credential stays `active` until the new one has been + * propagated to all runtimes, then flips to `revoked`. + */ +export const DatabaseCredentialStatusSchema = z + .enum(['active', 'rotating', 'revoked']) + .describe('Credential lifecycle status'); + +export type DatabaseCredentialStatus = z.infer; + +/** + * Encrypted credential for an environment's database. + * + * Stored as a separate row (not embedded in {@link EnvironmentDatabaseSchema}) + * so that secrets can be rotated, revoked, and audited independently of the + * addressing record. Multiple credentials can exist per database during + * rotation windows. + */ +export const DatabaseCredentialSchema = z.object({ + /** UUID of the credential. */ + id: z.string().uuid().describe('UUID of the credential'), + + /** Database this credential authorizes. */ + environmentDatabaseId: z.string().uuid().describe('Database this credential authorizes'), + + /** Encrypted auth token or secret (ciphertext). */ + secretCiphertext: z.string().describe('Encrypted auth token or secret (ciphertext)'), + + /** KMS/encryption key ID that produced `secretCiphertext`. */ + encryptionKeyId: z.string().describe('Encryption key ID used to encrypt the secret'), + + /** Authorization scope (e.g. `full_access`, `read_only`). */ + authorization: z + .enum(['full_access', 'read_only']) + .default('full_access') + .describe('Authorization scope for this credential'), + + /** Credential lifecycle status. */ + status: DatabaseCredentialStatusSchema.default('active').describe('Credential lifecycle status'), + + /** Credential creation timestamp. */ + createdAt: z.string().datetime().describe('Creation timestamp (ISO-8601)'), + + /** Optional expiry — after this timestamp the credential must be rotated. */ + expiresAt: z.string().datetime().optional().describe('Optional expiry timestamp'), + + /** Timestamp when the credential was revoked (null while active). */ + revokedAt: z.string().datetime().optional().describe('Revocation timestamp (if revoked)'), +}); + +export type DatabaseCredential = z.infer; + +// --------------------------------------------------------------------------- +// Environment-scoped RBAC +// --------------------------------------------------------------------------- + +/** + * Per-environment role assigned to a user/service principal. + * Scoped narrowly to environment boundaries so that prod can have a + * different cast of admins than dev. + */ +export const EnvironmentRoleSchema = z + .enum(['owner', 'admin', 'maker', 'reader', 'guest']) + .describe('Per-environment role'); + +export type EnvironmentRole = z.infer; + +/** + * Environment membership — grants a user access to a specific environment. + * + * Unique by `(environmentId, userId)`. A user may be a member of multiple + * environments within the same organization with different roles. + */ +export const EnvironmentMemberSchema = z.object({ + /** UUID of the membership. */ + id: z.string().uuid().describe('UUID of the membership'), + + /** Environment this membership grants access to. */ + environmentId: z.string().uuid().describe('Environment this membership grants access to'), + + /** User ID (references `user` in the control plane). */ + userId: z.string().describe('User ID'), + + /** Per-environment role. */ + role: EnvironmentRoleSchema.describe('Per-environment role'), + + /** User ID of the member who invited / granted this membership. */ + invitedBy: z.string().describe('User ID that granted this membership'), + + /** Creation timestamp. */ + createdAt: z.string().datetime().describe('Creation timestamp (ISO-8601)'), + + /** Last update timestamp. */ + updatedAt: z.string().datetime().describe('Last update timestamp (ISO-8601)'), +}); + +export type EnvironmentMember = z.infer; + +// --------------------------------------------------------------------------- +// Provisioning requests / responses +// --------------------------------------------------------------------------- + +/** + * Request to provision a new environment for an organization. + * + * Upstream callers typically invoke this through + * `EnvironmentProvisioningService.provisionEnvironment()`. + */ +export const ProvisionEnvironmentRequestSchema = z.object({ + organizationId: z.string().describe('Organization that will own the new environment'), + slug: z + .string() + .regex(/^[a-z0-9][a-z0-9-]{0,62}$/) + .describe('Slug unique per organization'), + displayName: z.string().optional().describe('Display name (defaults to slug)'), + envType: EnvironmentTypeSchema.describe('Environment type'), + region: z.string().optional().describe('Region preference for the physical DB'), + driver: DatabaseDriverSchema.optional().describe('Driver key (defaults to provisioning service config)'), + plan: TenantPlanSchema.optional().describe('Plan tier'), + storageLimitMb: z.number().int().positive().optional().describe('Storage quota in megabytes'), + isDefault: z.boolean().optional().describe('Mark as the organization default environment'), + createdBy: z.string().describe('User ID that initiated the provisioning'), + metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), +}); + +export type ProvisionEnvironmentRequest = z.infer; + +/** + * Response of a successful environment provisioning call. + * Includes the environment record, its physical database mapping, and the + * freshly-minted credential. + */ +export const ProvisionEnvironmentResponseSchema = z.object({ + environment: EnvironmentSchema.describe('Provisioned environment'), + database: EnvironmentDatabaseSchema.describe('Physical database backing the environment'), + credential: DatabaseCredentialSchema.describe('Freshly-minted credential for the environment DB'), + durationMs: z.number().describe('Total provisioning duration in milliseconds'), + warnings: z.array(z.string()).optional().describe('Non-fatal warnings emitted during provisioning'), +}); + +export type ProvisionEnvironmentResponse = z.infer; + +/** + * Request to bootstrap a brand-new organization — allocates the default + * environment (and its DB) in one atomic call. + */ +export const ProvisionOrganizationRequestSchema = z.object({ + organizationId: z.string().describe('Organization being bootstrapped'), + defaultEnvType: EnvironmentTypeSchema.default('production').describe('Env type for the default environment'), + defaultEnvSlug: z + .string() + .regex(/^[a-z0-9][a-z0-9-]{0,62}$/) + .default('prod') + .describe('Slug for the default environment'), + region: z.string().optional().describe('Region preference'), + driver: DatabaseDriverSchema.optional().describe('Driver key'), + plan: TenantPlanSchema.optional().describe('Plan tier'), + storageLimitMb: z.number().int().positive().optional().describe('Storage quota in megabytes'), + createdBy: z.string().describe('User ID that initiated provisioning'), + metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), +}); + +export type ProvisionOrganizationRequest = z.infer; + +/** + * Response of a successful organization bootstrap. + */ +export const ProvisionOrganizationResponseSchema = z.object({ + defaultEnvironment: ProvisionEnvironmentResponseSchema.describe('Default environment that was created'), + durationMs: z.number().describe('Total bootstrap duration in milliseconds'), + warnings: z.array(z.string()).optional().describe('Non-fatal warnings'), +}); + +export type ProvisionOrganizationResponse = z.infer; diff --git a/packages/spec/src/cloud/index.ts b/packages/spec/src/cloud/index.ts index 0e31ab8ab..ac474ce78 100644 --- a/packages/spec/src/cloud/index.ts +++ b/packages/spec/src/cloud/index.ts @@ -17,3 +17,4 @@ export * from './developer-portal.zod'; export * from './marketplace-admin.zod'; export * from './app-store.zod'; export * from './tenant.zod'; +export * from './environment.zod'; diff --git a/packages/spec/src/cloud/tenant.zod.ts b/packages/spec/src/cloud/tenant.zod.ts index b2d9cabcc..7ea4558c9 100644 --- a/packages/spec/src/cloud/tenant.zod.ts +++ b/packages/spec/src/cloud/tenant.zod.ts @@ -46,6 +46,13 @@ export type TenantPlan = z.infer; * * Tracks each tenant's dedicated database instance. * Stored in the global control plane database. + * + * @deprecated v4.x shim — superseded by the environment-per-database + * isolation model introduced in v4.1. New code should use + * {@link EnvironmentSchema} + {@link EnvironmentDatabaseSchema} + + * {@link DatabaseCredentialSchema} from `./environment.zod`. This schema + * (and the `sys_tenant_database` table) will be removed in v5.0. + * See `docs/adr/0002-environment-database-isolation.md`. */ export const TenantDatabaseSchema = z.object({ /**