@open-mercato/core 0.10.1-develop.7767.2.440f7c2b4c → 0.10.1-develop.7769.1.0e6458bb99

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.
@@ -195,7 +195,6 @@ export async function lockAuthorizationState(
195
195
  }
196
196
 
197
197
  const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? [])
198
- const userIds = uniqueSortedIds(targets.userIds ?? [])
199
198
  const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? [])
200
199
  const tenantIds = uniqueSortedIds(targets.tenantIds ?? [])
201
200
  const explicitApiKeys = apiKeyIds.length
@@ -207,9 +206,16 @@ export async function lockAuthorizationState(
207
206
  { tenantId: null, organizationId: null },
208
207
  )
209
208
  : []
210
- const explicitApiKeyRoleIds = uniqueSortedIds(explicitApiKeys.flatMap((apiKey) => (
211
- Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []
212
- )))
209
+ const explicitApiKeySnapshots = new Map(explicitApiKeys.map((apiKey) => [String(apiKey.id), {
210
+ sessionUserId: apiKey.sessionUserId ?? null,
211
+ roles: Array.isArray(apiKey.rolesJson) ? [...apiKey.rolesJson] : [],
212
+ }]))
213
+ const apiKeySnapshots = [...explicitApiKeySnapshots.values()]
214
+ const userIds = uniqueSortedIds([
215
+ ...(targets.userIds ?? []),
216
+ ...apiKeySnapshots.flatMap((apiKey) => apiKey.sessionUserId ? [apiKey.sessionUserId] : []),
217
+ ])
218
+ const explicitApiKeyRoleIds = uniqueSortedIds(apiKeySnapshots.flatMap((apiKey) => apiKey.roles))
213
219
  const discoveredBeforeLocks = userIds.length
