@open-mercato/shared 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c
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/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +23 -6
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/package.json +6 -2
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +45 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +36 -0
|
@@ -94,6 +94,244 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
94
94
|
expect(emFlush).toHaveBeenCalledTimes(1)
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
+
it('retains the creator identity for a regular tenant-scoped key', async () => {
|
|
98
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
99
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
100
|
+
id: 'key-tenant-scoped',
|
|
101
|
+
name: 'tenant scoped',
|
|
102
|
+
tenantId: 'tenant-1',
|
|
103
|
+
organizationId: null,
|
|
104
|
+
rolesJson: [],
|
|
105
|
+
sessionToken: null,
|
|
106
|
+
sessionUserId: null,
|
|
107
|
+
sessionSecretEncrypted: null,
|
|
108
|
+
opencodeSessionId: null,
|
|
109
|
+
createdBy: 'creator-1',
|
|
110
|
+
expiresAt: null,
|
|
111
|
+
lastUsedAt: null,
|
|
112
|
+
})
|
|
113
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
114
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
115
|
+
if (where.id === 'creator-1') {
|
|
116
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'creator-org' }
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const auth = await getAuthFromRequest(buildRequest('tenant-scoped-secret'))
|
|
122
|
+
|
|
123
|
+
expect(auth).toMatchObject({
|
|
124
|
+
sub: 'api_key:key-tenant-scoped',
|
|
125
|
+
tenantId: 'tenant-1',
|
|
126
|
+
orgId: null,
|
|
127
|
+
isApiKey: true,
|
|
128
|
+
keyId: 'key-tenant-scoped',
|
|
129
|
+
userId: 'creator-1',
|
|
130
|
+
})
|
|
131
|
+
// The creator remains the key's legacy identity, but its concrete
|
|
132
|
+
// organization does not narrow a tenant-scoped key.
|
|
133
|
+
expect(emFindOne).toHaveBeenCalledWith(
|
|
134
|
+
expect.anything(),
|
|
135
|
+
{ id: 'creator-1', deletedAt: null },
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('rejects a tenant-scoped regular key once its creator is soft-deleted', async () => {
|
|
140
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
141
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
142
|
+
id: 'key-deleted-creator',
|
|
143
|
+
name: 'deleted creator',
|
|
144
|
+
tenantId: 'tenant-1',
|
|
145
|
+
organizationId: null,
|
|
146
|
+
rolesJson: [],
|
|
147
|
+
sessionToken: null,
|
|
148
|
+
sessionUserId: null,
|
|
149
|
+
sessionSecretEncrypted: null,
|
|
150
|
+
opencodeSessionId: null,
|
|
151
|
+
createdBy: 'creator-1',
|
|
152
|
+
expiresAt: null,
|
|
153
|
+
lastUsedAt: null,
|
|
154
|
+
})
|
|
155
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
156
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
157
|
+
return null
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
getAuthFromRequest(buildRequest('deleted-creator-secret')),
|
|
162
|
+
).resolves.toBeNull()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('rejects a tenant-scoped regular key whose creator moved to another tenant', async () => {
|
|
166
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
167
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
168
|
+
id: 'key-foreign-creator',
|
|
169
|
+
name: 'foreign creator',
|
|
170
|
+
tenantId: 'tenant-1',
|
|
171
|
+
organizationId: null,
|
|
172
|
+
rolesJson: [],
|
|
173
|
+
sessionToken: null,
|
|
174
|
+
sessionUserId: null,
|
|
175
|
+
sessionSecretEncrypted: null,
|
|
176
|
+
opencodeSessionId: null,
|
|
177
|
+
createdBy: 'creator-1',
|
|
178
|
+
expiresAt: null,
|
|
179
|
+
lastUsedAt: null,
|
|
180
|
+
})
|
|
181
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
182
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
183
|
+
if (where.id === 'creator-1') {
|
|
184
|
+
return { id: 'creator-1', tenantId: 'tenant-2', organizationId: null }
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
getAuthFromRequest(buildRequest('foreign-creator-secret')),
|
|
191
|
+
).resolves.toBeNull()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('accepts a tenant-scoped regular key that records no creator', async () => {
|
|
195
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
196
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
197
|
+
id: 'key-no-creator',
|
|
198
|
+
name: 'no creator',
|
|
199
|
+
tenantId: 'tenant-1',
|
|
200
|
+
organizationId: null,
|
|
201
|
+
rolesJson: [],
|
|
202
|
+
sessionToken: null,
|
|
203
|
+
sessionUserId: null,
|
|
204
|
+
sessionSecretEncrypted: null,
|
|
205
|
+
opencodeSessionId: null,
|
|
206
|
+
createdBy: null,
|
|
207
|
+
expiresAt: null,
|
|
208
|
+
lastUsedAt: null,
|
|
209
|
+
})
|
|
210
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
211
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
212
|
+
return null
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
getAuthFromRequest(buildRequest('no-creator-secret')),
|
|
217
|
+
).resolves.toMatchObject({ keyId: 'key-no-creator', tenantId: 'tenant-1' })
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('retains the creator identity for an organization-scoped regular key', async () => {
|
|
221
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
222
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
223
|
+
id: 'key-organization-scoped',
|
|
224
|
+
name: 'organization scoped',
|
|
225
|
+
tenantId: 'tenant-1',
|
|
226
|
+
organizationId: 'org-1',
|
|
227
|
+
rolesJson: [],
|
|
228
|
+
sessionToken: null,
|
|
229
|
+
sessionUserId: null,
|
|
230
|
+
sessionSecretEncrypted: null,
|
|
231
|
+
opencodeSessionId: null,
|
|
232
|
+
createdBy: 'creator-1',
|
|
233
|
+
expiresAt: null,
|
|
234
|
+
lastUsedAt: null,
|
|
235
|
+
})
|
|
236
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
237
|
+
if (where.id === 'creator-1') {
|
|
238
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
getAuthFromRequest(buildRequest('organization-scoped-secret')),
|
|
245
|
+
).resolves.toMatchObject({
|
|
246
|
+
sub: 'api_key:key-organization-scoped',
|
|
247
|
+
tenantId: 'tenant-1',
|
|
248
|
+
orgId: 'org-1',
|
|
249
|
+
userId: 'creator-1',
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('rejects an organization-scoped regular key when its creator scope no longer matches', async () => {
|
|
254
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
255
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
256
|
+
id: 'key-organization-mismatch',
|
|
257
|
+
name: 'organization mismatch',
|
|
258
|
+
tenantId: 'tenant-1',
|
|
259
|
+
organizationId: 'org-1',
|
|
260
|
+
rolesJson: [],
|
|
261
|
+
sessionToken: null,
|
|
262
|
+
sessionUserId: null,
|
|
263
|
+
sessionSecretEncrypted: null,
|
|
264
|
+
opencodeSessionId: null,
|
|
265
|
+
createdBy: 'creator-1',
|
|
266
|
+
expiresAt: null,
|
|
267
|
+
lastUsedAt: null,
|
|
268
|
+
})
|
|
269
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
270
|
+
if (where.id === 'creator-1') {
|
|
271
|
+
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
getAuthFromRequest(buildRequest('organization-scope-mismatch-secret')),
|
|
278
|
+
).resolves.toBeNull()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('keeps session keys strictly bound to their persisted user and scope', async () => {
|
|
282
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
283
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
284
|
+
id: 'key-session-scoped',
|
|
285
|
+
name: 'session scoped',
|
|
286
|
+
tenantId: 'tenant-1',
|
|
287
|
+
organizationId: null,
|
|
288
|
+
rolesJson: [],
|
|
289
|
+
sessionToken: 'sess_123',
|
|
290
|
+
sessionUserId: 'session-user-1',
|
|
291
|
+
sessionSecretEncrypted: null,
|
|
292
|
+
opencodeSessionId: null,
|
|
293
|
+
createdBy: 'session-user-1',
|
|
294
|
+
expiresAt: null,
|
|
295
|
+
lastUsedAt: null,
|
|
296
|
+
})
|
|
297
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
298
|
+
if (where.id === 'session-user-1') {
|
|
299
|
+
return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
|
|
300
|
+
}
|
|
301
|
+
return null
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
getAuthFromRequest(buildRequest('session-scope-mismatch-secret')),
|
|
306
|
+
).resolves.toBeNull()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('fails closed when a row has session markers but no bound session user', async () => {
|
|
310
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
311
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
312
|
+
id: 'key-malformed-session',
|
|
313
|
+
name: 'malformed session',
|
|
314
|
+
tenantId: 'tenant-1',
|
|
315
|
+
organizationId: null,
|
|
316
|
+
rolesJson: [],
|
|
317
|
+
sessionToken: 'sess_missing_user',
|
|
318
|
+
sessionUserId: null,
|
|
319
|
+
sessionSecretEncrypted: null,
|
|
320
|
+
opencodeSessionId: null,
|
|
321
|
+
createdBy: 'creator-1',
|
|
322
|
+
expiresAt: null,
|
|
323
|
+
lastUsedAt: null,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await expect(
|
|
327
|
+
getAuthFromRequest(buildRequest('malformed-session-secret')),
|
|
328
|
+
).resolves.toBeNull()
|
|
329
|
+
expect(emFindOne).not.toHaveBeenCalledWith(
|
|
330
|
+
expect.anything(),
|
|
331
|
+
expect.objectContaining({ id: 'creator-1' }),
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
|
|
97
335
|
it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
|
|
98
336
|
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
99
337
|
findApiKeyBySecret.mockResolvedValue(null)
|
|
@@ -128,4 +366,90 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
128
366
|
await getAuthFromRequest(buildRequest('secret-invalidate'))
|
|
129
367
|
expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
|
|
130
368
|
})
|
|
369
|
+
|
|
370
|
+
describe('super-admin bit stays bounded by the effective key scope', () => {
|
|
371
|
+
async function resolveWithSuperAdminRole(input: {
|
|
372
|
+
secret: string
|
|
373
|
+
keyId: string
|
|
374
|
+
keyOrganizationId: string | null
|
|
375
|
+
aclOrganizations: string[] | null
|
|
376
|
+
}) {
|
|
377
|
+
const { RoleAcl } = await import('@open-mercato/core/modules/auth/data/entities')
|
|
378
|
+
const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
379
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
380
|
+
// A creator-less key is validated against a live tenant (and organization when bound).
|
|
381
|
+
emFindOne.mockImplementation(async (entity: unknown) => {
|
|
382
|
+
if (entity === Tenant) return { id: 'tenant-1' }
|
|
383
|
+
if (entity === Organization) return { id: input.keyOrganizationId, tenant: { id: 'tenant-1' } }
|
|
384
|
+
return null
|
|
385
|
+
})
|
|
386
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
387
|
+
id: input.keyId,
|
|
388
|
+
name: 'super admin key',
|
|
389
|
+
tenantId: 'tenant-1',
|
|
390
|
+
organizationId: input.keyOrganizationId,
|
|
391
|
+
rolesJson: ['role-super'],
|
|
392
|
+
sessionToken: null,
|
|
393
|
+
sessionUserId: null,
|
|
394
|
+
sessionSecretEncrypted: null,
|
|
395
|
+
opencodeSessionId: null,
|
|
396
|
+
createdBy: null,
|
|
397
|
+
expiresAt: null,
|
|
398
|
+
lastUsedAt: null,
|
|
399
|
+
})
|
|
400
|
+
emFind.mockImplementation(async (entity: unknown) => {
|
|
401
|
+
if (entity === RoleAcl) {
|
|
402
|
+
return [{ isSuperAdmin: true, organizationsJson: input.aclOrganizations }]
|
|
403
|
+
}
|
|
404
|
+
return []
|
|
405
|
+
})
|
|
406
|
+
return getAuthFromRequest(buildRequest(input.secret))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
it('withholds it from an organization-bound key even when a role grants it', async () => {
|
|
410
|
+
// Generic guards trust this bit before live RBAC, so an organization-restricted key must
|
|
411
|
+
// never take super-admin shortcuts outside its own scope.
|
|
412
|
+
const auth = await resolveWithSuperAdminRole({
|
|
413
|
+
secret: 'super-bound',
|
|
414
|
+
keyId: 'key-super-bound',
|
|
415
|
+
keyOrganizationId: 'org-1',
|
|
416
|
+
aclOrganizations: null,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('withholds it when the granting role ACL is itself organization-restricted', async () => {
|
|
423
|
+
const auth = await resolveWithSuperAdminRole({
|
|
424
|
+
secret: 'super-restricted',
|
|
425
|
+
keyId: 'key-super-restricted',
|
|
426
|
+
keyOrganizationId: null,
|
|
427
|
+
aclOrganizations: ['org-1'],
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('grants it for an unbound key with an unrestricted role grant', async () => {
|
|
434
|
+
const auth = await resolveWithSuperAdminRole({
|
|
435
|
+
secret: 'super-global',
|
|
436
|
+
keyId: 'key-super-global',
|
|
437
|
+
keyOrganizationId: null,
|
|
438
|
+
aclOrganizations: null,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('grants it for an unbound key whose role ACL uses the __all__ sentinel', async () => {
|
|
445
|
+
const auth = await resolveWithSuperAdminRole({
|
|
446
|
+
secret: 'super-all-sentinel',
|
|
447
|
+
keyId: 'key-super-all-sentinel',
|
|
448
|
+
keyOrganizationId: null,
|
|
449
|
+
aclOrganizations: ['__all__'],
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
|
|
453
|
+
})
|
|
454
|
+
})
|
|
131
455
|
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AuthContext } from './server'
|
|
2
|
+
|
|
3
|
+
export type PrincipalScope = {
|
|
4
|
+
tenantId: string
|
|
5
|
+
organizationId: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type AuthPrincipalType = 'user' | 'role'
|
|
9
|
+
|
|
10
|
+
export type AuthPrincipalLabel = {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
secondary: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AuthPrincipalRolePage = {
|
|
17
|
+
items: AuthPrincipalLabel[]
|
|
18
|
+
page: number
|
|
19
|
+
pageSize: number
|
|
20
|
+
total: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Public, request-scoped read boundary owned by the Auth module. */
|
|
24
|
+
export interface AuthPrincipalService {
|
|
25
|
+
principalExists(input: {
|
|
26
|
+
type: AuthPrincipalType
|
|
27
|
+
id: string
|
|
28
|
+
scope: PrincipalScope
|
|
29
|
+
}): Promise<boolean>
|
|
30
|
+
resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
|
|
31
|
+
filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
|
|
32
|
+
resolveLabels(input: {
|
|
33
|
+
type: AuthPrincipalType
|
|
34
|
+
ids: string[]
|
|
35
|
+
scope: PrincipalScope
|
|
36
|
+
}): Promise<AuthPrincipalLabel[]>
|
|
37
|
+
/**
|
|
38
|
+
* Optional additive capability for organization-eligible role pickers.
|
|
39
|
+
* Implementations must apply eligibility before pagination and bound the
|
|
40
|
+
* advertised result window so sparse ACL matches cannot amplify requests.
|
|
41
|
+
*/
|
|
42
|
+
queryActiveRolePage?(input: {
|
|
43
|
+
scope: PrincipalScope
|
|
44
|
+
search?: string
|
|
45
|
+
excludedIds?: string[]
|
|
46
|
+
page: number
|
|
47
|
+
pageSize: number
|
|
48
|
+
}): Promise<AuthPrincipalRolePage>
|
|
49
|
+
listSuperAdminUserIds(tenantId: string): Promise<string[]>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Public, request-scoped read boundary owned by the API Keys module. */
|
|
53
|
+
export interface ApiKeyPrincipalService {
|
|
54
|
+
resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type OrganizationScope = {
|
|
58
|
+
selectedId: string | null
|
|
59
|
+
filterIds: string[] | null
|
|
60
|
+
allowedIds: string[] | null
|
|
61
|
+
tenantId: string | null
|
|
62
|
+
// True when an explicit organization selection could not be honored. Reads
|
|
63
|
+
// fall back to the caller's accessible organizations; writes must fail.
|
|
64
|
+
selectionRejected?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type OrganizationScopeAcl = {
|
|
68
|
+
isSuperAdmin: boolean
|
|
69
|
+
features: string[]
|
|
70
|
+
organizations: string[] | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type OrganizationScopeRequest = Request | {
|
|
74
|
+
cookies?: { get: (name: string) => { value: string } | undefined }
|
|
75
|
+
headers?: { get(name: string): string | null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Narrow, request-scoped hierarchy boundary owned by Directory.
|
|
80
|
+
*
|
|
81
|
+
* `null` means the selected organization does not exist in the requested
|
|
82
|
+
* tenant. An empty array means it exists but has no ancestors.
|
|
83
|
+
*/
|
|
84
|
+
export interface OrganizationHierarchyService {
|
|
85
|
+
resolveAncestorIds(input: {
|
|
86
|
+
tenantId: string
|
|
87
|
+
organizationId: string
|
|
88
|
+
}): Promise<string[] | null>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Public, request-scoped organization expansion boundary owned by Directory. */
|
|
92
|
+
export interface OrganizationScopeService {
|
|
93
|
+
resolve(input: {
|
|
94
|
+
auth: AuthContext | null | undefined
|
|
95
|
+
selectedId?: string | null
|
|
96
|
+
tenantId?: string | null
|
|
97
|
+
freshAcl?: boolean
|
|
98
|
+
}): Promise<OrganizationScope>
|
|
99
|
+
resolveFresh(input: {
|
|
100
|
+
auth: NonNullable<AuthContext>
|
|
101
|
+
selectedId?: string | null
|
|
102
|
+
tenantId?: string | null
|
|
103
|
+
}): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
|
|
104
|
+
resolveForRequest(input: {
|
|
105
|
+
auth: AuthContext | null | undefined
|
|
106
|
+
request?: OrganizationScopeRequest
|
|
107
|
+
selectedId?: string | null
|
|
108
|
+
tenantId?: string | null
|
|
109
|
+
}): Promise<OrganizationScope>
|
|
110
|
+
}
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -195,13 +195,33 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
195
195
|
: []
|
|
196
196
|
const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
|
|
197
197
|
|
|
198
|
+
// A role-level super-admin grant is authorization-grade only when it is genuinely
|
|
199
|
+
// unrestricted: the grant itself must not be organization-scoped, it must belong to the
|
|
200
|
+
// key's tenant, and the key must not be bound to a single organization. RbacService applies
|
|
201
|
+
// the same intersection when projecting the scoped ACL, but generic guards
|
|
202
|
+
// (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
|
|
203
|
+
// live RBAC runs — so an organization-restricted key must never carry it.
|
|
198
204
|
let keyIsSuperAdmin = false
|
|
199
|
-
|
|
200
|
-
|
|
205
|
+
const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
|
|
206
|
+
? record.organizationId.trim()
|
|
207
|
+
: null
|
|
208
|
+
if (roleIds.length && !keyOrganizationId) {
|
|
209
|
+
const superAcls = await em.find(
|
|
201
210
|
RoleAcl,
|
|
202
|
-
{
|
|
211
|
+
{
|
|
212
|
+
role: { $in: roleIds } as any,
|
|
213
|
+
tenantId: record.tenantId ?? null,
|
|
214
|
+
isSuperAdmin: true,
|
|
215
|
+
deletedAt: null,
|
|
216
|
+
} as any,
|
|
203
217
|
)
|
|
204
|
-
keyIsSuperAdmin =
|
|
218
|
+
keyIsSuperAdmin = superAcls.some((acl) => {
|
|
219
|
+
const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
|
|
220
|
+
? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
|
|
221
|
+
: null
|
|
222
|
+
// An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
|
|
223
|
+
return !organizations || organizations.length === 0 || organizations.includes('__all__')
|
|
224
|
+
})
|
|
205
225
|
}
|
|
206
226
|
|
|
207
227
|
if (cache.shouldWriteLastUsed(record.id)) {
|
|
@@ -213,8 +233,25 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
213
233
|
}
|
|
214
234
|
}
|
|
215
235
|
|
|
216
|
-
//
|
|
217
|
-
|
|
236
|
+
// Ephemeral session keys are always user-bound. Regular keys retain their
|
|
237
|
+
// legacy creator identity, while tenant-scoped regular keys ignore only the
|
|
238
|
+
// creator's concrete organization when validating the wider key scope.
|
|
239
|
+
// Keep every session marker fail-closed so a malformed session key cannot
|
|
240
|
+
// fall back to the regular key path and escape its user/scope binding.
|
|
241
|
+
const isSessionBoundKey = Boolean(
|
|
242
|
+
record.sessionToken
|
|
243
|
+
|| record.sessionUserId
|
|
244
|
+
|| record.sessionSecretEncrypted
|
|
245
|
+
|| record.opencodeSessionId
|
|
246
|
+
)
|
|
247
|
+
const actualUserId = isSessionBoundKey
|
|
248
|
+
? record.sessionUserId ?? null
|
|
249
|
+
: record.createdBy ?? null
|
|
250
|
+
|
|
251
|
+
if (isSessionBoundKey && !actualUserId) {
|
|
252
|
+
cache.setMiss(secret)
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
218
255
|
|
|
219
256
|
if (actualUserId) {
|
|
220
257
|
const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
|
|
@@ -226,7 +263,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
226
263
|
cache.setMiss(secret)
|
|
227
264
|
return null
|
|
228
265
|
}
|
|
229
|
-
|
|
266
|
+
const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
|
|
267
|
+
if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
|
|
230
268
|
cache.setMiss(secret)
|
|
231
269
|
return null
|
|
232
270
|
}
|
|
@@ -68,7 +68,15 @@ describe('DataEngine event contract validation (issue #1421)', () => {
|
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
expect(emitted).toEqual([
|
|
71
|
-
expect.objectContaining({
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
name: 'issue1421_test.widget.created',
|
|
73
|
+
options: {
|
|
74
|
+
persistent: false,
|
|
75
|
+
tenantId: 'tenant-1',
|
|
76
|
+
organizationId: 'org-1',
|
|
77
|
+
emitterModuleId: 'issue1421_test',
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
72
80
|
])
|
|
73
81
|
expect(loggerWarn).not.toHaveBeenCalled()
|
|
74
82
|
} finally {
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -226,7 +226,12 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
226
226
|
const eventName = `${mod}.${ent}.updated`
|
|
227
227
|
warnIfUndeclaredEvent(eventName, 'setCustomFields')
|
|
228
228
|
try {
|
|
229
|
-
await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
|
|
229
|
+
await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
|
|
230
|
+
persistent: true,
|
|
231
|
+
tenantId,
|
|
232
|
+
organizationId,
|
|
233
|
+
emitterModuleId: mod,
|
|
234
|
+
})
|
|
230
235
|
} catch {
|
|
231
236
|
// non-blocking
|
|
232
237
|
}
|
|
@@ -627,6 +632,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
627
632
|
persistent: !!events.persistent,
|
|
628
633
|
tenantId: ctx.identifiers.tenantId ?? null,
|
|
629
634
|
organizationId: ctx.identifiers.organizationId ?? null,
|
|
635
|
+
emitterModuleId: events.module,
|
|
630
636
|
})
|
|
631
637
|
} catch {
|
|
632
638
|
// non-blocking
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
|
|
2
|
+
|
|
3
|
+
type EventFactoryModule = typeof import('../factory')
|
|
4
|
+
|
|
5
|
+
describe('event definition registry', () => {
|
|
6
|
+
const globalScope = globalThis as Record<string, unknown>
|
|
7
|
+
const originalRegistry = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
if (originalRegistry === undefined) {
|
|
15
|
+
delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
|
|
16
|
+
} else {
|
|
17
|
+
globalScope[GLOBAL_EVENT_REGISTRY_KEY] = originalRegistry
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('shares declarations and registered configs across isolated module instances', () => {
|
|
22
|
+
let firstInstance: EventFactoryModule | undefined
|
|
23
|
+
let secondInstance: EventFactoryModule | undefined
|
|
24
|
+
|
|
25
|
+
jest.isolateModules(() => {
|
|
26
|
+
firstInstance = require('../factory') as EventFactoryModule
|
|
27
|
+
const config = firstInstance.createModuleEvents({
|
|
28
|
+
moduleId: 'isolated_events_test',
|
|
29
|
+
events: [{
|
|
30
|
+
id: 'isolated_events_test.invalidated',
|
|
31
|
+
label: 'Invalidated',
|
|
32
|
+
crossProcessBroadcast: true,
|
|
33
|
+
}] as const,
|
|
34
|
+
})
|
|
35
|
+
firstInstance.registerEventModuleConfigs([config])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
jest.isolateModules(() => {
|
|
39
|
+
secondInstance = require('../factory') as EventFactoryModule
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(secondInstance).not.toBe(firstInstance)
|
|
43
|
+
expect(secondInstance?.isEventDeclared('isolated_events_test.invalidated')).toBe(true)
|
|
44
|
+
expect(secondInstance?.isCrossProcessBroadcastEvent('isolated_events_test.invalidated')).toBe(true)
|
|
45
|
+
expect(secondInstance?.getDeclaredEvents()).toEqual(expect.arrayContaining([
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
id: 'isolated_events_test.invalidated',
|
|
48
|
+
module: 'isolated_events_test',
|
|
49
|
+
}),
|
|
50
|
+
]))
|
|
51
|
+
expect(secondInstance?.getEventModuleConfigs()).toHaveLength(1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('refreshes a module definition during HMR without duplicating its event id', () => {
|
|
55
|
+
let firstInstance: EventFactoryModule | undefined
|
|
56
|
+
let secondInstance: EventFactoryModule | undefined
|
|
57
|
+
|
|
58
|
+
jest.isolateModules(() => {
|
|
59
|
+
firstInstance = require('../factory') as EventFactoryModule
|
|
60
|
+
firstInstance.createModuleEvents({
|
|
61
|
+
moduleId: 'hmr_events_test',
|
|
62
|
+
events: [{ id: 'hmr_events_test.changed', label: 'Before' }] as const,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
jest.isolateModules(() => {
|
|
67
|
+
secondInstance = require('../factory') as EventFactoryModule
|
|
68
|
+
secondInstance.createModuleEvents({
|
|
69
|
+
moduleId: 'hmr_events_test',
|
|
70
|
+
events: [{
|
|
71
|
+
id: 'hmr_events_test.changed',
|
|
72
|
+
label: 'After',
|
|
73
|
+
clientBroadcast: true,
|
|
74
|
+
}] as const,
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(firstInstance?.isBroadcastEvent('hmr_events_test.changed')).toBe(true)
|
|
79
|
+
expect(firstInstance?.getAllDeclaredEventIds()).toEqual(['hmr_events_test.changed'])
|
|
80
|
+
expect(firstInstance?.getDeclaredEvents()).toEqual([
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
id: 'hmr_events_test.changed',
|
|
83
|
+
label: 'After',
|
|
84
|
+
clientBroadcast: true,
|
|
85
|
+
}),
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
})
|