@open-mercato/core 0.6.8-develop.7067.1.82e9867ea1 → 0.6.8-develop.7071.1.58862d0c9f

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 (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/audit_logs/services/actionLogService.js +57 -12
  3. package/dist/modules/audit_logs/services/actionLogService.js.map +3 -3
  4. package/dist/modules/auth/backend/roles/[id]/edit/page.meta.js +5 -1
  5. package/dist/modules/auth/backend/roles/[id]/edit/page.meta.js.map +2 -2
  6. package/dist/modules/auth/backend/users/[id]/edit/page.meta.js +5 -1
  7. package/dist/modules/auth/backend/users/[id]/edit/page.meta.js.map +2 -2
  8. package/dist/modules/auth/components/AclEditor.js +39 -30
  9. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  10. package/dist/modules/customers/api/companies/[id]/people/route.js +1 -1
  11. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  12. package/dist/modules/customers/api/companies/[id]/route.js +8 -9
  13. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  14. package/dist/modules/customers/api/deals/[id]/companies/route.js +1 -1
  15. package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/[id]/people/route.js +1 -1
  17. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  18. package/dist/modules/customers/api/deals/[id]/route.js +8 -25
  19. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  20. package/dist/modules/customers/api/deals/[id]/stats/route.js +3 -22
  21. package/dist/modules/customers/api/deals/[id]/stats/route.js.map +2 -2
  22. package/dist/modules/customers/api/entity-roles-factory.js +4 -4
  23. package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
  24. package/dist/modules/customers/api/people/[id]/companies/context.js +1 -1
  25. package/dist/modules/customers/api/people/[id]/companies/context.js.map +2 -2
  26. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +1 -1
  27. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  28. package/dist/modules/customers/api/people/[id]/route.js +11 -10
  29. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  30. package/dist/modules/customers/lib/detailReadAccess.js +12 -0
  31. package/dist/modules/customers/lib/detailReadAccess.js.map +7 -0
  32. package/package.json +7 -7
  33. package/src/modules/audit_logs/services/actionLogService.ts +74 -11
  34. package/src/modules/auth/backend/roles/[id]/edit/page.meta.ts +5 -2
  35. package/src/modules/auth/backend/users/[id]/edit/page.meta.ts +5 -2
  36. package/src/modules/auth/components/AclEditor.tsx +63 -33
  37. package/src/modules/auth/i18n/de.json +380 -0
  38. package/src/modules/auth/i18n/en.json +380 -0
  39. package/src/modules/auth/i18n/es.json +380 -0
  40. package/src/modules/auth/i18n/ko.json +380 -0
  41. package/src/modules/auth/i18n/pl.json +382 -2
  42. package/src/modules/customers/api/companies/[id]/people/route.ts +4 -1
  43. package/src/modules/customers/api/companies/[id]/route.ts +13 -10
  44. package/src/modules/customers/api/deals/[id]/companies/route.ts +4 -1
  45. package/src/modules/customers/api/deals/[id]/people/route.ts +4 -1
  46. package/src/modules/customers/api/deals/[id]/route.ts +17 -28
  47. package/src/modules/customers/api/deals/[id]/stats/route.ts +10 -25
  48. package/src/modules/customers/api/entity-roles-factory.ts +8 -4
  49. package/src/modules/customers/api/people/[id]/companies/context.ts +4 -1
  50. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +4 -1
  51. package/src/modules/customers/api/people/[id]/route.ts +16 -11
  52. package/src/modules/customers/lib/detailReadAccess.ts +35 -0
  53. package/src/modules/dashboards/i18n/es.json +4 -4
  54. package/src/modules/dashboards/i18n/pl.json +7 -7
@@ -7,7 +7,6 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
7
  import type { EntityManager } from '@mikro-orm/postgresql'
8
8
  import { CustomerDeal, CustomerPipeline } from '../../../../data/entities'
9
9
  import { DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
10
- import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
11
10
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
12
  import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
@@ -24,10 +23,6 @@ function notFound(message: string) {
24
23
  return NextResponse.json({ error: message }, { status: 404 })
25
24
  }
26
25
 
27
- function forbidden(message: string) {
28
- return NextResponse.json({ error: message }, { status: 403 })
29
- }
30
-
31
26
  function badRequest(message: string, code?: string) {
32
27
  return NextResponse.json(
33
28
  code ? { error: message, code } : { error: message },
@@ -67,23 +62,10 @@ export async function GET(request: Request, context: { params?: Record<string, u
67
62
  return NextResponse.json({ error: translate('customers.errors.authentication_required', 'Authentication required') }, { status: 401 })
68
63
  }
69
64
 
70
- let rbac: RbacService | null = null
71
- try {
72
- rbac = (container.resolve('rbacService') as RbacService)
73
- } catch {
74
- rbac = null
75
- }
76
-
77
- if (!rbac || !auth?.sub) {
78
- return forbidden(translate('customers.errors.access_denied', 'Access denied'))
79
- }
80
- const hasFeature = await rbac.userHasAllFeatures(auth.sub, ['customers.deals.view'], {
81
- tenantId: auth.tenantId ?? null,
82
- organizationId: auth.orgId ?? null,
83
- })
84
- if (!hasFeature) {
85
- return forbidden(translate('customers.errors.access_denied', 'Access denied'))
86
- }
65
+ // The API dispatcher already enforces `requireFeatures: ['customers.deals.view']`
66
+ // against the caller's effective (selected) organization before this handler
67
+ // runs, so no in-route feature re-check is needed. Re-checking against the token
68
+ // org (auth.orgId) would wrongly 403 org-switched callers (#5012).
87
69
 
88
70
  const scope = await resolveOrganizationScopeForRequest({ container, auth, request })
89
71
  const em = (container.resolve('em') as EntityManager)
@@ -98,8 +80,11 @@ export async function GET(request: Request, context: { params?: Record<string, u
98
80
  return notFound(translate('customers.errors.deal_not_found', 'Deal not found'))
99
81
  }
100
82
 
83
+ // Existence oracle (#5504): deny a cross-org read as not-found — identical to
84
+ // the deal-not-found above — so it cannot reveal that a deal exists in an
85
+ // organization the caller cannot see.
101
86
  if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: deal.organizationId })) {
102
- return forbidden(translate('customers.errors.access_denied', 'Access denied'))
87
+ return notFound(translate('customers.errors.deal_not_found', 'Deal not found'))
103
88
  }
104
89
 
105
90
  if (!deal.closureOutcome) {
@@ -206,8 +191,8 @@ export const openApi: OpenApiRouteDoc = {
206
191
  errors: [
207
192
  { status: 400, description: 'Deal is not closed', schema: dealStatsErrorSchema },
208
193
  { status: 401, description: 'Unauthorized', schema: dealStatsErrorSchema },
209
- { status: 403, description: 'Forbidden for tenant/organization scope', schema: dealStatsErrorSchema },
210
- { status: 404, description: 'Deal not found', schema: dealStatsErrorSchema },
194
+ { status: 403, description: 'Forbidden — caller lacks the required feature', schema: dealStatsErrorSchema },
195
+ { status: 404, description: 'Deal not found, or its organization is not in the caller’s scope', schema: dealStatsErrorSchema },
211
196
  ],
212
197
  },
213
198
  },
@@ -77,10 +77,14 @@ function ensureRouteOrganizationAccess(
77
77
  organizationId: string,
78
78
  scope: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['scope'],
79
79
  auth: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['auth'],
80
- translate: Translator,
80
+ notFoundMessage: string,
81
81
  ) {
82
+ // Existence oracle (#5504): a caller who holds the feature but whose scope
83
+ // excludes the record's organization is denied as not-found (the same response
84
+ // the caller gets for a missing record), so this cannot reveal that the record
85
+ // exists in an organization they cannot see.
82
86
  if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId })) {
83
- throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
87
+ throw notFound(notFoundMessage)
84
88
  }
85
89
  }
