@opengeni/core 3.1.2 → 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.
@@ -0,0 +1,129 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import {
3
+ DestinationPolicyError,
4
+ ResponseBodyLimitError,
5
+ isLocalTestEnvironment,
6
+ pinnedFetch,
7
+ readResponseTextBounded,
8
+ validateHttpUrl,
9
+ type OutboundNetworkSettings,
10
+ } from "@opengeni/network";
11
+ import {
12
+ RemoteVaultTransportError,
13
+ VaultProviderError,
14
+ createRemoteVaultProvider,
15
+ type RemoteVaultTransport,
16
+ } from "@opengeni/vaults";
17
+ import type { ExternalVaultProviderFactories } from "./external-provider-connections";
18
+
19
+ /** Shared by API and worker. No credential-bearing request uses ambient fetch. */
20
+ export function createPinnedRemoteVaultTransport(
21
+ settings: OutboundNetworkSettings,
22
+ fetchImpl: typeof pinnedFetch = pinnedFetch,
23
+ ): RemoteVaultTransport {
24
+ return {
25
+ async post(request) {
26
+ if (request.signal?.aborted) throw new RemoteVaultTransportError("aborted", false);
27
+ const controller = new AbortController();
28
+ let timedOut = false;
29
+ const timer = setTimeout(() => {
30
+ timedOut = true;
31
+ controller.abort();
32
+ }, request.timeoutMs);
33
+ const onAbort = () => controller.abort();
34
+ request.signal?.addEventListener("abort", onAbort, { once: true });
35
+ let rejectAborted: (() => void) | undefined;
36
+ try {
37
+ // The deadline includes DNS, connect, headers and the complete body.
38
+ // Racing DNS does not retry it: pinnedFetch receives the aborted signal
39
+ // and cannot send a later request if lookup completes after the deadline.
40
+ const aborted = new Promise<never>((_resolve, reject) => {
41
+ rejectAborted = () =>
42
+ reject(new RemoteVaultTransportError(timedOut ? "timeout" : "aborted", true));
43
+ controller.signal.addEventListener("abort", rejectAborted, { once: true });
44
+ if (controller.signal.aborted) rejectAborted();
45
+ });
46
+ const exchange = async () => {
47
+ const response = await fetchImpl(
48
+ request.url,
49
+ {
50
+ method: "POST",
51
+ headers: { ...request.headers },
52
+ body: request.body,
53
+ redirect: "manual",
54
+ signal: controller.signal,
55
+ },
56
+ settings,
57
+ { label: "Vault provider", requireHttpsOutsideLocalTest: true },
58
+ );
59
+ if (response.status >= 300 && response.status < 400) {
60
+ await response.body?.cancel().catch(() => undefined);
61
+ throw new RemoteVaultTransportError("redirect", true);
62
+ }
63
+ return {
64
+ status: response.status,
65
+ headers: response.headers,
66
+ body: await readResponseTextBounded(
67
+ response,
68
+ request.maxResponseBytes,
69
+ "Vault provider",
70
+ { signal: controller.signal },
71
+ ),
72
+ };
73
+ };
74
+ return await Promise.race([exchange(), aborted]);
75
+ } catch (error) {
76
+ if (error instanceof RemoteVaultTransportError) throw error;
77
+ if (error instanceof DestinationPolicyError)
78
+ throw new RemoteVaultTransportError("refused", false);
79
+ if (error instanceof ResponseBodyLimitError)
80
+ throw new RemoteVaultTransportError("too_large", true);
81
+ throw new RemoteVaultTransportError(
82
+ timedOut ? "timeout" : controller.signal.aborted ? "aborted" : "network",
83
+ true,
84
+ );
85
+ } finally {
86
+ clearTimeout(timer);
87
+ request.signal?.removeEventListener("abort", onAbort);
88
+ if (rejectAborted) controller.signal.removeEventListener("abort", rejectAborted);
89
+ }
90
+ },
91
+ };
92
+ }
93
+
94
+ export function createRemoteExternalVaultFactories(
95
+ settings: Pick<Settings, "environment" | "integrationsAllowPrivateNetworkTargets">,
96
+ ): ExternalVaultProviderFactories {
97
+ const transport = createPinnedRemoteVaultTransport(settings);
98
+ const factory: NonNullable<ExternalVaultProviderFactories["external"]> = async ({
99
+ credentials,
100
+ operations,
101
+ }) => {
102
+ if (credentials.provider !== "external" && credentials.provider !== "bitwarden")
103
+ throw new VaultProviderError("provider_auth_invalid", "Vault provider does not match.");
104
+ let endpoint: string;
105
+ try {
106
+ endpoint = validateHttpUrl(credentials.endpoint, {
107
+ allowLoopbackHttp: isLocalTestEnvironment(settings.environment),
108
+ label: "Vault provider",
109
+ });
110
+ } catch {
111
+ throw new VaultProviderError(
112
+ "provider_auth_invalid",
113
+ "Use an HTTPS vault endpoint without URL credentials or a fragment.",
114
+ );
115
+ }
116
+ return {
117
+ credentials: { ...credentials, endpoint },
118
+ provider: createRemoteVaultProvider({
119
+ provider: credentials.provider,
120
+ endpoint,
121
+ signingSecret: credentials.signingSecret,
122
+ transport,
123
+ allowInsecureHttp: isLocalTestEnvironment(settings.environment),
124
+ ...(operations ? { operations } : {}),
125
+ }),
126
+ };
127
+ };
128
+ return { external: factory, bitwarden: factory };
129
+ }
@@ -4,6 +4,16 @@
4
4
  * chosen here (archived dropped, lowest tier, names over aliases, otherwise
5
5
  * ambiguous), provider values are fetched, hosted values decrypted, and the
