@open-mercato/core 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7016.1.4ed7b1e49d

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +1 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/api_keys/di.js +9 -0
  5. package/dist/modules/api_keys/di.js.map +7 -0
  6. package/dist/modules/api_keys/services/principalService.js +31 -0
  7. package/dist/modules/api_keys/services/principalService.js.map +7 -0
  8. package/dist/modules/attachments/di.js +11 -1
  9. package/dist/modules/attachments/di.js.map +2 -2
  10. package/dist/modules/attachments/index.js.map +1 -1
  11. package/dist/modules/attachments/lib/attachment-service.js +313 -0
  12. package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
  13. package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
  14. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  15. package/dist/modules/attachments/lib/upload-limits.js +6 -2
  16. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  17. package/dist/modules/auth/data/entities.js.map +2 -2
  18. package/dist/modules/auth/di.js +35 -9
  19. package/dist/modules/auth/di.js.map +2 -2
  20. package/dist/modules/auth/services/principalService.js +200 -0
  21. package/dist/modules/auth/services/principalService.js.map +7 -0
  22. package/dist/modules/auth/services/rbacService.js +67 -24
  23. package/dist/modules/auth/services/rbacService.js.map +3 -3
  24. package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
  25. package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
  26. package/dist/modules/directory/di.js +9 -0
  27. package/dist/modules/directory/di.js.map +2 -2
  28. package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
  29. package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
  30. package/dist/modules/directory/services/organizationScopeService.js +56 -0
  31. package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
  32. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  33. package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
  34. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  35. package/dist/modules/workflows/lib/activity-executor.js +4 -0
  36. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  37. package/package.json +7 -7
  38. package/src/modules/api_keys/api/keys/route.ts +3 -1
  39. package/src/modules/api_keys/di.ts +7 -0
  40. package/src/modules/api_keys/services/principalService.ts +33 -0
  41. package/src/modules/attachments/AGENTS.md +15 -0
  42. package/src/modules/attachments/di.ts +16 -0
  43. package/src/modules/attachments/i18n/de.json +5 -0
  44. package/src/modules/attachments/i18n/en.json +5 -0
  45. package/src/modules/attachments/i18n/es.json +5 -0
  46. package/src/modules/attachments/i18n/ko.json +5 -0
  47. package/src/modules/attachments/i18n/pl.json +5 -0
  48. package/src/modules/attachments/index.ts +10 -0
  49. package/src/modules/attachments/lib/attachment-service.ts +468 -0
  50. package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
  51. package/src/modules/attachments/lib/upload-limits.ts +6 -2
  52. package/src/modules/auth/data/entities.ts +4 -1
  53. package/src/modules/auth/di.ts +46 -16
  54. package/src/modules/auth/services/principalService.ts +266 -0
  55. package/src/modules/auth/services/rbacService.ts +110 -24
  56. package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
  57. package/src/modules/directory/di.ts +14 -2
  58. package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
  59. package/src/modules/directory/services/organizationScopeService.ts +85 -0
  60. package/src/modules/directory/utils/organizationScope.ts +4 -15
  61. package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
  62. package/src/modules/workflows/lib/activity-executor.ts +9 -0