86
90
 
@@ -132,7 +136,7 @@ async function resolveEntityRouteScope(
132
136
  if (!entity || entity.tenantId !== auth.tenantId) {
133
137
  throw notFound(translate('customers.errors.customer_not_found', 'Customer not found'))
134
138
  }
135
- ensureRouteOrganizationAccess(entity.organizationId, scope, auth, translate)
139
+ ensureRouteOrganizationAccess(entity.organizationId, scope, auth, translate('customers.errors.customer_not_found', 'Customer not found'))
136
140
  return {
137
141
  entity,
138
142
  organizationId: entity.organizationId,
@@ -164,7 +168,7 @@ async function resolveRoleRouteScope(
164
168
  ) {
165
169
  throw notFound(translate('customers.errors.role_not_found', 'Role not found'))
166
170
  }
167
- ensureRouteOrganizationAccess(role.organizationId, scope, auth, translate)
171
+ ensureRouteOrganizationAccess(role.organizationId, scope, auth, translate('customers.errors.role_not_found', 'Role not found'))
168
172
  return {
169
173
  role,
170
174
  organizationId: role.organizationId,
@@ -38,8 +38,11 @@ export async function loadPersonContext(req: Request, personId: string) {
38
38
  throw notFound(translate('customers.errors.person_not_found', 'Person not found'))
39
39
  }
40
40
 
41
+ // Existence oracle (#5504): deny a cross-org read as not-found — identical to
42
+ // the parent-not-found above — so it cannot reveal that a person exists in an
43
+ // organization the caller cannot see.
41
44
  if (!isOrganizationReadAccessAllowed({ scope, auth: authenticatedAuth, organizationId: person.organizationId })) {
42
- throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
45
+ throw notFound(translate('customers.errors.person_not_found', 'Person not found'))
43
46
  }
44
47
 
45
48
  const profile = await findOneWithDecryption(
@@ -178,8 +178,11 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
178
178
  throw notFound(translate('customers.errors.person_not_found', 'Person not found'))
179
179
  }
180
180
 
181
+ // Existence oracle (#5504): deny a cross-org read as not-found — identical to
182
+ // the parent-not-found above — so it cannot reveal that a person exists in an
183
+ // organization the caller cannot see.
181
184
  if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: person.organizationId })) {
182
- throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
185
+ throw notFound(translate('customers.errors.person_not_found', 'Person not found'))
183
186
  }