214
220
  ? await findWithDecryption(
215
221
  em,
@@ -236,10 +242,10 @@ export async function lockAuthorizationState(
236
242
  for (const apiKey of lockedApiKeys) {
237
243
  const id = String(apiKey.id)
238
244
  if (!apiKeyIds.includes(id)) continue
239
- const before = explicitApiKeys.find((candidate) => String(candidate.id) === id)
240
- const beforeRoles = Array.isArray(before?.rolesJson) ? before.rolesJson : []
245
+ const before = explicitApiKeySnapshots.get(id)
246
+ const beforeRoles = before?.roles ?? []
241
247
  const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []
242
- if (!sameIds(beforeRoles, lockedRoles)) {
248
+ if (!sameIds(beforeRoles, lockedRoles) || (before?.sessionUserId ?? null) !== (apiKey.sessionUserId ?? null)) {
243
249
  throw authorizationStateDrift()
244
250
  }
245
251
  }
@@ -13,6 +13,7 @@ import {
13
13
  resolveEffectiveFeatures,
14
14
  } from '@open-mercato/shared/security/featurePolicy'
15
15
  import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
16
+ import { hasFeature } from '@open-mercato/shared/security/features'
16
17
  import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
17
18
  import { createLogger } from '@open-mercato/shared/lib/logger'
18
19
 
@@ -24,6 +25,27 @@ interface AclData {
24
25
  organizations: string[] | null
25
26
  }
26
27
 
28
+ function intersectDelegatedAcl(keyAcl: AclData, ownerAcl: AclData): AclData {
29
+ const keyFeatures = keyAcl.isSuperAdmin ? ['*'] : keyAcl.features
30
+ const ownerFeatures = ownerAcl.isSuperAdmin ? ['*'] : ownerAcl.features
31
+ const features = Array.from(new Set([
32
+ ...keyFeatures.filter((feature) => hasFeature(ownerFeatures, feature)),
33
+ ...ownerFeatures.filter((feature) => hasFeature(keyFeatures, feature)),
34
+ ]))
35
+ const keyOrganizations = keyAcl.isSuperAdmin || keyAcl.organizations?.includes('__all__')
36
+ ? null : keyAcl.organizations
37
+ const ownerOrganizations = ownerAcl.isSuperAdmin || ownerAcl.organizations?.includes('__all__')
38
+ ? null : ownerAcl.organizations
39
+ const organizations = keyOrganizations === null ? ownerOrganizations
40
+ : ownerOrganizations === null ? keyOrganizations
41
+ : keyOrganizations.filter((organizationId) => ownerOrganizations.includes(organizationId))
42
+ return {
43
+ isSuperAdmin: keyAcl.isSuperAdmin && ownerAcl.isSuperAdmin,
44
+ features: organizations?.length === 0 ? [] : features,
45
+ organizations,
46
+ }
47
+ }
48
+
27
49
  async function resolveRoleOrganizationScopeWithEntityManager(
28
50
  em: EntityManager,
29
51
  tenantId: string | null,
@@ -81,7 +103,7 @@ type FeatureOrganizationRoleGrant = {
81
103
 
82
104
  type FeatureOrganizationGrants =
83
105
  | { kind: 'none' }
84
- | { kind: 'api_key'; organizationId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
106
+ | { kind: 'api_key'; organizationId: string | null; ownerId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
85
107
  | { kind: 'user_acl'; isSuperAdmin: boolean; features: string[]; organizations: string[] | null }
86
108
  | { kind: 'roles'; roleGrants: FeatureOrganizationRoleGrant[] }
87
109
 
@@ -105,6 +127,7 @@ function isFeatureOrganizationGrants(value: unknown): value is FeatureOrganizati
105
127
  return true
106
128
  case 'api_key':
107
129
  return (record.organizationId === null || typeof record.organizationId === 'string')
130
+ && (record.ownerId === null || typeof record.ownerId === 'string')
108
131
  && Array.isArray(record.roleGrants)
109
132
  && record.roleGrants.every(isFeatureOrganizationRoleGrant)
110
133
  case 'user_acl':
@@ -260,7 +283,8 @@ export class RbacService {
260
283
  }
261
284
 
262
285
  private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {
263
- return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
286
+ const namespace = userId.startsWith('api_key:') ? 'rbac:delegated-v1' : 'rbac'
287
+ return `${namespace}:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
264
288
  }
265
289
 
266
290
  private getUserTag(userId: string): string {
@@ -282,12 +306,12 @@ export class RbacService {
282
306
  return isAclData(cached) ? cached : null
283
307
  }
284
308
 
285
- private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {
309
+ private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }, ownerId?: string | null): Promise<void> {
286
310
  if (!this.cache) return
287
311
 
288
312
  await this.cache.set(cacheKey, data, {
289
313
  ttl: this.cacheTtlMs,
290
- tags: this.buildCacheTags(userId, scope),
314
+ tags: [...this.buildCacheTags(userId, scope), ...(ownerId ? [this.getUserTag(ownerId)] : [])],
291
315
  })
292
316
  }
293
317
 
@@ -584,8 +608,9 @@ export class RbacService {
584
608
  // the key is restricted so downstream super-admin shortcuts cannot
585
609
  // bypass the key or role organization bounds.
586
610
  if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
587
- const result = { isSuperAdmin: isSuper && organizations === null, features, organizations }
588
- if (cacheable) await this.setCache(cacheKey, result, userId, scope)
611
+ const keyAcl = { isSuperAdmin: isSuper && organizations === null, features, organizations }
612
+ const result = await this.limitDelegatedKeyAcl(em, key, { tenantId, organizationId: evaluatedOrganizationId }, keyAcl)
613
+ if (cacheable) await this.setCache(cacheKey, result, userId, { tenantId, organizationId: evaluatedOrganizationId }, key.sessionUserId)
589
614
  return result
590
615
  }
591
616
 
@@ -795,7 +820,13 @@ export class RbacService {
795
820
  : []
796
821
  }
797
822
  if (isSuperAdmin && organizations !== null && !features.includes('*')) features.push('*')
798
- return { isSuperAdmin: isSuperAdmin && organizations === null, features, organizations }
823
+ return this.limitDelegatedKeyAcl(
824
+ em,
825
+ key,
826
+ { tenantId, organizationId: evaluatedOrganizationId },
827
+ { isSuperAdmin: isSuperAdmin && organizations === null, features, organizations },
828
+ true,
829
+ )
799
830
  }
800
831
 
801
832
  const user = await findOneWithDecryption(
@@ -997,6 +1028,33 @@ export class RbacService {
997
1028
  })
998
1029
  }
999
1030
 
1031
+ private async limitDelegatedKeyAcl(
1032
+ em: EntityManager,
1033
+ key: ApiKey,
1034
+ scope: { tenantId: string | null; organizationId: string | null },
1035
+ keyAcl: AclData,
1036
+ uncached = false,
1037
+ ): Promise<AclData> {
1038
+ if (!key.sessionUserId) return keyAcl
1039
+ if (!key.tenantId || key.tenantId !== scope.tenantId) {
1040
+ return { isSuperAdmin: false, features: [], organizations: [] }
1041
+ }
1042
+ const owner = await findOneWithDecryption(em, User, {
1043
+ id: key.sessionUserId,
1044
+ tenantId: key.tenantId,
1045
+ deletedAt: null,
1046
+ }, undefined, scope)
1047
+ if (!owner) return { isSuperAdmin: false, features: [], organizations: [] }
1048
+ const ownerAcl = uncached
1049
+ ? await this.loadAclWithEntityManager(em, key.sessionUserId, scope)
1050
+ : await this.loadAcl(key.sessionUserId, scope)
1051
+ const result = intersectDelegatedAcl(keyAcl, ownerAcl)
1052
+ if (scope.organizationId && result.organizations && !result.organizations.includes(scope.organizationId)) {
1053
+ return { ...result, isSuperAdmin: false, features: [] }
1054
+ }
1055
+ return result
1056
+ }
1057
+
1000
1058
  async resolveFeatureOrganizationAccess(
1001
1059
  userId: string,
1002
1060
  required: readonly string[],
@@ -1029,13 +1087,18 @@ export class RbacService {
1029
1087
 
1030
1088
  if (grants.kind === 'api_key') {
1031
1089
  const keyOrganizationId = grants.organizationId
1090
+ const ownerAccess = grants.ownerId
1091
+ ? await this.resolveFeatureOrganizationAccess(grants.ownerId, required, input)
1092
+ : { unrestricted: true, filterOrganizationIds: allowAll }
1032
1093
  const unrestricted = keyOrganizationId === null
1094
+ && ownerAccess.unrestricted
1033
1095
  && roleAclsAuthorizeFeaturesGlobally(grants.roleGrants, required, true)
1034
1096
  if (unrestricted) return { unrestricted: true, filterOrganizationIds: allowAll }
1035
1097
 
1036
1098
  return {
1037
1099
  unrestricted: false,
1038
1100
  filterOrganizationIds: (candidates) => normalizeFeatureOrganizationCandidates(candidates)
1101
+ .filter((organization) => ownerAccess.filterOrganizationIds([organization]).length > 0)
1039
1102
  .filter((organization) => (
1040
1103
  (!keyOrganizationId || organization.id === keyOrganizationId)
1041
1104
  && roleAclsAuthorizeFeatures(grants.roleGrants, required, organization, true)
@@ -1091,7 +1154,7 @@ export class RbacService {
1091
1154
  userId: string,
1092
1155
  tenantId: string,
1093
1156
  ): Promise<FeatureOrganizationGrants> {
1094
- const cacheKey = `rbac:feature-organizations:${userId}:${tenantId}`
1157
+ const cacheKey = `rbac:feature-organizations:delegated-v1:${userId}:${tenantId}`
1095
1158
  const key = userId.startsWith('api_key:')
1096
1159
  ? await this.em.fork().findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })
1097
1160
  : undefined
@@ -1104,7 +1167,10 @@ export class RbacService {
1104
1167
  if (this.cache && cacheable) {
1105
1168
  await this.cache.set(cacheKey, grants, {
1106
1169
  ttl: this.cacheTtlMs,
1107
- tags: this.buildCacheTags(userId, { tenantId, organizationId: null }),
1170
+ tags: [
1171
+ ...this.buildCacheTags(userId, { tenantId, organizationId: null }),
1172
+ ...(grants.kind === 'api_key' && grants.ownerId ? [this.getUserTag(grants.ownerId)] : []),
1173
+ ],
1108
1174
  })
1109
1175
  }
1110
1176
  return grants
@@ -1129,6 +1195,16 @@ export class RbacService {
1129
1195
  return { kind: 'none' }
1130
1196
  }
1131
1197
 
1198
+ if (key.sessionUserId) {
1199
+ if (key.tenantId !== tenantId) return { kind: 'none' }
1200
+ const owner = await findOneWithDecryption(em, User, {
1201
+ id: key.sessionUserId,
1202
+ tenantId,
1203
+ deletedAt: null,
1204
+ }, undefined, { tenantId, organizationId: null })
1205
+ if (!owner) return { kind: 'none' }
1206
+ }
1207
+
1132
1208
  const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
1133
1209
  if (!roleIds.length) return { kind: 'none' }
1134
1210
  const roleAcls = await em.find(RoleAcl, {
@@ -1142,6 +1218,7 @@ export class RbacService {
1142
1218
  return {
1143
1219
  kind: 'api_key',
1144
1220
  organizationId: keyOrganizationId,
1221
+ ownerId: key.sessionUserId ?? null,
1145
1222
  roleGrants: roleAcls.map(toFeatureOrganizationRoleGrant),
1146
1223
  }
1147
1224
  }
@@ -49,7 +49,8 @@ function buildOrgScopeCacheKey(parts: {
49
49
  }): string {
50
50
  const selected = parts.selectedOrgId ?? 'none'
51
51
  const requested = parts.requestedTenantId ?? 'none'
52
- return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
52
+ const namespace = parts.userId.startsWith('api_key:') ? `${ORG_SCOPE_CACHE_KEY_PREFIX}:delegated-v1` : ORG_SCOPE_CACHE_KEY_PREFIX
53
+ return `${namespace}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
53
54
  }
54
55
 
55
56
  // Tag builders are exported so the modules that own the "this user's scope
@@ -65,10 +66,11 @@ export function buildOrgScopeTenantCacheTag(tenantId: string): string {
65
66
  return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`
66
67
  }
67
68
 
68
- function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
69
+ function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string; ownerUserId: string | null }): string[] {
69
70
  return [
70
71
  buildOrgScopeUserCacheTag(parts.userId),
71
72
  buildOrgScopeTenantCacheTag(parts.effectiveTenantId),
73
+ ...(parts.ownerUserId ? [buildOrgScopeUserCacheTag(parts.ownerUserId)] : []),
72
74
  ]
73
75
  }
74
76
 
@@ -488,6 +490,7 @@ export async function resolveOrganizationScopeForRequest({
488
490
  : null
489
491
 
490
492
  const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null
493
+ const ownerUserId = auth.isApiKey && typeof auth.userId === 'string' && auth.userId.length > 0 ? auth.userId : null
491
494
  const ttlMs = resolveOrgScopeTtlMs()
492
495
  const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null
493
496
  const cacheKey = userId
@@ -532,7 +535,7 @@ export async function resolveOrganizationScopeForRequest({
532
535
  try {
533
536
  await crossRequestCache.set(cacheKey, baseScope, {
534
537
  ttl: ttlMs,
535
- tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
538
+ tags: buildOrgScopeCacheTags({ userId, effectiveTenantId, ownerUserId }),
536
539
  })
537
540
  } catch (err) {
538
541
  logger.warn('Cache write failed', { err })
@@ -268,9 +268,13 @@ export type CreateFeatureNotificationServiceInput = CreateFeatureNotificationInp
268
268
  restrictRecipientsToOrganization?: boolean
269
269
  }
270
270
 
271
+ export type CreateBatchNotificationOptions = {
272
+ skipExistingGroup?: boolean
273
+ }
274
+
271
275
  export interface NotificationService {
272
276
  create(input: CreateNotificationInput, ctx: NotificationServiceContext): Promise<Notification>
273
- createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
277
+ createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext, options?: CreateBatchNotificationOptions): Promise<Notification[]>
274
278
  createForRole(input: CreateRoleNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
275
279
  createForFeature(input: CreateFeatureNotificationServiceInput, ctx: NotificationServiceContext): Promise<Notification[]>
276
280
  markAsRead(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>
@@ -436,8 +440,12 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
436
440
  return notification
437
441
  },
438
442
 
439
- async createBatch(input, ctx) {
443
+ async createBatch(input, ctx, options) {
444
+ if (options?.skipExistingGroup && !input.groupKey?.trim()) {
445
+ throw new Error('[internal] Notification group key is required for deduplicated batch delivery')
446
+ }
440
447
  const recipientUserIds = Array.from(new Set(input.recipientUserIds))
448
+ if (options?.skipExistingGroup) recipientUserIds.sort((left, right) => left.localeCompare(right))
441
449
  const { recipientUserIds: _recipientUserIds, ...content } = input
442
450
  const notifications: Notification[] = []
443
451
  const resolved = await resolveChannelsForRecipients(content, recipientUserIds, ctx)
@@ -446,12 +454,26 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
446
454
  await writeEm.transactional(async (tx) => {
447
455
  await assertNotificationRecipientsInScope(tx, recipientUserIds, ctx)
448
456
  for (const { recipientUserId, channels } of resolved) {
457
+ if (options?.skipExistingGroup) {
458
+ const organizationId = normalizeOrgScope(ctx.organizationId)
459
+ const lockKey = `notifications:${ctx.tenantId}:${organizationId ?? 'global'}:${recipientUserId}:${content.type}:${content.groupKey}`
460
+ await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(getDb(tx))
461
+ const existing = await tx.findOne(Notification, {
462
+ recipientUserId,
463
+ tenantId: ctx.tenantId,
464
+ organizationId,
465
+ type: content.type,
466
+ groupKey: content.groupKey,
467
+ })
468
+ if (existing) continue
469
+ }
449
470
  const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx, channels)
450
471
  notifications.push(notification)
451
472
  }
452
473
  await tx.flush()
453
474
  })
454
475
 
476
+ if (options?.skipExistingGroup && notifications.length === 0) return []
455
477
  await invalidateNotificationCache(container, ctx, 'created')
456
478
  await emitNotificationCreatedBatch(eventBus, notifications, ctx)
457
479
  await emitNotificationSseEvents(eventBus, notifications, ctx)