@@ -0,0 +1,266 @@
1
+ import type { FilterQuery } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { sql } from 'kysely'
4
+ import type {
5
+ AuthPrincipalLabel,
6
+ AuthPrincipalRolePage,
7
+ AuthPrincipalService,
8
+ OrganizationHierarchyService,
9
+ PrincipalScope,
10
+ } from '@open-mercato/shared/lib/auth/principal-service'
11
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
12
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
+ import { Role, RoleAcl, User, UserRole } from '../data/entities'
14
+ import { listSuperAdminUserIds } from '../lib/grantChecks'
15
+ import { resolveRoleOrganizationScope, roleAclGrantsPrincipalOrganization } from './roleOrganizationScope'
16
+
17
+ const MAX_ROLE_PAGE_SIZE = 100
18
+ const MAX_ROLE_RESULTS = 1_000
19
+
20
+ type AuthPrincipalDatabase = {
21
+ roles: {
22
+ id: string
23
+ tenant_id: string
24
+ name: string
25
+ deleted_at: Date | null
26
+ }
27
+ role_acls: {
28
+ role_id: string
29
+ tenant_id: string
30
+ organizations_json: string[] | null
31
+ deleted_at: Date | null
32
+ }
33
+ }
34
+
35
+ function normalizeIds(ids: string[]): string[] {
36
+ return Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)))
37
+ }
38
+
39
+ function relationId(value: unknown): string | null {
40
+ if (typeof value === 'string') return value.trim() || null
41
+ if (!value || typeof value !== 'object') return null
42
+ const id = (value as { id?: unknown }).id
43
+ return typeof id === 'string' && id.trim().length > 0 ? id.trim() : null
44
+ }
45
+
46
+ export class DefaultAuthPrincipalService implements AuthPrincipalService {
47
+ constructor(
48
+ private readonly em: EntityManager,
49
+ private readonly organizationHierarchyService?: OrganizationHierarchyService,
50
+ ) {}
51
+
52
+ async principalExists(input: {
53
+ type: 'user' | 'role'
54
+ id: string
55
+ scope: PrincipalScope
56
+ }): Promise<boolean> {
57
+ if (input.type === 'role') {
58
+ return (await this.filterActiveRoleIds([input.id], input.scope)).includes(input.id)
59
+ }
60
+ return Boolean(await findOneWithDecryption(
61
+ this.em,
62
+ User,
63
+ {
64
+ id: input.id,
65
+ tenantId: input.scope.tenantId,
66
+ deletedAt: null,
67
+ $or: [{ organizationId: null }, { organizationId: input.scope.organizationId }],
68
+ } as FilterQuery<User>,
69
+ { fields: ['id'] as const },
70
+ input.scope,
71
+ ))
72
+ }
73
+
74
+ async resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]> {
75
+ const links = await findWithDecryption(
76
+ this.em,
77
+ UserRole,
78
+ {
79
+ user: userId,
80
+ deletedAt: null,
81
+ role: { tenantId: scope.tenantId, deletedAt: null },
82
+ } as FilterQuery<UserRole>,
83
+ { fields: ['role'] as const },
84
+ scope,
85
+ )
86
+ return this.filterActiveRoleIds(
87
+ normalizeIds(links.flatMap((link) => {
88
+ const id = relationId(link.role)
89
+ return id ? [id] : []
90
+ })),
91
+ scope,
92
+ )
93
+ }
94
+
95
+ async filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]> {
96
+ const ids = normalizeIds(roleIds)
97
+ if (ids.length === 0) return []
98
+ const roles = await findWithDecryption(
99
+ this.em,
100
+ Role,
101
+ { id: { $in: ids }, tenantId: scope.tenantId, deletedAt: null } as FilterQuery<Role>,
102
+ { fields: ['id'] as const },
103
+ scope,
104
+ )
105
+ const activeIds = normalizeIds(roles.map((role) => role.id))
106
+ if (activeIds.length === 0) return []
107
+ const roleOrganizationScope = await resolveRoleOrganizationScope(
108
+ this.organizationHierarchyService,
109
+ scope.tenantId,
110
+ scope.organizationId,
111
+ )
112
+ const acls = await findWithDecryption(
113
+ this.em,
114
+ RoleAcl,
115
+ {
116
+ tenantId: scope.tenantId,
117
+ role: { $in: activeIds },
118
+ deletedAt: null,
119
+ } as FilterQuery<RoleAcl>,
120
+ { fields: ['role', 'organizationsJson'] as const },
121
+ scope,
122
+ )
123
+ const eligibilityById = new Map<string, boolean>()
124
+ for (const acl of acls) {
125
+ const id = relationId(acl.role)
126
+ if (!id) continue
127
+ eligibilityById.set(
128
+ id,
129
+ eligibilityById.get(id) === true || roleAclGrantsPrincipalOrganization(acl, roleOrganizationScope),
130
+ )
131
+ }
132
+ // A tenant role without an ACL remains a valid explicit-share principal.
133
+ // Only an existing ACL can restrict that role away from this organization.
134
+ return activeIds.filter((id) => eligibilityById.get(id) !== false)
135
+ }
136
+
137
+ async resolveLabels(input: {
138
+ type: 'user' | 'role'
139
+ ids: string[]
140
+ scope: PrincipalScope
141
+ }): Promise<AuthPrincipalLabel[]> {
142
+ const ids = normalizeIds(input.ids)
143
+ if (ids.length === 0) return []
144
+ if (input.type === 'role') {
145
+ const eligibleIds = await this.filterActiveRoleIds(ids, input.scope)
146
+ if (eligibleIds.length === 0) return []
147
+ const roles = await findWithDecryption(
148
+ this.em,
149
+ Role,
150
+ { id: { $in: eligibleIds }, tenantId: input.scope.tenantId, deletedAt: null } as FilterQuery<Role>,
151
+ { fields: ['id', 'name'] as const },
152
+ input.scope,
153
+ )
154
+ return roles.map((role) => ({ id: role.id, label: role.name, secondary: null }))
155
+ }
156
+ const users = await findWithDecryption(
157
+ this.em,
158
+ User,
159
+ {
160
+ id: { $in: ids },
161
+ tenantId: input.scope.tenantId,
162
+ deletedAt: null,
163
+ $or: [{ organizationId: null }, { organizationId: input.scope.organizationId }],
164
+ } as FilterQuery<User>,
165
+ { fields: ['id', 'name', 'email'] as const },
166
+ input.scope,
167
+ )
168
+ return users.map((user) => ({
169
+ id: user.id,
170
+ label: user.name?.trim() || user.email,
171
+ secondary: user.name?.trim() && user.email !== user.name.trim() ? user.email : null,
172
+ }))
173
+ }
174
+
175
+ async queryActiveRolePage(input: {
176
+ scope: PrincipalScope
177
+ search?: string
178
+ excludedIds?: string[]
179
+ page: number
180
+ pageSize: number
181
+ }): Promise<AuthPrincipalRolePage> {
182
+ const page = Math.max(1, Math.floor(input.page))
183
+ const pageSize = Math.min(MAX_ROLE_PAGE_SIZE, Math.max(1, Math.floor(input.pageSize)))
184
+ const offset = (page - 1) * pageSize
185
+ const excludedIds = normalizeIds(input.excludedIds ?? [])
186
+ const roleOrganizationScope = await resolveRoleOrganizationScope(
187
+ this.organizationHierarchyService,
188
+ input.scope.tenantId,
189
+ input.scope.organizationId,
190
+ )
191
+ const db = this.em.getKysely<AuthPrincipalDatabase>()
192
+ let eligibleRoles = db
193
+ .selectFrom('roles as r')
194
+ .where('r.tenant_id', '=', input.scope.tenantId)
195
+ .where('r.deleted_at', 'is', null)
196
+
197
+ if (roleOrganizationScope) {
198
+ const allowedOrganizations = Array.from(roleOrganizationScope)
199
+ const organizationPredicates = [
200
+ sql<boolean>`ra.organizations_json is null`,
201
+ sql<boolean>`ra.organizations_json::jsonb @> ${JSON.stringify(['__all__'])}::jsonb`,
202
+ ...allowedOrganizations.map((organizationId) => (
203
+ sql<boolean>`ra.organizations_json::jsonb @> ${JSON.stringify([organizationId])}::jsonb`
204
+ )),
205
+ ]
206
+ const organizationAllowed = sql<boolean>`(${sql.join(organizationPredicates, sql` or `)})`
207
+ eligibleRoles = eligibleRoles.where(sql<boolean>`(
208
+ not exists (
209
+ select 1
210
+ from role_acls as ra_any
211
+ where ra_any.role_id = r.id
212
+ and ra_any.tenant_id = ${input.scope.tenantId}
213
+ and ra_any.deleted_at is null
214
+ )
215
+ or exists (
216
+ select 1
217
+ from role_acls as ra
218
+ where ra.role_id = r.id
219
+ and ra.tenant_id = ${input.scope.tenantId}
220
+ and ra.deleted_at is null
221
+ and ${organizationAllowed}
222
+ )
223
+ )`)
224
+ }
225
+ if (input.search?.trim()) {
226
+ const searchPattern = `%${escapeLikePattern(input.search.trim())}%`
227
+ eligibleRoles = eligibleRoles.where(sql<boolean>`r.name ilike ${searchPattern}`)
228
+ }
229
+ if (excludedIds.length > 0) {
230
+ eligibleRoles = eligibleRoles.where('r.id', 'not in', excludedIds)
231
+ }
232
+
233
+ const boundedRoleIds = eligibleRoles
234
+ .select('r.id')
235
+ .limit(MAX_ROLE_RESULTS)
236
+ .as('bounded_roles')
237
+ const countQuery = db
238
+ .selectFrom(boundedRoleIds)
239
+ .select(sql<string>`count(*)`.as('count'))
240
+ const pageQuery = eligibleRoles
241
+ .select(['r.id', 'r.name'])
242
+ .orderBy('r.id', 'asc')
243
+ .limit(offset < MAX_ROLE_RESULTS ? Math.min(pageSize, MAX_ROLE_RESULTS - offset) : 0)
244
+ .offset(Math.min(offset, MAX_ROLE_RESULTS))
245
+
246
+ const [rows, countRow] = await Promise.all([
247
+ pageQuery.execute() as Promise<Array<{ id: string; name: string }>>,
248
+ countQuery.executeTakeFirst() as Promise<{ count?: string | number } | undefined>,
249
+ ])
250
+ const parsedTotal = Number(countRow?.count ?? 0)
251
+ const total = Number.isFinite(parsedTotal)
252
+ ? Math.min(MAX_ROLE_RESULTS, Math.max(0, Math.floor(parsedTotal)))
253
+ : 0
254
+
255
+ return {
256
+ items: rows.map((role) => ({ id: role.id, label: role.name, secondary: null })),
257
+ page,
258
+ pageSize,
259
+ total,
260
+ }
261
+ }
262
+
263
+ async listSuperAdminUserIds(tenantId: string): Promise<string[]> {
264
+ return Array.from(await listSuperAdminUserIds(this.em, tenantId))
265
+ }
266
+ }
@@ -4,12 +4,14 @@ import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
4
4
  import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
