@open-mercato/core 0.6.8-develop.7023.1.a1960eb208 → 0.6.8-develop.7026.1.23445b3f3e

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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/roles/acl/route.js +28 -33
  3. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/acl/route.js +96 -51
  5. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  6. package/dist/modules/auth/commands/acl.js +254 -0
  7. package/dist/modules/auth/commands/acl.js.map +7 -0
  8. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +9 -1
  10. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  11. package/dist/modules/inbox_ops/lib/htmlToPlainText.js +1 -12
  12. package/dist/modules/inbox_ops/lib/htmlToPlainText.js.map +2 -2
  13. package/dist/modules/messages/api/[id]/route.js +3 -0
  14. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  15. package/dist/modules/messages/api/openapi.js +3 -0
  16. package/dist/modules/messages/api/openapi.js.map +2 -2
  17. package/dist/modules/messages/components/MessagesInboxPageClient.js +3 -13
  18. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  19. package/dist/modules/messages/components/inboxFilters.js +15 -0
  20. package/dist/modules/messages/components/inboxFilters.js.map +2 -2
  21. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js +11 -1
  22. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js.map +2 -2
  23. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +3 -1
  24. package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
  25. package/dist/modules/messages/components/message-detail/panels/thread-panel.js +2 -1
  26. package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
  27. package/dist/modules/messages/components/messageListLabels.js +14 -2
  28. package/dist/modules/messages/components/messageListLabels.js.map +2 -2
  29. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +64 -14
  30. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  31. package/package.json +7 -9
  32. package/src/modules/auth/api/roles/acl/route.ts +42 -39
  33. package/src/modules/auth/api/users/acl/route.ts +177 -53
  34. package/src/modules/auth/commands/acl.ts +495 -0
  35. package/src/modules/auth/i18n/de.json +2 -0
  36. package/src/modules/auth/i18n/en.json +2 -0
  37. package/src/modules/auth/i18n/es.json +2 -0
  38. package/src/modules/auth/i18n/ko.json +2 -0
  39. package/src/modules/auth/i18n/pl.json +2 -0
  40. package/src/modules/auth/lib/grantChecks.ts +6 -0
  41. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +33 -1
  42. package/src/modules/inbox_ops/lib/htmlToPlainText.ts +1 -17
  43. package/src/modules/messages/api/[id]/route.ts +3 -0
  44. package/src/modules/messages/api/openapi.ts +3 -0
  45. package/src/modules/messages/components/MessagesInboxPageClient.tsx +5 -16
  46. package/src/modules/messages/components/inboxFilters.ts +36 -0
  47. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts +14 -1
  48. package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
  49. package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +2 -1
  50. package/src/modules/messages/components/message-detail/types.ts +3 -0
  51. package/src/modules/messages/components/messageListLabels.ts +52 -3
  52. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +101 -14
  53. package/src/modules/sales/i18n/de.json +2 -0
  54. package/src/modules/sales/i18n/en.json +2 -0
  55. package/src/modules/sales/i18n/es.json +2 -0
  56. package/src/modules/sales/i18n/ko.json +2 -0
  57. package/src/modules/sales/i18n/pl.json +2 -0
