@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/audit_logs/services/actionLogService.js +57 -12
- package/dist/modules/audit_logs/services/actionLogService.js.map +3 -3
- package/dist/modules/auth/backend/roles/[id]/edit/page.meta.js +5 -1
- package/dist/modules/auth/backend/roles/[id]/edit/page.meta.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.meta.js +5 -1
- package/dist/modules/auth/backend/users/[id]/edit/page.meta.js.map +2 -2
- package/dist/modules/auth/components/AclEditor.js +39 -30
- package/dist/modules/auth/components/AclEditor.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/people/route.js +1 -1
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +8 -9
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/companies/route.js +1 -1
- package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/people/route.js +1 -1
- package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +8 -25
- package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/stats/route.js +3 -22
- package/dist/modules/customers/api/deals/[id]/stats/route.js.map +2 -2
- package/dist/modules/customers/api/entity-roles-factory.js +4 -4
- package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/context.js +1 -1
- package/dist/modules/customers/api/people/[id]/companies/context.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +1 -1
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +11 -10
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/lib/detailReadAccess.js +12 -0
- package/dist/modules/customers/lib/detailReadAccess.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/audit_logs/services/actionLogService.ts +74 -11
- package/src/modules/auth/backend/roles/[id]/edit/page.meta.ts +5 -2
- package/src/modules/auth/backend/users/[id]/edit/page.meta.ts +5 -2
- package/src/modules/auth/components/AclEditor.tsx +63 -33
- package/src/modules/auth/i18n/de.json +380 -0
- package/src/modules/auth/i18n/en.json +380 -0
- package/src/modules/auth/i18n/es.json +380 -0
- package/src/modules/auth/i18n/ko.json +380 -0
- package/src/modules/auth/i18n/pl.json +382 -2
- package/src/modules/customers/api/companies/[id]/people/route.ts +4 -1
- package/src/modules/customers/api/companies/[id]/route.ts +13 -10
- package/src/modules/customers/api/deals/[id]/companies/route.ts +4 -1
- package/src/modules/customers/api/deals/[id]/people/route.ts +4 -1
- package/src/modules/customers/api/deals/[id]/route.ts +17 -28
- package/src/modules/customers/api/deals/[id]/stats/route.ts +10 -25
- package/src/modules/customers/api/entity-roles-factory.ts +8 -4
- package/src/modules/customers/api/people/[id]/companies/context.ts +4 -1
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +4 -1
- package/src/modules/customers/api/people/[id]/route.ts +16 -11
- package/src/modules/customers/lib/detailReadAccess.ts +35 -0
- package/src/modules/dashboards/i18n/es.json +4 -4
- 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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
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
|
|
11
|
-
"dashboards.analytics.comparison.vsPrevious7Days": "vs 7
|
|
12
|
-
"dashboards.analytics.comparison.vsPrevious90Days": "vs 90
|
|
13
|
-
"dashboards.analytics.comparison.vsPreviousPeriod": "vs
|
|
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
|
|
6
|
-
"dashboards.analytics.comparison.vsLastQuarter": "vs poprzedni
|
|
7
|
-
"dashboards.analytics.comparison.vsLastWeek": "vs poprzedni
|
|
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
|
|
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
|
|
15
|
-
"dashboards.analytics.comparison.vsWeekBefore": "vs
|
|
16
|
-
"dashboards.analytics.comparison.vsYearBefore": "vs rok
|
|
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.",
|