184
187
 
185
188
  const entityScope = { tenantId: auth.tenantId, organizationId: person.organizationId }
@@ -38,7 +38,7 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
38
38
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
39
39
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
40
40
  import { parseBooleanFromUnknown, parseBooleanToken } from '@open-mercato/shared/lib/boolean'
41
- import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
41
+ import { denyCustomerDetailReadAsNotFound } from '../../../lib/detailReadAccess'
42
42
  import { loadPersonCompanyLinks, summarizePersonCompanies } from '../../../lib/personCompanies'
43
43
  import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
44
44
  import { buildEmailVisibilityMikroFilter } from '../../../lib/visibilityFilter'
@@ -129,10 +129,6 @@ function parseIncludeParams(request: Request): Set<string> {
129
129
  return tokens
130
130
  }
131
131
 
132
- function forbidden(message: string) {
133
- return NextResponse.json({ error: message }, { status: 403 })
134
- }
135
-
136
132
  function notFound(message: string) {
137
133
  return NextResponse.json({ error: message }, { status: 404 })
138
134
  }
@@ -542,10 +538,19 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
542
538
  return notFound('Person not found')
543
539
  }
544
540
 
545
- if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: person.organizationId })) {
546
- statusCode = 403
547
- profileMeta = { reason: 'organization_forbidden' }
548
- return forbidden('Access denied')
541
+ // Existence oracle (issue #5504): a caller who holds customers.people.view
542
+ // but whose scope excludes the record's organization must get the SAME
543
+ // response as for a non-existent id, so 403-when-present / 404-when-absent
544
+ // collapses to a uniform 404 not-found. The dispatcher already returns a
545
+ // uniform 403 for callers who lack the feature entirely.
546
+ const organizationReadDenied = denyCustomerDetailReadAsNotFound(
547
+ { scope, auth, organizationId: person.organizationId },
548
+ 'Person not found',
549
+ )
550
+ if (organizationReadDenied) {
551
+ statusCode = 404
552
+ profileMeta = { reason: 'organization_not_in_scope' }
553
+ return organizationReadDenied
549
554
  }
550
555
 
551
556
  // Gated get-then-set cache. Key on the resolved person plus the effective
@@ -1337,8 +1342,8 @@ export const openApi: OpenApiRouteDoc = {
1337
1342
  errors: [
1338
1343
  { status: 400, description: 'Invalid identifier', schema: personDetailErrorSchema },
1339
1344
  { status: 401, description: 'Unauthorized', schema: personDetailErrorSchema },
1340
- { status: 403, description: 'Forbidden for tenant/organization scope', schema: personDetailErrorSchema },
1341
- { status: 404, description: 'Person not found', schema: personDetailErrorSchema },
1345
+ { status: 403, description: 'Forbidden — caller lacks the required feature', schema: personDetailErrorSchema },
1346
+ { status: 404, description: 'Person not found, or its organization is not in the caller’s scope', schema: personDetailErrorSchema },
1342
1347
  ],
1343
1348
  },
1344
1349
  },
@@ -0,0 +1,35 @@
1
+ import { NextResponse } from 'next/server'
2
+ import {
3
+ isOrganizationReadAccessAllowed,
4
+ type OrganizationReadAccessInput,
5
+ } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
6
+
7
+ /**
8
+ * Single-record customers detail routes (person / company / deal) must not leak
9
+ * whether a record exists through the organization-scope check.
10
+ *
11
+ * The API dispatcher already enforces each route's `metadata.requireFeatures`
12
+ * before the handler runs, returning a uniform `403` for callers who lack the
13
+ * grant — so that path never distinguishes an existing id from a missing one.
14
+ * The only remaining existence signal is the *post-load* organization guard: a
15
+ * caller who holds the feature but whose scope excludes the record's
16
+ * organization previously received `403` (record exists in another org) while a
17
+ * non-existent id received `404`. That 403-vs-404 split is an existence oracle
18
+ * (issue #5504).
19
+ *
20
+ * Collapse the organization-scope denial into the route's own not-found
21
+ * response so an out-of-scope caller cannot tell "exists in an organization you
22
+ * cannot see" from "does not exist" — the shape the api_keys hardening
23
+ * (#4033 / PR-4051) established. Sharing this decision keeps the three customers
24
+ * detail routes from drifting apart again.
25
+ *
26
+ * @returns the `404` response to return when read access is denied, or `null`
27
+ * when the caller may read the record.
28
+ */
29
+ export function denyCustomerDetailReadAsNotFound(
30
+ input: OrganizationReadAccessInput,
31
+ notFoundMessage: string,
32
+ ): NextResponse | null {
33
+ if (isOrganizationReadAccessAllowed(input)) return null
34
+ return NextResponse.json({ error: notFoundMessage }, { status: 404 })
35
+ }
@@ -7,10 +7,10 @@
7
7
  "dashboards.analytics.comparison.vsLastWeek": "vs semana pasada",
