@open-mercato/enterprise 0.6.8-develop.7031.1.005201cd70 → 0.6.8-develop.7037.1.ea0277b01e

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,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260811143757_security extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "sudo_sessions" add "scope_tenant_id" uuid null, add "scope_organization_id" uuid null, add "target_identifier" text null, add "sudo_config_id" uuid null, add "sudo_config_updated_at" timestamptz null, add "verified_at" timestamptz null;`);
7
+ }
8
+
9
+ override down(): void | Promise<void> {
10
+ this.addSql(`alter table "sudo_sessions" drop column "scope_tenant_id", drop column "scope_organization_id", drop column "target_identifier", drop column "sudo_config_id", drop column "sudo_config_updated_at", drop column "verified_at";`);
11
+ }
12
+
13
+ }
@@ -56,6 +56,12 @@ type UserScope = {
56
56
  organizationId: string | null
57
57
  }
58
58
 
59
+ type BoundPendingSudoSession = SudoSession & {
60
+ targetIdentifier: string
61
+ sudoConfigId: string
62
+ sudoConfigUpdatedAt: Date
63
+ }
64
+
59
65
  export type SudoAuthScope = {
60
66
  tenantId: string | null
61
67
  organizationId?: string | null
@@ -131,21 +137,7 @@ export class SudoChallengeService {
131
137
  organizationId?: string | null,
132
138
  ): Promise<SudoProtectionResolution> {
133
139
  await this.ensureDeveloperDefaultsRegistered()
134
-
135
- const candidates = await this.em.find(SudoChallengeConfig, {
136
- targetIdentifier,
137
- deletedAt: null,
138
- })
139
-
140
- const resolved = candidates
141
- .filter((config) => this.matchesScope(config, tenantId ?? null, organizationId ?? null))
142
- .sort((left, right) => this.compareConfigPriority(left, right, tenantId ?? null, organizationId ?? null))[0]
143
-
144
- if (!resolved || !resolved.isEnabled) {
145
- return { protected: false }
146
- }
147
-
148
- return { protected: true, config: resolved }
140
+ return this.resolveProtection(this.em, targetIdentifier, tenantId ?? null, organizationId ?? null)
149
141
  }
150
142
 
151
143
  async initiate(
@@ -159,21 +151,22 @@ export class SudoChallengeService {
159
151
  availableMfaMethods?: SudoAvailableMethod[]
160
152
  expiresAt?: Date
161
153
  }> {
154
+ const user = await this.findUserScope(userId)
155
+ if (!user?.tenantId) {
156
+ throw new SudoChallengeServiceError('User not found', 404)
157
+ }
158
+ const scopeTenantId = options?.tenantId !== undefined ? options.tenantId : user.tenantId
159
+ const scopeOrganizationId = options?.organizationId !== undefined ? options.organizationId : user.organizationId
162
160
  const protection = await this.isProtected(
163
161
  targetIdentifier,
164
- options?.tenantId ?? null,
165
- options?.organizationId ?? null,
162
+ scopeTenantId,
163
+ scopeOrganizationId,
166
164
  )
167
165
 
168
166
  if (!protection.protected || !protection.config) {
169
167
  return { required: false }
170
168
  }
171
169
 
172
- const user = await this.findUserScope(userId)
173
- if (!user?.tenantId) {
174
- throw new SudoChallengeServiceError('User not found', 404)
175
- }
176
-
177
170
  const userMethods = await this.mfaService.getUserMethods(userId)
178
171
  const method = this.resolveChallengeMethod(protection.config.challengeMethod, userMethods.length)
179
172
 
@@ -189,9 +182,15 @@ export class SudoChallengeService {
189
182
  const session = this.em.create(SudoSession, {
190
183
  userId,
191
184
  tenantId: user.tenantId,
185
+ scopeTenantId,
186
+ scopeOrganizationId,
187
+ targetIdentifier,
188
+ sudoConfigId: protection.config.id,
189
+ sudoConfigUpdatedAt: protection.config.updatedAt,
192
190
  sessionToken,
193
191
  challengeMethod: method,
194
192
  expiresAt,
193
+ verifiedAt: null,
195
194
  createdAt: new Date(),
196
195
  })
197
196
  this.em.persist(session)
@@ -255,17 +254,36 @@ export class SudoChallengeService {
255
254
  if (!user?.tenantId) {
256
255
  throw new SudoChallengeServiceError('User not found', 404)
257
256
  }
257
+ if (user.tenantId !== session.tenantId) {
258
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 403)
259
+ }
260
+
261
+ const requestedTenantId = options.tenantId !== undefined ? options.tenantId : user.tenantId
262
+ const requestedOrganizationId = options.organizationId !== undefined ? options.organizationId : user.organizationId
263
+ if (
264
+ options.targetIdentifier !== session.targetIdentifier
265
+ || requestedTenantId !== session.scopeTenantId
266
+ || requestedOrganizationId !== session.scopeOrganizationId
267
+ ) {
268
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 403)
269
+ }
258
270
 
259
- const scopeTenantId = options.tenantId !== undefined ? options.tenantId : user.tenantId
260
- const scopeOrganizationId = options.organizationId !== undefined ? options.organizationId : user.organizationId
271
+ const scopeTenantId = session.scopeTenantId ?? null
272
+ const scopeOrganizationId = session.scopeOrganizationId ?? null
261
273
  const protection = await this.isProtected(
262
- options.targetIdentifier,
274
+ session.targetIdentifier,
263
275
  scopeTenantId,
264
276
  scopeOrganizationId,
265
277
  )
266
278
  if (!protection.protected || !protection.config) {
267
279
  throw new SudoChallengeServiceError('Sudo protection is not configured for this target', 404)
268
280
  }
281
+ if (
282
+ protection.config.id !== session.sudoConfigId
283
+ || protection.config.updatedAt.getTime() !== session.sudoConfigUpdatedAt?.getTime()
284
+ ) {
285
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 403)
286
+ }
269
287
 
270
288
  let verified = false
271
289
  let methodUsed: string = methodType
@@ -288,33 +306,73 @@ export class SudoChallengeService {
288
306
  userId: session.userId,
289
307
  tenantId: user.tenantId,
290
308
  organizationId: user.organizationId,
291
- targetIdentifier: options.targetIdentifier,
309
+ targetIdentifier: session.targetIdentifier,
292
310
  method: methodUsed,
293
311
  })
294
312
  throw new SudoChallengeServiceError('Unable to verify sudo challenge', 401)
295
313
  }
296
314
 
297
- const ttlSeconds = this.normalizeTtl(protection.config.ttlSeconds)
298
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000)
299
- const sudoToken = this.signToken({
300
- sid: session.id,
301
- sub: session.userId,
302
- tid: scopeTenantId,
303
- oid: scopeOrganizationId,
304
- tgt: options.targetIdentifier,
305
- exp: expiresAt.getTime(),
306
- })
315
+ const pendingToken = session.sessionToken
316
+ const { sudoToken, expiresAt, verifiedAt } = await this.em.transactional(async (transactionalEm) => {
317
+ await transactionalEm.execute('lock table "sudo_challenge_configs" in share mode')
318
+ const currentProtection = await this.resolveProtection(
319
+ transactionalEm,
320
+ session.targetIdentifier,
321
+ scopeTenantId,
322
+ scopeOrganizationId,
323
+ )
324
+ if (
325
+ !currentProtection.protected
326
+ || !currentProtection.config
327
+ || currentProtection.config.id !== session.sudoConfigId
328
+ || currentProtection.config.updatedAt.getTime() !== session.sudoConfigUpdatedAt.getTime()
329
+ ) {
330
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 403)
331
+ }
307
332
 
333
+ const ttlSeconds = this.normalizeTtl(currentProtection.config.ttlSeconds)
334
+ const currentVerifiedAt = new Date()
335
+ const currentExpiresAt = new Date(Date.now() + ttlSeconds * 1000)
336
+ const currentSudoToken = this.signToken({
337
+ sid: session.id,
338
+ sub: session.userId,
339
+ tid: scopeTenantId,
340
+ oid: scopeOrganizationId,
341
+ tgt: session.targetIdentifier,
342
+ exp: currentExpiresAt.getTime(),
343
+ })
344
+ const consumedRows = await transactionalEm.nativeUpdate(
345
+ SudoSession,
346
+ {
347
+ id: session.id,
348
+ sessionToken: pendingToken,
349
+ verifiedAt: null,
350
+ expiresAt: { $gt: currentVerifiedAt },
351
+ },
352
+ {
353
+ sessionToken: currentSudoToken,
354
+ expiresAt: currentExpiresAt,
355
+ verifiedAt: currentVerifiedAt,
356
+ },
357
+ )
358
+ if (consumedRows !== 1) {
359
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 400)
360
+ }
361
+ return {
362
+ sudoToken: currentSudoToken,
363
+ expiresAt: currentExpiresAt,
364
+ verifiedAt: currentVerifiedAt,
365
+ }
366
+ })
308
367
  session.sessionToken = sudoToken
309
- session.challengeMethod = methodUsed
310
368
  session.expiresAt = expiresAt
311
- await this.em.flush()
369
+ session.verifiedAt = verifiedAt
312
370
 
313
371
  await emitSecurityEvent('security.sudo.verified', {
314
372
  userId: session.userId,
315
373
  tenantId: scopeTenantId,
316
374
  organizationId: scopeOrganizationId,
317
- targetIdentifier: options.targetIdentifier,
375
+ targetIdentifier: session.targetIdentifier,
318
376
  method: methodUsed,
319
377
  expiresAt: expiresAt.toISOString(),
320
378
  })
@@ -344,6 +402,10 @@ export class SudoChallengeService {
344
402
  id: payload.sid,
345
403
  userId: payload.sub,
346
404
  sessionToken: token,
405
+ targetIdentifier,
406
+ scopeTenantId: payload.tid,
407
+ scopeOrganizationId: payload.oid,
408
+ verifiedAt: { $ne: null },
347
409
  } as FilterQuery<SudoSession>)
348
410
 
349
411
  return Boolean(session && session.expiresAt.getTime() > Date.now())
@@ -463,12 +525,20 @@ export class SudoChallengeService {
463
525
  })
464
526
 
465
527
  if (existing) {
466
- existing.isEnabled = true
467
- existing.deletedAt = null
468
- existing.ttlSeconds = this.normalizeTtl(input.ttlSeconds)
469
- existing.challengeMethod = input.challengeMethod ?? ChallengeMethod.AUTO
470
- existing.updatedAt = new Date()
471
- await this.em.flush()
528
+ const ttlSeconds = this.normalizeTtl(input.ttlSeconds)
529
+ const challengeMethod = input.challengeMethod ?? ChallengeMethod.AUTO
530
+ const changed = !existing.isEnabled
531
+ || Boolean(existing.deletedAt)
532
+ || existing.ttlSeconds !== ttlSeconds
533
+ || existing.challengeMethod !== challengeMethod
534
+ if (changed) {
535
+ existing.isEnabled = true
536
+ existing.deletedAt = null
537
+ existing.ttlSeconds = ttlSeconds
538
+ existing.challengeMethod = challengeMethod
539
+ existing.updatedAt = new Date()
540
+ await this.em.flush()
541
+ }
472
542
  return
473
543
  }
474
544
 
@@ -587,6 +657,28 @@ export class SudoChallengeService {
587
657
  return availableMfaMethodCount > 0 ? 'mfa' : 'password'
588
658
  }
589
659
 
660
+ private async resolveProtection(
661
+ em: EntityManager,
662
+ targetIdentifier: string,
663
+ tenantId: string | null,
664
+ organizationId: string | null,
665
+ ): Promise<SudoProtectionResolution> {
666
+ const candidates = await em.find(SudoChallengeConfig, {
667
+ targetIdentifier,
668
+ deletedAt: null,
669
+ })
670
+
671
+ const resolved = candidates
672
+ .filter((config) => this.matchesScope(config, tenantId, organizationId))
673
+ .sort((left, right) => this.compareConfigPriority(left, right, tenantId, organizationId))[0]
674
+
675
+ if (!resolved || !resolved.isEnabled) {
676
+ return { protected: false }
677
+ }
678
+
679
+ return { protected: true, config: resolved }
680
+ }
681
+
590
682
  private matchesScope(config: SudoChallengeConfig, tenantId: string | null, organizationId: string | null): boolean {
591
683
  if (config.organizationId) {
592
684
  return config.organizationId === organizationId && config.tenantId === tenantId
@@ -624,15 +716,21 @@ export class SudoChallengeService {
624
716
  return 4
625
717
  }
626
718
 
627
- private async getPendingSession(sessionId: string): Promise<SudoSession> {
719
+ private async getPendingSession(sessionId: string): Promise<BoundPendingSudoSession> {
628
720
  const session = await this.em.findOne(SudoSession, { id: sessionId })
629
721
  if (!session) {
630
722
  throw new SudoChallengeServiceError('Sudo challenge session not found', 404)
631
723
  }
724
+ if (!session.targetIdentifier || !session.sudoConfigId || !session.sudoConfigUpdatedAt) {
725
+ throw new SudoChallengeServiceError('Sudo challenge session not found', 404)
726
+ }
727
+ if (session.verifiedAt) {
728
+ throw new SudoChallengeServiceError('Unable to verify sudo challenge', 400)
729
+ }
632
730
  if (session.expiresAt.getTime() <= Date.now()) {
633
731
  throw new SudoChallengeServiceError('Sudo challenge session expired', 400)
634
732
  }
635
- return session
733
+ return session as BoundPendingSudoSession
636
734
  }
637
735
 
638
736
  private async findUserScope(userId: string): Promise<UserScope | null> {