@serve.zone/dcrouter 18.1.0 → 18.2.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.
Files changed (49) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2072 -1624
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +64 -2
  5. package/dist_ts/classes.dcrouter.js +187 -6
  6. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
  7. package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
  8. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
  9. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
  10. package/dist_ts/db/documents/index.d.ts +2 -0
  11. package/dist_ts/db/documents/index.js +3 -1
  12. package/dist_ts/email/classes.accepted-email-spool.d.ts +67 -0
  13. package/dist_ts/email/classes.accepted-email-spool.js +245 -17
  14. package/dist_ts/email/classes.workapp-mail-manager.js +5 -2
  15. package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
  16. package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
  17. package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
  18. package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
  19. package/dist_ts/opsserver/handlers/security.handler.js +21 -3
  20. package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
  21. package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
  22. package/dist_ts/security/classes.authentication-event-manager.js +185 -0
  23. package/dist_ts/security/index.d.ts +1 -0
  24. package/dist_ts/security/index.js +2 -1
  25. package/dist_ts_interfaces/data/stats.d.ts +16 -0
  26. package/dist_ts_migrations/index.js +74 -15
  27. package/dist_ts_web/00_commitinfo_data.js +1 -1
  28. package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
  29. package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
  30. package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
  31. package/package.json +3 -3
  32. package/readme.md +24 -0
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.dcrouter.ts +230 -6
  35. package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
  36. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
  37. package/ts/db/documents/index.ts +2 -0
  38. package/ts/email/classes.accepted-email-spool.ts +319 -21
  39. package/ts/email/classes.workapp-mail-manager.ts +4 -1
  40. package/ts/monitoring/classes.metricsmanager.ts +287 -50
  41. package/ts/opsserver/handlers/admin.handler.ts +74 -15
  42. package/ts/opsserver/handlers/security.handler.ts +23 -2
  43. package/ts/opsserver/handlers/stats.handler.ts +29 -9
  44. package/ts/security/classes.authentication-event-manager.ts +221 -0
  45. package/ts/security/index.ts +1 -0
  46. package/ts_web/00_commitinfo_data.ts +1 -1
  47. package/ts_web/elements/overview/ops-view-overview.ts +2 -1
  48. package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
  49. package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
@@ -31,7 +31,7 @@ import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager, buildEd
31
31
  import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js';
32
32
  import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
33
33
  import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
34
- import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
34
+ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter, AuthenticationEventManager } from './security/index.js';
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
36
36
  import { applyDefaultInboundPolicy } from './email/inbound-policy.js';
37
37
  import { DnsManager } from './dns/manager.dns.js';
@@ -49,6 +49,32 @@ import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings
49
49
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
50
50
  import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
51
51
 
