diff --git a/.changeset/21728-install-local-purge.md b/.changeset/21728-install-local-purge.md index c7d68a4087a..18a90cc6f18 100644 --- a/.changeset/21728-install-local-purge.md +++ b/.changeset/21728-install-local-purge.md @@ -8,6 +8,6 @@ Clause-②: no - **What was wrong.** The purge looked up a bare `driver` service, a name no kernel registers (drivers register as `driver.`), so it refused everywhere. Behind that it matched seed records by `id`, which seed records rarely carry: the CRM example's 28 records key by `name`, `email` and `subject`. It also deleted through the driver, past every engine hook. - **What it does now.** It deletes through the ObjectQL engine, so lifecycle hooks and the audit trail run, under the posture the seed was written with (record-change automation suppressed). Rows are matched by each dataset's `externalId`, the key the install and the reseed upsert by. A row whose key no seed record declares is never touched. Children are deleted before parents, in the reverse of the seed loader's own dependency order. -- **Scope.** Under an organization wall the purge removes only the seed rows of the caller's active organization, the scope the install and the reseed seed into. A session with no active organization is answered `400 RESEED_SKIPPED` (`multi-tenant-no-active-org`), the way reseed answers it. Without a wall the deployment is one tenant, and the match is table-wide, as the install's own match is. +- **Scope.** Under an organization wall the purge removes only the seed rows of the caller's active organization, the scope the install and the reseed seed into. A session with no active organization is refused with `403 PERMISSION_DENIED` and a message naming the missing active organization, the way reseed refuses it. Without a wall the deployment is one tenant, and the match is table-wide, as the install's own match is. - **The response keeps its shape**, `{ manifestId, deleted, skipped, errors, withSampleData }`. `skipped` counts seed records no row carries (already deleted). `errors` counts records that could not be purged, each with its reason in the server log: a delete the engine refused (for example, a user's row still requires the seed row as its parent), a key that more than one row carries, or a seed record with no key value. - A runtime with no data engine or no metadata service still answers `500 DRIVER_UNAVAILABLE`, now naming what is missing. diff --git a/.changeset/21774-install-local-no-active-org.md b/.changeset/21774-install-local-no-active-org.md new file mode 100644 index 00000000000..24b52b59d30 --- /dev/null +++ b/.changeset/21774-install-local-no-active-org.md @@ -0,0 +1,12 @@ +--- +"@objectstack/cloud-connection": patch +--- + +Under an organization wall, the install-local sample-data doors now refuse a session with no active organization, and the refusal names what is missing (ADR-0123 D2 / D4). Before, they skipped quietly. + +Clause-②: no + +- **Reseed and purge.** `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and `…/purge-sample-data` answer `403 PERMISSION_DENIED`, with a message saying the session has no active organization and that one must be joined or selected. Before, the reseed answered `400 RESEED_SKIPPED` (`multi-tenant-no-active-org`); the purge, which starts deleting in this same release, refuses the same way from the start. Reseed's other declines are unchanged and still answer `400 RESEED_SKIPPED`: a package with no seed datasets, a runtime with no data engine or metadata service, and a seed run that threw. +- **Install.** `POST /api/v1/marketplace/install-local` still installs the package, which is environment-wide. Its `seeded` block now reads `{ mode: "refused", reason: "…" }`, where `reason` names the missing active organization and says to select one and then reseed. Before, it read `{ mode: "skipped", reason: "multi-tenant-no-active-org" }`. +- **No organization is guessed.** The active-organization read no longer falls back to the user's first membership. ADR-0123 D1 makes "authenticated, with no active organization" a declared state. A guess would write into an organization the caller never chose. The fallback read an object no package defines, so it never resolved anything. +- **Unchanged.** A session with an active organization seeds, reseeds and purges in that organization, as before. Without a wall (`single` posture), no organization is read, and the three doors act table-wide. diff --git a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts new file mode 100644 index 00000000000..17bfa8130d7 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0123 D1 / D2 / D4] A walled session with NO active organization, at the + * three install-local doors that seed or purge sample data. + * + * ## What was measured before this file + * + * On a posture-only (walled) boot, a signed-in admin whose session carried no + * `activeOrganizationId`, though their user held a `sys_member` row: + * + * POST …/install-local -> 200, seeded {mode: "skipped", reason: "multi-tenant-no-active-org"} + * POST …/install-local/:id/reseed-sample-data -> 400 RESEED_SKIPPED "Reseed did not run: multi-tenant-no-active-org" + * POST …/install-local/:id/purge-sample-data -> 400 RESEED_SKIPPED + * + * and the resolver's "first membership" fallback read an object no package + * defines, so it threw and was swallowed. ADR-0123 already rules on this state: + * D1 makes it legal and named, so no subsystem guesses an organization for it; + * D2 refuses a tenant-scoped write loudly, with the catalog's + * `PERMISSION_DENIED` / 403; D4 says the refusal names the missing active + * organization. + * + * ## What this file pins + * + * 1. walled, no active organization, the user a member of TWO organizations: + * the install registers the package and reports `seeded {mode: "refused"}` + * with the sentence in `reason`; the reseed and the purge answer the 403 + * envelope; no seed run starts and no organization is guessed; + * 2. walled, an active organization: all three doors act in it, as before; + * 3. unwalled (`single`): the active-organization read is never consulted. + * + * The membership rows are served by the store on purpose: a resolver that + * guessed from them (under either object name the platform has spelled) would + * seed into one of the two organizations, and case 1 would go red. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let loadCalls: any[] = []; + +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: any) { + loadCalls.push(request); + return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + } + async buildDependencyGraph(objects: string[]) { + return { insertOrder: objects, nodes: objects.map((object) => ({ object, dependsOn: [], references: [] })) }; + } + }, + recordSeedOutcome: vi.fn(), +})); +// Only the seed-request parse is stubbed; every other export stays the real one, +// because `@objectstack/core` reads `@objectstack/spec/data` values at module load. +vi.mock('@objectstack/spec/data', async (importOriginal) => ({ + ...(await importOriginal()), + SeedLoaderRequestSchema: { parse: (x: any) => x }, +})); + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { INSTALLER_USER_ID, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +type Handler = (c: any) => Promise; + +const BASE = '/api/v1/marketplace/install-local'; +const MANIFEST = { + id: 'app.test.noorg', + version: '1.0.0', + objects: [{ name: 'noorg_x', fields: { name: { type: 'text' } } }], + data: [{ object: 'noorg_x', externalId: 'name', records: [{ name: 'a' }, { name: 'b' }] }], +}; + +/** The installer is a member of two organizations; the store says so under both spellings. */ +const MEMBERSHIPS = [ + { id: 'mem_a', organization_id: 'org_a', user_id: INSTALLER_USER_ID, role: 'owner' }, + { id: 'mem_b', organization_id: 'org_b', user_id: INSTALLER_USER_ID, role: 'owner' }, +]; + +function makeC(body: unknown, manifestId?: string) { + return { + req: { + url: `http://localhost:3000${BASE}`, + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: (k: string) => (k === 'manifestId' ? manifestId : undefined), + header: () => undefined, + }, + json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })), + }; +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-noorg-')); + loadCalls = []; +}); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +/** + * Boot the real plugin. `posture` is the `tenancy` service's posture in force; + * `activeOrg` what the session's `activeOrganizationId` says (absent: none). + */ +async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string }) { + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const reads: Array<{ object: string; query: any }> = []; + const granted = withInstallerGrants({ + syncSchemas: async () => undefined, + find: async (object: string, query?: any) => { reads.push({ object, query }); return []; }, + }); + const objectql = { + ...granted, + find: async (object: string, query?: any) => { + if (object === 'sys_member' || object === 'sys_organization_member') { + reads.push({ object, query }); + return MEMBERSHIPS.map((m) => ({ ...m })); + } + return granted.find(object, query); + }, + }; + const register = vi.fn(); + const services: Record = { + manifest: { register }, + auth: { + api: { + getSession: async () => ({ + user: { id: INSTALLER_USER_ID }, + session: opts.activeOrg ? { activeOrganizationId: opts.activeOrg } : {}, + }), + }, + }, + objectql, + metadata: {}, + tenancy: { posture: opts.posture }, + }; + const hooks = new Map(); + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + const resolveActiveOrgId = vi.spyOn(plugin as any, 'resolveActiveOrgId'); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + return { + register, + reads, + resolveActiveOrgId, + install: () => routes.get(`POST ${BASE}`)!(makeC({ manifest: MANIFEST })), + reseed: () => routes.get(`POST ${BASE}/:manifestId/reseed-sample-data`)!(makeC({}, MANIFEST.id)), + purge: () => routes.get(`POST ${BASE}/:manifestId/purge-sample-data`)!(makeC({}, MANIFEST.id)), + }; +} + +const ledgerEntry = () => new LocalManifestSource(dir).read(MANIFEST.id).entry; + +describe('walled, no active organization — refused at every sample-data door, and no organization is guessed', () => { + it('install: the package registers, and `seeded` reports the refusal instead of seeding', async () => { + const m = await boot({ posture: 'isolated' }); + const res = await m.install(); + + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + expect(m.register).toHaveBeenCalled(); + expect(ledgerEntry()?.manifestId).toBe(MANIFEST.id); + expect(Object.keys(res.payload.data.seeded).sort()).toEqual(['mode', 'reason']); + expect(res.payload.data.seeded.mode).toBe('refused'); + expect(res.payload.data.seeded.reason).toContain('this session has no active organization'); + expect(loadCalls).toHaveLength(0); + expect(ledgerEntry()?.withSampleData).not.toBe(true); + }); + + it('reseed: 403 PERMISSION_DENIED naming the missing active organization, and no seed run', async () => { + const m = await boot({ posture: 'isolated' }); + await m.install(); + const res = await m.reseed(); + + expect(res.status).toBe(403); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('PERMISSION_DENIED'); + expect(res.payload.error.message).toContain('this session has no active organization'); + expect(loadCalls).toHaveLength(0); + expect(ledgerEntry()?.withSampleData).not.toBe(true); + }); + + it('purge: 403 PERMISSION_DENIED naming the missing active organization, and no seed object is read', async () => { + const m = await boot({ posture: 'isolated' }); + await m.install(); + const res = await m.purge(); + + expect(res.status).toBe(403); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('PERMISSION_DENIED'); + expect(res.payload.error.message).toContain('this session has no active organization'); + expect(m.reads.filter((r) => r.object === 'noorg_x')).toEqual([]); + expect(ledgerEntry()?.sampleDataPurged).toBeUndefined(); + }); +}); + +describe('walled, an active organization — all three doors act in it, as before', () => { + it('install seeds inline into the active organization', async () => { + const m = await boot({ posture: 'isolated', activeOrg: 'org_b' }); + const res = await m.install(); + + expect(res.status).toBe(200); + expect(res.payload.data.seeded).toMatchObject({ mode: 'inline', inserted: 2 }); + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].config.organizationId).toBe('org_b'); + }); + + it('reseed seeds into the active organization', async () => { + const m = await boot({ posture: 'isolated', activeOrg: 'org_b' }); + await m.install(); + const res = await m.reseed(); + + expect(res.status).toBe(200); + expect(res.payload.data).toMatchObject({ inserted: 2, withSampleData: true }); + expect(loadCalls).toHaveLength(2); + expect(loadCalls[1].config.organizationId).toBe('org_b'); + }); + + it('purge reads the seed object in the active organization only', async () => { + const m = await boot({ posture: 'isolated', activeOrg: 'org_b' }); + await m.install(); + const res = await m.purge(); + + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + const seedReads = m.reads.filter((r) => r.object === 'noorg_x'); + expect(seedReads).toHaveLength(1); + expect(seedReads[0].query.where).toEqual({ organization_id: 'org_b' }); + }); +}); + +describe('unwalled (`single`) — the active-organization read is never consulted', () => { + it('install, reseed and purge act table-wide with no organization, and never ask for one', async () => { + const m = await boot({ posture: 'single' }); + + const install = await m.install(); + expect(install.status).toBe(200); + expect(install.payload.data.seeded).toMatchObject({ mode: 'inline', inserted: 2 }); + const reseed = await m.reseed(); + expect(reseed.status).toBe(200); + const purge = await m.purge(); + expect(purge.status).toBe(200); + + expect(loadCalls).toHaveLength(2); + for (const call of loadCalls) expect(call.config.organizationId).toBeUndefined(); + const seedReads = m.reads.filter((r) => r.object === 'noorg_x'); + expect(seedReads).toHaveLength(1); + expect(seedReads[0].query.where).toBeUndefined(); + expect(m.resolveActiveOrgId).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index aa9489af5a7..5f88579b38e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -313,6 +313,47 @@ function organizationWallActive(ctx: PluginContext): boolean { return postureEnforcesWall(resolveTenancyPosture()); } +/** + * [ADR-0123 D2 / D4] The answer every sample-data door gives a walled session + * with NO active organization: the standard catalog's `PERMISSION_DENIED` / 403, + * and one sentence, built here, that names the missing active organization and + * the remedy. + * + * ADR-0123 D1 declares "authenticated, with no active organization" a legal, + * named state that every subsystem inherits instead of inventing a fourth + * semantics for it. Sample rows on a walled deployment are tenant-scoped writes, + * so D2 refuses them loudly, with the catalog code rather than a registered + * synonym, and D4 requires the message to say what is missing, so the reader + * does not go looking at permissions, which are fine. + * + * Three doors read it: the install's `seeded` report (`mode: 'refused'`, the + * sentence in `reason`: the package itself is installed, its rows are not), + * and the reseed and purge refusals (the code, the status and the sentence as + * the error envelope). The platform's other D2 sentences, the security layer's + * write wall and the sharing-rule service's, are built inline for one object + * and one operation; this door's subject is a package's sample data across + * several objects, so the sentence is this file's own, in the same words. + */ +const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED'; +const NO_ACTIVE_ORGANIZATION_STATUS = 403; + +/** The sample-data doors that seed or purge under the caller's active organization. */ +type SampleDataDoor = 'install' | 'reseed' | 'purge'; + +function noActiveOrganizationRefusal(door: SampleDataDoor): string { + const subject = door === 'purge' ? 'Purging sample data' + : door === 'reseed' ? 'Reseeding sample data' + : 'Seeding the sample data'; + const consequence = door === 'purge' + ? 'there is no organization to remove its rows from' + : 'there is no organization to place its rows in'; + const remedy = door === 'install' + ? 'The package itself is installed. Join or select an active organization, then reseed the sample data.' + : 'Join or select an active organization and retry.'; + return `${subject} was refused: sample data is scoped to an organization, and this session has no active ` + + `organization, so ${consequence}. ${remedy}`; +} + export interface MarketplaceInstallLocalPluginConfig { /** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL * and then to the public ObjectStack cloud so a fresh `objectstack dev` @@ -1607,7 +1648,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * • The user wants a clean baseline back after editing demo rows * * Multi-tenant: requires an active organization on the session (same - * rule as install seed path). + * rule as install seed path). A walled session with none is refused with + * ADR-0123 D2 / D4's answer ({@link noActiveOrganizationRefusal}); the + * reseed's other declines (no seed datasets, no data engine or metadata + * service, a seed run that threw) stay `400 RESEED_SKIPPED`. */ private handleReseed = async (c: any, ctx: PluginContext): Promise => { const admission = await this.requireInstallCapability(c, ctx, 'Reseeding sample data'); @@ -1627,7 +1671,15 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'reseed sample data'); } - const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c }); + const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c, door: 'reseed' }); + // [ADR-0123 D2 / D4] A walled session with no active organization: the + // reseed is a tenant-scoped write with no organization to write under. + if (summary.seeded.mode === 'refused') { + return c.json({ + success: false, + error: { code: NO_ACTIVE_ORGANIZATION_CODE, message: summary.seeded.reason }, + }, NO_ACTIVE_ORGANIZATION_STATUS); + } if (summary.seeded.mode === 'skipped') { return c.json({ success: false, @@ -1697,7 +1749,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * • Scope is the install's: under an organization wall, the caller's * active organization (the same `organizationWallActive` + * `resolveActiveOrgId` the install and reseed seed under), and a session - * with none is answered the way reseed answers it. Without a wall the + * with none is refused the way reseed refuses it: ADR-0123 D2 / D4's + * `403 PERMISSION_DENIED` naming the missing active organization + * ({@link noActiveOrganizationRefusal}). Without a wall the * deployment is one logical tenant and the match is table-wide, as the * loader's upsert match is. * • Children are deleted before parents — the reverse of the loader's own @@ -1759,12 +1813,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!resolved) { return c.json({ success: false, - error: { - code: 'RESEED_SKIPPED', - message: 'Purge did not run: multi-tenant-no-active-org. A purge removes the sample rows of the ' - + "caller's active organization only, and this session has none — set an active organization and retry.", - }, - }, 400); + error: { code: NO_ACTIVE_ORGANIZATION_CODE, message: noActiveOrganizationRefusal('purge') }, + }, NO_ACTIVE_ORGANIZATION_STATUS); } organizationId = resolved; } @@ -1990,7 +2040,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * • single-tenant: run SeedLoaderService inline (mirrors * AppPlugin single-tenant branch) * • multi-tenant: invoke `seed-replayer` for the caller's - * active org (resolved from the request session) + * active org (resolved from the request session); a session + * with none reports `mode: 'refused'`, ADR-0123 D2 / D4's + * sentence for `opts.door` in `reason`, and seeds nothing * * Errors are logged but never thrown — install succeeds even if * post-register side-effects partially fail (the manifest itself is @@ -2000,8 +2052,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { private applySideEffects = async ( ctx: PluginContext, manifest: any, - opts: { seedNow: boolean; c?: any }, - ): Promise<{ translationsLoaded: number; seeded: { mode: 'inline' | 'replayer' | 'skipped'; inserted?: number; updated?: number; skipped?: number; errors?: number; reason?: string; errorSample?: string } }> => { + opts: { seedNow: boolean; c?: any; door?: Exclude }, + ): Promise<{ translationsLoaded: number; seeded: { mode: 'inline' | 'replayer' | 'skipped' | 'refused'; inserted?: number; updated?: number; skipped?: number; errors?: number; reason?: string; errorSample?: string } }> => { const appId = String(manifest?.id ?? 'unknown'); let translationsLoaded = 0; let seedSummary: any = { mode: 'skipped', reason: 'no-datasets' }; @@ -2110,8 +2162,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const resolved = await this.resolveActiveOrgId(opts.c, ctx); if (resolved) organizationId = resolved; else { - seedSummary = { mode: 'skipped', reason: 'multi-tenant-no-active-org' }; - ctx.logger?.warn?.('[MarketplaceInstallLocal] multi-tenant: no active org on request — data not seeded'); + // [ADR-0123 D2 / D4] Refused, not skipped: the rows are + // tenant-scoped writes and this session names no tenant. + const reason = noActiveOrganizationRefusal(opts.door ?? 'install'); + seedSummary = { mode: 'refused', reason }; + ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${appId}: ${reason}`); } } if (!multiTenant || organizationId) { @@ -2182,8 +2237,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin { /** * Best-effort active-org resolution. Reads the better-auth session directly - * and returns `session.activeOrganizationId`, falling back to the user's - * first org membership. + * and returns `session.activeOrganizationId`, or `null` when it has none. + * + * [ADR-0123 D1] `null` is an answer, not a gap to fill: "authenticated, with + * no active organization" is a declared state, and every caller of this read + * refuses the tenant-scoped write it would scope ({@link noActiveOrganizationRefusal}). + * ⛔ No membership fallback. Guessing from the user's memberships would be a + * fourth semantics for that state, and for a user in several organizations it + * would write into one the caller never chose. The fallback this once carried + * read an object no package defines, so it threw, was swallowed, and never + * answered once. * * ⚠️ [#8976] This is a SCOPING read, not an authorization one — which org's * rows a seed lands in, asked only after {@link requireInstallCapability} @@ -2203,17 +2266,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const session = await api.getSession({ headers: c.req.raw.headers }); const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null; if (direct) return String(direct); - // Fall back to the user's first membership row. - const userId = session?.user?.id; - if (!userId) return null; - try { - const ql: any = ctx.getService('objectql'); - if (ql?.find) { - const rows = await ql.find('sys_organization_member', { where: { user_id: userId }, limit: 1, context: { isSystem: true } } as any); - const row = Array.isArray(rows) ? rows[0] : (rows?.items?.[0] ?? null); - return row?.organization_id ? String(row.organization_id) : null; - } - } catch { /* ignore */ } } catch { /* ignore */ } return null; }; diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts index 9329cefe13a..d46c6220109 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts @@ -426,13 +426,13 @@ describe('POST …/purge-sample-data — through the engine, in the install\'s s expect(tables.pg_contact.map((r) => r.id)).toEqual(['d1']); }); - it('wall, no active organization: answered the way reseed answers it, and nothing is deleted', async () => { + it('wall, no active organization: refused the way reseed refuses it (ADR-0123 D2 / D4), and nothing is deleted', async () => { const tables: Record = { pg_account: [{ id: 'a1', name: 'Acme', organization_id: 'org_a' }], pg_contact: [] }; const { purge, deletes } = await bootWith({ tables, posture: 'isolated' }); const res = await purge(makeC(MANIFEST.id)); - expect(res.status).toBe(400); - expect(res.payload.error.code).toBe('RESEED_SKIPPED'); - expect(res.payload.error.message).toContain('multi-tenant-no-active-org'); + expect(res.status).toBe(403); + expect(res.payload.error.code).toBe('PERMISSION_DENIED'); + expect(res.payload.error.message).toContain('this session has no active organization'); expect(deletes).toEqual([]); expect(tables.pg_account).toHaveLength(1); // The install still carries its sample data: the ledger is untouched. diff --git a/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts b/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts new file mode 100644 index 00000000000..dcd267cd0bc --- /dev/null +++ b/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — under an organization wall, a session with NO active +// organization is refused at the three install-local doors that seed or purge +// sample data, and the refusal names what is missing (ADR-0123 D1 / D2 / D4). +// +// ## What was measured before the fix +// +// The showcase booted walled (`multiTenant: 'posture-only'`, posture +// `isolated`, the wall enforced), the admin signed in BEFORE holding any +// membership, then given a `sys_member` row. Their session's +// `activeOrganizationId` stayed null. With that session: +// +// POST …/install-local -> 200, seeded {mode: "skipped", reason: "multi-tenant-no-active-org"} +// POST …/install-local/com.example.crm/reseed-sample-data -> 400 RESEED_SKIPPED "Reseed did not run: multi-tenant-no-active-org" +// POST …/install-local/com.example.crm/purge-sample-data -> 400 RESEED_SKIPPED +// +// and the resolver's "first membership" fallback read `sys_organization_member`, +// an object no package defines: the engine answered "Object +// 'sys_organization_member' not found", the `try` swallowed it, and the fallback +// never once fired. +// +// ## What this file pins, on one real boot +// +// 1. PRECONDITIONS: the wall is in force; the session carries no active +// organization; its user is a member of two organizations. +// 2. With that session: the install registers the package and its `seeded` +// block is `{mode: "refused"}` with a `reason` naming the missing active +// organization; the reseed and the purge answer `403 PERMISSION_DENIED` +// naming it; no seed row lands in either organization (nothing guessed). +// 3. The SAME session once it selects organization B: reseed, purge and a +// reinstall all act in B, as they always have. One fact differs between +// 2 and 3: the active organization. +// +// Boots a walled fixture stack of its own, so it stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true } as ExecutionContext; +const CRM = 'com.example.crm'; +const BASE = '/marketplace/install-local'; +const ORG_A = 'org_21774_a'; +const ORG_B = 'org_21774_b'; +/** The CRM example's seeded objects and their seed-row counts (3 + 3 + 12 + 5 + 5). */ +const SEEDED: Record = { + crm_account: 3, crm_contact: 3, crm_opportunity: 12, crm_lead: 5, crm_activity: 5, +}; +const SEED_TOTAL = Object.values(SEEDED).reduce((a, b) => a + b, 0); +/** The words ADR-0123 D4 requires the refusal to carry. */ +const NAMES_THE_MISSING_ORGANIZATION = 'this session has no active organization'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +/** The install body `os package install .json` sends. */ +function installBody(): { manifest: Record } { + const { artifact } = buildShapedArtifact(crmStack as unknown as Record); + const manifest = artifact.manifest as { id?: string; version?: string }; + return { manifest: { ...artifact, id: manifest.id, version: manifest.version } }; +} + +/** Seed-object rows, read as the system, in one organization or (none given) across all of them. */ +async function countRows(ql: IObjectQLEngine, organizationId?: string): Promise { + let total = 0; + for (const object of Object.keys(SEEDED)) { + total += rowsOf(await ql.find(object, { + ...(organizationId ? { where: { organization_id: organizationId } } : {}), + context: SYS, + })).length; + } + return total; +} + +async function json(res: Response): Promise<{ status: number; body: any }> { // eslint-disable-line @typescript-eslint/no-explicit-any + return { status: res.status, body: await res.json().catch(() => null) }; +} + +describe('dogfood: install-local under an organization wall, with a session that has no active organization', () => { + let stack: VerifyStack; + let storageDir: string; + let ql: IObjectQLEngine; + let token: string; + let tenancy: { posture?: string; isolationActive?: boolean } | undefined; + let sessionOrgBefore: unknown; + let sessionOrgAfter: unknown; + let memberships: number; + type Answer = { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + const orgless: Record<'install' | 'reseed' | 'purge', Answer> = {} as never; + const inOrgB: Record<'reseed' | 'purge' | 'reinstall', Answer> = {} as never; + let rowsAfterOrgless: number; + let orgBAfterReseed: number; + let orgBAfterPurge: number; + let orgBAfterReinstall: number; + let orgAAtEnd: number; + + async function activeOrganization(): Promise { + const res = await stack.apiAs(token, 'GET', '/auth/get-session'); + const body = await res.json().catch(() => null); + expect(body?.session, `get-session: ${JSON.stringify(body)}`).toBeTruthy(); + return body.session.activeOrganizationId ?? null; + } + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-no-active-org-')); + // `posture-only` requests the `isolated` posture — the wall is ACTIVE — + // without the organizations runtime; the memberships are written by hand. + stack = await bootStack(showcaseStack, { + multiTenant: 'posture-only', + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + ql = stack.kernel.getService('objectql'); + tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean }>('tenancy'); + + // The session is minted BEFORE any membership exists, so it carries no + // active organization; the memberships land after it. + token = await stack.signIn(); + const [admin] = rowsOf(await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS })); + for (const org of [ORG_A, ORG_B]) { + await ql.insert('sys_organization', { id: org, name: org, slug: org }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${org}`, organization_id: org, user_id: admin.id, role: 'owner' }, { context: SYS }); + } + memberships = rowsOf(await ql.find('sys_member', { where: { user_id: admin.id }, context: SYS })).length; + sessionOrgBefore = await activeOrganization(); + + orgless.install = await json(await stack.apiAs(token, 'POST', BASE, installBody())); + orgless.reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + orgless.purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); + rowsAfterOrgless = await countRows(ql); + + // The same session, once it selects organization B. + const set = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId: ORG_B }); + expect(set.status, `set-active: ${await set.clone().text()}`).toBe(200); + sessionOrgAfter = await activeOrganization(); + inOrgB.reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + orgBAfterReseed = await countRows(ql, ORG_B); + inOrgB.purge = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); + orgBAfterPurge = await countRows(ql, ORG_B); + inOrgB.reinstall = await json(await stack.apiAs(token, 'POST', BASE, installBody())); + orgBAfterReinstall = await countRows(ql, ORG_B); + orgAAtEnd = await countRows(ql, ORG_A); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: the organization wall is in force on this boot', () => { + expect(tenancy?.posture, 'tenancy posture in force').toBe('isolated'); + expect(tenancy?.isolationActive, 'the wall is enforced').toBe(true); + }); + + it('PRECONDITION: the session has no active organization, and its user is a member of two', () => { + expect(sessionOrgBefore).toBeNull(); + expect(memberships).toBe(2); + }); + + it('install: the package is installed, and `seeded` reports the refusal naming the missing active organization', () => { + expect(orgless.install.status, JSON.stringify(orgless.install.body)).toBe(200); + expect(orgless.install.body?.success).toBe(true); + expect(orgless.install.body?.data?.manifestId).toBe(CRM); + expect(orgless.install.body?.data?.seeded?.mode).toBe('refused'); + expect(orgless.install.body?.data?.seeded?.reason).toContain(NAMES_THE_MISSING_ORGANIZATION); + }); + + it('reseed: 403 PERMISSION_DENIED naming the missing active organization', () => { + expect(orgless.reseed.status, JSON.stringify(orgless.reseed.body)).toBe(403); + expect(orgless.reseed.body?.success).toBe(false); + expect(orgless.reseed.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(orgless.reseed.body?.error?.message).toContain(NAMES_THE_MISSING_ORGANIZATION); + }); + + it('purge: 403 PERMISSION_DENIED naming the missing active organization', () => { + expect(orgless.purge.status, JSON.stringify(orgless.purge.body)).toBe(403); + expect(orgless.purge.body?.success).toBe(false); + expect(orgless.purge.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(orgless.purge.body?.error?.message).toContain(NAMES_THE_MISSING_ORGANIZATION); + }); + + it('no organization was guessed: not one seed row landed, in either organization or in none', () => { + expect(rowsAfterOrgless).toBe(0); + }); + + it('CONTROL: the same session with organization B active — reseed, purge and reinstall act in B, as before', () => { + expect(sessionOrgAfter).toBe(ORG_B); + expect(inOrgB.reseed.status, JSON.stringify(inOrgB.reseed.body)).toBe(200); + expect(inOrgB.reseed.body?.data).toMatchObject({ inserted: SEED_TOTAL, errors: 0, withSampleData: true }); + expect(orgBAfterReseed).toBe(SEED_TOTAL); + expect(inOrgB.purge.status, JSON.stringify(inOrgB.purge.body)).toBe(200); + expect(inOrgB.purge.body?.data).toMatchObject({ deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false }); + expect(orgBAfterPurge).toBe(0); + expect(inOrgB.reinstall.status, JSON.stringify(inOrgB.reinstall.body)).toBe(200); + expect(inOrgB.reinstall.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL }); + expect(orgBAfterReinstall).toBe(SEED_TOTAL); + expect(orgAAtEnd).toBe(0); + }); +}); diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 614e7141694..96dd0974938 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -992,10 +992,10 @@ export function composeStorageRoutes( * flattened shape as a fallback for hosts that hand back the session record * directly. * - * ⛔ No membership fallback. `marketplace-install-local-plugin`'s - * `resolveActiveOrgId` falls back to the user's first `sys_organization_member` - * row; that is a SCOPING read for a seed, and its own doc warns it answers for - * "which org do these rows land in" only. Here the answer becomes a WALL: a + * ⛔ No membership fallback. A session with no active organization is a + * declared state (ADR-0123 D1), never a gap to fill from the user's `sys_member` + * rows; `marketplace-install-local-plugin`'s `resolveActiveOrgId` keeps the same + * rule for the rows a seed lands in. Here the answer becomes a WALL: a * file stamped from a guessed membership is a file its uploader can no longer * see from the organization they were actually acting in. No active * organization therefore means no stamp — the pre-#12745 behaviour, reported diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index c805529f6dd..d03ce1e872a 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -31,7 +31,7 @@ "assertion deleted. Measured on the branch point e900015cd (2026-08-05)." ], "nonTest": { - "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 2, + "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 1, "packages/core/src/security/resolve-authz-context.ts": 1, "packages/metadata-protocol/src/protocol.ts": 6, "packages/metadata-protocol/src/seed-loader.ts": 3, diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 39c71f5179b..31159402e3c 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -4,7 +4,7 @@ "packages/cli/src/commands/serve.ts": 9, "packages/client/src/client.hono.test.ts": 2, "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, - "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 15, + "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 14, "packages/core/examples/kernel-features-example.ts": 5, "packages/objectql/src/plugin.integration.test.ts": 23, "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 3,