@open-mercato/core 0.6.1-develop.3102.d6e7e6d57a → 0.6.1-develop.3113.9d12cd20b0

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.
@@ -3,17 +3,18 @@ import { NextResponse } from 'next/server'
3
3
  import { z } from 'zod'
4
4
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
5
  import { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
- import { forbidden } from '@open-mercato/shared/lib/crud/errors'
6
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
8
8
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
9
9
  import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
10
- import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
10
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
11
11
  import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
12
12
  import { E } from '#generated/entities.ids.generated'
13
13
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
14
14
  import type { EntityManager } from '@mikro-orm/postgresql'
15
15
  import { userCrudEvents, userCrudIndexer } from '@open-mercato/core/modules/auth/commands/users'
16
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
+ import { assertActorCanGrantRoleTokens } from '@open-mercato/core/modules/auth/lib/grantChecks'
17
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
17
18
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
18
19
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
19
20
  import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
@@ -102,7 +103,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
102
103
  schema: rawBodySchema,
103
104
  mapInput: async ({ parsed, ctx }) => {
104
105
  if (ctx.request) {
105
- await assertCanAssignRoles(ctx.request, parsed.roles)
106
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
106
107
  }
107
108
  return parsed
108
109
  },
@@ -117,7 +118,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
117
118
  schema: rawBodySchema,
118
119
  mapInput: async ({ parsed, ctx }) => {
119
120
  if (ctx.request) {
120
- await assertCanAssignRoles(ctx.request, parsed.roles)
121
+ await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
121
122
  }
122
123
  return parsed
123
124
  },
@@ -371,14 +372,10 @@ export async function GET(req: Request) {
371
372
  }
372
373
 
373
374
  export const POST = async (req: Request) => {
374
- const body = await req.clone().json().catch(() => ({}))
375
- await assertCanAssignRoles(req, body?.roles)
376
375
  return crud.POST(req)
377
376
  }
378
377
 
379
378
  export const PUT = async (req: Request) => {
380
- const body = await req.clone().json().catch(() => ({}))
381
- await assertCanAssignRoles(req, body?.roles)
382
379
  return crud.PUT(req)
383
380
  }
384
381
 
@@ -414,35 +411,53 @@ async function findUserIdsBySearchTokens(
414
411
  .filter((id): id is string => typeof id === 'string' && id.length > 0)
415
412
  }
416
413
 
417
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
418
-
419
- async function assertCanAssignRoles(req: Request, roles: unknown) {
414
+ async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
420
415
  if (!Array.isArray(roles)) return
421
- const values = roles
422
- .map((role) => (typeof role === 'string' ? role.trim() : null))
423
- .filter((role): role is string => !!role)
424
- if (!values.length) return
416
+ const auth = await getAuthFromRequest(req)
417
+ if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
418
+ const container = await createRequestContainer()
419
+ const em = container.resolve('em') as EntityManager
420
+ const tenantId = await resolveTargetTenantIdForRoleGrant(em, payload, auth.tenantId ?? null)
421
+ await assertActorCanGrantRoleTokens({
422
+ em,
423
+ rbacService: container.resolve('rbacService') as RbacService,
424
+ actorUserId: auth.sub,
425
+ tenantId,
426
+ organizationId: auth.orgId ?? null,
427
+ roleTokens: roles,
428
+ })
429
+ }
425
430
 
426
- let hasSuperAdmin = values.some((v) => v.toLowerCase() === 'superadmin')
427
- if (!hasSuperAdmin) {
428
- const uuids = values.filter((v) => UUID_RE.test(v))
429
- if (uuids.length) {
430
- const container = await createRequestContainer()
431
- const em = container.resolve('em') as EntityManager
432
- const matched = await em.find(Role, { id: { $in: uuids as any } })
433
- hasSuperAdmin = matched.some((r) => String(r.name).toLowerCase() === 'superadmin')
434
- }
431
+ async function resolveTargetTenantIdForRoleGrant(
432
+ em: EntityManager,
433
+ payload: Record<string, unknown>,
434
+ fallbackTenantId: string | null,
435
+ ): Promise<string | null> {
436
+ const organizationId = typeof payload.organizationId === 'string' ? payload.organizationId : null
437
+ if (organizationId) {
438
+ const organization = await findOneWithDecryption(
439
+ em,
440
+ Organization,
441
+ { id: organizationId },
442
+ { populate: ['tenant'] },
443
+ { tenantId: null, organizationId },
444
+ )
445
+ return organization?.tenant?.id ? String(organization.tenant.id) : fallbackTenantId
435
446
  }
436
- if (!hasSuperAdmin) return
437
447
 
438
- const auth = await getAuthFromRequest(req)
439
- if (!auth) throw new Error('Unauthorized')
440
- const container = await createRequestContainer()
441
- const rbac = container.resolve('rbacService') as RbacService
442
- const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
443
- if (!acl?.isSuperAdmin) {
444
- throw forbidden('Only super administrators can assign the superadmin role.')
448
+ const userId = typeof payload.id === 'string' ? payload.id : null
449
+ if (userId) {
450
+ const user = await findOneWithDecryption(
451
+ em,
452
+ User,
453
+ { id: userId, deletedAt: null },
454
+ {},
455
+ { tenantId: null, organizationId: null },
456
+ )
457
+ return user?.tenantId ? String(user.tenantId) : fallbackTenantId
445
458
  }
459
+
460
+ return fallbackTenantId
446
461
  }
447
462
 
448
463
  export const openApi: OpenApiRouteDoc = {
@@ -0,0 +1,234 @@
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
+ import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
+ import { hasFeature } from '@open-mercato/shared/security/features'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Role, RoleAcl } from '@open-mercato/core/modules/auth/data/entities'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+
8
+ type ActorAcl = {
9
+ isSuperAdmin: boolean
10
+ features: string[]
11
+ organizations: string[] | null
12
+ }
13
+
14
+ type GrantCheckContext = {
15
+ em: EntityManager
16
+ rbacService: RbacService
17
+ actorUserId: string | null | undefined
18
+ tenantId: string | null | undefined
19
+ organizationId?: string | null | undefined
20
+ }
21
+
22
+ type RoleGrantCheckInput = GrantCheckContext & {
23
+ roles: Role[]
24
+ }
25
+
26
+ type RoleTokenGrantCheckInput = GrantCheckContext & {
27
+ roleTokens: unknown
28
+ }
29
+
30
+ type FeatureGrantCheckInput = GrantCheckContext & {
31
+ features: unknown
32
+ isSuperAdmin?: boolean
33
+ organizations?: string[] | null
34
+ }
35
+
36
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
37
+
38
+ export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
39
+ const tokens = normalizeStringList(input.roleTokens)
40
+ if (!tokens.length) return []
41
+
42
+ const tenantId = normalizeNullableString(input.tenantId)
43
+ const roles = await resolveRolesForGrant(input.em, tokens, tenantId)
44
+ await assertActorCanGrantRoles({ ...input, tenantId, roles })
45
+ return roles
46
+ }
47
+
48
+ export async function assertActorCanGrantRoles(input: RoleGrantCheckInput): Promise<void> {
49
+ if (!input.roles.length) return
50
+
51
+ const tenantId = normalizeNullableString(input.tenantId)
52
+ const actorAcl = await loadActorAcl({ ...input, tenantId })
53
+ if (actorAcl.isSuperAdmin) return
54
+
55
+ if (!tenantId) {
56
+ throw forbidden('Tenant context is required to grant roles.')
57
+ }
58
+
59
+ for (const role of input.roles) {
60
+ const roleTenantId = normalizeNullableString(role.tenantId)
61
+ if (roleTenantId !== tenantId) {
62
+ throw forbidden('Cannot grant a role outside the target tenant.')
63
+ }
64
+
65
+ const acl = await findOneWithDecryption(
66
+ input.em,
67
+ RoleAcl,
68
+ { role, tenantId } as FilterQuery<RoleAcl>,
69
+ {},
70
+ { tenantId, organizationId: null },
71
+ )
72
+ if (!acl) continue
73
+
74
+ assertActorCanGrantAclSnapshot(actorAcl, {
75
+ isSuperAdmin: !!acl.isSuperAdmin,
76
+ features: normalizeStringList(acl.featuresJson),
77
+ organizations: normalizeOrganizationList(acl.organizationsJson),
78
+ })
79
+ }
80
+ }
81
+
82
+ export async function assertActorCanGrantAcl(input: FeatureGrantCheckInput): Promise<void> {
83
+ const actorAcl = await loadActorAcl(input)
84
+ if (actorAcl.isSuperAdmin) return
85
+
86
+ const tenantId = normalizeNullableString(input.tenantId)
87
+ if (!tenantId) {
88
+ throw forbidden('Tenant context is required to grant ACL features.')
89
+ }
90
+
91
+ assertActorCanGrantAclSnapshot(actorAcl, {
92
+ isSuperAdmin: !!input.isSuperAdmin,
93
+ features: normalizeStringList(input.features),
94
+ organizations: input.organizations === undefined ? undefined : normalizeOrganizationList(input.organizations),
95
+ })
96
+ }
97
+
98
+ export function normalizeGrantFeatureList(features: unknown): string[] {
99
+ return normalizeStringList(features)
100
+ }
101
+
102
+ async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
103
+ const actorUserId = normalizeNullableString(input.actorUserId)
104
+ if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')
105
+
106
+ const acl = await input.rbacService.loadAcl(actorUserId, {
107
+ tenantId: normalizeNullableString(input.tenantId),
108
+ organizationId: normalizeNullableString(input.organizationId),
109
+ })
110
+
111
+ return {
112
+ isSuperAdmin: !!acl?.isSuperAdmin,
113
+ features: normalizeStringList(acl?.features),
114
+ organizations: normalizeOrganizationList(acl?.organizations),
115
+ }
116
+ }
117
+
118
+ async function resolveRolesForGrant(
119
+ em: EntityManager,
120
+ roleTokens: string[],
121
+ tenantId: string | null,
122
+ ): Promise<Role[]> {
123
+ const roles: Role[] = []
124
+ const missingRoles: string[] = []
125
+
126
+ for (const token of roleTokens) {
127
+ const role = await resolveRoleForGrant(em, token, tenantId)
128
+ if (!role) {
129
+ missingRoles.push(token)
130
+ } else {
131
+ roles.push(role)
132
+ }
133
+ }
134
+
135
+ if (missingRoles.length) {
136
+ const labels = missingRoles.map((role) => `"${role}"`).join(', ')
137
+ throw new CrudHttpError(400, { error: `Role(s) not found: ${labels}` })
138
+ }
139
+
140
+ return roles
141
+ }
142
+
143
+ async function resolveRoleForGrant(
144
+ em: EntityManager,
145
+ token: string,
146
+ tenantId: string | null,
147
+ ): Promise<Role | null> {
148
+ const where: Record<string, unknown> = UUID_RE.test(token)
149
+ ? { id: token, deletedAt: null }
150
+ : { name: token, deletedAt: null }
151
+ if (tenantId) where.tenantId = tenantId
152
+ return findOneWithDecryption(
153
+ em,
154
+ Role,
155
+ where as FilterQuery<Role>,
156
+ {},
157
+ { tenantId, organizationId: null },
158
+ )
159
+ }
160
+
161
+ function assertActorCanGrantAclSnapshot(
162
+ actorAcl: ActorAcl,
163
+ requested: {
164
+ isSuperAdmin: boolean
165
+ features: string[]
166
+ organizations?: string[] | null
167
+ },
168
+ ): void {
169
+ if (requested.isSuperAdmin) {
170
+ throw forbidden('Only super administrators can grant super admin access.')
171
+ }
172
+
173
+ const actorGrantableFeatures = actorAcl.features.filter((grant) => grant !== '*')
174
+ for (const feature of requested.features) {
175
+ if (feature === '*') {
176
+ throw forbidden('Only super administrators can grant global wildcard access.')
177
+ }
178
+ if (isWildcardFeature(feature)) {
179
+ if (!hasFeature(actorGrantableFeatures, feature)) {
180
+ throw forbidden(`Cannot grant feature wildcard ${feature}.`)
181
+ }
182
+ continue
183
+ }
184
+ if (!hasFeature(actorGrantableFeatures, feature)) {
185
+ throw forbidden(`Cannot grant feature ${feature}.`)
186
+ }
187
+ }
188
+
189
+ if (requested.organizations !== undefined) {
190
+ assertActorCanGrantOrganizations(actorAcl.organizations, requested.organizations)
191
+ }
192
+ }
193
+
194
+ function assertActorCanGrantOrganizations(
195
+ actorOrganizations: string[] | null,
196
+ requestedOrganizations: string[] | null,
197
+ ): void {
198
+ if (actorOrganizations === null || actorOrganizations.includes('__all__')) return
199
+
200
+ if (requestedOrganizations === null || requestedOrganizations.includes('__all__')) {
201
+ throw forbidden('Cannot grant unrestricted organization access.')
202
+ }
203
+
204
+ for (const organizationId of requestedOrganizations) {
205
+ if (!actorOrganizations.includes(organizationId)) {
206
+ throw forbidden('Cannot grant organization access outside actor scope.')
207
+ }
208
+ }
209
+ }
210
+
211
+ function normalizeStringList(values: unknown): string[] {
212
+ if (!Array.isArray(values)) return []
213
+ const dedup = new Set<string>()
214
+ for (const value of values) {
215
+ if (typeof value !== 'string') continue
216
+ const trimmed = value.trim()
217
+ if (!trimmed) continue
218
+ dedup.add(trimmed)
219
+ }
220
+ return Array.from(dedup)
221
+ }
222
+
223
+ function normalizeOrganizationList(values: unknown): string[] | null {
224
+ if (values === null || values === undefined) return null
225
+ return normalizeStringList(values)
226
+ }
227
+
228
+ function normalizeNullableString(value: unknown): string | null {
229
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
230
+ }
231
+
232
+ function isWildcardFeature(feature: string): boolean {
233
+ return feature.endsWith('.*')
234
+ }