@open-mercato/enterprise 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/security/api/interceptors.js +5 -2
- package/dist/modules/security/api/interceptors.js.map +2 -2
- package/dist/modules/security/api/mfa/_shared.js +1 -0
- package/dist/modules/security/api/mfa/_shared.js.map +2 -2
- package/dist/modules/security/data/entities.js +18 -0
- package/dist/modules/security/data/entities.js.map +2 -2
- package/dist/modules/security/index.js +3 -0
- package/dist/modules/security/index.js.map +2 -2
- package/dist/modules/security/lib/enforcement-redirect.js +28 -2
- package/dist/modules/security/lib/enforcement-redirect.js.map +2 -2
- package/dist/modules/security/lib/mfaCompletionRoutes.js +17 -0
- package/dist/modules/security/lib/mfaCompletionRoutes.js.map +7 -0
- package/dist/modules/security/lib/security-config.js +27 -0
- package/dist/modules/security/lib/security-config.js.map +2 -2
- package/dist/modules/security/migrations/Migration20260811143757_security.js +13 -0
- package/dist/modules/security/migrations/Migration20260811143757_security.js.map +7 -0
- package/dist/modules/security/services/SudoChallengeService.js +130 -41
- package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
- package/dist/modules/sso/backend/page.js +1 -0
- package/dist/modules/sso/backend/page.js.map +2 -2
- package/package.json +7 -6
- package/src/modules/security/api/interceptors.ts +6 -2
- package/src/modules/security/api/mfa/_shared.ts +1 -0
- package/src/modules/security/data/entities.ts +18 -0
- package/src/modules/security/index.ts +4 -0
- package/src/modules/security/lib/enforcement-redirect.ts +30 -2
- package/src/modules/security/lib/mfaCompletionRoutes.ts +25 -0
- package/src/modules/security/lib/security-config.ts +29 -0
- package/src/modules/security/migrations/.snapshot-open-mercato.json +848 -275
- package/src/modules/security/migrations/Migration20260811143757_security.ts +13 -0
- package/src/modules/security/services/SudoChallengeService.ts +170 -49
- package/src/modules/sso/backend/page.tsx +2 -0
|
@@ -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
|
+
}
|
|
@@ -24,7 +24,7 @@ import type { MfaService } from './MfaService'
|
|
|
24
24
|
import type { MfaVerificationService } from './MfaVerificationService'
|
|
25
25
|
import { sudoTargets as defaultSudoTargets } from '../security.sudo'
|
|
26
26
|
import type { SecurityModuleConfig } from '../lib/security-config'
|
|
27
|
-
import { readSecurityModuleConfig } from '../lib/security-config'
|
|
27
|
+
import { emitMfaEmergencyBypassActiveWarning, readSecurityModuleConfig } from '../lib/security-config'
|
|
28
28
|
|
|
29
29
|
type SudoMethod = 'password' | 'mfa'
|
|
30
30
|
|
|
@@ -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,23 +151,24 @@ 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
|
-
|
|
165
|
-
|
|
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
|
-
const method = this.resolveChallengeMethod(protection.config.challengeMethod, userMethods.length)
|
|
171
|
+
const method = this.resolveChallengeMethod(protection.config.challengeMethod, userMethods.length, userId, targetIdentifier)
|
|
179
172
|
|
|
180
173
|
let sessionToken = randomBytes(16).toString('hex')
|
|
181
174
|
let availableMfaMethods: SudoAvailableMethod[] | undefined
|
|
@@ -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
|
+
}
|
|
258
260
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
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
|
+
}
|
|
270
|
+
|
|
271
|
+
const scopeTenantId = session.scopeTenantId ?? null
|
|
272
|
+
const scopeOrganizationId = session.scopeOrganizationId ?? null
|
|
261
273
|
const protection = await this.isProtected(
|
|
262
|
-
|
|
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:
|
|
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
|
|
298
|
-
const expiresAt =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
|
@@ -573,10 +643,33 @@ export class SudoChallengeService {
|
|
|
573
643
|
private resolveChallengeMethod(
|
|
574
644
|
configuredMethod: ChallengeMethod,
|
|
575
645
|
availableMfaMethodCount: number,
|
|
646
|
+
userId?: string,
|
|
647
|
+
targetIdentifier?: string,
|
|
576
648
|
): SudoMethod {
|
|
577
649
|
if (this.securityConfig.mfa.emergencyBypass) {
|
|
650
|
+
let wouldHaveBeenMfa = false
|
|
651
|
+
try {
|
|
652
|
+
wouldHaveBeenMfa = this.resolveChallengeMethodWithoutBypass(configuredMethod, availableMfaMethodCount) === 'mfa'
|
|
653
|
+
} catch {
|
|
654
|
+
wouldHaveBeenMfa = configuredMethod === ChallengeMethod.MFA
|
|
655
|
+
}
|
|
656
|
+
if (wouldHaveBeenMfa) {
|
|
657
|
+
emitMfaEmergencyBypassActiveWarning('sudo challenge downgraded to password', {
|
|
658
|
+
configuredMethod,
|
|
659
|
+
availableMfaMethodCount,
|
|
660
|
+
userId,
|
|
661
|
+
targetIdentifier,
|
|
662
|
+
})
|
|
663
|
+
}
|
|
578
664
|
return 'password'
|
|
579
665
|
}
|
|
666
|
+
return this.resolveChallengeMethodWithoutBypass(configuredMethod, availableMfaMethodCount)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private resolveChallengeMethodWithoutBypass(
|
|
670
|
+
configuredMethod: ChallengeMethod,
|
|
671
|
+
availableMfaMethodCount: number,
|
|
672
|
+
): SudoMethod {
|
|
580
673
|
if (configuredMethod === ChallengeMethod.PASSWORD) return 'password'
|
|
581
674
|
if (configuredMethod === ChallengeMethod.MFA) {
|
|
582
675
|
if (availableMfaMethodCount === 0) {
|
|
@@ -587,6 +680,28 @@ export class SudoChallengeService {
|
|
|
587
680
|
return availableMfaMethodCount > 0 ? 'mfa' : 'password'
|
|
588
681
|
}
|
|
589
682
|
|
|
683
|
+
private async resolveProtection(
|
|
684
|
+
em: EntityManager,
|
|
685
|
+
targetIdentifier: string,
|
|
686
|
+
tenantId: string | null,
|
|
687
|
+
organizationId: string | null,
|
|
688
|
+
): Promise<SudoProtectionResolution> {
|
|
689
|
+
const candidates = await em.find(SudoChallengeConfig, {
|
|
690
|
+
targetIdentifier,
|
|
691
|
+
deletedAt: null,
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
const resolved = candidates
|
|
695
|
+
.filter((config) => this.matchesScope(config, tenantId, organizationId))
|
|
696
|
+
.sort((left, right) => this.compareConfigPriority(left, right, tenantId, organizationId))[0]
|
|
697
|
+
|
|
698
|
+
if (!resolved || !resolved.isEnabled) {
|
|
699
|
+
return { protected: false }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { protected: true, config: resolved }
|
|
703
|
+
}
|
|
704
|
+
|
|
590
705
|
private matchesScope(config: SudoChallengeConfig, tenantId: string | null, organizationId: string | null): boolean {
|
|
591
706
|
if (config.organizationId) {
|
|
592
707
|
return config.organizationId === organizationId && config.tenantId === tenantId
|
|
@@ -624,15 +739,21 @@ export class SudoChallengeService {
|
|
|
624
739
|
return 4
|
|
625
740
|
}
|
|
626
741
|
|
|
627
|
-
private async getPendingSession(sessionId: string): Promise<
|
|
742
|
+
private async getPendingSession(sessionId: string): Promise<BoundPendingSudoSession> {
|
|
628
743
|
const session = await this.em.findOne(SudoSession, { id: sessionId })
|
|
629
744
|
if (!session) {
|
|
630
745
|
throw new SudoChallengeServiceError('Sudo challenge session not found', 404)
|
|
631
746
|
}
|
|
747
|
+
if (!session.targetIdentifier || !session.sudoConfigId || !session.sudoConfigUpdatedAt) {
|
|
748
|
+
throw new SudoChallengeServiceError('Sudo challenge session not found', 404)
|
|
749
|
+
}
|
|
750
|
+
if (session.verifiedAt) {
|
|
751
|
+
throw new SudoChallengeServiceError('Unable to verify sudo challenge', 400)
|
|
752
|
+
}
|
|
632
753
|
if (session.expiresAt.getTime() <= Date.now()) {
|
|
633
754
|
throw new SudoChallengeServiceError('Sudo challenge session expired', 400)
|
|
634
755
|
}
|
|
635
|
-
return session
|
|
756
|
+
return session as BoundPendingSudoSession
|
|
636
757
|
}
|
|
637
758
|
|
|
638
759
|
private async findUserScope(userId: string): Promise<UserScope | null> {
|
|
@@ -30,6 +30,7 @@ interface ListResponse {
|
|
|
30
30
|
items: SsoConfigRow[]
|
|
31
31
|
total: number
|
|
32
32
|
totalPages: number
|
|
33
|
+
totalIsCapped?: boolean
|
|
33
34
|
isSuperAdmin?: boolean
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -220,6 +221,7 @@ export default function SsoConfigListPage() {
|
|
|
220
221
|
pageSize: 50,
|
|
221
222
|
total: data.total,
|
|
222
223
|
totalPages: data.totalPages,
|
|
224
|
+
totalIsCapped: data?.totalIsCapped === true,
|
|
223
225
|
onPageChange: setPage,
|
|
224
226
|
}}
|
|
225
227
|
isLoading={isLoading}
|