@@ -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
257257const 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