@checkstack/healthcheck-backend 1.14.0 → 1.15.0

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/src/service.ts CHANGED
@@ -63,6 +63,17 @@ import {
63
63
  calculateLatencyStats,
64
64
  type NormalizedBucket,
65
65
  } from "./aggregation-utils";
66
+ import {
67
+ extractConfigurationSecrets,
68
+ mergeConfigurationSecrets,
69
+ deleteConfigurationSecrets,
70
+ pruneOrphanedConfigurationSecrets,
71
+ redactSecretFields,
72
+ listPopulatedSecretKeys,
73
+ healthcheckConfigLockKey,
74
+ type GetCollectorSchema,
75
+ type HealthCheckSecretsDeps,
76
+ } from "./config-secrets";
66
77
 
67
78
  // Drizzle type helper - uses SafeDatabase to prevent relational query API usage
68
79
  type Db = SafeDatabase<typeof schema>;
@@ -118,8 +129,26 @@ export class HealthCheckService {
118
129
  * test constructions), `systemName` falls back to the `systemId`.
119
130
  */
120
131
  private catalogClient?: CatalogClient,
132
+ /**
133
+ * Optional — the secrets channel for config credentials. When present,
134
+ * every write extracts inline `x-secret` values into internal secrets
135
+ * (the stored row holds only markers / `${{ secrets.* }}` references)
136
+ * and updates merge blank/absent secrets from the stored row. MUST be
137
+ * provided on every construction that serves writes (router, gitops);
138
+ * kept optional only for read-path and test constructions.
139
+ */
140
+ private secrets?: HealthCheckSecretsDeps,
121
141
  ) {}
122
142
 
