// End-to-end coverage for secret isolation *through the real HTTP API*. // // Complements tenant-isolation.node.test.ts (which already covers plain // cross-org isolation at the org scope) by exercising the two-scope stack // the cloud app actually ships: `[userOrgScope, orgScope]`. The harness // builds the same shape `apps/cloud/src/services/executor.ts#createScopedExecutor` // builds in production, and every request goes through `HttpApiClient` → // `fetch` → the real `ProtectedCloudApi` → real postgres adapter. // // Invariants the product is staking on: // // 1. Users in different orgs can't see each other's secrets — not even // secrets written at the org scope of the other org. // 2. Users in the same org can't see each other's user-scoped secrets // (per-user OAuth tokens etc. don't leak to co-workers). // 3. Org-scoped secrets ARE visible to every user in that org — an // admin writing a shared API key serves the whole tenant. // 4. The same user id in different orgs gets distinct per-user scopes — // the userOrgScope id bakes in the org id on purpose. // 5. secrets.set rejects a scope id outside the caller's executor stack. // // NOTE: "per-user override shadows org default" cross-scope co-existence // is NOT covered here. `executor.secrets.set` currently deletes secret // metadata rows across the full scope stack before re-inserting at the // target scope (see executor.ts `secretsSet`), so an overrider writing // at their user-org scope wipes the org-level default rather than // shadowing it. If the product wants both rows to coexist, that's an // SDK-level change — coverage for it belongs after the fix. import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { ScopeId, SecretId } from "@executor/sdk"; import { asUser, testUserOrgScopeId, } from "./services/__test-harness__/api-harness"; const uniq = () => crypto.randomUUID().slice(0, 8); const nextOrgId = () => `org_iso_${uniq()}`; const nextUserId = () => `user_iso_${uniq()}`; describe("cloud secret isolation (HTTP, user-org scope stack)", () => { it.effect( "users in different orgs cannot read each other's org-scoped secrets", () => Effect.gen(function* () { const orgA = nextOrgId(); const orgB = nextOrgId(); const alice = nextUserId(); const charlie = nextUserId(); const id = `sec_${uniq()}`; yield* asUser(alice, orgA, (client) => client.secrets.set({ path: { scopeId: ScopeId.make(orgA) }, payload: { id: SecretId.make(id), name: "Shared", value: "alice-org-secret", }, }), ); const charlieStatus = yield* asUser(charlie, orgB, (client) => client.secrets.status({ path: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(id) }, }), ); expect(charlieStatus.status).toBe("missing"); const charlieList = yield* asUser(charlie, orgB, (client) => client.secrets.list({ path: { scopeId: ScopeId.make(orgB) } }), ); expect(charlieList.map((s) => s.id)).not.toContain(id); const charlieResolve = yield* asUser(charlie, orgB, (client) => client.secrets .resolve({ path: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(id) }, }) .pipe(Effect.either), ); expect(charlieResolve._tag).toBe("Left"); }), ); it.effect( "users in same org cannot read each other's user-scoped secrets", () => Effect.gen(function* () { const orgId = nextOrgId(); const aliceId = nextUserId(); const bobId = nextUserId(); const id = `sec_${uniq()}`; // Alice writes at her per-user scope — where OAuth tokens land. yield* asUser(aliceId, orgId, (client) => client.secrets.set({ path: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)) }, payload: { id: SecretId.make(id), name: "Alice's token", value: "alice-token-value", }, }), ); // Bob is in the same org — his user-org scope differs. He should // see neither the token in a list nor be able to resolve it. const bobList = yield* asUser(bobId, orgId, (client) => client.secrets.list({ path: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)) }, }), ); expect(bobList.map((s) => s.id)).not.toContain(id); const bobResolve = yield* asUser(bobId, orgId, (client) => client.secrets .resolve({ path: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)), secretId: SecretId.make(id), }, }) .pipe(Effect.either), ); expect(bobResolve._tag).toBe("Left"); // And Alice still sees her own token. const aliceResolve = yield* asUser(aliceId, orgId, (client) => client.secrets.resolve({ path: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)), secretId: SecretId.make(id), }, }), ); expect(aliceResolve.value).toBe("alice-token-value"); }), ); it.effect( "org-scoped secrets are visible to every user in that org", () => Effect.gen(function* () { const orgId = nextOrgId(); const adminId = nextUserId(); const memberId = nextUserId(); const id = `sec_${uniq()}`; yield* asUser(adminId, orgId, (client) => client.secrets.set({ path: { scopeId: ScopeId.make(orgId) }, payload: { id: SecretId.make(id), name: "Org API Key", value: "shared-org-key", }, }), ); const adminValue = yield* asUser(adminId, orgId, (client) => client.secrets.resolve({ path: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) }, }), ); const memberValue = yield* asUser(memberId, orgId, (client) => client.secrets.resolve({ path: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) }, }), ); expect(adminValue.value).toBe("shared-org-key"); expect(memberValue.value).toBe("shared-org-key"); }), ); it.effect( "same userId in different orgs gets distinct per-user scopes", () => Effect.gen(function* () { const userId = nextUserId(); const orgA = nextOrgId(); const orgB = nextOrgId(); const id = `sec_${uniq()}`; yield* asUser(userId, orgA, (client) => client.secrets.set({ path: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)) }, payload: { id: SecretId.make(id), name: "A token", value: "value-in-a", }, }), ); // Same user id, different org → distinct user-org scope. The // secret written in org A must not be visible when the same user // logs into org B. const listInB = yield* asUser(userId, orgB, (client) => client.secrets.list({ path: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)) }, }), ); expect(listInB.map((s) => s.id)).not.toContain(id); const resolveInB = yield* asUser(userId, orgB, (client) => client.secrets .resolve({ path: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)), secretId: SecretId.make(id), }, }) .pipe(Effect.either), ); expect(resolveInB._tag).toBe("Left"); // Sanity: the original write is still readable under the org-A // user-org scope. const resolveInA = yield* asUser(userId, orgA, (client) => client.secrets.resolve({ path: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)), secretId: SecretId.make(id), }, }), ); expect(resolveInA.value).toBe("value-in-a"); }), ); it.effect("secrets.set rejects a scope outside the executor's stack", () => Effect.gen(function* () { const orgId = nextOrgId(); const userId = nextUserId(); const foreignOrg = nextOrgId(); const result = yield* asUser(userId, orgId, (client) => client.secrets .set({ path: { scopeId: ScopeId.make(foreignOrg) }, payload: { id: SecretId.make("wrong-scope"), name: "x", value: "should not land", }, }) .pipe(Effect.either), ); expect(result._tag).toBe("Left"); // And nothing landed in the foreign org — a fresh session pointed // at that org must not see `wrong-scope`. const foreignUser = nextUserId(); const leaked = yield* asUser(foreignUser, foreignOrg, (client) => client.secrets .resolve({ path: { scopeId: ScopeId.make(foreignOrg), secretId: SecretId.make("wrong-scope"), }, }) .pipe(Effect.either), ); expect(leaked._tag).toBe("Left"); }), ); });