5
5
  import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
6
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
7
8
  import { buildOrgScopeUserCacheTag, buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
8
9
  import {
9
10
  authorizeFeatures,
10
11
  resolveEffectiveFeatures,
11
12
  } from '@open-mercato/shared/security/featurePolicy'
12
13
  import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
14
+ import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
13
15
 
14
16
  interface AclData {
15
17
  isSuperAdmin: boolean
@@ -29,12 +31,22 @@ function isAclData(value: unknown): value is AclData {
29
31
  return true
30
32
  }
31
33
 
34
+ function isRestrictedRoleAcl(acl: Pick<RoleAcl, 'organizationsJson'>): boolean {
35
+ return Array.isArray(acl.organizationsJson)
36
+ && acl.organizationsJson.length > 0
37
+ && !acl.organizationsJson.includes('__all__')
38
+ }
39
+
32
40
  export class RbacService {
33
41
  private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default
34
42
  private cache: CacheStrategy | null = null
35
43
  private globalSuperAdminCache = new Map<string, boolean>()
36
44
 
37
- constructor(private em: EntityManager, cache?: CacheStrategy) {
45
+ constructor(
46
+ private em: EntityManager,
47
+ cache?: CacheStrategy,
48
+ private readonly organizationHierarchyService?: OrganizationHierarchyService,
49
+ ) {
38
50
  this.cache = cache || null
39
51
  }
40
52
 
@@ -51,13 +63,6 @@ export class RbacService {
51
63
  return authorizeFeatures(required, { grantedFeatures: granted })
52
64
  }
53
65
 
54
- private roleAclAllowsOrganization(acl: RoleAcl, organizationId: string | null | undefined): boolean {
55
- if (!organizationId) return true
56
- const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
57
- if (!organizations || !organizations.length || organizations.includes('__all__')) return true
58
- return organizations.includes(organizationId)
59
- }
60
-
61
66
  private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {
62
67
  return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
63
68
  }
@@ -91,6 +96,10 @@ export class RbacService {
91
96
 
92
97
  if (scope.tenantId) {
93
98
  tags.push(this.getTenantTag(scope.tenantId))
99
+ // Scoped role projections depend on the selected organization's ancestor
100
+ // chain, so Directory hierarchy changes must evict them as well as the
101
+ // separately cached OrganizationScope entries.
102
+ tags.push(buildOrgScopeTenantCacheTag(scope.tenantId))
94
103
  }
95
104
 
96
105
  if (scope.organizationId) {
@@ -203,8 +212,10 @@ export class RbacService {
203
212
  this.globalSuperAdminCache.set(userId, false)
204
213
  return false
205
214
  }
206
- const roleSuper = await em.findOne(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)
207
- const result = !!(roleSuper && (roleSuper as any).isSuperAdmin)
215
+ const roleSupers = await em.find(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)
216
+ const result = roleSupers.some((roleAcl) => (
217
+ !!roleAcl.isSuperAdmin && !isRestrictedRoleAcl(roleAcl)
218
+ ))
208
219
  this.globalSuperAdminCache.set(userId, result)
209
220
  return result
210
221
  }
@@ -239,6 +250,10 @@ export class RbacService {
239
250
  const cached = await this.getFromCache(cacheKey)
240
251
  if (cached) return cached
241
252
 
253
+ // Direct user-level super-admin grants and unrestricted role-level
254
+ // super-admin grants are global. Organization-restricted role grants are
255
+ // deliberately excluded by isGlobalSuperAdmin and continue through the
256
+ // scoped ACL projection below.
242
257
  if (!userId.startsWith('api_key:')) {
243
258
  if (await this.isGlobalSuperAdmin(userId)) {
244
259
  const result = { isSuperAdmin: true, features: ['*'], organizations: null }
@@ -258,28 +273,71 @@ export class RbacService {
258
273
  }
259
274
  const tenantId = scope.tenantId || key.tenantId || null
260
275
  const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []
276
+ const keyOrganizationId = typeof key.organizationId === 'string' && key.organizationId.trim().length > 0
277
+ ? key.organizationId.trim()
278
+ : null
279
+ const evaluatedOrganizationId = scope.organizationId || keyOrganizationId
261
280
  let isSuper = false
262
281
  const features: string[] = []
263
- let organizations: string[] | null = key.organizationId ? [key.organizationId] : null
282
+ let roleOrganizations: string[] | null = []
283
+ let hasApplicableRestrictedRole = false
264
284
  if (tenantId && roleIds.length) {
285
+ const roleOrganizationScope = await resolveRoleOrganizationScope(
286
+ this.organizationHierarchyService,
287
+ tenantId,
288
+ evaluatedOrganizationId,
289
+ )
265
290
  const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)
266
291
  for (const acl of racls) {
267
- isSuper = isSuper || !!acl.isSuperAdmin
268
- if (Array.isArray(acl.featuresJson)) {
269
- for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)
292
+ if (roleAclAllowsOrganization(acl, roleOrganizationScope)) {
293
+ isSuper = isSuper || !!acl.isSuperAdmin
294
+ if (Array.isArray(acl.featuresJson)) {
295
+ for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)
296
+ }
297
+ if (isRestrictedRoleAcl(acl)) hasApplicableRestrictedRole = true
270
298
  }
271
- if (organizations !== null) {
272
- if (acl.organizationsJson == null) {
273
- organizations = null
299
+ if (roleOrganizations !== null) {
300
+ if (acl.organizationsJson == null || (Array.isArray(acl.organizationsJson) && acl.organizationsJson.length === 0)) {
301
+ roleOrganizations = null
274
302
  } else if (Array.isArray(acl.organizationsJson) && acl.organizationsJson.includes('__all__')) {
275
- organizations = null
303
+ roleOrganizations = null
276
304
  } else {
277
- organizations = Array.from(new Set([...(organizations || []), ...acl.organizationsJson]))
305
+ roleOrganizations = Array.from(new Set([
306
+ ...roleOrganizations,
307
+ ...(Array.isArray(acl.organizationsJson) ? acl.organizationsJson : []),
308
+ ]))
278
309
  }
279
310
  }
280
311
  }
312
+ if (
313
+ roleOrganizations !== null
314
+ && evaluatedOrganizationId
315
+ && roleOrganizationScope !== null
316
+ && roleOrganizationScope.size > 0
317
+ && hasApplicableRestrictedRole
318
+ && !roleOrganizations.includes(evaluatedOrganizationId)
319
+ ) {
320
+ roleOrganizations.push(evaluatedOrganizationId)
321
+ }
322
+ }
323
+ let organizations = roleOrganizations
324
+ if (keyOrganizationId) {
325
+ const keyOrganizationScope = await resolveRoleOrganizationScope(
326
+ this.organizationHierarchyService,
327
+ tenantId,
328
+ keyOrganizationId,
329
+ )
330
+ organizations = roleOrganizations === null
331
+ || roleOrganizations.some((organizationId) => keyOrganizationScope?.has(organizationId))
332
+ ? [keyOrganizationId]
333
+ : []
281
334
  }
282
- const result = { isSuperAdmin: isSuper, features, organizations }
335
+ // A role-level super-admin grant still respects the API key's effective
336
+ // organization allowlist. Represent it as a wildcard feature whenever
337
+ // the key is restricted so downstream super-admin shortcuts cannot
338
+ // bypass the key or role organization bounds.
339
+ if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
340
+ const result = { isSuperAdmin: isSuper && organizations === null, features, organizations }
283
341
  await this.setCache(cacheKey, result, userId, scope)
284
342
  return result
285
343
  }
@@ -326,18 +384,42 @@ export class RbacService {
326
384
  let isSuper = false
327
385
  const features: string[] = []
328
386
  let organizations: string[] | null = []
387
+ let hasApplicableRestrictedRole = false
329
388
  if (roleIds.length) {
389
+ const roleOrganizationScope = await resolveRoleOrganizationScope(
390
+ this.organizationHierarchyService,
391
+ tenantId,
392
+ scope.organizationId,
393
+ )
330
394
  const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})
331
395
  const roleAcls = Array.isArray(racls) ? racls : []
332
396
  for (const r of roleAcls) {
333
- isSuper = isSuper || !!r.isSuperAdmin
334
- if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)
397
+ if (roleAclAllowsOrganization(r, roleOrganizationScope)) {
398
+ isSuper = isSuper || !!r.isSuperAdmin
399
+ if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)
400
+ if (isRestrictedRoleAcl(r)) hasApplicableRestrictedRole = true
401
+ }
335
402
  if (organizations !== null) {
403
+ // For a user principal an empty allowlist is a deny-all scope (#4033), not
404
+ // "unrestricted": it must merge to an empty set so scoped reads and deletes
405
+ // fail closed. Only an absent allowlist or an explicit '__all__' widens to
406
+ // every organization. (The API-key branch above deliberately differs — an
407
+ // empty role list there means the key inherits the key's own scope.)
336
408
  if (r.organizationsJson == null) organizations = null
337
409
  else if (Array.isArray(r.organizationsJson) && r.organizationsJson.includes('__all__')) organizations = null
338
410
  else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))
339
411
  }
340
412
  }
413
+ if (
414
+ organizations !== null
415
+ && scope.organizationId
416
+ && roleOrganizationScope !== null
417
+ && roleOrganizationScope.size > 0
418
+ && hasApplicableRestrictedRole
419
+ && !organizations.includes(scope.organizationId)
420
+ ) {
421
+ organizations.push(scope.organizationId)
422
+ }
341
423
  }
342
424
  if (organizations && orgId && !organizations.includes(orgId) && !organizations.includes('__all__')) {
343
425
  // Out-of-scope org; caller will enforce
@@ -361,12 +443,16 @@ export class RbacService {
361
443
  if (!tenantId || !feature) return false
362
444
 
363
445
  const em = this.em.fork()
446
+ const roleOrganizationScope = await resolveRoleOrganizationScope(
447
+ this.organizationHierarchyService,
448
+ tenantId,
449
+ opts?.organizationId,
450
+ )
364
451
  const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as any, {})
365
452
  const list = Array.isArray(roleAcls) ? roleAcls : []
366
- const organizationId = opts?.organizationId ?? null
367
453
 
368
454
  for (const acl of list) {
369
- if (!this.roleAclAllowsOrganization(acl, organizationId)) continue
455
+ if (!roleAclAllowsOrganization(acl, roleOrganizationScope)) continue
370
456
  const grants = Array.isArray(acl.featuresJson) ? acl.featuresJson : []
371
457
  if (authorizeFeatures([feature], {
372
458
  grantedFeatures: grants,
@@ -0,0 +1,70 @@
1
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
2
+ import type { RoleAcl } from '../data/entities'
3
+
4
+ export type RoleOrganizationScope = ReadonlySet<string> | null
5
+
6
+ function normalizeId(value: unknown): string | null {
7
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
8
+ }
9
+
10
+ export async function resolveRoleOrganizationScope(
11
+ hierarchyService: OrganizationHierarchyService | null | undefined,
12
+ tenantId: string | null | undefined,
13
+ organizationId: string | null | undefined,
14
+ ): Promise<RoleOrganizationScope> {
15
+ const selectedId = normalizeId(organizationId)
16
+ if (!selectedId) return null
17
+ const normalizedTenantId = normalizeId(tenantId)
18
+ if (!normalizedTenantId) return new Set()
19
+
20
+ const ids = new Set<string>([selectedId])
21
+ // Auth remains independently usable when Directory is disabled: exact-org
22
+ // grants still apply, while parent-to-descendant expansion fails closed.
23
+ if (!hierarchyService) return ids
24
+
25
+ const ancestorIds = await hierarchyService.resolveAncestorIds({
26
+ tenantId: normalizedTenantId,
27
+ organizationId: selectedId,
28
+ })
29
+ // A registered Directory seam can prove an invalid/wrong-tenant selection.
30
+ // Fail closed for restricted roles in that case.
31
+ if (ancestorIds === null) return new Set()
32
+ for (const ancestorId of ancestorIds) {
33
+ const normalized = normalizeId(ancestorId)
34
+ if (normalized) ids.add(normalized)
35
+ }
36
+ return ids
37
+ }
38
+
39
+ export function roleAclAllowsOrganization(
40
+ acl: Pick<RoleAcl, 'organizationsJson'>,
41
+ scope: RoleOrganizationScope,
42
+ ): boolean {
43
+ if (!scope) return true
44
+ const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
45
+ if (!organizations || organizations.length === 0 || organizations.includes('__all__')) return true
46
+ return organizations.some((organizationId) => scope.has(organizationId))
47
+ }
48
+
49
+ /**
50
+ * Principal eligibility — which roles may be selected as, or resolved into, an explicit share
51
+ * target — follows the human-principal deny-all contract instead: an empty allowlist grants no
52
+ * organization reach, so such a role must not become a share principal here (#4033).
53
+ *
54
+ * This deliberately differs from `roleAclAllowsOrganization` above, which governs *feature*
55
+ * evaluation and keeps the wider "an empty list applies everywhere" meaning, and from the
56
+ * API-key projection, where an empty list means "inherit the key's own organization binding".
57
+ * Without this split a user who reaches an organization through a second role could inherit
58
+ * viewer/editor access from a share assigned to a deny-all role.
59
+ */
60
+ export function roleAclGrantsPrincipalOrganization(
61
+ acl: Pick<RoleAcl, 'organizationsJson'>,
62
+ scope: RoleOrganizationScope,
63
+ ): boolean {
64
+ if (!scope) return true
65
+ const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
66
+ if (!organizations) return true
67
+ if (organizations.length === 0) return false
68
+ if (organizations.includes('__all__')) return true
69
+ return organizations.some((organizationId) => scope.has(organizationId))
70
+ }
@@ -1,6 +1,18 @@
1
+ import { asClass, asFunction } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
3
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { DefaultOrganizationScopeService } from './services/organizationScopeService'
5
+ import { DefaultOrganizationHierarchyService } from './services/organizationHierarchyService'
6
+
7
+ type OrganizationScopeRbac = ConstructorParameters<typeof DefaultOrganizationScopeService>[1]
2
8
 
3
9
  export function register(container: AppContainer) {
4
- // Directory module has no services yet; placeholder for future overrides
10
+ container.register({
11
+ organizationHierarchyService: asClass(DefaultOrganizationHierarchyService).scoped(),
12
+ organizationScopeService: asFunction((cradle: { em: EntityManager; rbacService: OrganizationScopeRbac }) =>
13
+ new DefaultOrganizationScopeService(cradle.em, cradle.rbacService, container),
14
+ )
15
+ .scoped()
16
+ .proxy(),
17
+ })
5
18
  }
6
-
@@ -0,0 +1,34 @@
1
+ import type { FilterQuery } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Organization } from '../data/entities'
6
+
7
+ function normalizeIds(values: unknown): string[] {
8
+ if (!Array.isArray(values)) return []
9
+ return Array.from(new Set(values
10
+ .map((value) => typeof value === 'string' ? value.trim() : '')
11
+ .filter(Boolean)))
12
+ }
13
+
14
+ export class DefaultOrganizationHierarchyService implements OrganizationHierarchyService {
15
+ constructor(private readonly em: EntityManager) {}
16
+
17
+ async resolveAncestorIds(input: {
18
+ tenantId: string
19
+ organizationId: string
20
+ }): Promise<string[] | null> {
21
+ const organization = await findOneWithDecryption(
22
+ this.em,
23
+ Organization,
24
+ {
25
+ id: input.organizationId,
26
+ tenant: input.tenantId,
27
+ deletedAt: null,
28
+ } as FilterQuery<Organization>,
29
+ { fields: ['id', 'ancestorIds'] },
30
+ { tenantId: input.tenantId, organizationId: input.organizationId },
31
+ )
32
+ return organization ? normalizeIds(organization.ancestorIds) : null
33
+ }
34
+ }