143
+ /** Schema lookup for a collector entry, or undefined when unregistered. */
144
+ private getCollectorSchema: GetCollectorSchema = (collectorId) =>
145
+ this.collectorRegistry.getCollector(collectorId)?.collector.config.schema;
146
+
147
+ /** Schema lookup for a strategy, or undefined when unregistered. */
148
+ private getStrategySchema(strategyId: string) {
149
+ return this.registry.getStrategy(strategyId)?.config.schema;
150
+ }
151
+
123
152
  /**
124
153
  * Resolve the platform-wide notification policy defaults. Returns
125
154
  * the compile-time defaults when no `configService` was provided or
@@ -163,18 +192,57 @@ export class HealthCheckService {
163
192
  async createConfiguration(
164
193
  data: CreateHealthCheckConfiguration,
165
194
  ): Promise<HealthCheckConfiguration> {
166
- const [config] = await this.db
167
- .insert(healthCheckConfigurations)
168
- .values({
169
- name: data.name,
170
- strategyId: data.strategyId,
195
+ // Generate the id up front: secret extraction keys internal secrets by
196
+ // configuration id, and the raw values must never reach the row insert.
197
+ const id = crypto.randomUUID();
198
+ let configToStore = data.config;
199
+ let collectorsToStore = data.collectors;
200
+
201
+ // Gate extraction on `this.secrets` alone, NOT on the strategy being
202
+ // registered: extractConfigurationSecrets handles an undefined strategy
203
+ // schema (passing the strategy config through untouched) while STILL
204
+ // extracting every registered collector's secrets. Gating on strategySchema
205
+ // would store a registered collector's inline secret as plaintext at rest
206
+ // when the strategy is unregistered - the leak updateConfiguration avoids.
207
+ const strategySchema = this.getStrategySchema(data.strategyId);
208
+ if (this.secrets) {
209
+ const extracted = await extractConfigurationSecrets({
210
+ configurationId: id,
211
+ strategySchema,
171
212
  config: data.config,
172
- collectors: data.collectors ?? undefined,
173
- intervalSeconds: data.intervalSeconds,
174
- isTemplate: false, // Defaulting for now
175
- })
176
- .returning();
177
- return this.mapConfig(config);
213
+ collectors: data.collectors,
214
+ getCollectorSchema: this.getCollectorSchema,
215
+ internalSecrets: this.secrets.internalSecrets,
216
+ });
217
+ configToStore = extracted.config;
218
+ collectorsToStore = extracted.collectors;
219
+ }
220
+
221
+ try {
222
+ const [config] = await this.db
223
+ .insert(healthCheckConfigurations)
224
+ .values({
225
+ id,
226
+ name: data.name,
227
+ strategyId: data.strategyId,
228
+ config: configToStore,
229
+ collectors: collectorsToStore ?? undefined,
230
+ intervalSeconds: data.intervalSeconds,
231
+ isTemplate: false, // Defaulting for now
232
+ })
233
+ .returning();
234
+ return this.mapConfig(config);
235
+ } catch (error) {
236
+ // The insert never committed, so the just-extracted internal secrets are
237
+ // now orphaned (a config id that owns no row). Delete them so a failed
238
+ // create leaves nothing dangling in the store.
239
+ await this.cleanupExtractedSecrets({
240
+ id,
241
+ config: configToStore,
242
+ collectors: collectorsToStore,
243
+ });
244
+ throw error;
245
+ }
178
246
  }
179
247
 
180
248
  async getConfiguration(
@@ -187,25 +255,303 @@ export class HealthCheckService {
187
255
  return config ? this.mapConfig(config) : undefined;
188
256
  }
189
257
 
258
+ /**
259
+ * Redact a configuration for a UI/AI read: every `x-secret` field is
260
+ * removed from the strategy config and each collector config. Stored
261
+ * values, `${{ secrets.* }}` references, and even internal markers never
262
+ * leave the backend; the editor treats the absent field as
263
+ * "keep existing" (`keepExistingSecretFields`).
264
+ */
265
+ redactConfiguration(
266
+ configuration: HealthCheckConfiguration,
267
+ ): HealthCheckConfiguration {
268
+ // FAIL CLOSED on an unregistered strategy/collector (plugin uninstalled):
269
+ // without its schema we cannot know which fields are secret, so return an
270
+ // empty config rather than a possibly-secret-bearing one.
271
+ const strategySchema = this.getStrategySchema(configuration.strategyId);
272
+ const config = strategySchema
273
+ ? redactSecretFields({ schema: strategySchema, config: configuration.config })
274
+ : {};
275
+ const collectors = configuration.collectors?.map((entry) => {
276
+ const schema = this.getCollectorSchema(entry.collectorId);
277
+ if (!schema) return { ...entry, config: {} };
278
+ return { ...entry, config: redactSecretFields({ schema, config: entry.config }) };
279
+ });
280
+ // Tell the editor which secret fields ACTUALLY have a stored value (computed
281
+ // from the pre-redaction config), so a never-set optional secret does not
282
+ // show a misleading "a secret is stored" hint / Clear affordance.
283
+ const configuredSecrets = {
284
+ strategy: strategySchema
285
+ ? listPopulatedSecretKeys({
286
+ schema: strategySchema,
287
+ config: configuration.config,
288
+ })
289
+ : [],
290
+ collectors: Object.fromEntries(
291
+ (configuration.collectors ?? []).map((entry) => {
292
+ const schema = this.getCollectorSchema(entry.collectorId);
293
+ return [
294
+ entry.id,
295
+ schema
296
+ ? listPopulatedSecretKeys({ schema, config: entry.config })
297
+ : [],
298
+ ];
299
+ }),
300
+ ),
301
+ };
302
+ return { ...configuration, config, collectors, configuredSecrets };
303
+ }
304
+
305
+ async getConfigurationRedacted(
306
+ id: string,
307
+ ): Promise<HealthCheckConfiguration | undefined> {
308
+ const configuration = await this.getConfiguration(id);
309
+ return configuration ? this.redactConfiguration(configuration) : undefined;
310
+ }
311
+
312
+ async getConfigurationsRedacted(): Promise<HealthCheckConfiguration[]> {
313
+ const configurations = await this.getConfigurations();
314
+ return configurations.map((c) => this.redactConfiguration(c));
315
+ }
316
+
190
317
  async updateConfiguration(
191
318
  id: string,
192
319
  data: UpdateHealthCheckConfiguration,
320
+ options?: {
321
+ /**
322
+ * Whether a blank/absent `x-secret` field means "keep the stored value"
323
+ * (the UI editor round-trips the REDACTED config, so this is `true` by
324
+ * default). GitOps is DECLARATIVE - the authored source is the whole
325
+ * truth, so an omitted secret field means "not set". Pass `false` there
326
+ * so absent secrets are removed rather than silently re-restored.
327
+ */
328
+ mergeSecrets?: boolean;
329
+ },
193
330
  ): Promise<HealthCheckConfiguration | undefined> {
194
- const [config] = await this.db
195
- .update(healthCheckConfigurations)
196
- .set({
197
- ...data,
198
- updatedAt: new Date(),
199
- })
200
- .where(eq(healthCheckConfigurations.id, id))
201
- .returning();
202
- return config ? this.mapConfig(config) : undefined;
331
+ const mergeSecrets = options?.mergeSecrets ?? true;
332
+
333
+ const applyUpdate = async (): Promise<
334
+ HealthCheckConfiguration | undefined
335
+ > => {
336
+ let body = data;
337
+ // Deferred until AFTER the row is written: deletes internal secrets
338
+ // orphaned by this edit (a cleared / declaratively-removed field, an
339
+ // inline secret swapped for a reference, a removed collector). Post-write
340
+ // means a crash can never leave the row pointing at a marker whose secret
341
+ // we already deleted (a dangling marker fails closed at run time).
342
+ let pruneOrphans: (() => Promise<void>) | undefined;
343
+
344
+ if (this.secrets && (data.config || data.collectors)) {
345
+ // The editor round-trips the REDACTED config, so blank/absent secrets
346
+ // mean "keep existing": restore them from the stored row BEFORE
347
+ // extraction (restored markers pass through extraction untouched).
348
+ const stored = await this.getConfiguration(id);
349
+ if (stored) {
350
+ const strategyId = data.strategyId ?? stored.strategyId;
351
+ const strategySchema = this.getStrategySchema(strategyId);
352
+
353
+ // Preserve an UNREGISTERED collector entry's stored config: a read
354
+ // redacts it to `{}` (fail-closed), and the editor round-trips that
355
+ // back - never persist the `{}` over the stored config.
356
+ const storedById = new Map(
357
+ (stored.collectors ?? []).map((entry) => [entry.id, entry]),
358
+ );
359
+ const sanitizedCollectors = data.collectors?.map((entry) => {
360
+ if (this.getCollectorSchema(entry.collectorId)) return entry;
361
+ const storedEntry = storedById.get(entry.id);
362
+ return storedEntry
363
+ ? { ...entry, config: storedEntry.config }
364
+ : entry;
365
+ });
366
+
367
+ // Strategy config: registered -> merge/extract (or wholesale for
368
+ // gitops); UNREGISTERED -> preserve the stored config (we have no
369
+ // schema to merge/extract/validate it). REGISTERED COLLECTORS are
370
+ // processed via their OWN schemas regardless of strategy
371
+ // registration, so a registered collector under an unregistered
372
+ // strategy still extracts its secrets (no plaintext-at-rest, no wipe).
373
+ const incomingStrategyConfig = strategySchema
374
+ ? (data.config ?? stored.config)
375
+ : stored.config;
376
+
377
+ // UI keeps existing secrets (blank = keep); GitOps applies the
378
+ // authored config wholesale so an omitted secret is genuinely removed.
379
+ const merged = mergeSecrets
380
+ ? mergeConfigurationSecrets({
381
+ strategySchema,
382
+ incomingConfig: incomingStrategyConfig,
383
+ storedConfig: stored.config,
384
+ incomingCollectors: sanitizedCollectors,
385
+ storedCollectors: stored.collectors,
386
+ getCollectorSchema: this.getCollectorSchema,
387
+ })
388
+ : { config: incomingStrategyConfig, collectors: sanitizedCollectors };
389
+
390
+ const extracted = await extractConfigurationSecrets({
391
+ configurationId: id,
392
+ strategySchema,
393
+ config: merged.config,
394
+ collectors: merged.collectors,
395
+ getCollectorSchema: this.getCollectorSchema,
396
+ internalSecrets: this.secrets.internalSecrets,
397
+ });
398
+ body = {
399
+ ...data,
400
+ ...(data.config ? { config: extracted.config } : {}),
401
+ ...(data.collectors ? { collectors: extracted.collectors } : {}),
402
+ };
403
+
404
+ // A partial update only changes provided fields, so the effective new
405
+ // state of an omitted field is the stored one (unchanged, no orphan).
406
+ const newConfig = data.config ? extracted.config : stored.config;
407
+ const newCollectors = data.collectors
408
+ ? extracted.collectors
409
+ : stored.collectors;
410
+ const secrets = this.secrets;
411
+ pruneOrphans = async () => {
412
+ await pruneOrphanedConfigurationSecrets({
413
+ configurationId: id,
414
+ oldConfig: stored.config,
415
+ newConfig,
416
+ oldCollectors: stored.collectors,
417
+ newCollectors,
418
+ internalSecrets: secrets.internalSecrets,
419
+ });
420
+ };
421
+ }
422
+ }
423
+
424
+ const [config] = await this.db
425
+ .update(healthCheckConfigurations)
426
+ .set({
427
+ ...body,
428
+ updatedAt: new Date(),
429
+ })
430
+ .where(eq(healthCheckConfigurations.id, id))
431
+ .returning();
432
+
433
+ // The row now holds the new markers; delete the internal secrets this
434
+ // edit orphaned. Post-write so a dangling marker can never outlive its
435
+ // secret.
436
+ if (config) await pruneOrphans?.();
437
+
438
+ return config ? this.mapConfig(config) : undefined;
439
+ };
440
+
441
+ // Serialize the read-modify-write-prune per config id so a concurrent
442
+ // writer to the SAME id (e.g. a UI edit racing a GitOps reconcile across
443
+ // pods) cannot have its just-written secret deleted by this writer's stale
444
+ // orphan-prune, which would leave a dangling marker. Only needed on the
445
+ // secret-bearing path and only when a cluster-wide lock is wired.
446
+ const lock = this.secrets?.advisoryLock;
447
+ if (lock && (data.config || data.collectors)) {
448
+ return lock.withXactLock({
449
+ key: healthcheckConfigLockKey(id),
450
+ fn: applyUpdate,
451
+ });
452
+ }
453
+ return applyUpdate();
454
+ }
455
+
456
+ /**
457
+ * Restore a stored config's secrets into a proposed (redacted) config so it
458
+ * can be deep-validated as an UPDATE without spuriously failing a
459
+ * required-secret check. Mirrors the merge `updateConfiguration` does
460
+ * before persisting, so validate and apply agree; the restored values are
461
+ * used only for validation and never returned to the caller. Returns the
462
+ * proposed config unchanged when the id is unknown or secrets are not wired.
463
+ */
464
+ async restoreSecretsForValidation({
465
+ existingConfigurationId,
466
+ strategyId,
467
+ config,
468
+ collectors,
469
+ }: {
470
+ existingConfigurationId: string;
471
+ strategyId: string;
472
+ config: Record<string, unknown>;
473
+ collectors: CollectorConfigEntry[] | undefined;
474
+ }): Promise<{
475
+ config: Record<string, unknown>;
476
+ collectors: CollectorConfigEntry[] | undefined;
477
+ }> {
478
+ const strategySchema = this.getStrategySchema(strategyId);
479
+ const stored = await this.getConfiguration(existingConfigurationId);
480
+ if (!strategySchema || !stored) return { config, collectors };
481
+ return mergeConfigurationSecrets({
482
+ strategySchema,
483
+ incomingConfig: config,
484
+ storedConfig: stored.config,
485
+ incomingCollectors: collectors,
486
+ storedCollectors: stored.collectors,
487
+ getCollectorSchema: this.getCollectorSchema,
488
+ });
203
489
  }
