@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +28 -33
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +96 -51
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +254 -0
- package/dist/modules/auth/commands/acl.js.map +7 -0
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +9 -1
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/inbox_ops/lib/htmlToPlainText.js +1 -12
- package/dist/modules/inbox_ops/lib/htmlToPlainText.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +3 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/openapi.js +3 -0
- package/dist/modules/messages/api/openapi.js.map +2 -2
- package/dist/modules/messages/components/MessagesInboxPageClient.js +3 -13
- package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
- package/dist/modules/messages/components/inboxFilters.js +15 -0
- package/dist/modules/messages/components/inboxFilters.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js +11 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +3 -1
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js +2 -1
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
- package/dist/modules/messages/components/messageListLabels.js +14 -2
- package/dist/modules/messages/components/messageListLabels.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js +64 -14
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
- package/package.json +7 -9
- package/src/modules/auth/api/roles/acl/route.ts +42 -39
- package/src/modules/auth/api/users/acl/route.ts +177 -53
- package/src/modules/auth/commands/acl.ts +495 -0
- package/src/modules/auth/i18n/de.json +2 -0
- package/src/modules/auth/i18n/en.json +2 -0
- package/src/modules/auth/i18n/es.json +2 -0
- package/src/modules/auth/i18n/ko.json +2 -0
- package/src/modules/auth/i18n/pl.json +2 -0
- package/src/modules/auth/lib/grantChecks.ts +6 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +33 -1
- package/src/modules/inbox_ops/lib/htmlToPlainText.ts +1 -17
- package/src/modules/messages/api/[id]/route.ts +3 -0
- package/src/modules/messages/api/openapi.ts +3 -0
- package/src/modules/messages/components/MessagesInboxPageClient.tsx +5 -16
- package/src/modules/messages/components/inboxFilters.ts +36 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsConversation.ts +14 -1
- package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
- package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +2 -1
- package/src/modules/messages/components/message-detail/types.ts +3 -0
- package/src/modules/messages/components/messageListLabels.ts +52 -3
- package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +101 -14
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/ko.json +2 -0
- 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 {
|
|
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
|
|
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({
|
|
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({
|
|
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:
|
|
125
|
+
? await rbacService.loadAcl(auth.sub, { tenantId: actorTenantId, organizationId: auth.orgId ?? null })
|
|
60
126
|
: null
|
|
61
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
],
|