Rhys Sullivanfmtc92c57d6 months agoHistory
244 lines · 8.5 KB · TypeScript
RawDownload
// 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 secret metadata.//   2. Users in the same org can't see each other's user-scoped secret//      metadata (per-user OAuth tokens etc. don't leak to co-workers).//   3. Org-scoped secret metadata IS 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, Result } from "effect"; import { ScopeId, SecretId } from "@executor-js/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({          params: { 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({          params: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(id) },        }),      );      expect(charlieStatus.status).toBe("missing");       const charlieList = yield* asUser(charlie, orgB, (client) =>        client.secrets.list({ params: { scopeId: ScopeId.make(orgB) } }),      );      expect(charlieList.map((s) => s.id)).not.toContain(id);    }),  );   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({          params: { 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      // not see the token in a list.      const bobList = yield* asUser(bobId, orgId, (client) =>        client.secrets.list({          params: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)) },        }),      );      expect(bobList.map((s) => s.id)).not.toContain(id);       const bobStatus = yield* asUser(bobId, orgId, (client) =>        client.secrets.status({          params: {            scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)),            secretId: SecretId.make(id),          },        }),      );      expect(bobStatus.status).toBe("missing");       // And Alice still sees her own token metadata.      const aliceStatus = yield* asUser(aliceId, orgId, (client) =>        client.secrets.status({          params: {            scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)),            secretId: SecretId.make(id),          },        }),      );      expect(aliceStatus.status).toBe("resolved");    }),  );   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({          params: { scopeId: ScopeId.make(orgId) },          payload: {            id: SecretId.make(id),            name: "Org API Key",            value: "shared-org-key",          },        }),      );       const adminStatus = yield* asUser(adminId, orgId, (client) =>        client.secrets.status({          params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) },        }),      );      const memberStatus = yield* asUser(memberId, orgId, (client) =>        client.secrets.status({          params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) },        }),      );      expect(adminStatus.status).toBe("resolved");      expect(memberStatus.status).toBe("resolved");    }),  );   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({          params: { 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({          params: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)) },        }),      );      expect(listInB.map((s) => s.id)).not.toContain(id);       const statusInB = yield* asUser(userId, orgB, (client) =>        client.secrets.status({          params: {            scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)),            secretId: SecretId.make(id),          },        }),      );      expect(statusInB.status).toBe("missing");       // Sanity: the original write is still visible under the org-A      // user-org scope.      const statusInA = yield* asUser(userId, orgA, (client) =>        client.secrets.status({          params: {            scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)),            secretId: SecretId.make(id),          },        }),      );      expect(statusInA.status).toBe("resolved");    }),  );   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({            params: { scopeId: ScopeId.make(foreignOrg) },            payload: {              id: SecretId.make("wrong-scope"),              name: "x",              value: "should not land",            },          })          .pipe(Effect.result),      );      expect(Result.isFailure(result)).toBe(true);       // 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.status({          params: {            scopeId: ScopeId.make(foreignOrg),            secretId: SecretId.make("wrong-scope"),          },        }),      );      expect(leaked.status).toBe("missing");    }),  );});