@@ -1,13 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
- import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
7
7
  import { forbidden, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
- import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
10
- import { UserAcl } from '@open-mercato/core/modules/auth/data/entities'
9
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
10
+ import { User, UserAcl } from '@open-mercato/core/modules/auth/data/entities'
11
11
  import {
12
12
  assertActorCanAccessUserTarget,
13
13
  assertActorCanGrantAcl,
@@ -15,15 +15,25 @@ import {
15
15
  normalizeGrantFeatureList,
16
16
  } from '@open-mercato/core/modules/auth/lib/grantChecks'
17
17
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
18
- import type { EntityManager } from '@mikro-orm/postgresql'
18
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
19
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
19
20
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
21
+ import {
22
+ AUTH_USER_ACL_UPDATE_COMMAND_ID,
23
+ type AclUpdateResult,
24
+ type UserAclUpdateInput,
25
+ } from '@open-mercato/core/modules/auth/commands/acl'
20
26
 
21
- const getSchema = z.object({ userId: z.string().uuid() })
27
+ const getSchema = z.object({
28
+ userId: z.string().uuid(),
29
+ tenantId: z.string().uuid().optional(),
30
+ })
22
31
  const putSchema = z.object({
23
32
  userId: z.string().uuid(),
24
33
  isSuperAdmin: z.boolean().optional(),
25
34
  features: z.array(z.string()).optional(),
26
35
  organizations: z.array(z.string()).nullable().optional(),
36
+ tenantId: z.string().uuid().optional(),
27
37
  })
28
38
 
29
39
  export const metadata = {
@@ -46,25 +56,95 @@ const userAclUpdateResponseSchema = z.object({
46
56
 
47
57
  const userAclErrorSchema = z.object({ error: z.string() })
48
58
 
59
+ type TenantResolution = { tenantId: string | null } | { error: NextResponse }
60
+
61
+ /**
62
+ * The single tenant scope both handlers work in. GET and PUT MUST resolve it the
63
+ * same way: the admin user form PUTs the ACL panel's state on every save, so a
64
+ * GET that reads a narrower scope than the PUT writes hands the operator an
65
+ * empty form and turns the next name-only edit into a silent revocation, with no
66
+ * `updatedAt` to arm the optimistic lock against it.
67
+ *
68
+ * `user_acls.tenant_id` is NOT NULL while `users.tenant_id` is nullable, so a
69
+ * global account legitimately signs in with `auth.tenantId === null` and the
70
+ * scope has to come from the target user instead. That fallback is reserved for
71
+ * a super admin: for anyone else it would let the target pick the scope its own
72
+ * access is then checked against, and it decrypts a possibly foreign user ahead
73
+ * of every guard. A tenant-less non-super-admin therefore resolves to `null`,
74
+ * which reads as "no override" and refuses to write — the behaviour that held
75
+ * before this route resolved a scope at all. The read is also skipped whenever a
76
+ * tenant is already known.
77
+ *
78
+ * An explicit `tenantId` wins, mirroring the role ACL route (additive there and
79
+ * here; no caller sends it yet) — but only for a super admin or for the actor's
80
+ * own tenant, so it cannot widen anyone's reach. The result is that for a
81
+ * non-super-admin the resolved scope is always the actor's own.
82
+ */
83
+ async function resolveAclTenantId(args: {
84
+ em: EntityManager
85
+ auth: NonNullable<AuthContext>
86
+ actorIsSuperAdmin: boolean
87
+ userId: string
88
+ requestedTenantId?: string
89
+ }): Promise<TenantResolution> {
90
+ const authTenantId = args.auth.tenantId ?? null
91
+ if (args.requestedTenantId && args.requestedTenantId !== authTenantId && !args.actorIsSuperAdmin) {
92
+ return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
93
+ }
94
+ const known = args.requestedTenantId ?? authTenantId
95
+ if (known) return { tenantId: known }
96
+ if (!args.actorIsSuperAdmin) return { tenantId: null }
97
+ const targetUser = await findOneWithDecryption(
98
+ args.em,
99
+ User,
100
+ { id: args.userId } as FilterQuery<User>,
101
+ {},
102
+ { tenantId: null, organizationId: args.auth.orgId ?? null },
103
+ )
104
+ return { tenantId: targetUser?.tenantId ? String(targetUser.tenantId) : null }
105
+ }
106
+
49
107
  export async function GET(req: Request) {
50
108
  const auth = await getAuthFromRequest(req)
51
109
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
52
110
  const url = new URL(req.url)
53
- const parsed = getSchema.safeParse({ userId: url.searchParams.get('userId') })
111
+ const parsed = getSchema.safeParse({
112
+ userId: url.searchParams.get('userId'),
113
+ tenantId: url.searchParams.get('tenantId') || undefined,
114
+ })
54
115
  if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
55
116
  const container = await createRequestContainer()
56
117
  const em = container.resolve('em') as any
57
118
  const rbacService = container.resolve('rbacService') as any
119
+ // Every grant check is answered in the actor's own scope, never in the scope
120
+ // resolved for the record: passing the resolved one would ask a foreign tenant
121
+ // whether this caller may act there, and for a tenant-less actor — whose scope
122
+ // comes from the target — it would compare the target against itself.
123
+ const actorTenantId = auth.tenantId ?? null
58
124
  const actorAcl = auth.sub
59
- ? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
125
+ ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })
60
126
  : null
61
- if (!actorAcl?.isSuperAdmin && auth.sub) {
127
+ const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
128
+
129
+ const resolution = await resolveAclTenantId({
130
+ em: em as EntityManager,
131
+ auth,
132
+ actorIsSuperAdmin,
133
+ userId: parsed.data.userId,
134
+ requestedTenantId: parsed.data.tenantId,
135
+ })
136
+ if ('error' in resolution) return resolution.error
137
+ // An unresolvable scope reads as "no override", which is what PUT then refuses
138
+ // to write (`Tenant required`) — so the pair still cannot destroy a row.
139
+ const tenantId = resolution.tenantId
140
+
141
+ if (!actorIsSuperAdmin && auth.sub) {
62
142
  try {
63
143
  await assertActorCanModifySuperAdminUserTarget({
64
144
  em: em as EntityManager,
65
145
  rbacService: rbacService as RbacService,
66
146
  actorUserId: auth.sub,
67
- tenantId: auth.tenantId ?? null,
147
+ tenantId: actorTenantId,
68
148
  organizationId: auth.orgId ?? null,
69
149
  targetUserId: parsed.data.userId,
70
150
  actorIsSuperAdmin: false,
@@ -73,7 +153,7 @@ export async function GET(req: Request) {
73
153
  em: em as EntityManager,
74
154
  rbacService: rbacService as RbacService,
75
155
  actorUserId: auth.sub,
76
- tenantId: auth.tenantId ?? null,
156
+ tenantId: actorTenantId,
77
157
  organizationId: auth.orgId ?? null,
78
158
  targetUserId: parsed.data.userId,
79
159
  actorIsSuperAdmin: false,
@@ -81,7 +161,7 @@ export async function GET(req: Request) {
81
161
  container,
82
162
  auth,
83
163
  request: req,
84
- tenantId: auth.tenantId ?? null,
164
+ tenantId: actorTenantId,
85
165
  }),
86
166
  })
87
167
  } catch (err) {
@@ -89,7 +169,9 @@ export async function GET(req: Request) {
89
169
  throw err
90
170
  }
91
171
  }
92
- const acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId: auth.tenantId as any })
172
+ const acl = tenantId
173
+ ? await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId })
174
+ : null
93
175
  const response = acl
94
176
  ? {
95
177
  hasCustomAcl: true,
@@ -108,8 +190,8 @@ export async function GET(req: Request) {
108
190
  idField: 'id',
109
191
  resourceKind: 'auth.user_acl',
110
192
  organizationId: auth.orgId ?? null,
111
- tenantId: auth.tenantId ?? null,
112
- query: { userId: parsed.data.userId },
193
+ tenantId,
194
+ query: { userId: parsed.data.userId, tenantId },
113
195
  accessType: 'read:item',
114
196
  })
115
197
 
@@ -126,18 +208,40 @@ export async function PUT(req: Request) {
126
208
  const em = container.resolve('em') as any
127
209
  const rbacService = container.resolve('rbacService') as any
128
210
 
211
+ // Every grant check is answered in the actor's own scope, never in the scope
212
+ // resolved for the record: passing the resolved one would ask a foreign tenant
213
+ // whether this caller may act there, and for a tenant-less actor — whose scope
214
+ // comes from the target — it would compare the target against itself.
215
+ const actorTenantId = auth.tenantId ?? null
129
216
  const actorAcl = auth.sub
130
- ? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
217
+ ? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })
131
218
  : null
132
219
  const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
133
220
 
221
+ // The scope the row is read and written in. `auth.tenantId` is `string | null`
222
+ // — never `undefined`, since every producer normalizes with `?? null` — so the
223
+ // pre-fix lookup ran `tenant_id IS NULL` against a NOT NULL column: it matched
224
+ // no row, leaving create/update to fail the NOT NULL constraint and clear to
225
+ // no-op silently. Nothing cross-tenant was ever matched; the fix turns a 500
226
+ // into a working, correctly scoped write.
227
+ const resolution = await resolveAclTenantId({
228
+ em: em as EntityManager,
229
+ auth,
230
+ actorIsSuperAdmin,
231
+ userId: parsed.data.userId,
232
+ requestedTenantId: parsed.data.tenantId,
233
+ })
234
+ if ('error' in resolution) return resolution.error
235
+ const tenantId = resolution.tenantId
236
+ if (!tenantId) return NextResponse.json({ error: 'Tenant required' }, { status: 400 })
237
+
134
238
  if (!actorIsSuperAdmin && auth.sub) {
135
239
  try {
136
240
  await assertActorCanModifySuperAdminUserTarget({
137
241
  em: em as EntityManager,
138
242
  rbacService: rbacService as RbacService,
139
243
  actorUserId: auth.sub,
140
- tenantId: auth.tenantId ?? null,
244
+ tenantId: actorTenantId,
141
245
  organizationId: auth.orgId ?? null,
142
246
  targetUserId: parsed.data.userId,
143
247
  actorIsSuperAdmin: false,
@@ -146,7 +250,7 @@ export async function PUT(req: Request) {
146
250
  em: em as EntityManager,
147
251
  rbacService: rbacService as RbacService,
148
252
  actorUserId: auth.sub,
149
- tenantId: auth.tenantId ?? null,
253
+ tenantId: actorTenantId,
150
254
  organizationId: auth.orgId ?? null,
151
255
  targetUserId: parsed.data.userId,
152
256
  actorIsSuperAdmin: false,
@@ -154,7 +258,7 @@ export async function PUT(req: Request) {
154
258
  container,
155
259
  auth,
156
260
  request: req,
157
- tenantId: auth.tenantId ?? null,
261
+ tenantId: actorTenantId,
158
262
  }),
159
263
  })
160
264
  } catch (err) {
@@ -166,7 +270,7 @@ export async function PUT(req: Request) {
166
270
  const requestedFeatures = normalizeGrantFeatureList(parsed.data.features)
167
271
  const organizations = normalizeOrganizations(parsed.data.organizations)
168
272
 
169
- let acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId: auth.tenantId as any })
273
+ const acl = await em.findOne(UserAcl, { user: parsed.data.userId as any, tenantId })
170
274
  // Optimistic lock: refuse a stale per-user ACL overwrite so concurrent edits
171
275
  // cannot silently clobber each other (#2055). Strictly additive — a no-op when
172
276
  // the client sends no expected-version header; skipped when no ACL row exists.
@@ -193,7 +297,7 @@ export async function PUT(req: Request) {
193
297
  em: em as EntityManager,
194
298
  rbacService: rbacService as RbacService,
195
299
  actorUserId: auth.sub,
196
- tenantId: auth.tenantId ?? null,
300
+ tenantId: actorTenantId,
197
301
  organizationId: auth.orgId ?? null,
198
302
  isSuperAdmin: requestedIsSuperAdmin,
199
303
  features: requestedFeatures,
@@ -223,38 +327,57 @@ export async function PUT(req: Request) {
223
327
 
224
328
  const hasCustomAcl = effectiveIsSuperAdmin || effectiveFeatures.length > 0
225
329
 
226
- // Persist the ACL mutation inside a transaction so the per-user permission
227
- // write (or removal) commits atomically (proper ACL-edit transaction handling).
228
- if (!hasCustomAcl) {
229
- if (acl) {
230
- const aclToRemove = acl
231
- await withAtomicFlush(em, [() => em.remove(aclToRemove)], { transaction: true })
232
- }
233
- } else {
234
- if (!acl) {
235
- acl = em.create(UserAcl, { user: parsed.data.userId as any, tenantId: auth.tenantId as any })
236
- }
237
- const aclRecord = acl as any
238
- await withAtomicFlush(
239
- em,
240
- [
241
- () => {
242
- aclRecord.isSuperAdmin = effectiveIsSuperAdmin
243
- aclRecord.featuresJson = effectiveFeatures
244
- aclRecord.organizationsJson = organizations
245
- em.persist(aclRecord)
246
- },
247
- ],
248
- { transaction: true },
249
- )
250
- }
330
+ // What the caller asked for, handed to the command only when it exceeds what
331
+ // is about to be written. `assertActorCanGrantAcl` above refuses the blatant
332
+ // escalations; what reaches the strip is quieter — a restricted grant the
333
+ // actor does hold — and it leaves before/after identical, so the audit entry
334
+ // would otherwise be indistinguishable from a no-op and skipped as one.
335
+ //
336
+ // Features are the only axis that can be trimmed. A super-admin request is
337
+ // either honoured or refused outright above, never silently downgraded, so
338
+ // comparing the two flags here would be dead weight.
339
+ //
340
+ // Deliberately independent of the `sanitized` response flag below:
341
+ // `hasRestrictedChanges` stays false when the trimmed result equals the
342
+ // existing ACL, to avoid nagging the user about a save that changed nothing —
343
+ // but that is precisely an attempt the trail must keep.
344
+ const strippedFeatures = requestedFeatures.filter((feature) => !effectiveFeatures.includes(feature))
251
345
 
252
- // Invalidate cache for this user
253
- await rbacService.invalidateUserCache(parsed.data.userId)
254
- try {
255
- const cache = container.resolve('cache') as any
256
- if (cache) await cache.deleteByTags([`rbac:user:${parsed.data.userId}`])
257
- } catch {}
346
+ // Route the write through the command bus so the permission change lands in
347
+ // the action log. The command owns the transactional write (or removal) and
348
+ // the RBAC cache invalidation that used to live here.
349
+ const commandBus = container.resolve('commandBus') as CommandBus
350
+ // A tenant-less actor edits an override in the target user's tenant, so their
351
+ // organization belongs to a tenant the entry is not about. The bus resolves
352
+ // the log row's organization as `metadata.organizationId ??
353
+ // ctx.selectedOrganizationId ?? ctx.auth.orgId` — a `??` chain, so the
354
+ // handler's `resolveOrganizationId` cannot express "explicitly no
355
+ // organization" and the actor's would be restored. Strip it from the context
356
+ // instead, exactly as the roles route does: otherwise the entry carries a
357
+ // (target tenant, foreign organization) pair that `ActionLogService` — which
358
+ // filters organization with strict equality — can never surface for anyone.
359
+ const isForeignTenant = tenantId !== (auth.tenantId ?? null)
360
+ const actorOrgId = isForeignTenant ? null : auth.orgId ?? null
361
+ const commandCtx: CommandRuntimeContext = {
362
+ container,
363
+ auth: isForeignTenant ? { ...auth, orgId: null } : auth,
364
+ organizationScope: null,
365
+ selectedOrganizationId: actorOrgId,
366
+ organizationIds: actorOrgId ? [actorOrgId] : null,
367
+ request: req,
368
+ }
369
+ await commandBus.execute<UserAclUpdateInput, AclUpdateResult>(AUTH_USER_ACL_UPDATE_COMMAND_ID, {
370
+ input: {
371
+ userId: parsed.data.userId,
372
+ tenantId,
373
+ isSuperAdmin: effectiveIsSuperAdmin,
374
+ features: effectiveFeatures,
375
+ organizations,
376
+ clear: !hasCustomAcl,
377
+ requested: strippedFeatures.length ? { features: requestedFeatures } : null,
378
+ },
379
+ ctx: commandCtx,
380
+ })
258
381
 
259
382
  return NextResponse.json({
260
383
  ok: true,
@@ -301,12 +424,13 @@ export const openApi: OpenApiRouteDoc = {
301
424
  methods: {
302
425
  GET: {
303
426
  summary: 'Fetch user ACL',
304
- description: 'Returns custom ACL overrides for a user within the current tenant, if any.',
427
+ description: 'Returns custom ACL overrides for a user, scoped to the requested tenant when supplied and to the actor or target user tenant otherwise.',
305
428
  query: getSchema,
306
429
  responses: [
307
430
  { status: 200, description: 'User ACL entry', schema: userAclResponseSchema },
308
431
  { status: 400, description: 'Invalid user id', schema: userAclErrorSchema },
309
432
  { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },
433
+ { status: 403, description: 'Insufficient privileges for the requested tenant scope', schema: userAclErrorSchema },
310
434
  ],
311
435
  },
312
436
  PUT: {
@@ -318,7 +442,7 @@ export const openApi: OpenApiRouteDoc = {
318
442
  },
319
443
  responses: [
320
444
  { status: 200, description: 'User ACL updated', schema: userAclUpdateResponseSchema },
321
- { status: 400, description: 'Invalid payload', schema: userAclErrorSchema },
445
+ { status: 400, description: 'Invalid payload or unresolved tenant scope', schema: userAclErrorSchema },
322
446
  { status: 401, description: 'Unauthorized', schema: userAclErrorSchema },
323
447
  { status: 403, description: 'Insufficient privileges to modify ACL', schema: userAclErrorSchema },
324
448
  ],