8
8
  "dashboards.analytics.comparison.vsLastYear": "vs año pasado",
9
9
  "dashboards.analytics.comparison.vsMonthBefore": "vs mes anterior",
10
- "dashboards.analytics.comparison.vsPrevious30Days": "vs 30 dias anteriores",
11
- "dashboards.analytics.comparison.vsPrevious7Days": "vs 7 dias anteriores",
12
- "dashboards.analytics.comparison.vsPrevious90Days": "vs 90 dias anteriores",
13
- "dashboards.analytics.comparison.vsPreviousPeriod": "vs periodo anterior",
10
+ "dashboards.analytics.comparison.vsPrevious30Days": "vs 30 días anteriores",
11
+ "dashboards.analytics.comparison.vsPrevious7Days": "vs 7 días anteriores",
12
+ "dashboards.analytics.comparison.vsPrevious90Days": "vs 90 días anteriores",
13
+ "dashboards.analytics.comparison.vsPreviousPeriod": "vs período anterior",
14
14
  "dashboards.analytics.comparison.vsQuarterBefore": "vs trimestre anterior",
15
15
  "dashboards.analytics.comparison.vsWeekBefore": "vs semana anterior",
16
16
  "dashboards.analytics.comparison.vsYearBefore": "vs año anterior",
@@ -2,18 +2,18 @@
2
2
  "dashboards.analytics.chartVariant.donut": "Pierścieniowy",
3
3
  "dashboards.analytics.chartVariant.pie": "Kołowy",
4
4
  "dashboards.analytics.comparison.vsDayBefore": "vs przedwczoraj",
5
- "dashboards.analytics.comparison.vsLastMonth": "vs poprzedni miesiac",
6
- "dashboards.analytics.comparison.vsLastQuarter": "vs poprzedni kwartal",
7
- "dashboards.analytics.comparison.vsLastWeek": "vs poprzedni tydzien",
5
+ "dashboards.analytics.comparison.vsLastMonth": "vs poprzedni miesiąc",
6
+ "dashboards.analytics.comparison.vsLastQuarter": "vs poprzedni kwartał",
7
+ "dashboards.analytics.comparison.vsLastWeek": "vs poprzedni tydzień",
8
8
  "dashboards.analytics.comparison.vsLastYear": "vs poprzedni rok",
9
- "dashboards.analytics.comparison.vsMonthBefore": "vs miesiac wczesniej",
9
+ "dashboards.analytics.comparison.vsMonthBefore": "vs miesiąc wcześniej",
10
10
  "dashboards.analytics.comparison.vsPrevious30Days": "vs poprzednie 30 dni",
11
11
  "dashboards.analytics.comparison.vsPrevious7Days": "vs poprzednie 7 dni",
12
12
  "dashboards.analytics.comparison.vsPrevious90Days": "vs poprzednie 90 dni",
13
13
  "dashboards.analytics.comparison.vsPreviousPeriod": "vs poprzedni okres",
14
- "dashboards.analytics.comparison.vsQuarterBefore": "vs kwartal wczesniej",
15
- "dashboards.analytics.comparison.vsWeekBefore": "vs tydzien wczesniej",
16
- "dashboards.analytics.comparison.vsYearBefore": "vs rok wczesniej",
14
+ "dashboards.analytics.comparison.vsQuarterBefore": "vs kwartał wcześniej",
15
+ "dashboards.analytics.comparison.vsWeekBefore": "vs tydzień wcześniej",
16
+ "dashboards.analytics.comparison.vsYearBefore": "vs rok wcześniej",
17
17
  "dashboards.analytics.comparison.vsYesterday": "vs wczoraj",
18
18
  "dashboards.analytics.currency.unlabelled": "Kwoty pokazane bez waluty",
19
19
  "dashboards.analytics.currency.unlabelledHint": "Nie udało się wiarygodnie ustalić waluty, dlatego kwota jest wyświetlana bez jej oznaczenia.",