@opengeni/api-router 3.1.3-canary.38890816741001 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +1 -1
- package/dist/{chunk-OT56OY4V.js → chunk-PQBHZ2YH.js} +1090 -68
- package/dist/{chunk-OT56OY4V.js.map → chunk-PQBHZ2YH.js.map} +1 -1
- package/dist/index.js +49 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/external-vault-providers.d.ts +29 -0
- package/dist/interaction-secrets-vault.d.ts +2 -2
- package/dist/mcp/action-descriptions.d.ts +1 -1
- package/dist/routes/vault-providers.d.ts +4 -2
- package/dist/shutdown.d.ts +14 -0
- package/dist/workspace-tool-gateway-secrets.d.ts +1 -1
- package/package.json +25 -22
- package/src/app.ts +14 -2
- package/src/index.ts +13 -1
- package/src/integrations/external-vault-providers.ts +136 -0
- package/src/interaction-secrets-vault.ts +6 -2
- package/src/mcp/action-catalog.gen.ts +253 -4
- package/src/mcp/action-descriptions.ts +32 -2
- package/src/mcp/action-schema-imports.gen.ts +3 -1
- package/src/routes/connections.ts +17 -0
- package/src/routes/vault-providers.ts +788 -29
- package/src/routes/vaults.ts +5 -1
- package/src/shutdown.ts +54 -0
- package/src/workspace-tool-gateway-secrets.ts +9 -2
- package/src/workspace-tool-gateway.ts +6 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Context, Hono } from "hono";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { createNativeExternalVaultFactories } from "../integrations/external-vault-providers";
|
|
3
5
|
import {
|
|
4
6
|
CreateManagedProviderVaultRequest,
|
|
5
7
|
DisconnectOnePasswordResponse,
|
|
@@ -10,17 +12,28 @@ import {
|
|
|
10
12
|
OnePasswordConnectionSummary,
|
|
11
13
|
ProviderVaultMeta,
|
|
12
14
|
ReplaceOnePasswordTokenRequest,
|
|
15
|
+
InstallVaultProviderRequest,
|
|
16
|
+
InstallVaultProviderResponse,
|
|
17
|
+
ListVaultProviderConnectionsResponse,
|
|
18
|
+
ReplaceVaultProviderCredentialsRequest,
|
|
19
|
+
DisconnectVaultProviderResponse,
|
|
13
20
|
VAULT_ERROR_HTTP_STATUS,
|
|
14
21
|
type VaultApiError,
|
|
15
22
|
type VaultSummary,
|
|
23
|
+
type ExternalVaultProviderKind,
|
|
24
|
+
type VaultProviderCredentials,
|
|
25
|
+
type VaultProviderConnectionSummary,
|
|
26
|
+
type ConnectionMetadata,
|
|
16
27
|
} from "@opengeni/contracts";
|
|
17
28
|
import {
|
|
18
29
|
ONEPASSWORD_CREDENTIAL_ROLE,
|
|
19
30
|
ONEPASSWORD_PROVIDER_DOMAIN,
|
|
20
31
|
createWorkspaceVaultProviderRegistry,
|
|
32
|
+
createRemoteExternalVaultFactories,
|
|
21
33
|
externalActorContinuationForAuthorization,
|
|
22
34
|
hasPermission,
|
|
23
35
|
isVaultError,
|
|
36
|
+
VaultError,
|
|
24
37
|
providerSecretName,
|
|
25
38
|
requirePermission,
|
|
26
39
|
syncLinkedProviderVault,
|
|
@@ -37,6 +50,16 @@ import {
|
|
|
37
50
|
type ApiRouteDeps,
|
|
38
51
|
type OnePasswordConnectionMetadata,
|
|
39
52
|
type OnePasswordVaultProviderFactory,
|
|
53
|
+
externalVaultConnectionDetails,
|
|
54
|
+
externalVaultConnectionMetadata,
|
|
55
|
+
externalVaultProviderDomain,
|
|
56
|
+
externalVaultProviderForConnection,
|
|
57
|
+
isExternalVaultConnection,
|
|
58
|
+
StoredVaultProviderCredentials,
|
|
59
|
+
type ExternalVaultConnectionDetails,
|
|
60
|
+
type ExternalVaultProviderFactories,
|
|
61
|
+
loadExternalVaultConnection,
|
|
62
|
+
pendingVaultCredentialBundle,
|
|
40
63
|
} from "@opengeni/core";
|
|
41
64
|
import {
|
|
42
65
|
createConnection,
|
|
@@ -45,6 +68,7 @@ import {
|
|
|
45
68
|
getConnectionMetadata,
|
|
46
69
|
getVault,
|
|
47
70
|
linkProviderVault,
|
|
71
|
+
preflightProviderVaultLink,
|
|
48
72
|
listConnectionsMetadata,
|
|
49
73
|
listVaultsForSubject,
|
|
50
74
|
revokeConnection,
|
|
@@ -59,6 +83,10 @@ import {
|
|
|
59
83
|
type VaultProvider,
|
|
60
84
|
type VaultProviderContext,
|
|
61
85
|
type VaultProviderRegistry,
|
|
86
|
+
type VaultProviderAuthority,
|
|
87
|
+
createProviderCallBudget,
|
|
88
|
+
listProviderVaults,
|
|
89
|
+
providerOperations,
|
|
62
90
|
} from "@opengeni/vaults";
|
|
63
91
|
import { isPersonalConnectionOwnerPrincipal } from "../connection-ownership";
|
|
64
92
|
import { parseRequestJson } from "../http/request-body";
|
|
@@ -132,7 +160,14 @@ export async function onePasswordProviderForConnection(
|
|
|
132
160
|
*/
|
|
133
161
|
export function vaultProviderRegistryFor(
|
|
134
162
|
deps: ApiRouteDeps,
|
|
135
|
-
input: {
|
|
163
|
+
input: {
|
|
164
|
+
workspaceId: string;
|
|
165
|
+
subjectId: string | null;
|
|
166
|
+
executionContext?: Pick<
|
|
167
|
+
VaultProviderAuthority,
|
|
168
|
+
"sessionId" | "turnId" | "attemptId" | "siteId"
|
|
169
|
+
>;
|
|
170
|
+
},
|
|
136
171
|
): VaultProviderRegistry {
|
|
137
172
|
return createWorkspaceVaultProviderRegistry({
|
|
138
173
|
db: deps.db,
|
|
@@ -142,9 +177,62 @@ export function vaultProviderRegistryFor(
|
|
|
142
177
|
hostVaultProvider: deps.connectionCredentials?.vaultProvider ?? null,
|
|
143
178
|
onePasswordVaultProvider:
|
|
144
179
|
deps.onePasswordVaultProvider === null ? null : onePasswordFactoryFor(deps),
|
|
180
|
+
externalVaultProviders: externalVaultFactoriesFor(deps),
|
|
181
|
+
...(input.executionContext ? { executionContext: input.executionContext } : {}),
|
|
145
182
|
});
|
|
146
183
|
}
|
|
147
184
|
|
|
185
|
+
const defaultExternalVaultFactories = new WeakMap<ApiRouteDeps, ExternalVaultProviderFactories>();
|
|
186
|
+
|
|
187
|
+
export function externalVaultFactoriesFor(deps: ApiRouteDeps): ExternalVaultProviderFactories {
|
|
188
|
+
if (deps.externalVaultProviders !== undefined) return deps.externalVaultProviders ?? {};
|
|
189
|
+
let factories = defaultExternalVaultFactories.get(deps);
|
|
190
|
+
if (!factories) {
|
|
191
|
+
factories = {
|
|
192
|
+
...createRemoteExternalVaultFactories(deps.settings),
|
|
193
|
+
...createNativeExternalVaultFactories(deps.settings),
|
|
194
|
+
};
|
|
195
|
+
defaultExternalVaultFactories.set(deps, factories);
|
|
196
|
+
}
|
|
197
|
+
return factories;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Both native 1Password and the generic connection lifecycle use this catalog. */
|
|
201
|
+
async function providerForConnection(
|
|
202
|
+
deps: ApiRouteDeps,
|
|
203
|
+
input: { workspaceId: string; connectionId: string; subjectId: string | null },
|
|
204
|
+
): Promise<{ provider: VaultProvider; source: "onepassword" | ExternalVaultProviderKind }> {
|
|
205
|
+
const connection = await getConnectionMetadata(
|
|
206
|
+
deps.db,
|
|
207
|
+
input.workspaceId,
|
|
208
|
+
input.connectionId,
|
|
209
|
+
input.subjectId,
|
|
210
|
+
);
|
|
211
|
+
if (connection && isOnePasswordConnection(connection)) {
|
|
212
|
+
return { ...(await onePasswordProviderForConnection(deps, input)), source: "onepassword" };
|
|
213
|
+
}
|
|
214
|
+
if (!connection || !isExternalVaultConnection(connection) || connection.status === "revoked") {
|
|
215
|
+
throw new HTTPException(404, { message: "Vault connection not found" });
|
|
216
|
+
}
|
|
217
|
+
const details = externalVaultConnectionDetails(connection.metadata)!;
|
|
218
|
+
const factory = externalVaultFactoriesFor(deps)[details.provider];
|
|
219
|
+
if (!factory)
|
|
220
|
+
throw new VaultProviderError(
|
|
221
|
+
"provider_unavailable",
|
|
222
|
+
"This vault provider is not available in this deployment.",
|
|
223
|
+
);
|
|
224
|
+
return {
|
|
225
|
+
source: details.provider,
|
|
226
|
+
provider: await externalVaultProviderForConnection({
|
|
227
|
+
...input,
|
|
228
|
+
db: deps.db,
|
|
229
|
+
settings: deps.settings,
|
|
230
|
+
provider: details.provider,
|
|
231
|
+
factory,
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
148
236
|
function vaultContextFor(
|
|
149
237
|
authorization: AccessGrantAuthorization,
|
|
150
238
|
workspaceId: string,
|
|
@@ -286,7 +374,7 @@ export function registerVaultProviderRoutes(
|
|
|
286
374
|
rows
|
|
287
375
|
.filter(
|
|
288
376
|
(row) =>
|
|
289
|
-
row.source
|
|
377
|
+
row.source !== "opengeni" &&
|
|
290
378
|
row.providerConnectionId === connectionId &&
|
|
291
379
|
row.providerVaultRef,
|
|
292
380
|
)
|
|
@@ -332,7 +420,7 @@ export function registerVaultProviderRoutes(
|
|
|
332
420
|
planTier: payload.planTier,
|
|
333
421
|
allowFakeTokens: settings.onePasswordFakeTokens,
|
|
334
422
|
});
|
|
335
|
-
vaults = await provider
|
|
423
|
+
vaults = await listProviderVaults(provider, routeProviderContext(null));
|
|
336
424
|
} catch (error) {
|
|
337
425
|
return providerErrorResponse(c, error);
|
|
338
426
|
}
|
|
@@ -398,13 +486,13 @@ export function registerVaultProviderRoutes(
|
|
|
398
486
|
);
|
|
399
487
|
const { grant } = authorization;
|
|
400
488
|
try {
|
|
401
|
-
const { provider } = await
|
|
489
|
+
const { provider } = await providerForConnection(deps, {
|
|
402
490
|
workspaceId,
|
|
403
491
|
connectionId,
|
|
404
492
|
subjectId: grant.subjectId,
|
|
405
493
|
});
|
|
406
494
|
const [vaults, linked] = await Promise.all([
|
|
407
|
-
provider
|
|
495
|
+
listProviderVaults(provider, routeProviderContext(connectionId)),
|
|
408
496
|
linkedVaults({
|
|
409
497
|
workspaceId,
|
|
410
498
|
connectionId,
|
|
@@ -418,6 +506,8 @@ export function registerVaultProviderRoutes(
|
|
|
418
506
|
name: vault.name,
|
|
419
507
|
itemCount: vault.itemCount,
|
|
420
508
|
linkedVaultId: linked.get(vault.externalVaultId) ?? null,
|
|
509
|
+
writeAccess: vault.writeAccess ?? "unknown",
|
|
510
|
+
operations: providerOperations(provider.capabilities(), vault),
|
|
421
511
|
}),
|
|
422
512
|
),
|
|
423
513
|
});
|
|
@@ -441,15 +531,20 @@ export function registerVaultProviderRoutes(
|
|
|
441
531
|
const context = vaultContextFor(authorization, workspaceId);
|
|
442
532
|
try {
|
|
443
533
|
let provider: VaultProvider;
|
|
444
|
-
if (payload.provider
|
|
534
|
+
if (payload.provider !== "host") {
|
|
445
535
|
if (!payload.connectionId) {
|
|
446
|
-
throw new HTTPException(422, {
|
|
536
|
+
throw new HTTPException(422, {
|
|
537
|
+
message: "connectionId is required for a connected provider",
|
|
538
|
+
});
|
|
447
539
|
}
|
|
448
|
-
|
|
540
|
+
const resolved = await providerForConnection(deps, {
|
|
449
541
|
workspaceId,
|
|
450
542
|
connectionId: payload.connectionId,
|
|
451
543
|
subjectId: authorization.grant.subjectId,
|
|
452
|
-
})
|
|
544
|
+
});
|
|
545
|
+
if (resolved.source !== payload.provider)
|
|
546
|
+
throw new HTTPException(422, { message: "Connection provider does not match" });
|
|
547
|
+
provider = resolved.provider;
|
|
453
548
|
} else {
|
|
454
549
|
const host = deps.connectionCredentials?.vaultProvider;
|
|
455
550
|
if (!host) {
|
|
@@ -460,14 +555,14 @@ export function registerVaultProviderRoutes(
|
|
|
460
555
|
}
|
|
461
556
|
provider = host;
|
|
462
557
|
}
|
|
463
|
-
const connectionId = payload.provider
|
|
558
|
+
const connectionId = payload.provider !== "host" ? payload.connectionId! : null;
|
|
464
559
|
const providerVault = findProviderVault(
|
|
465
|
-
await provider
|
|
560
|
+
await listProviderVaults(provider, routeProviderContext(connectionId)),
|
|
466
561
|
payload.externalVaultId,
|
|
467
562
|
);
|
|
468
563
|
const row = await linkProviderVault(db, context, {
|
|
469
564
|
ownerKind: payload.ownerKind,
|
|
470
|
-
name: payload.name ?? providerSecretName(providerVault.name, "
|
|
565
|
+
name: payload.name ?? providerSecretName(providerVault.name, "Connected vault"),
|
|
471
566
|
source: payload.provider,
|
|
472
567
|
providerConnectionId: connectionId,
|
|
473
568
|
providerVaultRef: providerVault.externalVaultId,
|
|
@@ -509,6 +604,7 @@ export function registerVaultProviderRoutes(
|
|
|
509
604
|
workspaceId,
|
|
510
605
|
subjectId: authorization.grant.subjectId,
|
|
511
606
|
}),
|
|
607
|
+
force: true,
|
|
512
608
|
});
|
|
513
609
|
const rateLimitedUntil =
|
|
514
610
|
result.status === "skipped_rate_limited" ||
|
|
@@ -541,7 +637,40 @@ export function registerVaultProviderRoutes(
|
|
|
541
637
|
throw new HTTPException(403, { message: "missing permission: account:admin" });
|
|
542
638
|
}
|
|
543
639
|
const context = vaultContextFor(authorization, workspaceId);
|
|
640
|
+
let created: ProviderVault | undefined;
|
|
641
|
+
let linked: VaultRow | undefined;
|
|
642
|
+
let sharingFailure: VaultProviderError | undefined;
|
|
544
643
|
try {
|
|
644
|
+
// Match link_provider_vault's ownership gate before making a remote
|
|
645
|
+
// vault. A refused local link must not leave a newly created vault behind.
|
|
646
|
+
const { connection, metadata } = await requireOnePasswordConnection(
|
|
647
|
+
deps,
|
|
648
|
+
authorization,
|
|
649
|
+
workspaceId,
|
|
650
|
+
connectionId,
|
|
651
|
+
);
|
|
652
|
+
const ownership = connection.subjectId !== null ? "user" : metadata.ownership;
|
|
653
|
+
if (
|
|
654
|
+
ownership !== payload.ownerKind ||
|
|
655
|
+
(ownership === "user" && connection.subjectId !== authorization.grant.subjectId)
|
|
656
|
+
) {
|
|
657
|
+
throw new VaultError(
|
|
658
|
+
"vault_permission_denied",
|
|
659
|
+
`A ${ownership} 1Password connection backs only ${ownership} vaults`,
|
|
660
|
+
{ connectionId, connectionOwnership: ownership, ownerKind: payload.ownerKind },
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const link = {
|
|
664
|
+
ownerKind: payload.ownerKind,
|
|
665
|
+
name: payload.name,
|
|
666
|
+
source: "onepassword" as const,
|
|
667
|
+
providerConnectionId: connectionId,
|
|
668
|
+
mode: "read_write" as const,
|
|
669
|
+
allowOrganization: isAccountAdmin(authorization),
|
|
670
|
+
};
|
|
671
|
+
// Exact SQL policy, including names, quotas and personal authority; all
|
|
672
|
+
// effects roll back. This is not a reservation over the provider call.
|
|
673
|
+
await preflightProviderVaultLink(db, context, link);
|
|
545
674
|
const { provider } = await onePasswordProviderForConnection(deps, {
|
|
546
675
|
workspaceId,
|
|
547
676
|
connectionId,
|
|
@@ -553,33 +682,70 @@ export function registerVaultProviderRoutes(
|
|
|
553
682
|
"this 1Password connection cannot create vaults",
|
|
554
683
|
);
|
|
555
684
|
}
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
685
|
+
const { OnePasswordVaultCreatedError } = await loadOnePasswordModule();
|
|
686
|
+
try {
|
|
687
|
+
created = await provider.createVault(routeProviderContext(connectionId), {
|
|
688
|
+
name: payload.name,
|
|
689
|
+
description: "Managed by Opengeni",
|
|
690
|
+
allowAdminsAccess: true,
|
|
691
|
+
...(payload.groupId ? { groupId: payload.groupId } : {}),
|
|
692
|
+
});
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (error instanceof OnePasswordVaultCreatedError) {
|
|
695
|
+
created = error.createdVault;
|
|
696
|
+
sharingFailure = error;
|
|
697
|
+
} else if (isVaultProviderError(error)) {
|
|
698
|
+
throw error;
|
|
699
|
+
} else {
|
|
700
|
+
throw new VaultProviderError(
|
|
701
|
+
"provider_outcome_unknown",
|
|
702
|
+
"1Password did not confirm vault creation; it may have been applied. Check the connection's vault list before creating again.",
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
linked = await linkProviderVault(db, context, {
|
|
707
|
+
...link,
|
|
567
708
|
providerVaultRef: created.externalVaultId,
|
|
568
|
-
mode: "read_write",
|
|
569
|
-
allowOrganization: isAccountAdmin(authorization),
|
|
570
709
|
});
|
|
571
710
|
await syncLinkedProviderVault({
|
|
572
711
|
db,
|
|
573
712
|
context,
|
|
574
|
-
vaultId:
|
|
713
|
+
vaultId: linked.id,
|
|
575
714
|
providers: vaultProviderRegistryFor(deps, {
|
|
576
715
|
workspaceId,
|
|
577
716
|
subjectId: authorization.grant.subjectId,
|
|
578
717
|
}),
|
|
579
718
|
force: true,
|
|
580
719
|
});
|
|
581
|
-
|
|
720
|
+
if (sharingFailure) throw sharingFailure;
|
|
721
|
+
return c.json(await vaultSummaryFor(deps, authorization, workspaceId, linked.id), 201);
|
|
582
722
|
} catch (error) {
|
|
723
|
+
if (created) {
|
|
724
|
+
const local = isVaultError(error) ? error : vaultErrorFromDatabase(error);
|
|
725
|
+
const code =
|
|
726
|
+
local?.code ??
|
|
727
|
+
(isVaultProviderError(error)
|
|
728
|
+
? vaultErrorCodeForProvider(error.code, "write")
|
|
729
|
+
: "provider_unavailable");
|
|
730
|
+
const message = linked
|
|
731
|
+
? sharingFailure
|
|
732
|
+
? "The vault was created and linked, but its group access could not be confirmed. Check sharing in 1Password. Do not create it again."
|
|
733
|
+
: "The vault was created and linked, but its refresh could not be confirmed. Refresh the linked vault. Do not create it again."
|
|
734
|
+
: "The vault was created in 1Password, but its local link could not be confirmed. Use Link on the existing provider vault after resolving the local refusal. Do not create it again.";
|
|
735
|
+
return vaultRouteError(
|
|
736
|
+
c,
|
|
737
|
+
new VaultError(code, message, {
|
|
738
|
+
created: true,
|
|
739
|
+
// A lost DB acknowledgement is not proof that the link rolled back.
|
|
740
|
+
linked: linked ? true : local ? false : null,
|
|
741
|
+
connectionId,
|
|
742
|
+
externalVaultId: created.externalVaultId,
|
|
743
|
+
...(linked ? { vaultId: linked.id } : {}),
|
|
744
|
+
...(sharingFailure ? { groupGrantConfirmed: false } : {}),
|
|
745
|
+
}),
|
|
746
|
+
true,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
583
749
|
return vaultRouteError(c, error, true);
|
|
584
750
|
}
|
|
585
751
|
});
|
|
@@ -653,7 +819,7 @@ export function registerVaultProviderRoutes(
|
|
|
653
819
|
planTier,
|
|
654
820
|
allowFakeTokens: settings.onePasswordFakeTokens,
|
|
655
821
|
});
|
|
656
|
-
vaults = await provider
|
|
822
|
+
vaults = await listProviderVaults(provider, routeProviderContext(connectionId));
|
|
657
823
|
} catch (error) {
|
|
658
824
|
return providerErrorResponse(c, error);
|
|
659
825
|
}
|
|
@@ -766,6 +932,599 @@ export function registerVaultProviderRoutes(
|
|
|
766
932
|
return c.json(DisconnectOnePasswordResponse.parse({ unlinkedVaultIds }));
|
|
767
933
|
});
|
|
768
934
|
|
|
935
|
+
/** Metadata-only unified catalog; listing never decrypts a provider token. */
|
|
936
|
+
app.get("/v1/workspaces/:workspaceId/vault-providers/connections", async (c) => {
|
|
937
|
+
const workspaceId = c.req.param("workspaceId");
|
|
938
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
939
|
+
c,
|
|
940
|
+
deps,
|
|
941
|
+
workspaceId,
|
|
942
|
+
"secrets:list",
|
|
943
|
+
);
|
|
944
|
+
const factories = externalVaultFactoriesFor(deps);
|
|
945
|
+
const rows = await listConnectionsMetadata(db, workspaceId, authorization.grant.subjectId);
|
|
946
|
+
const connections = rows
|
|
947
|
+
.filter((row) => row.status !== "revoked")
|
|
948
|
+
.flatMap<VaultProviderConnectionSummary>((row) => {
|
|
949
|
+
if (isOnePasswordConnection(row)) {
|
|
950
|
+
return [
|
|
951
|
+
{
|
|
952
|
+
...onePasswordConnectionSummary(authorization, row),
|
|
953
|
+
provider: "onepassword" as const,
|
|
954
|
+
// The published legacy 1Password port has no granular override.
|
|
955
|
+
operations: providerOperations({
|
|
956
|
+
write: true,
|
|
957
|
+
createVault: true,
|
|
958
|
+
totp: true,
|
|
959
|
+
files: false,
|
|
960
|
+
groupGrants: true,
|
|
961
|
+
passkeys: false,
|
|
962
|
+
}),
|
|
963
|
+
available: deps.onePasswordVaultProvider !== null,
|
|
964
|
+
},
|
|
965
|
+
];
|
|
966
|
+
}
|
|
967
|
+
if (!isExternalVaultConnection(row)) return [];
|
|
968
|
+
const details = externalVaultConnectionDetails(row.metadata)!;
|
|
969
|
+
return [
|
|
970
|
+
{
|
|
971
|
+
id: row.id,
|
|
972
|
+
provider: details.provider,
|
|
973
|
+
ownership: details.ownership,
|
|
974
|
+
accountName: details.accountName,
|
|
975
|
+
label: details.label,
|
|
976
|
+
verifiedAt: details.verifiedAt,
|
|
977
|
+
createdAt: row.createdAt,
|
|
978
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
979
|
+
canManage: canManageExternal(authorization, row, details),
|
|
980
|
+
operations: details.operations,
|
|
981
|
+
available: Boolean(factories[details.provider]),
|
|
982
|
+
verificationStatus: details.verificationStatus ?? "ready",
|
|
983
|
+
},
|
|
984
|
+
];
|
|
985
|
+
});
|
|
986
|
+
const providers = (
|
|
987
|
+
["onepassword", "external", "protonpass", "keeper", "bitwarden"] as const
|
|
988
|
+
).map((provider) => {
|
|
989
|
+
const available =
|
|
990
|
+
provider === "onepassword"
|
|
991
|
+
? deps.onePasswordVaultProvider !== null
|
|
992
|
+
: Boolean(factories[provider]);
|
|
993
|
+
return { provider, available, reason: available ? null : ("not_enabled" as const) };
|
|
994
|
+
});
|
|
995
|
+
return c.json(ListVaultProviderConnectionsResponse.parse({ connections, providers }));
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
app.post("/v1/workspaces/:workspaceId/vault-providers/connections", async (c) => {
|
|
999
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1000
|
+
const authorization = await requireSetupAuthority(c, workspaceId);
|
|
1001
|
+
const { grant } = authorization;
|
|
1002
|
+
const payload = await parseRequestJson(c, InstallVaultProviderRequest);
|
|
1003
|
+
requireOwnership(authorization, payload.ownership);
|
|
1004
|
+
const key = requireEnvironmentEncryption(settings);
|
|
1005
|
+
if (!externalVaultFactoriesFor(deps)[payload.credentials.provider]) {
|
|
1006
|
+
return vaultRouteError(
|
|
1007
|
+
c,
|
|
1008
|
+
new VaultProviderError(
|
|
1009
|
+
"provider_unavailable",
|
|
1010
|
+
"This vault provider is not available in this deployment.",
|
|
1011
|
+
),
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
const details: ExternalVaultConnectionDetails = {
|
|
1015
|
+
version: 1,
|
|
1016
|
+
provider: payload.credentials.provider,
|
|
1017
|
+
ownership: payload.ownership,
|
|
1018
|
+
accountId: null,
|
|
1019
|
+
accountName: null,
|
|
1020
|
+
label: payload.label ?? null,
|
|
1021
|
+
verifiedAt: new Date().toISOString(),
|
|
1022
|
+
verificationStatus: "pending",
|
|
1023
|
+
operations: providerOperations({
|
|
1024
|
+
write: false,
|
|
1025
|
+
createVault: false,
|
|
1026
|
+
totp: false,
|
|
1027
|
+
files: false,
|
|
1028
|
+
groupGrants: false,
|
|
1029
|
+
passkeys: false,
|
|
1030
|
+
}),
|
|
1031
|
+
};
|
|
1032
|
+
const continuation = externalActorContinuationForAuthorization(authorization);
|
|
1033
|
+
// Reserve identity before a remote call or one-time-token binding. No
|
|
1034
|
+
// usable credential (in particular no bootstrap token) is stored here.
|
|
1035
|
+
const connection = await db.transaction(async (tx) => {
|
|
1036
|
+
await requireConnectOwnerAuthority(tx, {
|
|
1037
|
+
accountId: grant.accountId,
|
|
1038
|
+
workspaceId,
|
|
1039
|
+
subjectId: grant.subjectId,
|
|
1040
|
+
personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
|
|
1041
|
+
...(continuation ? { externalContinuation: continuation } : {}),
|
|
1042
|
+
});
|
|
1043
|
+
return createConnection(tx, {
|
|
1044
|
+
accountId: grant.accountId,
|
|
1045
|
+
workspaceId,
|
|
1046
|
+
subjectId: payload.ownership === "user" ? grant.subjectId : null,
|
|
1047
|
+
providerDomain: externalVaultProviderDomain(details.provider),
|
|
1048
|
+
kind: "api_key",
|
|
1049
|
+
status: "needs_reauth",
|
|
1050
|
+
credentialEncrypted: encryptEnvironmentValue(key, "{}"),
|
|
1051
|
+
grantedScopes: [],
|
|
1052
|
+
expiresAt: null,
|
|
1053
|
+
metadata: externalVaultConnectionMetadata(details),
|
|
1054
|
+
createdBySubjectId: grant.subjectId,
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
try {
|
|
1058
|
+
const result = await finishExternalSetup({
|
|
1059
|
+
authorization,
|
|
1060
|
+
workspaceId,
|
|
1061
|
+
connection,
|
|
1062
|
+
credentials: payload.credentials,
|
|
1063
|
+
currentCredentials: null,
|
|
1064
|
+
isNew: true,
|
|
1065
|
+
});
|
|
1066
|
+
return c.json(result.body, result.status);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
return vaultRouteError(c, error);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
app.put(
|
|
1073
|
+
"/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId/credentials",
|
|
1074
|
+
async (c) => {
|
|
1075
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1076
|
+
const connectionId = c.req.param("connectionId");
|
|
1077
|
+
const authorization = await requireSetupAuthority(c, workspaceId);
|
|
1078
|
+
const { grant } = authorization;
|
|
1079
|
+
const connection = await getConnectionMetadata(
|
|
1080
|
+
db,
|
|
1081
|
+
workspaceId,
|
|
1082
|
+
connectionId,
|
|
1083
|
+
grant.subjectId,
|
|
1084
|
+
);
|
|
1085
|
+
if (
|
|
1086
|
+
!connection ||
|
|
1087
|
+
!isExternalVaultConnection(connection) ||
|
|
1088
|
+
connection.status === "revoked"
|
|
1089
|
+
) {
|
|
1090
|
+
throw new HTTPException(404, { message: "Vault connection not found" });
|
|
1091
|
+
}
|
|
1092
|
+
const details = externalVaultConnectionDetails(connection.metadata)!;
|
|
1093
|
+
if (!canManageExternal(authorization, connection, details))
|
|
1094
|
+
throw new HTTPException(403, { message: "You cannot change this vault connection" });
|
|
1095
|
+
requireOwnership(authorization, details.ownership);
|
|
1096
|
+
const payload = await parseRequestJson(c, ReplaceVaultProviderCredentialsRequest);
|
|
1097
|
+
if (payload.credentials.provider !== details.provider)
|
|
1098
|
+
throw new HTTPException(422, { message: "Connection provider does not match" });
|
|
1099
|
+
try {
|
|
1100
|
+
const loaded = await loadExternalVaultConnection(db, settings, {
|
|
1101
|
+
workspaceId,
|
|
1102
|
+
connectionId,
|
|
1103
|
+
subjectId: grant.subjectId,
|
|
1104
|
+
provider: details.provider,
|
|
1105
|
+
allowPendingVerification: true,
|
|
1106
|
+
});
|
|
1107
|
+
const result = await finishExternalSetup({
|
|
1108
|
+
authorization,
|
|
1109
|
+
workspaceId,
|
|
1110
|
+
connection: loaded.connection,
|
|
1111
|
+
credentials: payload.credentials,
|
|
1112
|
+
currentCredentials: loaded.currentCredentials,
|
|
1113
|
+
pendingCredentials: loaded.pendingCredentials,
|
|
1114
|
+
isNew: false,
|
|
1115
|
+
});
|
|
1116
|
+
return c.json(result.body, result.status);
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
return vaultRouteError(c, error);
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
app.post(
|
|
1124
|
+
"/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId/verify",
|
|
1125
|
+
async (c) => {
|
|
1126
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1127
|
+
const connectionId = c.req.param("connectionId");
|
|
1128
|
+
const authorization = await requireSetupAuthority(c, workspaceId);
|
|
1129
|
+
const connection = await getConnectionMetadata(
|
|
1130
|
+
db,
|
|
1131
|
+
workspaceId,
|
|
1132
|
+
connectionId,
|
|
1133
|
+
authorization.grant.subjectId,
|
|
1134
|
+
);
|
|
1135
|
+
if (!connection || !isExternalVaultConnection(connection) || connection.status === "revoked")
|
|
1136
|
+
throw new HTTPException(404, { message: "Vault connection not found" });
|
|
1137
|
+
const details = externalVaultConnectionDetails(connection.metadata)!;
|
|
1138
|
+
if (!canManageExternal(authorization, connection, details))
|
|
1139
|
+
throw new HTTPException(403, { message: "You cannot verify this vault connection" });
|
|
1140
|
+
requireOwnership(authorization, details.ownership);
|
|
1141
|
+
try {
|
|
1142
|
+
const loaded = await loadExternalVaultConnection(db, settings, {
|
|
1143
|
+
workspaceId,
|
|
1144
|
+
connectionId,
|
|
1145
|
+
subjectId: authorization.grant.subjectId,
|
|
1146
|
+
provider: details.provider,
|
|
1147
|
+
allowPendingVerification: true,
|
|
1148
|
+
});
|
|
1149
|
+
const result = await finishExternalSetup({
|
|
1150
|
+
authorization,
|
|
1151
|
+
workspaceId,
|
|
1152
|
+
connection: loaded.connection,
|
|
1153
|
+
credentials: loaded.credentials,
|
|
1154
|
+
currentCredentials: loaded.currentCredentials,
|
|
1155
|
+
pendingCredentials: loaded.pendingCredentials,
|
|
1156
|
+
isNew: false,
|
|
1157
|
+
});
|
|
1158
|
+
return c.json(result.body, result.status);
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
return vaultRouteError(c, error);
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
app.delete("/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId", async (c) => {
|
|
1166
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1167
|
+
const connectionId = c.req.param("connectionId");
|
|
1168
|
+
const authorization = await requireSetupAuthority(c, workspaceId);
|
|
1169
|
+
const { grant } = authorization;
|
|
1170
|
+
const connection = await getConnectionMetadata(db, workspaceId, connectionId, grant.subjectId);
|
|
1171
|
+
if (!connection || connection.status === "revoked")
|
|
1172
|
+
throw new HTTPException(404, { message: "Vault connection not found" });
|
|
1173
|
+
if (isOnePasswordConnection(connection)) {
|
|
1174
|
+
requireManageOnePasswordConnection(
|
|
1175
|
+
authorization,
|
|
1176
|
+
connection,
|
|
1177
|
+
onePasswordConnectionMetadata(connection.metadata)!,
|
|
1178
|
+
);
|
|
1179
|
+
} else {
|
|
1180
|
+
const details = externalVaultConnectionDetails(connection.metadata);
|
|
1181
|
+
if (!details || !isExternalVaultConnection(connection))
|
|
1182
|
+
throw new HTTPException(404, { message: "Vault connection not found" });
|
|
1183
|
+
if (!canManageExternal(authorization, connection, details))
|
|
1184
|
+
throw new HTTPException(403, { message: "You cannot disconnect this vault connection" });
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
// Local unlink and credential revocation commit together; no vendor mutation.
|
|
1188
|
+
const unlinkedVaultIds = await db.transaction(async (tx) => {
|
|
1189
|
+
const context = vaultContextFor(authorization, workspaceId);
|
|
1190
|
+
const rows = await listVaultsForSubject(tx, context, { workspaceId });
|
|
1191
|
+
const unlinked: string[] = [];
|
|
1192
|
+
for (const row of rows.filter(
|
|
1193
|
+
(candidate) => candidate.providerConnectionId === connectionId,
|
|
1194
|
+
)) {
|
|
1195
|
+
await deleteVault(tx, context, {
|
|
1196
|
+
vaultId: row.id,
|
|
1197
|
+
confirmName: row.name,
|
|
1198
|
+
allowOrganization: isAccountAdmin(authorization),
|
|
1199
|
+
});
|
|
1200
|
+
unlinked.push(row.id);
|
|
1201
|
+
}
|
|
1202
|
+
const revoked = await revokeConnection(
|
|
1203
|
+
tx,
|
|
1204
|
+
workspaceId,
|
|
1205
|
+
connectionId,
|
|
1206
|
+
grant.subjectId,
|
|
1207
|
+
connection.version,
|
|
1208
|
+
);
|
|
1209
|
+
if (!revoked)
|
|
1210
|
+
throw new HTTPException(409, {
|
|
1211
|
+
message: "This vault connection changed. Reload it before disconnecting.",
|
|
1212
|
+
});
|
|
1213
|
+
return unlinked;
|
|
1214
|
+
});
|
|
1215
|
+
return c.json(DisconnectVaultProviderResponse.parse({ unlinkedVaultIds }));
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
return vaultRouteError(c, error);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
async function requireSetupAuthority(
|
|
1222
|
+
c: Context,
|
|
1223
|
+
workspaceId: string,
|
|
1224
|
+
): Promise<AccessGrantAuthorization> {
|
|
1225
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
1226
|
+
c,
|
|
1227
|
+
deps,
|
|
1228
|
+
workspaceId,
|
|
1229
|
+
"connections:write",
|
|
1230
|
+
);
|
|
1231
|
+
requirePermission(authorization.grant, "secrets:write");
|
|
1232
|
+
return authorization;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function requireOwnership(
|
|
1236
|
+
authorization: AccessGrantAuthorization,
|
|
1237
|
+
ownership: "user" | "workspace" | "organization",
|
|
1238
|
+
): void {
|
|
1239
|
+
if (ownership === "organization" && !isAccountAdmin(authorization))
|
|
1240
|
+
throw new HTTPException(403, { message: "missing permission: account:admin" });
|
|
1241
|
+
if (ownership === "user" && !isPersonalConnectionOwnerPrincipal(authorization))
|
|
1242
|
+
throw new HTTPException(403, {
|
|
1243
|
+
message: "Only a signed-in person can manage a personal vault connection",
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function canManageExternal(
|
|
1248
|
+
authorization: AccessGrantAuthorization,
|
|
1249
|
+
connection: OnePasswordConnectionRow,
|
|
1250
|
+
details: ExternalVaultConnectionDetails,
|
|
1251
|
+
): boolean {
|
|
1252
|
+
const grant = authorization.grant;
|
|
1253
|
+
return (
|
|
1254
|
+
hasPermission(grant.permissions, "connections:write", grant.permissionMode) &&
|
|
1255
|
+
hasPermission(grant.permissions, "secrets:write", grant.permissionMode) &&
|
|
1256
|
+
(connection.subjectId !== null
|
|
1257
|
+
? connection.subjectId === grant.subjectId &&
|
|
1258
|
+
isPersonalConnectionOwnerPrincipal(authorization)
|
|
1259
|
+
: details.ownership !== "organization" || isAccountAdmin(authorization))
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function catalogVault(
|
|
1264
|
+
provider: VaultProvider,
|
|
1265
|
+
vault: ProviderVault,
|
|
1266
|
+
linkedVaultId: string | null,
|
|
1267
|
+
): ProviderVaultMeta {
|
|
1268
|
+
return {
|
|
1269
|
+
externalVaultId: vault.externalVaultId,
|
|
1270
|
+
name: vault.name,
|
|
1271
|
+
itemCount: vault.itemCount,
|
|
1272
|
+
linkedVaultId,
|
|
1273
|
+
writeAccess: vault.writeAccess ?? "unknown",
|
|
1274
|
+
operations: providerOperations(provider.capabilities(), vault),
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
async function finishExternalSetup(input: {
|
|
1279
|
+
authorization: AccessGrantAuthorization;
|
|
1280
|
+
workspaceId: string;
|
|
1281
|
+
connection: ConnectionMetadata;
|
|
1282
|
+
credentials: VaultProviderCredentials | StoredVaultProviderCredentials;
|
|
1283
|
+
currentCredentials: StoredVaultProviderCredentials | null;
|
|
1284
|
+
pendingCredentials?: StoredVaultProviderCredentials | null;
|
|
1285
|
+
isNew: boolean;
|
|
1286
|
+
}): Promise<{ body: InstallVaultProviderResponse; status: 200 | 201 | 202 }> {
|
|
1287
|
+
const { authorization, workspaceId } = input;
|
|
1288
|
+
const { grant } = authorization;
|
|
1289
|
+
const key = requireEnvironmentEncryption(settings);
|
|
1290
|
+
const details = externalVaultConnectionDetails(input.connection.metadata)!;
|
|
1291
|
+
const context = vaultContextFor(authorization, workspaceId);
|
|
1292
|
+
const linked = await linkedVaultRows(context, workspaceId, input.connection.id);
|
|
1293
|
+
let connection = input.connection;
|
|
1294
|
+
let saved = false;
|
|
1295
|
+
let hasPending = Boolean(input.pendingCredentials);
|
|
1296
|
+
let rejectedAccount: VaultProviderError | undefined;
|
|
1297
|
+
let storedBundle: unknown = input.pendingCredentials
|
|
1298
|
+
? pendingVaultCredentialBundle(input.currentCredentials, input.pendingCredentials)
|
|
1299
|
+
: (input.currentCredentials ?? {});
|
|
1300
|
+
const continuation = externalActorContinuationForAuthorization(authorization);
|
|
1301
|
+
const persist = async (
|
|
1302
|
+
credentials: StoredVaultProviderCredentials,
|
|
1303
|
+
ready?: ExternalVaultConnectionDetails,
|
|
1304
|
+
) => {
|
|
1305
|
+
const unchangedCurrent =
|
|
1306
|
+
input.currentCredentials !== null &&
|
|
1307
|
+
isDeepStrictEqual(credentials, input.currentCredentials);
|
|
1308
|
+
const pending = !ready && !unchangedCurrent;
|
|
1309
|
+
const nextMetadata = ready ?? {
|
|
1310
|
+
...details,
|
|
1311
|
+
verificationStatus: pending ? ("pending" as const) : ("ready" as const),
|
|
1312
|
+
};
|
|
1313
|
+
const nextBundle = pending
|
|
1314
|
+
? pendingVaultCredentialBundle(input.currentCredentials, credentials)
|
|
1315
|
+
: credentials;
|
|
1316
|
+
const nextStatus = pending ? connection.status : ("active" as const);
|
|
1317
|
+
const previousDetails = externalVaultConnectionDetails(connection.metadata);
|
|
1318
|
+
// A read-only verification must not invalidate in-flight provider calls.
|
|
1319
|
+
// Actual credential, status or capability changes still advance the fence.
|
|
1320
|
+
const generationChanged =
|
|
1321
|
+
!isDeepStrictEqual(nextBundle, storedBundle) ||
|
|
1322
|
+
nextStatus !== connection.status ||
|
|
1323
|
+
!isDeepStrictEqual(previousDetails?.operations, nextMetadata.operations);
|
|
1324
|
+
if (!generationChanged && isDeepStrictEqual(previousDetails, nextMetadata)) {
|
|
1325
|
+
saved = true;
|
|
1326
|
+
hasPending = pending;
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const updated = await db.transaction(async (tx) => {
|
|
1330
|
+
await requireConnectOwnerAuthority(tx, {
|
|
1331
|
+
accountId: grant.accountId,
|
|
1332
|
+
workspaceId,
|
|
1333
|
+
subjectId: grant.subjectId,
|
|
1334
|
+
personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
|
|
1335
|
+
...(continuation ? { externalContinuation: continuation } : {}),
|
|
1336
|
+
});
|
|
1337
|
+
return updateConnection(tx, {
|
|
1338
|
+
workspaceId,
|
|
1339
|
+
connectionId: connection.id,
|
|
1340
|
+
visibleToSubjectId: grant.subjectId,
|
|
1341
|
+
expectedVersion: connection.version,
|
|
1342
|
+
status: nextStatus,
|
|
1343
|
+
...(generationChanged
|
|
1344
|
+
? { credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(nextBundle)) }
|
|
1345
|
+
: {}),
|
|
1346
|
+
metadata: { ...connection.metadata, ...externalVaultConnectionMetadata(nextMetadata) },
|
|
1347
|
+
updatedBySubjectId: grant.subjectId,
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
if (!updated)
|
|
1351
|
+
throw new HTTPException(409, {
|
|
1352
|
+
message: "This vault connection changed. Reload it before continuing setup.",
|
|
1353
|
+
});
|
|
1354
|
+
connection = updated;
|
|
1355
|
+
saved = true;
|
|
1356
|
+
hasPending = pending;
|
|
1357
|
+
storedBundle = nextBundle;
|
|
1358
|
+
};
|
|
1359
|
+
try {
|
|
1360
|
+
const checked = await prepareExternal(
|
|
1361
|
+
input.credentials,
|
|
1362
|
+
authorization,
|
|
1363
|
+
workspaceId,
|
|
1364
|
+
connection.id,
|
|
1365
|
+
persist,
|
|
1366
|
+
);
|
|
1367
|
+
const sameAccount =
|
|
1368
|
+
details.accountId !== null
|
|
1369
|
+
? checked.accountId === details.accountId
|
|
1370
|
+
: linked.length === 0 ||
|
|
1371
|
+
linked.every((row) =>
|
|
1372
|
+
checked.vaults.some((vault) => vault.externalVaultId === row.providerVaultRef),
|
|
1373
|
+
);
|
|
1374
|
+
if (!sameAccount) {
|
|
1375
|
+
rejectedAccount = new VaultProviderError(
|
|
1376
|
+
"provider_conflict",
|
|
1377
|
+
"These credentials do not reach the same account and linked vaults. Connect them separately instead.",
|
|
1378
|
+
);
|
|
1379
|
+
throw rejectedAccount;
|
|
1380
|
+
}
|
|
1381
|
+
const ready: ExternalVaultConnectionDetails = {
|
|
1382
|
+
...details,
|
|
1383
|
+
accountId: checked.accountId ?? details.accountId,
|
|
1384
|
+
accountName: checked.accountName,
|
|
1385
|
+
operations: checked.operations,
|
|
1386
|
+
verifiedAt: new Date().toISOString(),
|
|
1387
|
+
verificationStatus: "ready",
|
|
1388
|
+
};
|
|
1389
|
+
await persist(checked.credentials, ready);
|
|
1390
|
+
const providers = vaultProviderRegistryFor(deps, { workspaceId, subjectId: grant.subjectId });
|
|
1391
|
+
for (const row of linked) {
|
|
1392
|
+
await syncLinkedProviderVault({
|
|
1393
|
+
db,
|
|
1394
|
+
context,
|
|
1395
|
+
vaultId: row.id,
|
|
1396
|
+
providers,
|
|
1397
|
+
force: true,
|
|
1398
|
+
}).catch(() => undefined);
|
|
1399
|
+
}
|
|
1400
|
+
const linkedIds = new Map(linked.map((row) => [row.providerVaultRef, row.id]));
|
|
1401
|
+
return {
|
|
1402
|
+
status: input.isNew ? 201 : 200,
|
|
1403
|
+
body: InstallVaultProviderResponse.parse({
|
|
1404
|
+
connectionId: connection.id,
|
|
1405
|
+
provider: details.provider,
|
|
1406
|
+
accountName: ready.accountName,
|
|
1407
|
+
operations: ready.operations,
|
|
1408
|
+
vaults: checked.vaults.map((vault) =>
|
|
1409
|
+
catalogVault(checked.provider, vault, linkedIds.get(vault.externalVaultId) ?? null),
|
|
1410
|
+
),
|
|
1411
|
+
}),
|
|
1412
|
+
};
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
// Only our explicit identity rejection discards the candidate. An
|
|
1415
|
+
// adapter conflict during discovery can happen after a one-time bind;
|
|
1416
|
+
// preserve that bound key so verification can finish without rebinding.
|
|
1417
|
+
if (rejectedAccount && error === rejectedAccount && hasPending) {
|
|
1418
|
+
if (input.currentCredentials) {
|
|
1419
|
+
// Reject the candidate, not the working connection. CAS prevents this
|
|
1420
|
+
// rollback from overwriting a concurrent reconnect or disconnect.
|
|
1421
|
+
await persist(input.currentCredentials, { ...details, verificationStatus: "ready" });
|
|
1422
|
+
} else {
|
|
1423
|
+
await revokeConnection(
|
|
1424
|
+
db,
|
|
1425
|
+
workspaceId,
|
|
1426
|
+
connection.id,
|
|
1427
|
+
grant.subjectId,
|
|
1428
|
+
connection.version,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
throw error;
|
|
1432
|
+
}
|
|
1433
|
+
// A bound key is durable even if later discovery fails. Never tell the
|
|
1434
|
+
// person to repeat a consumed bootstrap token or overwrite working keys.
|
|
1435
|
+
if (saved && hasPending && isVaultProviderError(error)) {
|
|
1436
|
+
return {
|
|
1437
|
+
status: 202,
|
|
1438
|
+
body: InstallVaultProviderResponse.parse({
|
|
1439
|
+
connectionId: connection.id,
|
|
1440
|
+
provider: details.provider,
|
|
1441
|
+
accountName: details.accountName,
|
|
1442
|
+
operations: details.operations,
|
|
1443
|
+
vaults: [],
|
|
1444
|
+
discoveryPending: true,
|
|
1445
|
+
errorCode: error.code,
|
|
1446
|
+
}),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
if (!saved && input.isNew) {
|
|
1450
|
+
await revokeConnection(db, workspaceId, connection.id, grant.subjectId, connection.version);
|
|
1451
|
+
}
|
|
1452
|
+
throw error;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function prepareExternal(
|
|
1457
|
+
credentials: VaultProviderCredentials | StoredVaultProviderCredentials,
|
|
1458
|
+
authorization: AccessGrantAuthorization,
|
|
1459
|
+
workspaceId: string,
|
|
1460
|
+
connectionId: string | null,
|
|
1461
|
+
persistCredentials: (credentials: StoredVaultProviderCredentials) => Promise<void>,
|
|
1462
|
+
) {
|
|
1463
|
+
const factory = externalVaultFactoriesFor(deps)[credentials.provider];
|
|
1464
|
+
if (!factory)
|
|
1465
|
+
throw new VaultProviderError(
|
|
1466
|
+
"provider_unavailable",
|
|
1467
|
+
"This vault provider is not available in this deployment.",
|
|
1468
|
+
);
|
|
1469
|
+
let rotated: StoredVaultProviderCredentials | undefined;
|
|
1470
|
+
const signal = AbortSignal.timeout(30_000);
|
|
1471
|
+
const prepared = await factory({
|
|
1472
|
+
credentials,
|
|
1473
|
+
...(connectionId ? { connectionId } : {}),
|
|
1474
|
+
signal,
|
|
1475
|
+
persistCredentials: async (next) => {
|
|
1476
|
+
const parsed = StoredVaultProviderCredentials.safeParse(next);
|
|
1477
|
+
if (!parsed.success || parsed.data.provider !== credentials.provider)
|
|
1478
|
+
throw new VaultProviderError(
|
|
1479
|
+
"provider_unavailable",
|
|
1480
|
+
"The provider returned invalid connection state.",
|
|
1481
|
+
);
|
|
1482
|
+
rotated = parsed.data;
|
|
1483
|
+
await persistCredentials(parsed.data);
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
if (prepared.provider.id !== credentials.provider)
|
|
1487
|
+
throw new VaultProviderError(
|
|
1488
|
+
"provider_unavailable",
|
|
1489
|
+
"The configured adapter does not match this connection.",
|
|
1490
|
+
);
|
|
1491
|
+
const preparedCredentials = StoredVaultProviderCredentials.safeParse(prepared.credentials);
|
|
1492
|
+
if (!preparedCredentials.success || preparedCredentials.data.provider !== credentials.provider)
|
|
1493
|
+
throw new VaultProviderError(
|
|
1494
|
+
"provider_unavailable",
|
|
1495
|
+
"The provider returned invalid connection state.",
|
|
1496
|
+
);
|
|
1497
|
+
if (!rotated) await persistCredentials(preparedCredentials.data);
|
|
1498
|
+
const ctx: VaultProviderContext = {
|
|
1499
|
+
connectionId,
|
|
1500
|
+
budget: createProviderCallBudget(50),
|
|
1501
|
+
signal,
|
|
1502
|
+
forceRefresh: true,
|
|
1503
|
+
authority: {
|
|
1504
|
+
accountId: authorization.grant.accountId,
|
|
1505
|
+
workspaceId,
|
|
1506
|
+
connectionWorkspaceId: workspaceId,
|
|
1507
|
+
subjectId: authorization.grant.subjectId,
|
|
1508
|
+
},
|
|
1509
|
+
};
|
|
1510
|
+
const validated = await prepared.provider.validate(ctx);
|
|
1511
|
+
const vaults = await listProviderVaults(prepared.provider, ctx);
|
|
1512
|
+
const stored = StoredVaultProviderCredentials.safeParse(rotated ?? prepared.credentials);
|
|
1513
|
+
if (!stored.success || stored.data.provider !== credentials.provider)
|
|
1514
|
+
throw new VaultProviderError(
|
|
1515
|
+
"provider_unavailable",
|
|
1516
|
+
"The provider returned invalid connection state.",
|
|
1517
|
+
);
|
|
1518
|
+
return {
|
|
1519
|
+
provider: prepared.provider,
|
|
1520
|
+
credentials: stored.data,
|
|
1521
|
+
vaults,
|
|
1522
|
+
accountId: validated.accountId ?? null,
|
|
1523
|
+
accountName: validated.accountName,
|
|
1524
|
+
operations: providerOperations(prepared.provider.capabilities()),
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
|
|
769
1528
|
async function linkedVaultRows(
|
|
770
1529
|
context: VaultDbContext,
|
|
771
1530
|
workspaceId: string,
|
|
@@ -774,7 +1533,7 @@ export function registerVaultProviderRoutes(
|
|
|
774
1533
|
const rows = await listVaultsForSubject(db, context, { workspaceId });
|
|
775
1534
|
return rows.filter(
|
|
776
1535
|
(row): row is VaultRow & { providerVaultRef: string } =>
|
|
777
|
-
row.source
|
|
1536
|
+
row.source !== "opengeni" &&
|
|
778
1537
|
row.providerConnectionId === connectionId &&
|
|
779
1538
|
row.providerVaultRef !== null,
|
|
780
1539
|
);
|