Skip to content

Commit 99b9406

Browse files
committed
Scope WorkOS Vault object names by owner
1 parent a2c6a23 commit 99b9406

3 files changed

Lines changed: 221 additions & 46 deletions

File tree

packages/plugins/workos-vault/src/sdk/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const workosVaultPlugin = definePlugin((options?: WorkOSVaultPluginOption
7272
makeWorkOSVaultCredentialProvider({
7373
client,
7474
store: ctx.storage,
75+
owner: ctx.owner,
7576
objectPrefix: options?.objectPrefix,
7677
}),
7778
];

packages/plugins/workos-vault/src/sdk/secret-store.test.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ const makeProvider = (
251251
): ReturnType<typeof makeWorkOSVaultCredentialProvider> => {
252252
const deps = makeFakeStorageDeps(binding);
253253
const store = makeWorkosVaultStore(deps);
254-
return makeWorkOSVaultCredentialProvider({ client, store });
254+
return makeWorkOSVaultCredentialProvider({ client, store, owner: binding });
255255
};
256256

257257
const id = (value: string) => ProviderItemId.make(value);
@@ -392,7 +392,7 @@ const makePartitionedBacking = () => {
392392
};
393393
const rows = new Map<string, Row>();
394394
const partKey = (owner: string, subject: string, collection: string, key: string) =>
395-
`${owner}
395+
`${owner} ${subject} ${collection} ${key}`;
396396
const subjectFor = (owner: Owner, binding: OwnerBinding) =>
397397
owner === "org" ? "" : String(binding.subject ?? "");
398398
const toEntry = (row: Row): PluginStorageEntry => ({
@@ -483,10 +483,12 @@ describe("WorkOS Vault — credential owner partitioning", () => {
483483
const creator = makeWorkOSVaultCredentialProvider({
484484
client,
485485
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-a"))),
486+
owner: userBinding("subject-a"),
486487
});
487488
const other = makeWorkOSVaultCredentialProvider({
488489
client,
489490
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-b"))),
491+
owner: userBinding("subject-b"),
490492
});
491493

492494
// user A pastes the org connection's API key
@@ -502,6 +504,7 @@ describe("WorkOS Vault — credential owner partitioning", () => {
502504
store: makeWorkosVaultStore(
503505
backing.depsFor({ tenant: Tenant.make("tenant-a"), subject: null }),
504506
),
507+
owner: { tenant: Tenant.make("tenant-a"), subject: null },
505508
});
506509
expect(yield* automation.get(id("connection:org:exa_search_api:workspaceexa:token"))).toBe(
507510
"exa_secret",
@@ -516,10 +519,12 @@ describe("WorkOS Vault — credential owner partitioning", () => {
516519
const userA = makeWorkOSVaultCredentialProvider({
517520
client,
518521
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-a"))),
522+
owner: userBinding("subject-a"),
519523
});
520524
const userB = makeWorkOSVaultCredentialProvider({
521525
client,
522526
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-b"))),
527+
owner: userBinding("subject-b"),
523528
});
524529

525530
yield* userA.set!(id("connection:user:notion:personal:token"), "private_secret");
@@ -537,10 +542,12 @@ describe("WorkOS Vault — credential owner partitioning", () => {
537542
const creator = makeWorkOSVaultCredentialProvider({
538543
client,
539544
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-a"))),
545+
owner: userBinding("subject-a"),
540546
});
541547
const other = makeWorkOSVaultCredentialProvider({
542548
client,
543549
store: makeWorkosVaultStore(backing.depsFor(userBinding("subject-b"))),
550+
owner: userBinding("subject-b"),
544551
});
545552

546553
yield* creator.set!(id("oauth:org:slack:workspace"), "access_tok");
@@ -551,3 +558,100 @@ describe("WorkOS Vault — credential owner partitioning", () => {
551558
}),
552559
);
553560
});
561+
562+
// ---------------------------------------------------------------------------
563+
// Object-name partition isolation. A logical item id
564+
// (connection:/oauth:/oauth-client:) carries no tenant and no subject, so two
565+
// parties that pick the same integration + connection name produce a
566+
// byte-identical id. The vault object keyspace is GLOBAL (one WorkOS
567+
// environment), so the object name must encode the partition for two parties'
568+
// objects to stay distinct. These tests use ONE shared fake client (the global
569+
// keyspace) with per-party metadata stores (each party's own DB partition), and
570+
// assert each party reads back its OWN secret — i.e. identical ids in different
571+
// partitions never resolve to the same object.
572+
// ---------------------------------------------------------------------------
573+
574+
const orgBindingFor = (tenant: string): OwnerBinding => ({
575+
tenant: Tenant.make(tenant),
576+
subject: null,
577+
});
578+
579+
describe("WorkOS Vault — object-name partition isolation", () => {
580+
it.effect("the same org item id in two tenants does not share a vault object", () =>
581+
Effect.gen(function* () {
582+
const client = makeFakeClient(); // one global vault keyspace
583+
const tenantOne = makeProvider(client, orgBindingFor("org-1"));
584+
const tenantTwo = makeProvider(client, orgBindingFor("org-2"));
585+
586+
// Both orgs connect the same integration under the same default name, so
587+
// the logical item id is identical across tenants.
588+
const sharedId = id("connection:org:websets:workspacewebsets:token");
589+
yield* tenantOne.set!(sharedId, "org-1-key");
590+
yield* tenantTwo.set!(sharedId, "org-2-key");
591+
592+
expect(yield* tenantOne.get(sharedId)).toBe("org-1-key");
593+
expect(yield* tenantTwo.get(sharedId)).toBe("org-2-key");
594+
}),
595+
);
596+
597+
it.effect("the same personal item id for two users does not share a vault object", () =>
598+
Effect.gen(function* () {
599+
const client = makeFakeClient();
600+
const userA = makeProvider(client, userBinding("subject-a"));
601+
const userB = makeProvider(client, userBinding("subject-b"));
602+
603+
const sharedId = id("connection:user:dealcloud:personaldealcloud:token");
604+
yield* userA.set!(sharedId, "a-token");
605+
yield* userB.set!(sharedId, "b-token");
606+
607+
expect(yield* userA.get(sharedId)).toBe("a-token");
608+
expect(yield* userB.get(sharedId)).toBe("b-token");
609+
}),
610+
);
611+
612+
// WorkOS Vault accepts createObject names of ANY length, but an object whose
613+
// name exceeds 200 characters is permanently unreadable — every read 400s,
614+
// by name AND by id (verified against the live vault 2026-06-10: 200 reads
615+
// fine, 201 never does). createObject succeeding says nothing about the name
616+
// being usable. Ids whose scoped name would exceed the limit swap the
617+
// encoded tail for a sha256 digest; the fake mirrors the real API (creates
618+
// always succeed, reads reject names over 200), so an over-long generated
619+
// name fails this roundtrip instead of silently surfacing
620+
// `connection_value_missing` in prod.
621+
it.effect("a long item id roundtrips through a capped (hashed-tail) object name", () =>
622+
Effect.gen(function* () {
623+
const client = makeFakeClient({ rejectReadNamesLongerThan: 200 });
624+
const longId = id(
625+
"oauth:user:microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated:" +
626+
"personalmicrosoftgraphv10sharepointfilesexceloutlookcombinedcurated:refresh",
627+
);
628+
const siblingId = id(
629+
"oauth:user:microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated:" +
630+
"personalmicrosoftgraphv10sharepointfilesexceloutlookcombinedcurated",
631+
);
632+
633+
const userA = makeProvider(client, {
634+
tenant: Tenant.make("org_01KP6XMWC2WGC2960Y63FGP7BM"),
635+
subject: Subject.make("user_01KP6XM1ZPVQVTPJ77F0YV4EAX"),
636+
});
637+
const userB = makeProvider(client, {
638+
tenant: Tenant.make("org_01KP6XMWC2WGC2960Y63FGP7BM"),
639+
subject: Subject.make("user_01KP6XRGC9ZF41ZZF5CXWVCPCC"),
640+
});
641+
642+
yield* userA.set!(longId, "a-refresh-token");
643+
yield* userA.set!(siblingId, "a-access-token");
644+
yield* userB.set!(longId, "b-refresh-token");
645+
646+
// resolves (name under the limit), hashed siblings stay distinct, and
647+
// identical long ids in two partitions stay isolated
648+
expect(yield* userA.get(longId)).toBe("a-refresh-token");
649+
expect(yield* userA.get(siblingId)).toBe("a-access-token");
650+
expect(yield* userB.get(longId)).toBe("b-refresh-token");
651+
652+
// a second write lands on the same capped name (update, not duplicate)
653+
yield* userA.set!(longId, "a-refresh-token-2");
654+
expect(yield* userA.get(longId)).toBe("a-refresh-token-2");
655+
}),
656+
);
657+
});

0 commit comments

Comments
 (0)