6
6
  * batch is recorded. A call resolves all of its references or none (I2).
7
+ *
8
+ * A reference that finds nothing first refreshes the connected vaults that
9
+ * could change the answer: those that could hold it at the priority tier that
10
+ * decided the lookup or above, even when a hosted item matched without the
11
+ * field. When one of those could not be checked (unreachable, rate limited,
12
+ * needing reconnection or still refreshing), the failure names that source
13
+ * instead of reporting the secret missing, so no missing-secret ask card is
14
+ * made for a secret that may exist (external vaults §9). Hosted and
15
+ * explicitly named healthy vaults are never blocked by unrelated outages, and
16
+ * lower-priority vaults never block a miss a higher tier decided.
7
17
  */
8
18
  import {
9
19
  buildMissingSecretAskCard,
@@ -11,6 +21,7 @@ import {
11
21
  isValidTotpInput,
12
22
  parseTotpInput,
13
23
  referenceKey,
24
+ secretNameKey,
14
25
  SecretReferenceError,
15
26
  VAULT_LIMITS,
16
27
  type ResolvedSecretValue,
@@ -20,6 +31,7 @@ import {
20
31
  type SecretReferenceFailure,
21
32
  type SecretResolver,
22
33
  type SecretUseContext,
34
+ type VaultProviderFreshness,
23
35
  } from "@opengeni/contracts";
24
36
  import {
25
37
  decryptEnvironmentValue,
@@ -29,6 +41,7 @@ import {
29
41
  type SecretReferenceCandidate,
30
42
  type SecretReferenceLookup,
31
43
  type SecretResolutionMeta,
44
+ type VaultRow,
32
45
  } from "@opengeni/db";
33
46
  import {
34
47
  createProviderCallBudget,
@@ -38,7 +51,11 @@ import {
38
51
  type VaultProviderErrorCode,
39
52
  type VaultProviderRegistry,
40
53
  } from "@opengeni/vaults";
41
- import { LINKED_VAULT_MISS_MAX_AGE_MS, refreshStaleLinkedVaults } from "./provider-sync";
54
+ import {
55
+ LINKED_VAULT_MISS_MAX_AGE_MS,
56
+ providerVaultFreshness,
57
+ refreshLinkedVaults,
58
+ } from "./provider-sync";
42
59
  import type { VaultServiceDeps } from "./types";
43
60
 
44
61
  export type SecretResolverOptions = {
@@ -204,6 +221,108 @@ function providerFailureCode(code: VaultProviderErrorCode): SecretReferenceFailu
204
221
  return "provider_unavailable";
205
222
  }
206
223
 
224
+ /**
225
+ * The connected vaults whose mirror decides a reference that found nothing.
226
+ *
227
+ * Where the item could be: the vault a three-segment reference names; for two
228
+ * segments the named vault when the first segment names one (else every
229
+ * connected vault, since it may be `Item/field`); every connected vault for a
230
+ * bare item name; none for an ID.
231
+ *
232
+ * When some item matched but lacks the field (or the one-time password), the
233
+ * lookup keeps the highest-priority tier, so only vaults at that tier or above
234
+ * can change the answer: the connected vaults of matched items there that lack
235
+ * the field, and the connected vaults that could hold the item there but have
236
+ * no match yet. A hosted match never hides a higher-priority connected vault
237
+ * whose provider gained the item or its one-time password, and a vault of a
238
+ * lower-priority tier that is out of date or unavailable never turns a
239
+ * definite miss into an unchecked one.
240
+ */
241
+ export function relevantProviderVaults(
242
+ reference: SecretReference,
243
+ candidates: readonly SecretReferenceCandidate[],
244
+ reachable: readonly VaultRow[],
245
+ ): VaultRow[] {
246
+ const connected = reachable.filter((vault) => vault.source !== "opengeni");
247
+ if (connected.length === 0) return [];
248
+ const where = possibleProviderVaults(reference, reachable, connected);
249
+ const live = candidates.filter((candidate) => !candidate.archived);
250
+ if (live.length === 0) return where;
251
+ const deciding = Math.min(...live.map((candidate) => candidate.tier));
252
+ const lacking = new Set(
253
+ live
254
+ .filter(
255
+ (candidate) =>
256
+ candidate.tier <= deciding &&
257
+ candidate.source !== "opengeni" &&
258
+ (candidate.missing === "field" || candidate.missing === "totp_field"),
259
+ )
260
+ .map((candidate) => candidate.vaultId),
261
+ );
262
+ const matched = new Set(live.map((candidate) => candidate.vaultId));
263
+ const unmatched = new Set(
264
+ where
265
+ .filter((vault) => vault.tier <= deciding && !matched.has(vault.id))
266
+ .map((vault) => vault.id),
267
+ );
268
+ return connected.filter((vault) => lacking.has(vault.id) || unmatched.has(vault.id));
269
+ }
270
+
271
+ /** The connected vaults a reference's path could point into, matched or not. */
272
+ function possibleProviderVaults(
273
+ reference: SecretReference,
274
+ reachable: readonly VaultRow[],
275
+ connected: readonly VaultRow[],
276
+ ): VaultRow[] {
277
+ if (reference.target.form !== "path") return [];
278
+ const segments = reference.target.segments;
279
+ if (segments.length === 1) return [...connected];
280
+ const nameKey = secretNameKey(segments[0]!);
281
+ const named = reachable.filter((vault) => secretNameKey(vault.name) === nameKey);
282
+ if (segments.length === 2 && named.length === 0) return [...connected];
283
+ return named.filter((vault) => vault.source !== "opengeni");
284
+ }
285
+
286
+ /** A miss that cannot be trusted because a relevant connected vault was not checked. */
287
+ function uncheckedSourceFailure(
288
+ reference: SecretReference,
289
+ unchecked: ReadonlyArray<{ vault: VaultRow; freshness: VaultProviderFreshness }>,
290
+ ): SecretReferenceFailure {
291
+ const authExpired = unchecked.filter(({ vault }) => vault.syncStatus === "auth_expired");
292
+ const rateLimited = unchecked.filter(
293
+ ({ freshness }) => freshness.state === "unavailable" && freshness.retryAt !== null,
294
+ );
295
+ const [code, named, why] =
296
+ authExpired.length > 0
297
+ ? (["provider_auth_expired", authExpired, "needs to be reconnected"] as const)
298
+ : rateLimited.length > 0
299
+ ? (["provider_rate_limited", rateLimited, "is rate limited by its provider"] as const)
300
+ : ([
301
+ "provider_unavailable",
302
+ unchecked,
303
+ unchecked.every(({ freshness }) => freshness.state === "refreshing")
304
+ ? "is still refreshing"
305
+ : "could not be checked",
306
+ ] as const);
307
+ const names = named
308
+ .slice(0, 3)
309
+ .map(({ vault }) => vault.name)
310
+ .join(", ");
311
+ const retryAt = rateLimited
312
+ .map(({ freshness }) => freshness.retryAt!)
313
+ .sort()
314
+ .at(0);
315
+ return {
316
+ raw: reference.raw,
317
+ code,
318
+ message:
319
+ `not found in the vaults that could be checked, but ${names} ${why}, so it may be there` +
320
+ (code === "provider_rate_limited" && retryAt
321
+ ? `; try again after ${retryAt}`
322
+ : "; try again later"),
323
+ };
324
+ }
325
+
207
326
  function decodeBase64(value: string): Uint8Array {
208
327
  return new Uint8Array(Buffer.from(value, "base64"));
209
328
  }
@@ -292,38 +411,86 @@ export function createSecretResolver(
292
411
  const { meta, candidates } = await resolveSecretReferenceCandidates(deps.db, context, {
293
412
  refs: references.map((reference, index) => lookupFor(reference, index)),
294
413
  });
295
- const failures: SecretReferenceFailure[] = [];
296
- const chosen: Chosen[] = [];
297
- references.forEach((reference, index) => {
298
- const outcome = chooseSecretCandidate(
299
- reference,
300
- candidates.filter((candidate) => candidate.refIndex === index),
301
- meta,
302
- );
303
- if ("failure" in outcome) failures.push(outcome.failure);
304
- else chosen.push({ reference, candidate: outcome.candidate });
414
+ const outcomes = references.map((reference, index) => {
415
+ const own = candidates.filter((candidate) => candidate.refIndex === index);
416
+ return { reference, candidates: own, outcome: chooseSecretCandidate(reference, own, meta) };
305
417
  });
306
- return { meta, failures, chosen };
418
+ return { meta, outcomes };
419
+ };
420
+ let { meta, outcomes } = await choose();
421
+ // One provider registry per resolve, made only when a connected vault is needed.
422
+ let registry: VaultProviderRegistry | null | undefined;
423
+ const providers = (): VaultProviderRegistry | null => {
424
+ if (registry === undefined) {
425
+ registry = deps.providers ?? opts.providersForSubject?.(meta.subjectId) ?? null;
426
+ }
427
+ return registry;
307
428
  };
308
- let { meta, failures, chosen } = await choose();
429
+ const misses = () =>
430
+ outcomes.filter(
431
+ (entry) => "failure" in entry.outcome && entry.outcome.failure.code === "not_found",
432
+ );
309
433
  // A reference to an item added straight in a connected provider (1Password)
310
- // finds nothing until the vault's mirror catches up: refresh stale linked
311
- // vaults once and look again before reporting it missing.
312
- if (failures.some((failure) => failure.code === "not_found")) {
313
- const registry = deps.providers ?? opts.providersForSubject?.(meta.subjectId);
314
- if (registry) {
315
- const reachable = await listVaultsForSubject(deps.db, context, {
316
- workspaceId: principal.workspaceId,
317
- });
318
- const changed = await refreshStaleLinkedVaults({
434
+ // finds nothing until the vault's mirror catches up: refresh the relevant
435
+ // stale linked vaults once and look again before reporting it missing.
436
+ let reachable: VaultRow[] | null = null;
437
+ let refreshing = new Set<string>();
438
+ if (misses().length > 0) {
439
+ reachable = await listVaultsForSubject(deps.db, context, {
440
+ workspaceId: principal.workspaceId,
441
+ });
442
+ const relevant = new Map<string, VaultRow>();
443
+ for (const entry of misses()) {
444
+ for (const vault of relevantProviderVaults(entry.reference, entry.candidates, reachable)) {
445
+ relevant.set(vault.id, vault);
446
+ }
447
+ }
448
+ const refreshProviders = relevant.size > 0 ? providers() : null;
449
+ if (refreshProviders) {
450
+ const report = await refreshLinkedVaults({
319
451
  db: deps.db,
320
452
  context,
321
- vaults: reachable,
322
- providers: registry,
453
+ vaults: [...relevant.values()],
454
+ providers: refreshProviders,
323
455
  maxAgeMs: LINKED_VAULT_MISS_MAX_AGE_MS,
324
456
  });
325
- if (changed.length > 0) ({ meta, failures, chosen } = await choose());
457
+ refreshing = new Set(report.pending);
458
+ if (
459
+ report.changed.length > 0 ||
460
+ report.outcomes.some((outcome) => outcome.result?.status === "superseded")
461
+ )
462
+ ({ meta, outcomes } = await choose());
463
+ if (
464
+ report.outcomes.some((outcome) => outcome.state === "done" || outcome.state === "failed")
465
+ ) {
466
+ reachable = await listVaultsForSubject(deps.db, context, {
467
+ workspaceId: principal.workspaceId,
468
+ });
469
+ }
470
+ }
471
+ }
472
+ const failures: SecretReferenceFailure[] = [];
473
+ const chosen: Chosen[] = [];
474
+ const checkedAt = new Date();
475
+ for (const entry of outcomes) {
476
+ if ("candidate" in entry.outcome) {
477
+ chosen.push({ reference: entry.reference, candidate: entry.outcome.candidate });
478
+ continue;
479
+ }
480
+ let failure = entry.outcome.failure;
481
+ if (failure.code === "not_found" && reachable) {
482
+ const unchecked = relevantProviderVaults(entry.reference, entry.candidates, reachable)
483
+ .map((vault) => ({
484
+ vault,
485
+ freshness: providerVaultFreshness(vault, checkedAt, {
486
+ maxAgeMs: LINKED_VAULT_MISS_MAX_AGE_MS,
487
+ refreshing: refreshing.has(vault.id),
488
+ })!,
489
+ }))
490
+ .filter(({ freshness }) => freshness.state !== "current");
491
+ if (unchecked.length > 0) failure = uncheckedSourceFailure(entry.reference, unchecked);
326
492
  }
493
+ failures.push(failure);
327
494
  }
328
495
  const hasActingHuman = meta.humanExpected && meta.humanValid;
329
496
  if (failures.length > 0) {
@@ -344,17 +511,18 @@ export function createSecretResolver(
344
511
  for (const group of providerGroups.values()) {
345
512
  const first = group[0]!.candidate;
346
513
  try {
347
- const registry =
348
- deps.providers ??
349
- opts.providersForSubject?.(meta.subjectId) ??
350
- EMPTY_VAULT_PROVIDER_REGISTRY;
351
- const provider = await registry.forVault({
514
+ const provider = await (providers() ?? EMPTY_VAULT_PROVIDER_REGISTRY).forVault({
352
515
  source: first.source,
353
516
  providerConnectionId: first.providerConnectionId,
354
517
  });
355
- // The provider charges `budget` per call (one readFields per item).
518
+ // The provider charges `budget` per call (one readFields per item). The
519
+ // durable epoch invalidates its value cache after a refresh anywhere.
356
520
  const values = await provider.readFields(
357
- { connectionId: first.providerConnectionId, budget },
521
+ {
522
+ connectionId: first.providerConnectionId,
523
+ budget,
524
+ cacheEpoch: first.providerCacheEpoch ?? null,
525
+ },
358
526
  first.providerVaultRef ?? "",
359
527
  first.providerItemRef ?? "",
360
528
  [...new Set(group.map((entry) => entry.candidate.providerFieldRef ?? ""))],
@@ -375,7 +543,9 @@ export function createSecretResolver(
375
543
  ? "the connected vault is rate limited; try again later"
376
544
  : code === "provider_auth_expired"
377
545
  ? "the connected vault needs to be reconnected"
378
- : "the connected vault is unavailable",
546
+ : code === "not_found"
547
+ ? "the connected vault no longer has this item"
548
+ : "the connected vault is unavailable",
379
549
  });
380
550
  }
381
551
  }