@open-mercato/core 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.
Files changed (82) 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/customers/components/calendar/CalendarSettingsModal.js +1 -1
  27. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/EventPeekPopover.js +3 -2
  29. package/dist/modules/customers/components/calendar/EventPeekPopover.js.map +2 -2
  30. package/dist/modules/data_sync/lib/abandoned-run.js +19 -0
  31. package/dist/modules/data_sync/lib/abandoned-run.js.map +7 -0
  32. package/dist/modules/data_sync/workers/sync-export.js +5 -1
  33. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  34. package/dist/modules/data_sync/workers/sync-import.js +5 -1
  35. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  36. package/dist/modules/directory/di.js +9 -0
  37. package/dist/modules/directory/di.js.map +2 -2
  38. package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
  39. package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
  40. package/dist/modules/directory/services/organizationScopeService.js +56 -0
  41. package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
  42. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  43. package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
  44. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  45. package/dist/modules/workflows/lib/activity-executor.js +4 -0
  46. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  47. package/package.json +7 -7
  48. package/src/modules/api_keys/api/keys/route.ts +3 -1
  49. package/src/modules/api_keys/di.ts +7 -0
  50. package/src/modules/api_keys/services/principalService.ts +33 -0
  51. package/src/modules/attachments/AGENTS.md +15 -0
  52. package/src/modules/attachments/di.ts +16 -0
  53. package/src/modules/attachments/i18n/de.json +5 -0
  54. package/src/modules/attachments/i18n/en.json +5 -0
  55. package/src/modules/attachments/i18n/es.json +5 -0
  56. package/src/modules/attachments/i18n/ko.json +5 -0
  57. package/src/modules/attachments/i18n/pl.json +5 -0
  58. package/src/modules/attachments/index.ts +10 -0
  59. package/src/modules/attachments/lib/attachment-service.ts +468 -0
  60. package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
  61. package/src/modules/attachments/lib/upload-limits.ts +6 -2
  62. package/src/modules/auth/data/entities.ts +4 -1
  63. package/src/modules/auth/di.ts +46 -16
  64. package/src/modules/auth/services/principalService.ts +266 -0
  65. package/src/modules/auth/services/rbacService.ts +110 -24
  66. package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
  67. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +1 -1
  68. package/src/modules/customers/components/calendar/EventPeekPopover.tsx +4 -2
  69. package/src/modules/customers/i18n/de.json +1 -1
  70. package/src/modules/customers/i18n/en.json +1 -1
  71. package/src/modules/customers/i18n/es.json +1 -1
  72. package/src/modules/customers/i18n/ko.json +1 -1
  73. package/src/modules/customers/i18n/pl.json +1 -1
  74. package/src/modules/data_sync/lib/abandoned-run.ts +56 -0
  75. package/src/modules/data_sync/workers/sync-export.ts +4 -0
  76. package/src/modules/data_sync/workers/sync-import.ts +4 -0
  77. package/src/modules/directory/di.ts +14 -2
  78. package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
  79. package/src/modules/directory/services/organizationScopeService.ts +85 -0
  80. package/src/modules/directory/utils/organizationScope.ts +4 -15
  81. package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
  82. 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