204
490
 
205
491
  async deleteConfiguration(id: string): Promise<void> {
206
- await this.db
207
- .delete(healthCheckConfigurations)
208
- .where(eq(healthCheckConfigurations.id, id));
492
+ const applyDelete = async (): Promise<void> => {
493
+ // Clean up the internal secrets the stored markers point at BEFORE the
494
+ // row disappears (the markers are the only index into them). Schema-free,
495
+ // so an uninstalled strategy/collector plugin does NOT leave its secrets
496
+ // orphaned - marker enumeration does not depend on the plugin being loaded.
497
+ if (this.secrets) {
498
+ const stored = await this.getConfiguration(id);
499
+ if (stored) {
500
+ await deleteConfigurationSecrets({
501
+ configurationId: id,
502
+ config: stored.config,
503
+ collectors: stored.collectors,
504
+ internalSecrets: this.secrets.internalSecrets,
505
+ });
506
+ }
507
+ }
508
+ await this.db
509
+ .delete(healthCheckConfigurations)
510
+ .where(eq(healthCheckConfigurations.id, id));
511
+ };
512
+
513
+ // Serialize delete under the SAME per-config lock updateConfiguration uses,
514
+ // so a concurrent update on this id cannot interleave: a delete reading the
515
+ // pre-update row would clean up the OLD markers and drop the row while the
516
+ // update writes a fresh internal secret, orphaning it. Delete and update of
517
+ // one id must be mutually exclusive.
518
+ const lock = this.secrets?.advisoryLock;
519
+ if (lock) {
520
+ return lock.withXactLock({
521
+ key: healthcheckConfigLockKey(id),
522
+ fn: applyDelete,
523
+ });
524
+ }
525
+ return applyDelete();
526
+ }
527
+
528
+ /**
529
+ * Delete internal secrets extracted for a config whose row insert did NOT
530
+ * commit (a rolled-back create), so a failed create never orphans the
531
+ * secrets it extracted. Best-effort: swallows cleanup errors so the original
532
+ * insert error is the one surfaced.
533
+ */
534
+ private async cleanupExtractedSecrets({
535
+ id,
536
+ config,
537
+ collectors,
538
+ }: {
539
+ id: string;
540
+ config: Record<string, unknown>;
541
+ collectors: CollectorConfigEntry[] | undefined;
542
+ }): Promise<void> {
543
+ if (!this.secrets) return;
544
+ try {
545
+ await deleteConfigurationSecrets({
546
+ configurationId: id,
547
+ config,
548
+ collectors,
549
+ internalSecrets: this.secrets.internalSecrets,
550
+ });
551
+ } catch {
552
+ // Ignore: the create already failed; surfacing a cleanup error would mask
553
+ // the real cause. The orphan (if any) is a harmless encrypted blob.
554
+ }
209
555
  }
