@open-mercato/core 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5619.1.29f01e2c42
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/dist/modules/sales/acl.js +6 -0
- package/dist/modules/sales/acl.js.map +2 -2
- package/dist/modules/sales/api/returns/route.js +43 -3
- package/dist/modules/sales/api/returns/route.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +473 -213
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +2 -0
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnEditDialog.js +125 -0
- package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +7 -0
- package/dist/modules/sales/components/documents/ReturnsSection.js +102 -6
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
- package/dist/modules/sales/data/validators.js +13 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/setup.js +1 -0
- package/dist/modules/sales/setup.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
- package/src/modules/sales/acl.ts +6 -0
- package/src/modules/sales/api/returns/route.ts +41 -3
- package/src/modules/sales/commands/returns.ts +561 -229
- package/src/modules/sales/commands/shared.ts +1 -0
- package/src/modules/sales/components/documents/ReturnEditDialog.tsx +157 -0
- package/src/modules/sales/components/documents/ReturnsSection.tsx +105 -3
- package/src/modules/sales/data/validators.ts +15 -0
- package/src/modules/sales/i18n/de.json +11 -0
- package/src/modules/sales/i18n/en.json +11 -0
- package/src/modules/sales/i18n/es.json +11 -0
- package/src/modules/sales/i18n/pl.json +11 -0
- package/src/modules/sales/setup.ts +1 -0
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
7
|
import { Attachment, AttachmentPartition } from '../../data/entities'
|
|
7
8
|
import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
|
|
8
9
|
import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
|
|
@@ -23,6 +24,24 @@ const deleteSchema = z.object({
|
|
|
23
24
|
|
|
24
25
|
const DEFAULT_CODES = new Set(DEFAULT_ATTACHMENT_PARTITIONS.map((entry) => entry.code))
|
|
25
26
|
|
|
27
|
+
type AuthLike = { sub?: string; tenantId?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null | undefined
|
|
28
|
+
|
|
29
|
+
function isSuperAdmin(auth: AuthLike): boolean {
|
|
30
|
+
return auth?.isSuperAdmin === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function canManagePartition(auth: AuthLike, entry: AttachmentPartition): boolean {
|
|
34
|
+
if (!auth?.tenantId) return false
|
|
35
|
+
if (entry.tenantId && entry.tenantId === auth.tenantId) return true
|
|
36
|
+
if (entry.tenantId == null && isSuperAdmin(auth)) return true
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function partitionVisibilityFilter(auth: AuthLike) {
|
|
41
|
+
if (isSuperAdmin(auth)) return {}
|
|
42
|
+
return { $or: [{ tenantId: null }, { tenantId: auth?.tenantId ?? null }] }
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
function serializePartition(entry: AttachmentPartition) {
|
|
27
46
|
return {
|
|
28
47
|
id: entry.id,
|
|
@@ -54,12 +73,18 @@ async function resolveEm() {
|
|
|
54
73
|
|
|
55
74
|
export async function GET(req: Request) {
|
|
56
75
|
const auth = await getAuthFromRequest(req)
|
|
57
|
-
if (!auth?.sub) {
|
|
76
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
58
77
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
59
78
|
}
|
|
60
79
|
const em = await resolveEm()
|
|
61
80
|
await ensureDefaultPartitions(em)
|
|
62
|
-
const rows = await
|
|
81
|
+
const rows = await findWithDecryption(
|
|
82
|
+
em,
|
|
83
|
+
AttachmentPartition,
|
|
84
|
+
partitionVisibilityFilter(auth),
|
|
85
|
+
{ orderBy: { createdAt: 'asc' } },
|
|
86
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
87
|
+
)
|
|
63
88
|
return NextResponse.json({ items: rows.map((entry) => serializePartition(entry)) })
|
|
64
89
|
}
|
|
65
90
|
|
|
@@ -71,7 +96,7 @@ export async function POST(req: Request) {
|
|
|
71
96
|
)
|
|
72
97
|
}
|
|
73
98
|
const auth = await getAuthFromRequest(req)
|
|
74
|
-
if (!auth?.sub) {
|
|
99
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
75
100
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
76
101
|
}
|
|
77
102
|
let json: unknown = null
|
|
@@ -90,7 +115,13 @@ export async function POST(req: Request) {
|
|
|
90
115
|
}
|
|
91
116
|
const em = await resolveEm()
|
|
92
117
|
await ensureDefaultPartitions(em)
|
|
93
|
-
const exists = await
|
|
118
|
+
const exists = await findOneWithDecryption(
|
|
119
|
+
em,
|
|
120
|
+
AttachmentPartition,
|
|
121
|
+
{ code },
|
|
122
|
+
undefined,
|
|
123
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
124
|
+
)
|
|
94
125
|
if (exists) {
|
|
95
126
|
return NextResponse.json({ error: 'Partition code already exists.' }, { status: 409 })
|
|
96
127
|
}
|
|
@@ -106,6 +137,8 @@ export async function POST(req: Request) {
|
|
|
106
137
|
? parsed.data.requiresOcr
|
|
107
138
|
: resolveDefaultAttachmentOcrEnabled(),
|
|
108
139
|
ocrModel: parsed.data.ocrModel?.trim() || null,
|
|
140
|
+
tenantId: auth.tenantId,
|
|
141
|
+
organizationId: auth.orgId ?? null,
|
|
109
142
|
})
|
|
110
143
|
await em.persist(entry).flush()
|
|
111
144
|
return NextResponse.json({ item: serializePartition(entry) }, { status: 201 })
|
|
@@ -119,7 +152,7 @@ export async function PUT(req: Request) {
|
|
|
119
152
|
)
|
|
120
153
|
}
|
|
121
154
|
const auth = await getAuthFromRequest(req)
|
|
122
|
-
if (!auth?.sub) {
|
|
155
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
123
156
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
124
157
|
}
|
|
125
158
|
let json: unknown = null
|
|
@@ -133,8 +166,14 @@ export async function PUT(req: Request) {
|
|
|
133
166
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
134
167
|
}
|
|
135
168
|
const em = await resolveEm()
|
|
136
|
-
const entry = await
|
|
137
|
-
|
|
169
|
+
const entry = await findOneWithDecryption(
|
|
170
|
+
em,
|
|
171
|
+
AttachmentPartition,
|
|
172
|
+
{ id: parsed.data.id },
|
|
173
|
+
undefined,
|
|
174
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
175
|
+
)
|
|
176
|
+
if (!entry || !canManagePartition(auth, entry)) {
|
|
138
177
|
return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
|
|
139
178
|
}
|
|
140
179
|
if (sanitizePartitionCode(parsed.data.code) !== entry.code) {
|
|
@@ -167,7 +206,7 @@ export async function DELETE(req: Request) {
|
|
|
167
206
|
)
|
|
168
207
|
}
|
|
169
208
|
const auth = await getAuthFromRequest(req)
|
|
170
|
-
if (!auth?.sub) {
|
|
209
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
171
210
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
172
211
|
}
|
|
173
212
|
const url = new URL(req.url)
|
|
@@ -177,14 +216,22 @@ export async function DELETE(req: Request) {
|
|
|
177
216
|
return NextResponse.json({ error: 'Partition id is required' }, { status: 400 })
|
|
178
217
|
}
|
|
179
218
|
const em = await resolveEm()
|
|
180
|
-
const entry = await
|
|
181
|
-
|
|
219
|
+
const entry = await findOneWithDecryption(
|
|
220
|
+
em,
|
|
221
|
+
AttachmentPartition,
|
|
222
|
+
{ id: parsed.data.id },
|
|
223
|
+
undefined,
|
|
224
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
225
|
+
)
|
|
226
|
+
if (!entry || !canManagePartition(auth, entry)) {
|
|
182
227
|
return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
|
|
183
228
|
}
|
|
184
229
|
if (DEFAULT_CODES.has(entry.code)) {
|
|
185
230
|
return NextResponse.json({ error: 'Default partitions cannot be removed.' }, { status: 400 })
|
|
186
231
|
}
|
|
187
|
-
const
|
|
232
|
+
const usageFilter: Record<string, unknown> = { partitionCode: entry.code }
|
|
233
|
+
if (entry.tenantId) usageFilter.tenantId = entry.tenantId
|
|
234
|
+
const usage = await em.count(Attachment, usageFilter)
|
|
188
235
|
if (usage > 0) {
|
|
189
236
|
return NextResponse.json({ error: 'Partition is in use and cannot be removed.' }, { status: 409 })
|
|
190
237
|
}
|
|
@@ -34,6 +34,13 @@ export class AttachmentPartition {
|
|
|
34
34
|
@Property({ name: 'ocr_model', type: 'text', nullable: true })
|
|
35
35
|
ocrModel?: string | null
|
|
36
36
|
|
|
37
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
38
|
+
organizationId?: string | null
|
|
39
|
+
|
|
40
|
+
@Property({ name: 'tenant_id', type: 'uuid', nullable: true })
|
|
41
|
+
@Index({ name: 'attachment_partitions_tenant_idx' })
|
|
42
|
+
tenantId?: string | null
|
|
43
|
+
|
|
37
44
|
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
38
45
|
createdAt: Date = new Date()
|
|
39
46
|
|
|
@@ -270,6 +270,24 @@
|
|
|
270
270
|
"nullable": true,
|
|
271
271
|
"mappedType": "text"
|
|
272
272
|
},
|
|
273
|
+
"organization_id": {
|
|
274
|
+
"name": "organization_id",
|
|
275
|
+
"type": "uuid",
|
|
276
|
+
"unsigned": false,
|
|
277
|
+
"autoincrement": false,
|
|
278
|
+
"primary": false,
|
|
279
|
+
"nullable": true,
|
|
280
|
+
"mappedType": "uuid"
|
|
281
|
+
},
|
|
282
|
+
"tenant_id": {
|
|
283
|
+
"name": "tenant_id",
|
|
284
|
+
"type": "uuid",
|
|
285
|
+
"unsigned": false,
|
|
286
|
+
"autoincrement": false,
|
|
287
|
+
"primary": false,
|
|
288
|
+
"nullable": true,
|
|
289
|
+
"mappedType": "uuid"
|
|
290
|
+
},
|
|
273
291
|
"created_at": {
|
|
274
292
|
"name": "created_at",
|
|
275
293
|
"type": "timestamptz",
|
|
@@ -294,6 +312,16 @@
|
|
|
294
312
|
"name": "attachment_partitions",
|
|
295
313
|
"schema": "public",
|
|
296
314
|
"indexes": [
|
|
315
|
+
{
|
|
316
|
+
"keyName": "attachment_partitions_tenant_idx",
|
|
317
|
+
"columnNames": [
|
|
318
|
+
"tenant_id"
|
|
319
|
+
],
|
|
320
|
+
"composite": false,
|
|
321
|
+
"constraint": false,
|
|
322
|
+
"primary": false,
|
|
323
|
+
"unique": false
|
|
324
|
+
},
|
|
297
325
|
{
|
|
298
326
|
"keyName": "attachment_partitions_code_unique",
|
|
299
327
|
"columnNames": [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
export class Migration20260524000000 extends Migration {
|
|
4
|
+
override async up(): Promise<void> {
|
|
5
|
+
this.addSql(
|
|
6
|
+
`alter table "attachment_partitions" add column "organization_id" uuid null;`,
|
|
7
|
+
)
|
|
8
|
+
this.addSql(
|
|
9
|
+
`alter table "attachment_partitions" add column "tenant_id" uuid null;`,
|
|
10
|
+
)
|
|
11
|
+
this.addSql(
|
|
12
|
+
`create index "attachment_partitions_tenant_idx" on "attachment_partitions" ("tenant_id");`,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override async down(): Promise<void> {
|
|
17
|
+
this.addSql(`drop index if exists "attachment_partitions_tenant_idx";`)
|
|
18
|
+
this.addSql(
|
|
19
|
+
`alter table "attachment_partitions" drop column "tenant_id";`,
|
|
20
|
+
)
|
|
21
|
+
this.addSql(
|
|
22
|
+
`alter table "attachment_partitions" drop column "organization_id";`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -14,11 +14,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
14
14
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
15
15
|
import { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'
|
|
16
16
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
assertActorCanModifySuperAdminRoleTarget,
|
|
20
|
-
} from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
21
|
-
import { enforceTenantSelection } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
17
|
+
import { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
18
|
+
import { enforceRoleTenantAccess } from '@open-mercato/core/modules/auth/lib/roleTenantGuard'
|
|
22
19
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
23
20
|
|
|
24
21
|
const querySchema = z.object({
|
|
@@ -88,18 +85,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
88
85
|
create: {
|
|
89
86
|
commandId: 'auth.roles.create',
|
|
90
87
|
schema: rawBodySchema,
|
|
91
|
-
mapInput: async ({ parsed, ctx }) =>
|
|
92
|
-
|
|
93
|
-
// roles are unsupported); only resolve string/omitted tenantId against the actor.
|
|
94
|
-
if (parsed.tenantId === null) return parsed
|
|
95
|
-
const requestedTenantId = typeof parsed.tenantId === 'string' ? parsed.tenantId : undefined
|
|
96
|
-
const resolvedTenantId = await enforceTenantSelection(
|
|
97
|
-
{ auth: ctx.auth, container: ctx.container },
|
|
98
|
-
requestedTenantId,
|
|
99
|
-
)
|
|
100
|
-
if (resolvedTenantId) parsed.tenantId = resolvedTenantId
|
|
101
|
-
return parsed
|
|
102
|
-
},
|
|
88
|
+
mapInput: async ({ parsed, ctx }) =>
|
|
89
|
+
enforceRoleTenantAccess('create', parsed, { auth: ctx.auth, container: ctx.container }),
|
|
103
90
|
response: ({ result }) => ({ id: String(result.id) }),
|
|
104
91
|
status: 201,
|
|
105
92
|
},
|
|
@@ -109,9 +96,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
109
96
|
mapInput: async ({ parsed, ctx }) => {
|
|
110
97
|
if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {
|
|
111
98
|
await assertCanModifySuperAdminRole(ctx.request, parsed.id)
|
|
112
|
-
await assertCanAccessRoleTarget(ctx.request, parsed.id)
|
|
113
99
|
}
|
|
114
|
-
return parsed
|
|
100
|
+
return enforceRoleTenantAccess('update', parsed, { auth: ctx.auth, container: ctx.container })
|
|
115
101
|
},
|
|
116
102
|
response: () => ({ ok: true }),
|
|
117
103
|
},
|
|
@@ -121,9 +107,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
121
107
|
const targetId = resolveDeleteTargetId(parsed, raw)
|
|
122
108
|
if (ctx.request && targetId) {
|
|
123
109
|
await assertCanModifySuperAdminRole(ctx.request, targetId)
|
|
124
|
-
await assertCanAccessRoleTarget(ctx.request, targetId)
|
|
125
110
|
}
|
|
126
|
-
return parsed
|
|
111
|
+
return enforceRoleTenantAccess('delete', parsed, { auth: ctx.auth, container: ctx.container })
|
|
127
112
|
},
|
|
128
113
|
response: () => ({ ok: true }),
|
|
129
114
|
},
|
|
@@ -275,7 +260,6 @@ export const DELETE = async (req: Request) => {
|
|
|
275
260
|
if (targetId) {
|
|
276
261
|
try {
|
|
277
262
|
await assertCanModifySuperAdminRole(req, targetId)
|
|
278
|
-
await assertCanAccessRoleTarget(req, targetId)
|
|
279
263
|
} catch (err) {
|
|
280
264
|
if (err instanceof CrudHttpError) {
|
|
281
265
|
return NextResponse.json(err.body, { status: err.status })
|
|
@@ -301,21 +285,6 @@ async function assertCanModifySuperAdminRole(req: Request, targetRoleId: string)
|
|
|
301
285
|
})
|
|
302
286
|
}
|
|
303
287
|
|
|
304
|
-
async function assertCanAccessRoleTarget(req: Request, targetRoleId: string) {
|
|
305
|
-
const auth = await getAuthFromRequest(req)
|
|
306
|
-
if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
307
|
-
const container = await createRequestContainer()
|
|
308
|
-
const em = container.resolve('em') as EntityManager
|
|
309
|
-
await assertActorCanAccessRoleTarget({
|
|
310
|
-
em,
|
|
311
|
-
rbacService: container.resolve('rbacService') as RbacService,
|
|
312
|
-
actorUserId: auth.sub,
|
|
313
|
-
tenantId: auth.tenantId ?? null,
|
|
314
|
-
organizationId: auth.orgId ?? null,
|
|
315
|
-
targetRoleId,
|
|
316
|
-
})
|
|
317
|
-
}
|
|
318
|
-
|
|
319
288
|
function resolveDeleteTargetId(parsed: unknown, raw: unknown): string | null {
|
|
320
289
|
const fromParsed = readId(parsed as Record<string, unknown> | null | undefined)
|
|
321
290
|
if (fromParsed) return fromParsed
|
|
@@ -343,7 +312,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
343
312
|
},
|
|
344
313
|
POST: {
|
|
345
314
|
summary: 'Create role',
|
|
346
|
-
description: 'Creates a new role
|
|
315
|
+
description: 'Creates a new role anchored to the caller\'s tenant. Non-superadmins cannot target another tenant; supplying a foreign `tenantId` is rejected.',
|
|
347
316
|
requestBody: {
|
|
348
317
|
contentType: 'application/json',
|
|
349
318
|
schema: roleCreateSchema,
|
package/src/modules/auth/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
5
5
|
import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
6
6
|
import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
7
7
|
import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
|
|
8
|
-
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError } from './lib/setup-app'
|
|
8
|
+
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError, DerivedUserPasswordRequiredError } from './lib/setup-app'
|
|
9
9
|
import { normalizeTenantId } from './lib/tenantAccess'
|
|
10
10
|
import { computeEmailHash, emailHashLookupValues } from './lib/emailHash'
|
|
11
11
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
@@ -401,7 +401,7 @@ const addOrganization: ModuleCli = {
|
|
|
401
401
|
},
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--with-examples] [--json]'
|
|
404
|
+
const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--include-demo-users] [--with-examples] [--json]'
|
|
405
405
|
|
|
406
406
|
const setupApp: ModuleCli = {
|
|
407
407
|
command: 'setup',
|
|
@@ -440,6 +440,10 @@ const setupApp: ModuleCli = {
|
|
|
440
440
|
const withExamples = typeof withExamplesRaw === 'boolean'
|
|
441
441
|
? withExamplesRaw
|
|
442
442
|
: parseBooleanToken(typeof withExamplesRaw === 'string' ? withExamplesRaw : null) ?? false
|
|
443
|
+
const includeDemoUsersRaw = args['include-demo-users'] ?? args.includeDemoUsers
|
|
444
|
+
const includeDemoUsers = typeof includeDemoUsersRaw === 'boolean'
|
|
445
|
+
? includeDemoUsersRaw
|
|
446
|
+
: parseBooleanToken(typeof includeDemoUsersRaw === 'string' ? includeDemoUsersRaw : null) ?? false
|
|
443
447
|
const jsonModeRaw = args.json
|
|
444
448
|
const jsonMode = typeof jsonModeRaw === 'boolean'
|
|
445
449
|
? jsonModeRaw
|
|
@@ -489,7 +493,14 @@ const setupApp: ModuleCli = {
|
|
|
489
493
|
orgSlug,
|
|
490
494
|
roleNames,
|
|
491
495
|
primaryUser: { email, password, confirm: true },
|
|
492
|
-
|
|
496
|
+
// Derived admin/employee demo accounts only land when the operator
|
|
497
|
+
// explicitly opts in via --include-demo-users. Default-deny prevents
|
|
498
|
+
// production deployments from silently seeding well-known emails.
|
|
499
|
+
includeDerivedUsers: includeDemoUsers,
|
|
500
|
+
// When demo users are explicitly requested, the operator has consented
|
|
501
|
+
// to having admin@/employee@ accounts created with autogenerated
|
|
502
|
+
// passwords (surfaced in stdout) when no env override is provided.
|
|
503
|
+
allowDemoDerivedPasswords: includeDemoUsers,
|
|
493
504
|
// When the caller passes an explicit slug, treat it as a "fresh tenant"
|
|
494
505
|
// signal — silent reuse of an existing user's tenant defeats the point
|
|
495
506
|
// of slugging the new tenant for downstream tooling.
|
|
@@ -537,7 +548,12 @@ const setupApp: ModuleCli = {
|
|
|
537
548
|
if (env.NODE_ENV !== 'test') {
|
|
538
549
|
for (const snapshot of result.users) {
|
|
539
550
|
if (snapshot.created) {
|
|
540
|
-
|
|
551
|
+
if (snapshot.generatedPassword) {
|
|
552
|
+
console.log('⚠️ GENERATED password — copy now; it is not stored in plain text')
|
|
553
|
+
console.log('🎉 Created user', snapshot.user.email, 'password:', snapshot.generatedPassword)
|
|
554
|
+
} else {
|
|
555
|
+
console.log('🎉 Created user', snapshot.user.email)
|
|
556
|
+
}
|
|
541
557
|
} else {
|
|
542
558
|
console.log(`Updated user ${snapshot.user.email}`)
|
|
543
559
|
}
|
|
@@ -552,6 +568,13 @@ const setupApp: ModuleCli = {
|
|
|
552
568
|
process.exitCode = 1
|
|
553
569
|
return
|
|
554
570
|
}
|
|
571
|
+
if (err instanceof DerivedUserPasswordRequiredError) {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`Setup aborted: ${err.message}. In production, set OM_INIT_ADMIN_PASSWORD and OM_INIT_EMPLOYEE_PASSWORD before passing --include-demo-users, or omit --include-demo-users to skip derived demo accounts entirely.\n`,
|
|
574
|
+
)
|
|
575
|
+
process.exitCode = 2
|
|
576
|
+
return
|
|
577
|
+
}
|
|
555
578
|
if (err instanceof Error && err.message === 'USER_EXISTS') {
|
|
556
579
|
process.stderr.write('Setup aborted: user already exists with the provided email.\n')
|
|
557
580
|
process.exitCode = 1
|
|
@@ -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
|