@open-mercato/core 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5617.1.62538c48ca
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/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entities/attachment_partition/index.js +4 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +3 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/api_keys/data/entities.js +9 -2
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20260523234901.js +17 -0
- package/dist/modules/api_keys/migrations/Migration20260523234901.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +26 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +54 -12
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +7 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20260524000000.js +27 -0
- package/dist/modules/attachments/migrations/Migration20260524000000.js.map +7 -0
- package/dist/modules/auth/api/roles/route.js +6 -35
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/cli.js +26 -4
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +34 -11
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/lib/roleTenantGuard.js +41 -13
- package/dist/modules/auth/lib/roleTenantGuard.js.map +3 -3
- package/dist/modules/auth/lib/setup-app.js +88 -26
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/currencies/api/fetch-configs/route.js +4 -2
- package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/route.js +5 -1
- package/dist/modules/integrations/api/[id]/route.js.map +2 -2
- package/dist/modules/integrations/api/route.js +5 -1
- package/dist/modules/integrations/api/route.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +6 -4
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entities/attachment_partition/index.ts +2 -0
- package/generated/entity-fields-registry.ts +3 -0
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +15 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +18 -0
- package/src/modules/api_keys/migrations/Migration20260523234901.ts +17 -0
- package/src/modules/api_keys/services/apiKeyService.ts +63 -0
- package/src/modules/attachments/api/partitions/route.ts +58 -11
- package/src/modules/attachments/data/entities.ts +7 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +28 -0
- package/src/modules/attachments/migrations/Migration20260524000000.ts +25 -0
- package/src/modules/auth/api/roles/route.ts +7 -38
- package/src/modules/auth/cli.ts +27 -4
- package/src/modules/auth/commands/roles.ts +39 -11
- package/src/modules/auth/lib/roleTenantGuard.ts +56 -17
- package/src/modules/auth/lib/setup-app.ts +160 -32
- package/src/modules/currencies/api/fetch-configs/route.ts +4 -2
- package/src/modules/integrations/api/[id]/route.ts +5 -2
- package/src/modules/integrations/api/route.ts +5 -2
- package/src/modules/integrations/lib/credentials-service.ts +15 -5
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from '@open-mercato/shared/lib/commands/customFieldSnapshots'
|
|
25
25
|
import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
|
|
26
26
|
import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
|
|
27
|
-
import { normalizeTenantId } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
27
|
+
import { resolveIsSuperAdmin, normalizeTenantId } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
28
28
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
29
29
|
|
|
30
30
|
type SerializedRole = {
|
|
@@ -84,6 +84,22 @@ function assertRoleNameAllowed(name: string | undefined | null) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
type ResolvedActorScope = { isSuperAdmin: boolean; actorTenantId: string | null }
|
|
88
|
+
|
|
89
|
+
async function resolveActorScope(ctx: { auth: { tenantId?: string | null; sub?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null; container: { resolve<T = unknown>(name: string): T } }): Promise<ResolvedActorScope> {
|
|
90
|
+
const isSuperAdmin = await resolveIsSuperAdmin(ctx)
|
|
91
|
+
const actorTenantId = normalizeTenantId(ctx.auth?.tenantId ?? null) ?? null
|
|
92
|
+
return { isSuperAdmin, actorTenantId }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildScopedRoleFilter(roleId: string, scope: ResolvedActorScope): FilterQuery<Role> {
|
|
96
|
+
const filter: FilterQuery<Role> = { id: roleId, deletedAt: null }
|
|
97
|
+
if (!scope.isSuperAdmin) {
|
|
98
|
+
;(filter as Record<string, unknown>).tenantId = scope.actorTenantId
|
|
99
|
+
}
|
|
100
|
+
return filter
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
const createSchema = z.object({
|
|
88
104
|
name: z.string().min(2).max(100),
|
|
89
105
|
tenantId: z.string().uuid().optional(),
|
|
@@ -128,7 +144,14 @@ const createRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
|
|
|
128
144
|
}
|
|
129
145
|
const { parsed, custom } = parseWithCustomFields(createSchema, rawInput)
|
|
130
146
|
assertRoleNameAllowed(parsed.name)
|
|
131
|
-
const
|
|
147
|
+
const scope = await resolveActorScope(ctx)
|
|
148
|
+
const requestedTenantId = parsed.tenantId ?? null
|
|
149
|
+
if (!scope.isSuperAdmin && requestedTenantId && requestedTenantId !== scope.actorTenantId) {
|
|
150
|
+
throw new CrudHttpError(403, { error: 'Not authorized to target this tenant.' })
|
|
151
|
+
}
|
|
152
|
+
const resolvedTenantId = scope.isSuperAdmin
|
|
153
|
+
? (requestedTenantId ?? scope.actorTenantId)
|
|
154
|
+
: scope.actorTenantId
|
|
132
155
|
if (!resolvedTenantId) {
|
|
133
156
|
throw new CrudHttpError(400, { error: 'tenantId is required — global roles are not supported' })
|
|
134
157
|
}
|
|
@@ -279,7 +302,8 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
|
|
|
279
302
|
async prepare(rawInput, ctx) {
|
|
280
303
|
const { parsed } = parseWithCustomFields(updateSchema, rawInput)
|
|
281
304
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
282
|
-
const
|
|
305
|
+
const scope = await resolveActorScope(ctx)
|
|
306
|
+
const existing = await findOneWithDecryption(em, Role, buildScopedRoleFilter(parsed.id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
|
|
283
307
|
if (!existing) throw new CrudHttpError(404, { error: 'Role not found' })
|
|
284
308
|
assertRoleTenantInScope(resolveActorTenantScope(ctx), existing.tenantId)
|
|
285
309
|
const acls = await loadRoleAclSnapshots(em, parsed.id)
|
|
@@ -293,7 +317,8 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
|
|
|
293
317
|
async execute(rawInput, ctx) {
|
|
294
318
|
const { parsed, custom } = parseWithCustomFields(updateSchema, rawInput)
|
|
295
319
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
296
|
-
const
|
|
320
|
+
const scope = await resolveActorScope(ctx)
|
|
321
|
+
const current = await findOneWithDecryption(em, Role, buildScopedRoleFilter(parsed.id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
|
|
297
322
|
if (!current) throw new CrudHttpError(404, { error: 'Role not found' })
|
|
298
323
|
const actorTenantScope = resolveActorTenantScope(ctx)
|
|
299
324
|
assertRoleTenantInScope(actorTenantScope, current.tenantId)
|
|
@@ -309,21 +334,22 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
|
|
|
309
334
|
}
|
|
310
335
|
const wantsTenantChange = parsed.tenantId !== undefined && parsed.tenantId !== current.tenantId
|
|
311
336
|
if (wantsTenantChange) {
|
|
337
|
+
if (!scope.isSuperAdmin) {
|
|
338
|
+
throw new CrudHttpError(403, { error: 'Not authorized to target this tenant.' })
|
|
339
|
+
}
|
|
312
340
|
const assignments = await em.count(UserRole, { role: current, deletedAt: null })
|
|
313
341
|
if (assignments > 0) {
|
|
314
342
|
throw new CrudHttpError(400, { error: 'Role cannot be moved to another tenant while users are assigned' })
|
|
315
343
|
}
|
|
316
344
|
await em.nativeDelete(RoleAcl, { role: parsed.id as unknown as Role })
|
|
317
345
|
}
|
|
318
|
-
const updateWhere: Record<string, unknown> = { id: parsed.id, deletedAt: null }
|
|
319
|
-
if (actorTenantScope) updateWhere.tenantId = actorTenantScope
|
|
320
346
|
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
321
347
|
const role = await de.updateOrmEntity({
|
|
322
348
|
entity: Role,
|
|
323
|
-
where:
|
|
349
|
+
where: buildScopedRoleFilter(parsed.id, scope),
|
|
324
350
|
apply: (entity) => {
|
|
325
351
|
if (parsed.name !== undefined) entity.name = parsed.name
|
|
326
|
-
if (parsed.tenantId !== undefined) entity.tenantId = parsed.tenantId
|
|
352
|
+
if (parsed.tenantId !== undefined && scope.isSuperAdmin) entity.tenantId = parsed.tenantId
|
|
327
353
|
},
|
|
328
354
|
})
|
|
329
355
|
if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
|
|
@@ -446,7 +472,8 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
446
472
|
async prepare(input, ctx) {
|
|
447
473
|
const id = requireId(input, 'Role id required')
|
|
448
474
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
449
|
-
const
|
|
475
|
+
const scope = await resolveActorScope(ctx)
|
|
476
|
+
const existing = await findOneWithDecryption(em, Role, buildScopedRoleFilter(id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
|
|
450
477
|
if (!existing) return {}
|
|
451
478
|
const actorTenantScope = resolveActorTenantScope(ctx)
|
|
452
479
|
if (actorTenantScope) {
|
|
@@ -464,7 +491,8 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
464
491
|
async execute(input, ctx) {
|
|
465
492
|
const id = requireId(input, 'Role id required')
|
|
466
493
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
467
|
-
const
|
|
494
|
+
const scope = await resolveActorScope(ctx)
|
|
495
|
+
const role = await findOneWithDecryption(em, Role, buildScopedRoleFilter(id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
|
|
468
496
|
if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
|
|
469
497
|
assertRoleTenantInScope(resolveActorTenantScope(ctx), role.tenantId)
|
|
470
498
|
const activeAssignments = await em.count(UserRole, { role, deletedAt: null })
|
|
@@ -475,7 +503,7 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
475
503
|
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
476
504
|
const deleted = await de.deleteOrmEntity({
|
|
477
505
|
entity: Role,
|
|
478
|
-
where:
|
|
506
|
+
where: buildScopedRoleFilter(id, scope),
|
|
479
507
|
soft: false,
|
|
480
508
|
})
|
|
481
509
|
if (!deleted) throw new CrudHttpError(404, { error: 'Role not found' })
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import { forbidden } from '@open-mercato/shared/lib/crud/errors'
|
|
2
|
+
import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
4
|
import { Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
4
5
|
import { enforceTenantSelection, normalizeTenantId, resolveIsSuperAdmin } from './tenantAccess'
|
|
5
6
|
|
|
7
|
+
function roleNotFound(): never {
|
|
8
|
+
throw new CrudHttpError(404, { error: 'Role not found' })
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
type RoleTenantAccessCtx = {
|
|
7
12
|
auth: {
|
|
8
13
|
tenantId?: string | null
|
|
@@ -17,40 +22,74 @@ type RoleTenantAccessCtx = {
|
|
|
17
22
|
type RoleTenantPayload = {
|
|
18
23
|
id?: unknown
|
|
19
24
|
tenantId?: unknown
|
|
25
|
+
body?: { id?: unknown } | null
|
|
26
|
+
query?: { id?: unknown } | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractRoleId(input: Record<string, unknown>): string | null {
|
|
30
|
+
const payload = input as RoleTenantPayload
|
|
31
|
+
const candidates: unknown[] = [payload.id, payload.body?.id, payload.query?.id]
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
if (typeof candidate === 'string' && candidate.length) return candidate
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function assertActorOwnsRole(
|
|
39
|
+
roleId: string,
|
|
40
|
+
ctx: RoleTenantAccessCtx,
|
|
41
|
+
): Promise<{ matched: boolean }> {
|
|
42
|
+
const auth = ctx.auth
|
|
43
|
+
if (!auth) throw forbidden('Not authorized')
|
|
44
|
+
const em = (ctx.container.resolve('em') as EntityManager)
|
|
45
|
+
const existing = await findOneWithDecryption(
|
|
46
|
+
em,
|
|
47
|
+
Role,
|
|
48
|
+
{ id: roleId, deletedAt: null },
|
|
49
|
+
{},
|
|
50
|
+
{ tenantId: null, organizationId: null },
|
|
51
|
+
)
|
|
52
|
+
if (!existing) return { matched: false }
|
|
53
|
+
|
|
54
|
+
const isSuperAdmin = await resolveIsSuperAdmin(ctx)
|
|
55
|
+
if (isSuperAdmin) return { matched: true }
|
|
56
|
+
|
|
57
|
+
const actorTenant = normalizeTenantId(auth.tenantId ?? null) ?? null
|
|
58
|
+
const existingTenantId = normalizeTenantId(existing.tenantId ?? null) ?? null
|
|
59
|
+
// Unified 404 for "out of scope" — matches grantChecks.assertActorCanAccessRoleTarget
|
|
60
|
+
// and prevents existence enumeration of foreign-tenant or global roles.
|
|
61
|
+
if (!actorTenant) roleNotFound()
|
|
62
|
+
if (existingTenantId !== actorTenant) roleNotFound()
|
|
63
|
+
return { matched: true }
|
|
20
64
|
}
|
|
21
65
|
|
|
22
66
|
export async function enforceRoleTenantAccess(
|
|
23
|
-
mode: 'create' | 'update',
|
|
67
|
+
mode: 'create' | 'update' | 'delete',
|
|
24
68
|
input: Record<string, unknown>,
|
|
25
69
|
ctx: RoleTenantAccessCtx,
|
|
26
70
|
): Promise<Record<string, unknown>> {
|
|
27
71
|
const auth = ctx.auth
|
|
28
72
|
if (!auth) throw forbidden('Not authorized')
|
|
29
|
-
const isSuperAdmin = await resolveIsSuperAdmin(ctx)
|
|
30
73
|
|
|
31
74
|
if (mode === 'create') {
|
|
32
75
|
const tenantId = await enforceTenantSelection(ctx, (input as RoleTenantPayload).tenantId)
|
|
33
76
|
return { ...input, tenantId }
|
|
34
77
|
}
|
|
35
78
|
|
|
79
|
+
if (mode === 'delete') {
|
|
80
|
+
const roleId = extractRoleId(input)
|
|
81
|
+
if (!roleId) return input
|
|
82
|
+
await assertActorOwnsRole(roleId, ctx)
|
|
83
|
+
return input
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// mode === 'update'
|
|
36
87
|
const roleIdCandidate = (input as RoleTenantPayload).id
|
|
37
88
|
const roleId = typeof roleIdCandidate === 'string' ? roleIdCandidate : null
|
|
38
89
|
if (!roleId) return input
|
|
39
90
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
if (!existing) return input
|
|
43
|
-
|
|
44
|
-
const actorTenant = normalizeTenantId(auth.tenantId ?? null) ?? null
|
|
45
|
-
const existingTenantId = normalizeTenantId(existing.tenantId ?? null) ?? null
|
|
46
|
-
|
|
47
|
-
if (!isSuperAdmin && !actorTenant) {
|
|
48
|
-
throw forbidden('Not authorized')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!isSuperAdmin && existingTenantId !== actorTenant) {
|
|
52
|
-
throw forbidden('Not authorized')
|
|
53
|
-
}
|
|
91
|
+
const ownership = await assertActorOwnsRole(roleId, ctx)
|
|
92
|
+
if (!ownership.matched) return input
|
|
54
93
|
|
|
55
94
|
if ((input as RoleTenantPayload).tenantId === undefined) {
|
|
56
95
|
return input
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hash } from 'bcryptjs'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
2
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
4
|
import { Role, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
4
5
|
import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
@@ -80,6 +81,29 @@ const DERIVED_EMAIL_ENV = {
|
|
|
80
81
|
employee: 'OM_INIT_EMPLOYEE_EMAIL',
|
|
81
82
|
} as const
|
|
82
83
|
|
|
84
|
+
export type DemoUserRole = 'superadmin' | 'admin' | 'employee'
|
|
85
|
+
export type DemoUserEmail = { role: DemoUserRole; email: string }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns the canonical list of demo user emails the setup path may have
|
|
89
|
+
* seeded. Honors OM_INIT_ADMIN_EMAIL / OM_INIT_EMPLOYEE_EMAIL exactly the
|
|
90
|
+
* same way the derived-user seeding branch does, so the deactivation loop
|
|
91
|
+
* never drifts from the seeding loop.
|
|
92
|
+
*
|
|
93
|
+
* Pure function — no side effects, safe to unit-test in isolation.
|
|
94
|
+
*
|
|
95
|
+
* @internal Exported for tests; not part of the public auth API.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveDemoUserEmails(): DemoUserEmail[] {
|
|
98
|
+
const adminEmail = readEnvValue(DERIVED_EMAIL_ENV.admin) ?? `admin@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
|
|
99
|
+
const employeeEmail = readEnvValue(DERIVED_EMAIL_ENV.employee) ?? `employee@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
|
|
100
|
+
return [
|
|
101
|
+
{ role: 'superadmin', email: DEMO_SUPERADMIN_EMAIL },
|
|
102
|
+
{ role: 'admin', email: adminEmail },
|
|
103
|
+
{ role: 'employee', email: employeeEmail },
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
export type SetupInitialTenantOptions = {
|
|
84
108
|
orgName: string
|
|
85
109
|
primaryUser: PrimaryUserInput
|
|
@@ -97,6 +121,20 @@ export type SetupInitialTenantOptions = {
|
|
|
97
121
|
* reusing or clobbering an existing organization.
|
|
98
122
|
*/
|
|
99
123
|
orgSlug?: string
|
|
124
|
+
/**
|
|
125
|
+
* Opt-in flag that allows seeding the derived admin/employee accounts when
|
|
126
|
+
* the OM_INIT_ADMIN_PASSWORD / OM_INIT_EMPLOYEE_PASSWORD env vars are unset.
|
|
127
|
+
*
|
|
128
|
+
* - In non-production (`NODE_ENV !== 'production'`): the demo accounts are
|
|
129
|
+
* seeded with the well-known `'secret'` password so local dev workflows
|
|
130
|
+
* (e.g. `mercato init`) stay predictable for developers.
|
|
131
|
+
* - In production: the fallback uses a randomly generated 96-bit password
|
|
132
|
+
* (printed once by the CLI) so an operator who deliberately opts in still
|
|
133
|
+
* avoids the historical hardcoded credential. When this flag is false/unset
|
|
134
|
+
* in production and the env vars are missing, `DerivedUserPasswordRequiredError`
|
|
135
|
+
* is thrown instead of silently seeding any account.
|
|
136
|
+
*/
|
|
137
|
+
allowDemoDerivedPasswords?: boolean
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
export class OrgSlugExistsError extends Error {
|
|
@@ -106,10 +144,19 @@ export class OrgSlugExistsError extends Error {
|
|
|
106
144
|
}
|
|
107
145
|
}
|
|
108
146
|
|
|
147
|
+
export class DerivedUserPasswordRequiredError extends Error {
|
|
148
|
+
constructor(public readonly missing: string[]) {
|
|
149
|
+
super(
|
|
150
|
+
`DERIVED_USER_PASSWORD_REQUIRED: missing ${missing.join(', ')} (set the env vars, pass allowDemoDerivedPasswords: true / --include-demo-users in non-production, or disable derived user seeding)`,
|
|
151
|
+
)
|
|
152
|
+
this.name = 'DerivedUserPasswordRequiredError'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
export type SetupInitialTenantResult = {
|
|
110
157
|
tenantId: string
|
|
111
158
|
organizationId: string
|
|
112
|
-
users: Array<{ user: User; roles: string[]; created: boolean }>
|
|
159
|
+
users: Array<{ user: User; roles: string[]; created: boolean; generatedPassword?: string | null }>
|
|
113
160
|
reusedExistingUser: boolean
|
|
114
161
|
}
|
|
115
162
|
|
|
@@ -161,7 +208,7 @@ export async function setupInitialTenant(
|
|
|
161
208
|
let tenantId: string | undefined
|
|
162
209
|
let organizationId: string | undefined
|
|
163
210
|
let reusedExistingUser = false
|
|
164
|
-
const userSnapshots: Array<{ user: User; roles: string[]; created: boolean }> = []
|
|
211
|
+
const userSnapshots: Array<{ user: User; roles: string[]; created: boolean; generatedPassword?: string | null }> = []
|
|
165
212
|
|
|
166
213
|
await em.transactional(async (tem) => {
|
|
167
214
|
if (!existingUser) return
|
|
@@ -202,6 +249,7 @@ export async function setupInitialTenant(
|
|
|
202
249
|
roles: string[]
|
|
203
250
|
name?: string | null
|
|
204
251
|
passwordHash?: string | null
|
|
252
|
+
generatedPassword?: string | null
|
|
205
253
|
}> = [
|
|
206
254
|
{ email: primaryUser.email, roles: primaryRoles, name: resolvePrimaryName(primaryUser) },
|
|
207
255
|
]
|
|
@@ -210,14 +258,43 @@ export async function setupInitialTenant(
|
|
|
210
258
|
const employeeOverride = readEnvValue(DERIVED_EMAIL_ENV.employee)
|
|
211
259
|
const adminEmail = adminOverride ?? `admin@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
|
|
212
260
|
const employeeEmail = employeeOverride ?? `employee@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
:
|
|
219
|
-
|
|
220
|
-
|
|
261
|
+
const envAdminPwd = readEnvValue('OM_INIT_ADMIN_PASSWORD')
|
|
262
|
+
const envEmployeePwd = readEnvValue('OM_INIT_EMPLOYEE_PASSWORD')
|
|
263
|
+
const isProduction = process.env.NODE_ENV === 'production'
|
|
264
|
+
const allowDemo = options.allowDemoDerivedPasswords === true
|
|
265
|
+
if (isProduction && !allowDemo) {
|
|
266
|
+
const missing: string[] = []
|
|
267
|
+
if (!envAdminPwd) missing.push('OM_INIT_ADMIN_PASSWORD')
|
|
268
|
+
if (!envEmployeePwd) missing.push('OM_INIT_EMPLOYEE_PASSWORD')
|
|
269
|
+
if (missing.length) {
|
|
270
|
+
throw new DerivedUserPasswordRequiredError(missing)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// In non-production, fall back to the well-known DEMO_DERIVED_PASSWORD so
|
|
274
|
+
// local dev workflows stay predictable (admin@/employee@ login with the
|
|
275
|
+
// documented demo password). In production we either have env-supplied
|
|
276
|
+
// values, or — for opt-in --include-demo-users flows — we generate a
|
|
277
|
+
// random one-time password and surface it via `generatedPassword` so the
|
|
278
|
+
// operator can capture it. Production callers without env vars and
|
|
279
|
+
// without the opt-in already threw above.
|
|
280
|
+
const fallbackAdminPwd = isProduction ? generateDerivedPassword() : DEMO_DERIVED_PASSWORD
|
|
281
|
+
const fallbackEmployeePwd = isProduction ? generateDerivedPassword() : DEMO_DERIVED_PASSWORD
|
|
282
|
+
const adminPasswordPlain = envAdminPwd ?? fallbackAdminPwd
|
|
283
|
+
const employeePasswordPlain = envEmployeePwd ?? fallbackEmployeePwd
|
|
284
|
+
const adminPasswordHash = await hash(adminPasswordPlain, 10)
|
|
285
|
+
const employeePasswordHash = await hash(employeePasswordPlain, 10)
|
|
286
|
+
addUniqueBaseUser(baseUsers, {
|
|
287
|
+
email: adminEmail,
|
|
288
|
+
roles: ['admin'],
|
|
289
|
+
passwordHash: adminPasswordHash,
|
|
290
|
+
generatedPassword: envAdminPwd ? null : adminPasswordPlain,
|
|
291
|
+
})
|
|
292
|
+
addUniqueBaseUser(baseUsers, {
|
|
293
|
+
email: employeeEmail,
|
|
294
|
+
roles: ['employee'],
|
|
295
|
+
passwordHash: employeePasswordHash,
|
|
296
|
+
generatedPassword: envEmployeePwd ? null : employeePasswordPlain,
|
|
297
|
+
})
|
|
221
298
|
}
|
|
222
299
|
const passwordHash = await resolvePasswordHash(primaryUser)
|
|
223
300
|
|
|
@@ -327,7 +404,7 @@ export async function setupInitialTenant(
|
|
|
327
404
|
if (base.name) user.name = base.name
|
|
328
405
|
if (confirm) user.isConfirmed = true
|
|
329
406
|
tem.persist(user)
|
|
330
|
-
userSnapshots.push({ user, roles: base.roles, created: false })
|
|
407
|
+
userSnapshots.push({ user, roles: base.roles, created: false, generatedPassword: base.generatedPassword ?? null })
|
|
331
408
|
} else {
|
|
332
409
|
user = tem.create(User, {
|
|
333
410
|
email: (encryptedPayload as any).email ?? base.email,
|
|
@@ -340,7 +417,7 @@ export async function setupInitialTenant(
|
|
|
340
417
|
createdAt: new Date(),
|
|
341
418
|
})
|
|
342
419
|
tem.persist(user)
|
|
343
|
-
userSnapshots.push({ user, roles: base.roles, created: true })
|
|
420
|
+
userSnapshots.push({ user, roles: base.roles, created: true, generatedPassword: base.generatedPassword ?? null })
|
|
344
421
|
}
|
|
345
422
|
await tem.flush()
|
|
346
423
|
for (const roleName of base.roles) {
|
|
@@ -362,7 +439,7 @@ export async function setupInitialTenant(
|
|
|
362
439
|
}
|
|
363
440
|
|
|
364
441
|
await ensureDefaultRoleAcls(em, tenantId, resolvedModules, { includeSuperadminRole })
|
|
365
|
-
await
|
|
442
|
+
await deactivateDemoUsersIfSelfOnboardingEnabled(em)
|
|
366
443
|
|
|
367
444
|
// Call module onTenantCreated hooks
|
|
368
445
|
for (const mod of resolvedModules) {
|
|
@@ -394,8 +471,8 @@ function readEnvValue(key: string): string | undefined {
|
|
|
394
471
|
}
|
|
395
472
|
|
|
396
473
|
function addUniqueBaseUser(
|
|
397
|
-
baseUsers: Array<{ email: string; roles: string[]; name?: string | null; passwordHash?: string | null }>,
|
|
398
|
-
entry: { email: string; roles: string[]; name?: string | null; passwordHash?: string | null },
|
|
474
|
+
baseUsers: Array<{ email: string; roles: string[]; name?: string | null; passwordHash?: string | null; generatedPassword?: string | null }>,
|
|
475
|
+
entry: { email: string; roles: string[]; name?: string | null; passwordHash?: string | null; generatedPassword?: string | null },
|
|
399
476
|
) {
|
|
400
477
|
if (!entry.email) return
|
|
401
478
|
const normalized = entry.email.toLowerCase()
|
|
@@ -403,6 +480,26 @@ function addUniqueBaseUser(
|
|
|
403
480
|
baseUsers.push(entry)
|
|
404
481
|
}
|
|
405
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Well-known demo password seeded for derived admin@/employee@ accounts in
|
|
485
|
+
* non-production (`NODE_ENV !== 'production'`) when no env override is set.
|
|
486
|
+
* Keeps the documented `yarn dev` / `mercato init` DX predictable. Never used
|
|
487
|
+
* in production: the production branch either consumes env-supplied values or
|
|
488
|
+
* falls back to `generateDerivedPassword()` so credentials remain non-guessable.
|
|
489
|
+
*/
|
|
490
|
+
const DEMO_DERIVED_PASSWORD = 'secret'
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Generate a 16-character base64url password (96 bits of entropy) for a derived
|
|
494
|
+
* demo user in production when no env override is provided AND the caller
|
|
495
|
+
* explicitly opted in via `allowDemoDerivedPasswords`. Surfaced via the
|
|
496
|
+
* `users[].generatedPassword` snapshot so CLI callers can print it to the
|
|
497
|
+
* operator — there is no other recovery path for these credentials.
|
|
498
|
+
*/
|
|
499
|
+
function generateDerivedPassword(): string {
|
|
500
|
+
return randomBytes(12).toString('base64url')
|
|
501
|
+
}
|
|
502
|
+
|
|
406
503
|
function isDemoModeEnabled(): boolean {
|
|
407
504
|
const parsed = parseBooleanToken(process.env.DEMO_MODE ?? '')
|
|
408
505
|
return parsed === false ? false : true
|
|
@@ -562,29 +659,60 @@ async function ensureRoleAclFor(
|
|
|
562
659
|
}
|
|
563
660
|
}
|
|
564
661
|
|
|
565
|
-
|
|
662
|
+
/**
|
|
663
|
+
* Neutralizes every demo account the setup path may have seeded
|
|
664
|
+
* (superadmin, admin, employee — honoring OM_INIT_*_EMAIL overrides)
|
|
665
|
+
* when SELF_SERVICE_ONBOARDING_ENABLED is on and the operator did not
|
|
666
|
+
* opt in to keeping demo credentials via shouldKeepDemoSuperadminDuringInit.
|
|
667
|
+
*
|
|
668
|
+
* Each user is processed in its own try/catch so a single failure (e.g.
|
|
669
|
+
* decryption error on a legacy row) does not skip the remaining accounts.
|
|
670
|
+
*
|
|
671
|
+
* @internal Exported for tests; not part of the public auth API.
|
|
672
|
+
*/
|
|
673
|
+
export async function deactivateDemoUsersIfSelfOnboardingEnabled(em: EntityManager) {
|
|
566
674
|
if (process.env.SELF_SERVICE_ONBOARDING_ENABLED !== 'true') return
|
|
567
675
|
if (shouldKeepDemoSuperadminDuringInit()) return
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
user
|
|
578
|
-
dirty =
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
676
|
+
for (const { role, email } of resolveDemoUserEmails()) {
|
|
677
|
+
try {
|
|
678
|
+
const user = await findOneWithDecryption(
|
|
679
|
+
em,
|
|
680
|
+
User,
|
|
681
|
+
{ email },
|
|
682
|
+
{},
|
|
683
|
+
{ tenantId: null, organizationId: null },
|
|
684
|
+
)
|
|
685
|
+
if (!user) continue
|
|
686
|
+
let dirty = false
|
|
687
|
+
if (user.passwordHash) {
|
|
688
|
+
user.passwordHash = null
|
|
689
|
+
dirty = true
|
|
690
|
+
}
|
|
691
|
+
if (user.isConfirmed !== false) {
|
|
692
|
+
user.isConfirmed = false
|
|
693
|
+
dirty = true
|
|
694
|
+
}
|
|
695
|
+
if (dirty) {
|
|
696
|
+
await em.persist(user).flush()
|
|
697
|
+
}
|
|
698
|
+
} catch (error) {
|
|
699
|
+
console.error(
|
|
700
|
+
`[auth.setup] failed to deactivate demo ${role} user (${email})`,
|
|
701
|
+
error,
|
|
702
|
+
)
|
|
582
703
|
}
|
|
583
|
-
} catch (error) {
|
|
584
|
-
console.error('[auth.setup] failed to deactivate demo superadmin user', error)
|
|
585
704
|
}
|
|
586
705
|
}
|
|
587
706
|
|
|
707
|
+
/**
|
|
708
|
+
* @deprecated Renamed to {@link deactivateDemoUsersIfSelfOnboardingEnabled}
|
|
709
|
+
* because the helper now neutralizes admin/employee demo accounts in addition
|
|
710
|
+
* to superadmin (security tracker finding #5). Kept as an internal alias to
|
|
711
|
+
* avoid breaking any out-of-tree caller that imported the old name; will be
|
|
712
|
+
* removed in a future major.
|
|
713
|
+
*/
|
|
714
|
+
const deactivateDemoSuperAdminIfSelfOnboardingEnabled = deactivateDemoUsersIfSelfOnboardingEnabled
|
|
715
|
+
|
|
588
716
|
/** Try to get modules from runtime registry; returns empty array if not yet registered. */
|
|
589
717
|
function tryGetModules(): Module[] {
|
|
590
718
|
try {
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
import { currencyFetchConfigCreateSchema, currencyFetchConfigUpdateSchema } from '../../data/validators'
|
|
14
14
|
|
|
15
15
|
export const metadata = {
|
|
16
|
-
requireAuth: true,
|
|
17
|
-
requireFeatures: ['currencies.fetch.
|
|
16
|
+
GET: { requireAuth: true, requireFeatures: ['currencies.fetch.view'] },
|
|
17
|
+
POST: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
|
|
18
|
+
PUT: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
|
|
19
|
+
DELETE: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export async function GET(req: NextRequest) {
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { getBundle, getBundleIntegrations, getIntegration } from '@open-mercato/shared/modules/integrations/types'
|
|
6
|
-
import type
|
|
6
|
+
import { CredentialsEncryptionUnavailableError, type CredentialsService } from '../../lib/credentials-service'
|
|
7
7
|
import type { IntegrationStateService } from '../../lib/state-service'
|
|
8
8
|
import type { IntegrationLogService } from '../../lib/log-service'
|
|
9
9
|
import { deriveIntegrationHealthStatus, getEffectiveHealthCheckConfig } from '../../lib/health-service'
|
|
@@ -60,7 +60,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
60
60
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
61
61
|
|
|
62
62
|
const [credentials, state, analyticsMap] = await Promise.all([
|
|
63
|
-
credentialsService.resolve(integration.id, scope)
|
|
63
|
+
credentialsService.resolve(integration.id, scope).catch((err) => {
|
|
64
|
+
if (err instanceof CredentialsEncryptionUnavailableError) return null
|
|
65
|
+
throw err
|
|
66
|
+
}),
|
|
64
67
|
stateService.resolveState(integration.id, scope),
|
|
65
68
|
logService.aggregateAnalytics([integration.id], scope, 30),
|
|
66
69
|
])
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
3
3
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
-
import type
|
|
4
|
+
import { CredentialsEncryptionUnavailableError, type CredentialsService } from '../lib/credentials-service'
|
|
5
5
|
import type { IntegrationStateService } from '../lib/state-service'
|
|
6
6
|
import type { IntegrationLogService } from '../lib/log-service'
|
|
7
7
|
import { deriveIntegrationHealthStatus, getEffectiveHealthCheckConfig } from '../lib/health-service'
|
|
@@ -101,7 +101,10 @@ export async function GET(req: Request) {
|
|
|
101
101
|
const baseRows: ListRow[] = await Promise.all(
|
|
102
102
|
getAllIntegrations().map(async (integration) => {
|
|
103
103
|
const [resolvedCredentials, state] = await Promise.all([
|
|
104
|
-
credentialsService.resolve(integration.id, scope)
|
|
104
|
+
credentialsService.resolve(integration.id, scope).catch((err) => {
|
|
105
|
+
if (err instanceof CredentialsEncryptionUnavailableError) return null
|
|
106
|
+
throw err
|
|
107
|
+
}),
|
|
105
108
|
stateService.resolveState(integration.id, scope),
|
|
106
109
|
])
|
|
107
110
|
|
|
@@ -13,12 +13,22 @@ import { EncryptionMap } from '../../entities/data/entities'
|
|
|
13
13
|
import { IntegrationCredentials } from '../data/entities'
|
|
14
14
|
|
|
15
15
|
const ENCRYPTED_CREDENTIALS_BLOB_KEY = '__om_encrypted_credentials_blob_v1'
|
|
16
|
-
const CREDENTIALS_ENCRYPTION_UNAVAILABLE_MESSAGE =
|
|
17
|
-
'Integration credentials encryption key is unavailable. Configure Vault KMS or TENANT_DATA_ENCRYPTION_FALLBACK_KEY.'
|
|
18
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Raised when integration credentials cannot be encrypted or decrypted because
|
|
19
|
+
* no tenant DEK is available — typically a production deployment with neither
|
|
20
|
+
* Vault nor `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` (or equivalent env vars)
|
|
21
|
+
* configured. The credentials path deliberately fails closed instead of using
|
|
22
|
+
* a hardcoded fallback secret; see security tracker finding #7.
|
|
23
|
+
*/
|
|
19
24
|
export class CredentialsEncryptionUnavailableError extends Error {
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
readonly code = 'CREDENTIALS_ENCRYPTION_UNAVAILABLE'
|
|
26
|
+
constructor(tenantId: string) {
|
|
27
|
+
super(
|
|
28
|
+
`Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` +
|
|
29
|
+
`no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ` +
|
|
30
|
+
`set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.`,
|
|
31
|
+
)
|
|
22
32
|
this.name = 'CredentialsEncryptionUnavailableError'
|
|
23
33
|
}
|
|
24
34
|
}
|
|
@@ -110,7 +120,7 @@ export function createCredentialsService(em: EntityManager) {
|
|
|
110
120
|
const created = await kms.createTenantDek(scope.tenantId)
|
|
111
121
|
if (created?.key) return created.key
|
|
112
122
|
|
|
113
|
-
throw new CredentialsEncryptionUnavailableError()
|
|
123
|
+
throw new CredentialsEncryptionUnavailableError(scope.tenantId)
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
async function encryptCredentialsBlob(
|