210
556
 
211
557
  async pauseConfiguration(id: string): Promise<void> {
@@ -330,32 +676,70 @@ export class HealthCheckService {
330
676
  const versionedThresholds: VersionedStateThresholds | undefined =
331
677
  stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
332
678
 
333
- const created = await this.db.transaction(async (tx) => {
334
- const [config] = await tx
335
- .insert(healthCheckConfigurations)
336
- .values({
337
- name: configuration.name,
338
- strategyId: configuration.strategyId,
339
- config: configuration.config,
340
- collectors: configuration.collectors ?? undefined,
341
- intervalSeconds: configuration.intervalSeconds,
342
- isTemplate: false,
343
- })
344
- .returning();
345
-
346
- await tx.insert(systemHealthChecks).values({
347
- systemId,
348
- configurationId: config.id,
349
- enabled,
350
- stateThresholds: versionedThresholds,
351
- satelliteIds: satelliteIds ?? undefined,
352
- environmentIds: environmentIdsValue,
353
- includeLocal,
354
- notificationPolicy: notificationPolicy ?? undefined,
679
+ // Extract inline secrets into the encrypted internal store BEFORE insert -
680
+ // identical to createConfiguration. Without this, the first-check wizard
681
+ // and the AI propose tool (both createAndAssign callers) would persist
682
+ // credentials as plaintext. The id is generated up front so the internal
683
+ // secrets can be keyed by it.
684
+ const id = crypto.randomUUID();
685
+ let configToStore = configuration.config;
686
+ let collectorsToStore = configuration.collectors;
687
+ // Gate on `this.secrets` alone (see createConfiguration): gating on
688
+ // strategySchema would leave a registered collector's inline secret as
689
+ // plaintext at rest when the strategy is unregistered.
690
+ const strategySchema = this.getStrategySchema(configuration.strategyId);
691
+ if (this.secrets) {
692
+ const extracted = await extractConfigurationSecrets({
693
+ configurationId: id,
694
+ strategySchema,
695
+ config: configuration.config,
696
+ collectors: configuration.collectors,
697
+ getCollectorSchema: this.getCollectorSchema,
698
+ internalSecrets: this.secrets.internalSecrets,
355
699
  });
700
+ configToStore = extracted.config;
701
+ collectorsToStore = extracted.collectors;
702
+ }
356
703
 
357
- return config;
358
- });
704
+ let created: typeof healthCheckConfigurations.$inferSelect;
705
+ try {
706
+ created = await this.db.transaction(async (tx) => {
707
+ const [config] = await tx
708
+ .insert(healthCheckConfigurations)
709
+ .values({
710
+ id,
711
+ name: configuration.name,
712
+ strategyId: configuration.strategyId,
713
+ config: configToStore,
714
+ collectors: collectorsToStore ?? undefined,
715
+ intervalSeconds: configuration.intervalSeconds,
716
+ isTemplate: false,
717
+ })
718
+ .returning();
719
+
720
+ await tx.insert(systemHealthChecks).values({
721
+ systemId,
722
+ configurationId: config.id,
723
+ enabled,
724
+ stateThresholds: versionedThresholds,
725
+ satelliteIds: satelliteIds ?? undefined,
726
+ environmentIds: environmentIdsValue,
727
+ includeLocal,
728
+ notificationPolicy: notificationPolicy ?? undefined,
729
+ });
730
+
731
+ return config;
732
+ });
733
+ } catch (error) {
734
+ // The transaction rolled back, so no row owns the secrets we just
735
+ // extracted; delete them so a failed create-and-assign never orphans them.
736
+ await this.cleanupExtractedSecrets({
737
+ id,
738
+ config: configToStore,
739
+ collectors: collectorsToStore,
740
+ });
741
+ throw error;
742
+ }
359
743
 
360
744
  return this.mapConfig(created);
361
745
  }
@@ -487,6 +871,18 @@ export class HealthCheckService {
487
871
  return Promise.all(rows.map((r) => this.mapConfig(r.config)));
488
872
  }
489
873
 
874
+ /**
875
+ * System-scoped configuration list for UI reads - REDACTED, like
876
+ * `getConfigurationsRedacted`. Any `x-secret` field is stripped before the
877
+ * config leaves the backend.
878
+ */
879
+ async getSystemConfigurationsRedacted(
880
+ systemId: string,
881
+ ): Promise<HealthCheckConfiguration[]> {
882
+ const configurations = await this.getSystemConfigurations(systemId);
883
+ return configurations.map((c) => this.redactConfiguration(c));
884
+ }
885
+
490
886
  /**
491
887
  * Get system associations with their threshold configurations.
492
888
  */