@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/security/data/entities.js +18 -0
- package/dist/modules/security/data/entities.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 +110 -38
- package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/security/data/entities.ts +18 -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 +145 -47
|
@@ -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
|
-
|
|
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
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 =
|
|
260
|
-
const scopeOrganizationId =
|
|
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
|
|
|
@@ -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<
|
|
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> {
|