+ }
@@ -72,7 +72,7 @@ export function CalendarSettingsModal({
72
72
 
73
73
  const toggleRows: Array<{ key: ToggleKey; label: string }> = [
74
74
  { key: 'showCrmActivities', label: t('customers.calendar.settings.showCrmActivities', 'Show CRM activities on calendar') },
75
- { key: 'aiSummaries', label: t('customers.calendar.settings.aiSummaries', 'AI summaries & quick actions') },
75
+ { key: 'aiSummaries', label: t('customers.calendar.settings.aiSummaries', 'AI summaries') },
76
76
  { key: 'conflictWarnings', label: t('customers.calendar.settings.conflictWarnings', 'Conflict warnings') },
77
77
  { key: 'showWeekends', label: t('customers.calendar.settings.showWeekends', 'Show weekends') },
78
78
  ]
@@ -22,6 +22,7 @@ export type EventPeekPopoverProps = {
22
22
  joinUrl: string | null
23
23
  aiSummaries: boolean
24
24
  canManage?: boolean
25
+ side?: React.ComponentProps<typeof PopoverContent>['side']
25
26
  onOpenChange(open: boolean): void
26
27
  onJoin(item: CalendarItem): void
27
28
  onEdit(item: CalendarItem): void
@@ -34,6 +35,7 @@ export function EventPeekPopover({
34
35
  joinUrl,
35
36
  aiSummaries,
36
37
  canManage = true,
38
+ side = 'right',
37
39
  onOpenChange,
38
40
  onJoin,
39
41
  onEdit,
@@ -63,12 +65,12 @@ export function EventPeekPopover({
63
65
  (part): part is string => Boolean(part),
64
66
  )
65
67
  const showSummary = aiSummaries && metaParts.length > 0
66
- const showJoin = aiSummaries && Boolean(joinUrl)
68
+ const showJoin = Boolean(joinUrl)
67
69
 
68
70
  return (
69
71
  <Popover open={open} onOpenChange={onOpenChange}>
70
72
  <PopoverTrigger asChild>{children}</PopoverTrigger>
71
- <PopoverContent align="start" side="right" sideOffset={8} className="w-56 p-3">
73
+ <PopoverContent align="start" side={side} sideOffset={8} className="w-56 p-3">
72
74
  <div className="flex flex-col gap-2">
73
75
  <p className="text-sm font-semibold leading-5 text-foreground">{title}</p>
74
76
  <p className="text-xs leading-4 text-muted-foreground">{`${dateLabel} · ${timeRange}`}</p>
@@ -354,7 +354,7 @@
354
354
  "customers.calendar.settings.activityTypesHint": "Die Aktivitätstypen, die Ihr Kalender beim Erstellen eines Ereignisses anbietet. Aus dem Workspace-Wörterbuch übernommen.",
355
355
  "customers.calendar.settings.addCategory": "Kategorie hinzufügen…",
356
356
  "customers.calendar.settings.addType": "Typ hinzufügen…",
357
- "customers.calendar.settings.aiSummaries": "KI-Zusammenfassungen & Schnellaktionen",
357
+ "customers.calendar.settings.aiSummaries": "KI-Zusammenfassungen",
358
358
  "customers.calendar.settings.cancel": "Abbrechen",
359
359
  "customers.calendar.settings.close": "Schließen",
360
360
  "customers.calendar.settings.conflictScope": "Konfliktbereich",
@@ -354,7 +354,7 @@
354
354
  "customers.calendar.settings.activityTypesHint": "The activity types your calendar surfaces when creating an event. Seeded from your workspace dictionary.",
355
355
  "customers.calendar.settings.addCategory": "Add a category…",
356
356
  "customers.calendar.settings.addType": "Add a type…",
357
- "customers.calendar.settings.aiSummaries": "AI summaries & quick actions",
357
+ "customers.calendar.settings.aiSummaries": "AI summaries",
358
358
  "customers.calendar.settings.cancel": "Cancel",
359
359
  "customers.calendar.settings.close": "Close",
360
360
  "customers.calendar.settings.conflictScope": "Conflict scope",
@@ -354,7 +354,7 @@
354
354
  "customers.calendar.settings.activityTypesHint": "Los tipos de actividad que muestra tu calendario al crear un evento. Tomados del diccionario del espacio de trabajo.",
355
355
  "customers.calendar.settings.addCategory": "Añadir una categoría…",
356
356
  "customers.calendar.settings.addType": "Añadir un tipo…",
357
- "customers.calendar.settings.aiSummaries": "Resúmenes de IA y acciones rápidas",
357
+ "customers.calendar.settings.aiSummaries": "Resúmenes de IA",
358
358
  "customers.calendar.settings.cancel": "Cancelar",
359
359
  "customers.calendar.settings.close": "Cerrar",
360
360
  "customers.calendar.settings.conflictScope": "Ámbito de conflictos",
@@ -354,7 +354,7 @@
354
354
  "customers.calendar.settings.activityTypesHint": "이벤트를 생성할 때 캘린더에 표시되는 활동 유형입니다. 워크스페이스 사전에서 초기화됩니다.",
355
355
  "customers.calendar.settings.addCategory": "카테고리 추가…",
356
356
  "customers.calendar.settings.addType": "유형 추가…",
357
- "customers.calendar.settings.aiSummaries": "AI 요약 및 빠른 작업",
357
+ "customers.calendar.settings.aiSummaries": "AI 요약",
358
358
  "customers.calendar.settings.cancel": "취소",
359
359
  "customers.calendar.settings.close": "닫기",
360
360
  "customers.calendar.settings.conflictScope": "충돌 범위",
@@ -354,7 +354,7 @@
354
354
  "customers.calendar.settings.activityTypesHint": "Typy aktywności udostępniane przez kalendarz podczas tworzenia wydarzenia. Pobrane ze słownika obszaru roboczego.",
355
355
  "customers.calendar.settings.addCategory": "Dodaj kategorię…",
356
356
  "customers.calendar.settings.addType": "Dodaj typ…",
357
- "customers.calendar.settings.aiSummaries": "Podsumowania AI i szybkie akcje",
357
+ "customers.calendar.settings.aiSummaries": "Podsumowania AI",
358
358
  "customers.calendar.settings.cancel": "Anuluj",
359
359
  "customers.calendar.settings.close": "Zamknij",
360
360
  "customers.calendar.settings.conflictScope": "Zakres konfliktów",