52
+ /**
53
+ * dcrouter's superset of SmartMTA's SMTP TLS options.
54
+ *
55
+ * SmartMTA consumes PEM content (`certPem`/`keyPem`). Deployments configure
56
+ * certificate FILES, so dcrouter accepts the path form too and loads it eagerly
57
+ * at startup — a path that cannot be read fails startup rather than silently
58
+ * leaving the listener without TLS.
59
+ */
60
+ export interface IDcRouterEmailTlsConfig extends NonNullable<IUnifiedEmailServerOptions['tls']> {
61
+ /** Path to the certificate chain PEM file. Requires keyPath. */
62
+ certPath?: string;
63
+ /** Path to the private key PEM file. Requires certPath. */
64
+ keyPath?: string;
65
+ }
66
+
67
+ /** dcrouter's email server options: SmartMTA's, with the path-shaped tls block. */
68
+ export type IDcRouterEmailConfig = Omit<IUnifiedEmailServerOptions, 'tls'> & {
69
+ tls?: IDcRouterEmailTlsConfig;
70
+ };
71
+
72
+ /** Whether an email configuration carries any SMTP AUTH credentials. */
73
+ const emailConfigHasAuth = (emailConfigArg: IUnifiedEmailServerOptions | undefined): boolean =>
74
+ !!emailConfigArg?.auth?.required
75
+ || !!emailConfigArg?.auth?.users?.length
76
+ || !!emailConfigArg?.auth?.accounts?.length;
77
+
52
78
  export interface IDcRouterOptions {
53
79
  /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
54
80
  baseDir?: string;
@@ -66,7 +92,7 @@ export interface IDcRouterOptions {
66
92
  * Email server configuration
67
93
  * This enables all email handling with pattern-based routing
68
94
  */
69
- emailConfig?: IUnifiedEmailServerOptions;
95
+ emailConfig?: IDcRouterEmailConfig;
70
96
 
71
97
  /** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */
72
98
  emailBlobStorage?: ISmartMtaBlobStorageConfig;
@@ -277,6 +303,7 @@ export class DcRouter {
277
303
  public emailServer?: UnifiedEmailServer;
278
304
  public radiusServer?: RadiusServer;
279
305
  public opsServer!: OpsServer;
306
+ private opsServerStopped = false;
280
307
  public metricsManager?: MetricsManager;
281
308
  private emailEventSubscriptions: Array<{
282
309
  emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
@@ -333,6 +360,7 @@ export class DcRouter {
333
360
  public mailDnsSync: MailDnsSync;
334
361
  public mailEdgeEligibility: MailEdgeEligibility;
335
362
  public securityPolicyManager?: SecurityPolicyManager;
363
+ public authenticationEventManager: AuthenticationEventManager;
336
364
 
337
365
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
338
366
  public detectedPublicIp: string | null = null;
@@ -386,6 +414,9 @@ export class DcRouter {
386
414
  smartProxyConfig: coreTrafficConfig,
387
415
  };
388
416
 
417
+ // Authentication handlers and metrics share this single bounded event owner.
418
+ this.authenticationEventManager = new AuthenticationEventManager(this);
419
+
389
420
  // Capture smartmta's own log stream into the ops log buffer — without
390
421
  // this, mailer bridge crashes and delivery failures inside smartmta are
391
422
  // invisible to `getLogs` and postmortems.
@@ -429,11 +460,12 @@ export class DcRouter {
429
460
  new plugins.taskbuffer.Service('OpsServer')
430
461
  .critical()
431
462
  .withStart(async () => {
463
+ this.opsServerStopped = false;
432
464
  this.opsServer = new OpsServer(this);
433
465
  await this.opsServer.start();
434
466
  })
435
467
  .withStop(async () => {
436
- await this.opsServer?.stop();
468
+ await this.stopOpsServer();
437
469
  })
438
470
  .withRetry({ maxRetries: 0 }),
439
471
  );
@@ -480,11 +512,17 @@ export class DcRouter {
480
512
  .withRetry({ maxRetries: 0 }),
481
513
  );
482
514
 
483
- // MetricsManager: optional, depends on OpsServer
515
+ // Keep the database alive through the MetricsManager's final persistence drain.
516
+ const metricsDependencies = ['OpsServer'];
517
+ if (this.options.dbConfig?.enabled !== false) {
518
+ metricsDependencies.push('DcRouterDb');
519
+ }
520
+
521
+ // MetricsManager: optional, depends on OpsServer and the database when enabled.
484
522
  this.serviceManager.addService(
485
523
  new plugins.taskbuffer.Service('MetricsManager')
486
524
  .optional()
487
- .dependsOn('OpsServer')
525
+ .dependsOn(...metricsDependencies)
488
526
  .withStart(async () => {
489
527
  this.metricsManager = new MetricsManager(this);
490
528
  await this.metricsManager.start();
@@ -1611,6 +1649,10 @@ export class DcRouter {
1611
1649
  expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
1612
1650
  source: event.source,
1613
1651
  });
1652
+ // Renewals arrive here too. The SMTP listener holds its PEM material as
1653
+ // listener-level Rust configuration, so a renewed mail-hostname
1654
+ // certificate must be pushed or STARTTLS keeps serving the stale one.
1655
+ void this.reapplyEmailTlsMaterial(event.domain);
1614
1656
  });
1615
1657
 
1616
1658
  // Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
@@ -1820,6 +1862,17 @@ export class DcRouter {
1820
1862
  return names;
1821
1863
  }
1822
1864
 
1865
+ private async stopOpsServer(): Promise<void> {
1866
+ if (this.opsServerStopped) return;
1867
+ this.opsServerStopped = true;
1868
+ try {
1869
+ await this.opsServer?.stop();
1870
+ } catch (error) {
1871
+ this.opsServerStopped = false;
1872
+ throw error;
1873
+ }
1874
+ }
1875
+
1823
1876
  public async stop() {
1824
1877
  logger.log('info', 'Stopping DcRouter services...');
1825
1878
 
@@ -1829,6 +1882,10 @@ export class DcRouter {
1829
1882
  this.serviceSubjectSubscription = undefined;
1830
1883
  }
1831
1884
 
1885
+ // Stop accepting authenticated requests before MetricsManager performs its
1886
+ // final durable authentication-event and email-traffic drain.
1887
+ await this.stopOpsServer();
1888
+
1832
1889
  // ServiceManager handles reverse-dependency-ordered shutdown
1833
1890
  await this.serviceManager.stop();
1834
1891
 
@@ -1928,6 +1985,10 @@ export class DcRouter {
1928
1985
  storageManager: this.smartMtaBlobStorageManager,
1929
1986
  };
1930
1987
 
1988
+ const emailTls = await this.resolveEmailTlsMaterial();
1989
+ const tlsTerminatedPorts = this.resolveEdgeTerminatedEmailPorts(portMapping);
1990
+ this.logEmailAuthTransportPosture(emailConfigHasAuth(baseEmailConfig), emailTls, mappedEmailPorts, tlsTerminatedPorts, mappedSecurePort);
1991
+
1931
1992
  let emailConfig: IUnifiedEmailServerOptions = await this.smtpAccountManager.composeEmailConfig({
1932
1993
  ...this.options.emailConfig,
1933
1994
  ports: mappedEmailPorts,
@@ -1935,6 +1996,7 @@ export class DcRouter {
1935
1996
  dkimKeyProvisioning: 'caller-managed',
1936
1997
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
1937
1998
  queue: queueOptions,
1999
+ ...(emailTls ? { tls: { ...baseEmailConfig.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem } } : {}),
1938
2000
  outbound: {
1939
2001
  ...baseEmailConfig.outbound,
1940
2002
  connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context),
@@ -1944,6 +2006,7 @@ export class DcRouter {
1944
2006
  ...(mappedSecurePort !== undefined
1945
2007
  ? { securePort: mappedSecurePort }
1946
2008
  : {}),
2009
+ ...(tlsTerminatedPorts.length > 0 ? { tlsTerminatedPorts } : {}),
1947
2010
  recipientValidation: true,
1948
2011
  proxyProtocol: {
1949
2012
  ...baseEmailConfig.smtp?.proxyProtocol,
@@ -2098,7 +2161,6 @@ export class DcRouter {
2098
2161
 
2099
2162
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
2100
2163
  const envelope = getEnvelope(item);
2101
- this.metricsManager!.trackEmailReceived(envelope.from);
2102
2164
  updateQueueSize();
2103
2165
  logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
2104
2166
  });
@@ -2152,6 +2214,168 @@ export class DcRouter {
2152
2214
  this.mailDnsSync.requestSync('email server start');
2153
2215
  }
2154
2216
 
2217
+ /**
2218
+ * Resolve the PEM material for the SMTP listener.
2219
+ *
2220
+ * SmartMTA consumes `tls.certPem`/`tls.keyPem`; a path-shaped `tls` block is
2221
+ * ignored, which leaves the Rust listener with no TLS material at all — no
2222
+ * STARTTLS on the plain submission ports. Resolution order matches the
2223
+ * RemoteIngress tunnel precedent: explicit paths, then the ACME cert store.
2224
+ *
2225
+ * Explicitly configured paths that cannot be read FAIL STARTUP. Silently
2226
+ * continuing without TLS is what produced a cleartext submission port in
2227
+ * production, so a broken explicit configuration must be impossible to miss.
2228
+ */
2229
+ private async resolveEmailTlsMaterial(): Promise<{ certPem: string; keyPem: string; source: string } | undefined> {
2230
+ const tlsConfig = this.options.emailConfig?.tls as (IUnifiedEmailServerOptions['tls'] & {
2231
+ certPath?: string;
2232
+ keyPath?: string;
2233
+ }) | undefined;
2234
+
2235
+ if (tlsConfig?.certPem && tlsConfig?.keyPem) {
2236
+ return { certPem: tlsConfig.certPem, keyPem: tlsConfig.keyPem, source: 'inline PEM' };
2237
+ }
2238
+
2239
+ if (tlsConfig?.certPath || tlsConfig?.keyPath) {
2240
+ if (!tlsConfig.certPath || !tlsConfig.keyPath) {
2241
+ throw new Error(
2242
+ 'emailConfig.tls requires both certPath and keyPath when either is configured',
2243
+ );
2244
+ }
2245
+ let certPem: string;
2246
+ let keyPem: string;
2247
+ try {
2248
+ certPem = await plugins.fs.promises.readFile(tlsConfig.certPath, 'utf8');
2249
+ keyPem = await plugins.fs.promises.readFile(tlsConfig.keyPath, 'utf8');
2250
+ } catch (error: unknown) {
2251
+ throw new Error(
2252
+ `Unable to read the configured SMTP TLS material (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath}): ${(error as Error).message}`,
2253
+ );
2254
+ }
2255
+ if (!certPem.trim() || !keyPem.trim()) {
2256
+ throw new Error(
2257
+ `The configured SMTP TLS material is empty (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath})`,
2258
+ );
2259
+ }
2260
+ logger.log('info', `SMTP TLS material loaded from configured paths (${tlsConfig.certPath})`);
2261
+ return { certPem, keyPem, source: `configured paths (${tlsConfig.certPath})` };
2262
+ }
2263
+
2264
+ const mailHostname = this.options.emailConfig?.hostname;
2265
+ if (mailHostname) {
2266
+ try {
2267
+ const stored = await ProxyCertDoc.findByDomain(mailHostname);
2268
+ if (stored?.publicKey && stored?.privateKey) {
2269
+ logger.log('info', `SMTP TLS material loaded from the stored ACME certificate for ${mailHostname}`);
2270
+ return { certPem: stored.publicKey, keyPem: stored.privateKey, source: `stored ACME certificate for ${mailHostname}` };
2271
+ }
2272
+ } catch (error: unknown) {
2273
+ logger.log('warn', `Unable to read the stored certificate for the mail hostname ${mailHostname}: ${(error as Error).message}`);
2274
+ }
2275
+ }
2276
+
2277
+ return undefined;
2278
+ }
2279
+
2280
+ /**
2281
+ * Push renewed TLS material to the running SMTP listener.
2282
+ *
2283
+ * Only reacts to the mail hostname, and only when explicit paths are NOT
2284
+ * configured — an operator-managed file pair is not superseded by an ACME
2285
+ * renewal for the same name. SmartMTA restarts just the Rust listener when
2286
+ * the PEM material actually changes.
2287
+ */
2288
+ private async reapplyEmailTlsMaterial(domainArg: string): Promise<void> {
2289
+ const emailServer = this.emailServer;
2290
+ const mailHostname = this.options.emailConfig?.hostname;
2291
+ if (!emailServer || !mailHostname) return;
2292
+ if (domainArg.toLowerCase() !== mailHostname.toLowerCase()) return;
2293
+ const tlsConfig = this.options.emailConfig?.tls;
2294
+ if (tlsConfig?.certPath || tlsConfig?.keyPath) return;
2295
+
2296
+ try {
2297
+ const emailTls = await this.resolveEmailTlsMaterial();
2298
+ if (!emailTls) return;
2299
+ emailServer.updateOptions({
2300
+ tls: { ...this.options.emailConfig?.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem },
2301
+ });
2302
+ if (this.options.emailConfig) {
2303
+ this.options.emailConfig.tls = {
2304
+ ...this.options.emailConfig.tls,
2305
+ certPem: emailTls.certPem,
2306
+ keyPem: emailTls.keyPem,
2307
+ };
2308
+ }
2309
+ logger.log('info', `Pushed renewed SMTP TLS material for ${mailHostname} to the email listener`);
2310
+ } catch (error: unknown) {
2311
+ logger.log('error', `Unable to apply renewed SMTP TLS material for ${mailHostname}: ${(error as Error).message}`);
2312
+ }
2313
+ }
2314
+
2315
+ /**
2316
+ * Internal SMTP ports whose public leg is TLS-terminated by CoreTraffic.
2317
+ *
2318
+ * Those backend legs arrive as plaintext even though the client's channel was
2319
+ * encrypted, so without declaring them the AUTH-requires-encryption gate would
2320
+ * refuse authentication on the only encrypted submission port. Derived from
2321
+ * the generated route set rather than hardcoding 465, so it stays true when
2322
+ * emailPortConfig changes.
2323
+ */
2324
+ private resolveEdgeTerminatedEmailPorts(portMapping: Record<number, number>): number[] {
2325
+ if (!this.options.emailConfig) return [];
2326
+ const terminatedPorts: number[] = [];
2327
+ for (const route of this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig)) {
2328
+ if ((route.action as { tls?: { mode?: string } }).tls?.mode !== 'terminate') continue;
2329
+ const publicPorts = Array.isArray(route.match?.ports) ? route.match.ports : [];
2330
+ for (const publicPort of publicPorts) {
2331
+ if (typeof publicPort !== 'number') continue;
2332
+ const internalPort = portMapping[publicPort] || publicPort + 10000;
2333
+ // The implicit-TLS securePort is terminated by smartmta itself.
2334
+ if (internalPort === this.options.emailConfig.smtp?.securePort) continue;
2335
+ if (!terminatedPorts.includes(internalPort)) {
2336
+ terminatedPorts.push(internalPort);
2337
+ }
2338
+ }
2339
+ }
2340
+ return terminatedPorts;
2341
+ }
2342
+
2343
+ /**
2344
+ * State the transport posture of every SMTP listener port at startup.
2345
+ *
2346
+ * AUTH is only offered on an encrypted transport, so an operator has to be
2347
+ * able to see at a glance which submission ports can actually authenticate —
2348
+ * missing certificate material silently disabling AUTH on 587 would otherwise
2349
+ * look like a client problem.
2350
+ */
2351
+ private logEmailAuthTransportPosture(
2352
+ authConfigured: boolean,
2353
+ emailTls: { source: string } | undefined,
2354
+ mappedPorts: number[],
2355
+ tlsTerminatedPorts: number[],
2356
+ mappedSecurePort: number | undefined,
2357
+ ): void {
2358
+ const authCapable: number[] = [];
2359
+ const cleartext: number[] = [];
2360
+ for (const port of mappedPorts) {
2361
+ if (port === mappedSecurePort || tlsTerminatedPorts.includes(port) || emailTls) {
2362
+ authCapable.push(port);
2363
+ } else {
2364
+ cleartext.push(port);
2365
+ }
2366
+ }
2367
+ logger.log(
2368
+ 'info',
2369
+ `SMTP transport posture: TLS material=${emailTls ? emailTls.source : 'NONE'}, edge-terminated ports=[${tlsTerminatedPorts.join(', ') || 'none'}], implicit-TLS port=${mappedSecurePort ?? 'none'}, AUTH-capable ports=[${authCapable.join(', ') || 'none'}]`,
2370
+ );
2371
+ if (authConfigured && !emailTls && cleartext.length > 0) {
2372
+ logger.log(
2373
+ 'error',
2374
+ `SMTP AUTH is configured but ports [${cleartext.join(', ')}] have no TLS material and no upstream TLS terminator — AUTH is refused there because credentials must never cross a cleartext channel. Configure emailConfig.tls.certPath/keyPath, or provision an ACME certificate for ${this.options.emailConfig?.hostname || 'the mail hostname'}.`,
2375
+ );
2376
+ }
2377
+ }
2378
+
2155
2379
  /**
2156
2380
  * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
2157
2381
  */
@@ -0,0 +1,248 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type {
4
+ IAuthenticationEvent,
5
+ TAuthenticationFailureReason,
6
+ TAuthenticationSource,
7
+ TResolvedAuthenticationSource,
8
+ } from '../../../ts_interfaces/data/stats.js';
9
+
10
+ const DB_OPERATION_TIMEOUT_MS = 5_000;
11
+
12
+ const getDb = () => DcRouterDb.getInstance().getDb();
13
+
14
+ @plugins.smartdata.Collection(() => getDb())
15
+ export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
16
+ AuthenticationEventDoc,
17
+ AuthenticationEventDoc
18
+ > implements IAuthenticationEvent {
19
+ @plugins.smartdata.unI()
20
+ @plugins.smartdata.svDb()
21
+ public id!: string;
22
+
23
+ @plugins.smartdata.svDb()
24
+ public timestamp!: number;
25
+
26
+ @plugins.smartdata.svDb()
27
+ public username!: string;
28
+
29
+ @plugins.smartdata.svDb()
30
+ public userId?: string;
31
+
32
+ @plugins.smartdata.svDb()
33
+ public success!: boolean;
34
+
35
+ @plugins.smartdata.svDb()
36
+ public requestedAuthSource!: TAuthenticationSource;
37
+
38
+ @plugins.smartdata.svDb()
39
+ public resolvedAuthSource?: TResolvedAuthenticationSource;
40
+
41
+ @plugins.smartdata.svDb()
42
+ public failureReason?: TAuthenticationFailureReason;
43
+
44
+ constructor() {
45
+ super();
46
+ }
47
+
48
+ public toApiObject(): IAuthenticationEvent {
49
+ return {
50
+ id: this.id,
51
+ timestamp: this.timestamp,
52
+ username: this.username,
53
+ success: this.success,
54
+ requestedAuthSource: this.requestedAuthSource,
55
+ ...(this.userId ? { userId: this.userId } : {}),
56
+ ...(this.resolvedAuthSource ? { resolvedAuthSource: this.resolvedAuthSource } : {}),
57
+ ...(this.failureReason ? { failureReason: this.failureReason } : {}),
58
+ };
59
+ }
60
+
61
+ private static async getNativeCollection() {
62
+ const smartdataCollection = (AuthenticationEventDoc as typeof AuthenticationEventDoc & {
63
+ collection: plugins.smartdata.SmartdataCollection<AuthenticationEventDoc>;
64
+ }).collection;
65
+ await smartdataCollection.init();
66
+ const probe = new AuthenticationEventDoc();
67
+ await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
68
+ await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
69
+ return smartdataCollection.mongoDbCollection;
70
+ }
71
+
72
+ private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
73
+ return {
74
+ id: eventArg.id,
75
+ timestamp: eventArg.timestamp,
76
+ username: eventArg.username,
77
+ success: eventArg.success,
78
+ requestedAuthSource: eventArg.requestedAuthSource,
79
+ ...(eventArg.userId !== undefined ? { userId: eventArg.userId } : {}),
80
+ ...(eventArg.resolvedAuthSource !== undefined
81
+ ? { resolvedAuthSource: eventArg.resolvedAuthSource }
82
+ : {}),
83
+ ...(eventArg.failureReason !== undefined ? { failureReason: eventArg.failureReason } : {}),
84
+ };
85
+ }
86
+
87
+ private static validateEvent(eventArg: IAuthenticationEvent): void {
88
+ if (
89
+ typeof eventArg.id !== 'string'
90
+ || !eventArg.id.trim()
91
+ || !Number.isSafeInteger(eventArg.timestamp)
92
+ || eventArg.timestamp < 0
93
+ || typeof eventArg.username !== 'string'
94
+ || !eventArg.username.trim()
95
+ || typeof eventArg.success !== 'boolean'
96
+ || !['auto', 'local', 'idp.global'].includes(eventArg.requestedAuthSource)
97
+ ) {
98
+ throw new Error('Invalid authentication event');
99
+ }
100
+ if (
101
+ eventArg.userId !== undefined
102
+ && (typeof eventArg.userId !== 'string' || !eventArg.userId.trim())
103
+ ) {
104
+ throw new Error('Invalid authentication event user ID');
105
+ }
106
+ if (
107
+ eventArg.resolvedAuthSource !== undefined
108
+ && eventArg.resolvedAuthSource !== 'local'
109
+ && eventArg.resolvedAuthSource !== 'idp.global'
110
+ ) {
111
+ throw new Error('Invalid resolved authentication source');
112
+ }
113
+ if (
114
+ eventArg.failureReason !== undefined
115
+ && ![
116
+ 'invalidCredentials',
117
+ 'serviceUnavailable',
118
+ 'identityIssuanceFailed',
119
+ 'internalError',
120
+ ].includes(eventArg.failureReason)
121
+ ) {
122
+ throw new Error('Invalid authentication failure reason');
123
+ }
124
+ if (eventArg.success && eventArg.failureReason) {
125
+ throw new Error('Successful authentication events cannot carry a failure reason');
126
+ }
127
+ if (!eventArg.success && !eventArg.failureReason) {
128
+ throw new Error('Failed authentication events require a failure reason');
129
+ }
130
+ }
131
+
132
+ public static async upsertMany(eventsArg: IAuthenticationEvent[]): Promise<void> {
133
+ if (eventsArg.length === 0) return;
134
+ if (eventsArg.length > 500) {
135
+ throw new Error('AuthenticationEventDoc.upsertMany accepts at most 500 events');
136
+ }
137
+ for (const event of eventsArg) {
138
+ AuthenticationEventDoc.validateEvent(event);
139
+ }
140
+ const collection = await AuthenticationEventDoc.getNativeCollection();
141
+ await collection.bulkWrite(
142
+ eventsArg.map((event) => {
143
+ const timestamp = new Date(event.timestamp).toISOString();
144
+ const persistedEvent = AuthenticationEventDoc.toPersistedFields(event);
145
+ return {
146
+ updateOne: {
147
+ filter: { id: event.id },
148
+ update: {
149
+ $setOnInsert: {
150
+ ...persistedEvent,
151
+ _createdAt: timestamp,
152
+ _updatedAt: timestamp,
153
+ },
154
+ },
155
+ upsert: true,
156
+ },
157
+ };
158
+ }),
159
+ { ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
160
+ );
161
+ }
162
+
163
+ public static async getWindowSummary(
164
+ cutoffArg: number,
165
+ limitArg = 100,
166
+ ): Promise<{
167
+ successes: number;
168
+ failures: number;
169
+ events: IAuthenticationEvent[];
170
+ }> {
171
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
172
+ throw new Error('AuthenticationEventDoc.getWindowSummary requires a valid cutoff');
173
+ }
174
+ if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
175
+ throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
176
+ }
177
+ const collection = await AuthenticationEventDoc.getNativeCollection();
178
+ const selector = { timestamp: { $gte: cutoffArg } };
179
+ const cursor = collection
180
+ .find(selector, { timeoutMS: DB_OPERATION_TIMEOUT_MS })
181
+ .sort({ timestamp: -1, id: -1 })
182
+ .limit(limitArg);
183
+ try {
184
+ const [successes, failures, rows] = await Promise.all([
185
+ collection.countDocuments(
186
+ { ...selector, success: true },
187
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
188
+ ),
189
+ collection.countDocuments(
190
+ { ...selector, success: false },
191
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
192
+ ),
193
+ cursor.toArray(),
194
+ ]);
195
+ return {
196
+ successes,
197
+ failures,
198
+ events: rows.map((row) => AuthenticationEventDoc
199
+ .createInstanceFromMongoDbNativeDoc(row as any)
200
+ .toApiObject()),
201
+ };
202
+ } finally {
203
+ await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
204
+ }
205
+ }
206
+
207
+ public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
208
+ const ids = [...new Set(idsArg)];
209
+ if (ids.length === 0) return new Set();
210
+ if (ids.length > 500) {
211
+ throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
212
+ }
213
+ const collection = await AuthenticationEventDoc.getNativeCollection();
214
+ const rows = await collection
215
+ .find(
216
+ { id: { $in: ids } },
217
+ { projection: { id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
218
+ )
219
+ .toArray();
220
+ return new Set(rows.map((row) => String(row.id)));
221
+ }
222
+
223
+ public static async pruneBefore(cutoffArg: number): Promise<number> {
224
+ if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
225
+ throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
226
+ }
227
+ const collection = await AuthenticationEventDoc.getNativeCollection();
228
+ let deletedCount = 0;
229
+ while (true) {
230
+ const rows = await collection
231
+ .find(
232
+ { timestamp: { $lt: cutoffArg } },
233
+ { projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
234
+ )
235
+ .sort({ timestamp: 1, _id: 1 })
236
+ .limit(500)
237
+ .toArray();
238
+ if (rows.length === 0) break;
239
+ const result = await collection.deleteMany(
240
+ { _id: { $in: rows.map((row) => row._id) } },
241
+ { timeoutMS: DB_OPERATION_TIMEOUT_MS },
242
+ );
243
+ deletedCount += result.deletedCount;
244
+ if (rows.length < 500) break;
245
+ }
246
+ return deletedCount;
247
+ }
248
+ }