@open-mercato/search 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc

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.
@@ -0,0 +1,165 @@
1
+ import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
2
+ import {
3
+ canReadSearchEntity,
4
+ filterSearchResultsByEntityAccess,
5
+ resolveReadableEntityTypes,
6
+ type SearchEntityDenyReason,
7
+ } from '../lib/entity-access'
8
+
9
+ const CONFIGS: Record<string, SearchEntityConfig> = {
10
+ 'customers:customer_person_profile': {
11
+ entityId: 'customers:customer_person_profile',
12
+ aclFeatures: ['customers.people.view'],
13
+ },
14
+ 'catalog:catalog_product': {
15
+ entityId: 'catalog:catalog_product',
16
+ aclFeatures: ['catalog.products.view'],
17
+ },
18
+ // Deliberately declares no aclFeatures: models a module that forgot to opt in.
19
+ 'wms:warehouse': {
20
+ entityId: 'wms:warehouse',
21
+ },
22
+ }
23
+
24
+ const lookup = {
25
+ getEntityConfig: (entityId: string) => CONFIGS[entityId],
26
+ getAllEntityConfigs: () => Object.values(CONFIGS),
27
+ }
28
+
29
+ function result(entityId: string, recordId: string) {
30
+ return { entityId, recordId }
31
+ }
32
+
33
+ describe('canReadSearchEntity', () => {
34
+ it('allows a caller holding the entity view feature', () => {
35
+ expect(
36
+ canReadSearchEntity('customers:customer_person_profile', lookup, {
37
+ grantedFeatures: ['search.global', 'customers.people.view'],
38
+ }),
39
+ ).toBe(true)
40
+ })
41
+
42
+ it('denies a caller holding only search.global', () => {
43
+ expect(
44
+ canReadSearchEntity('customers:customer_person_profile', lookup, {
45
+ grantedFeatures: ['search.global'],
46
+ }),
47
+ ).toBe(false)
48
+ })
49
+
50
+ it('honours wildcard grants', () => {
51
+ expect(
52
+ canReadSearchEntity('catalog:catalog_product', lookup, {
53
+ grantedFeatures: ['catalog.*'],
54
+ }),
55
+ ).toBe(true)
56
+ })
57
+
58
+ it('fails closed for an entity that is not configured for search', () => {
59
+ const reasons: SearchEntityDenyReason[] = []
60
+ expect(
61
+ canReadSearchEntity('unknown:entity', lookup, { grantedFeatures: ['*'] }, {
62
+ onDeny: (_entityId, reason) => reasons.push(reason),
63
+ }),
64
+ ).toBe(false)
65
+ expect(reasons).toEqual(['unconfigured'])
66
+ })
67
+
68
+ it('fails closed for a configured entity that declares no aclFeatures', () => {
69
+ const reasons: SearchEntityDenyReason[] = []
70
+ expect(
71
+ canReadSearchEntity('wms:warehouse', lookup, { grantedFeatures: ['wms.view'] }, {
72
+ onDeny: (_entityId, reason) => reasons.push(reason),
73
+ }),
74
+ ).toBe(false)
75
+ expect(reasons).toEqual(['no-acl-features'])
76
+ })
77
+
78
+ it('lets a superadmin through regardless of declared features', () => {
79
+ expect(
80
+ canReadSearchEntity('wms:warehouse', lookup, { grantedFeatures: [], isSuperAdmin: true }),
81
+ ).toBe(true)
82
+ })
83
+ })
84
+
85
+ describe('resolveReadableEntityTypes', () => {
86
+ it('narrows the query to the entity types the caller can read', () => {
87
+ expect(
88
+ resolveReadableEntityTypes(lookup, { grantedFeatures: ['customers.people.view'] }),
89
+ ).toEqual(['customers:customer_person_profile'])
90
+ })
91
+
92
+ it('returns an empty list when nothing is readable, so callers can short-circuit', () => {
93
+ expect(resolveReadableEntityTypes(lookup, { grantedFeatures: ['search.global'] })).toEqual([])
94
+ })
95
+
96
+ it('intersects the readable types with the explicitly requested ones', () => {
97
+ expect(
98
+ resolveReadableEntityTypes(lookup, { grantedFeatures: ['*'] }, ['catalog:catalog_product', 'wms:warehouse']),
99
+ ).toEqual(['catalog:catalog_product'])
100
+ })
101
+
102
+ it('applies no restriction for a superadmin and passes an explicit request through', () => {
103
+ expect(
104
+ resolveReadableEntityTypes(lookup, { grantedFeatures: [], isSuperAdmin: true }),
105
+ ).toBeUndefined()
106
+ expect(
107
+ resolveReadableEntityTypes(lookup, { grantedFeatures: [], isSuperAdmin: true }, ['wms:warehouse']),
108
+ ).toEqual(['wms:warehouse'])
109
+ })
110
+
111
+ it('skips entities explicitly disabled for search', () => {
112
+ const disabledLookup = {
113
+ getEntityConfig: (entityId: string) => CONFIGS[entityId],
114
+ getAllEntityConfigs: () => [
115
+ { entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: false },
116
+ ],
117
+ }
118
+
119
+ expect(resolveReadableEntityTypes(disabledLookup, { grantedFeatures: ['*'] })).toEqual([])
120
+ })
121
+ })
122
+
123
+ describe('filterSearchResultsByEntityAccess', () => {
124
+ const results = [
125
+ result('customers:customer_person_profile', 'person-1'),
126
+ result('catalog:catalog_product', 'product-1'),
127
+ result('customers:customer_person_profile', 'person-2'),
128
+ result('wms:warehouse', 'warehouse-1'),
129
+ ]
130
+
131
+ it('drops results for entity types the caller cannot view', () => {
132
+ const filtered = filterSearchResultsByEntityAccess(results, lookup, {
133
+ grantedFeatures: ['search.global', 'customers.people.view'],
134
+ })
135
+
136
+ expect(filtered.map((r) => r.recordId)).toEqual(['person-1', 'person-2'])
137
+ })
138
+
139
+ it('returns nothing when the caller holds only search.global', () => {
140
+ expect(
141
+ filterSearchResultsByEntityAccess(results, lookup, { grantedFeatures: ['search.global'] }),
142
+ ).toEqual([])
143
+ })
144
+
145
+ it('returns everything for a superadmin', () => {
146
+ expect(
147
+ filterSearchResultsByEntityAccess(results, lookup, {
148
+ grantedFeatures: [],
149
+ isSuperAdmin: true,
150
+ }),
151
+ ).toHaveLength(results.length)
152
+ })
153
+
154
+ it('reports each denied entity type once, not once per result', () => {
155
+ const denied: string[] = []
156
+ filterSearchResultsByEntityAccess(
157
+ results,
158
+ lookup,
159
+ { grantedFeatures: ['catalog.products.view'] },
160
+ { onDeny: (entityId) => denied.push(entityId) },
161
+ )
162
+
163
+ expect(denied).toEqual(['customers:customer_person_profile', 'wms:warehouse'])
164
+ })
165
+ })
@@ -0,0 +1,112 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { setup } from '../setup'
4
+ import features from '../acl'
5
+
6
+ describe('global search ACL contract', () => {
7
+ it('gates GET /api/search/search/global on the same feature the topbar renders on', () => {
8
+ // BackendHeaderChrome renders TopbarSearchInline on `search.global`. When the
9
+ // endpoint enforced the search-administration feature `search.view` instead, a
10
+ // role holding one but not the other either saw a box that 403'd on every
11
+ // keystroke or could query the endpoint with no UI (issue #5163).
12
+ //
13
+ // The route's metadata is asserted from source rather than by importing it,
14
+ // because importing the route pulls the whole request container (and its
15
+ // cross-package runtime deps) into this unit test.
16
+ const source = readFileSync(join(__dirname, '..', 'api', 'search', 'global', 'route.ts'), 'utf8')
17
+ const requireFeatures = source.match(/GET: \{ requireAuth: true, requireFeatures: \[([^\]]*)\] \}/)
18
+
19
+ expect(requireFeatures).not.toBeNull()
20
+ expect(requireFeatures?.[1]).toBe("'search.global'")
21
+ })
22
+
23
+ it('keeps both search.view and search.global declared — ACL feature IDs are frozen', () => {
24
+ const ids = features.map((feature) => feature.id)
25
+ expect(ids).toContain('search.view')
26
+ expect(ids).toContain('search.global')
27
+ })
28
+
29
+ it('grants employees the palette but not search administration', () => {
30
+ const employee = setup.defaultRoleFeatures?.employee ?? []
31
+ expect(employee).toContain('search.global')
32
+ expect(employee).not.toContain('search.view')
33
+ expect(employee).not.toContain('search.manage')
34
+ expect(employee).not.toContain('search.reindex')
35
+ expect(employee).not.toContain('search.*')
36
+ })
37
+ })
38
+
39
+ // Drift guard for the class of bug fixed in #5163: the single `search.global` gate
40
+ // says a caller may use search, not which records they may read. Every searchable
41
+ // entity therefore has to name the owning module's view feature in `aclFeatures`,
42
+ // or the global-search route fails closed and its results silently disappear.
43
+ describe('searchable entity ACL coverage', () => {
44
+ const repoRoot = join(__dirname, '..', '..', '..', '..', '..', '..')
45
+
46
+ function readEntityBlock(file: string, entityId: string): string {
47
+ const source = readFileSync(file, 'utf8')
48
+ const start = source.indexOf(` entityId: '${entityId}',`)
49
+ expect(start).toBeGreaterThan(-1)
50
+ const nextEntity = source.indexOf('\n {', start + 1)
51
+ return source.slice(start, nextEntity === -1 ? undefined : nextEntity)
52
+ }
53
+
54
+ function findSearchConfigFiles(): string[] {
55
+ const roots = [
56
+ join(repoRoot, 'packages', 'core', 'src', 'modules'),
57
+ join(repoRoot, 'packages', 'checkout', 'src', 'modules'),
58
+ ].filter((dir) => existsSync(dir))
59
+
60
+ return roots.flatMap((root) =>
61
+ readdirSync(root, { withFileTypes: true })
62
+ .filter((entry) => entry.isDirectory())
63
+ .map((entry) => join(root, entry.name, 'search.ts'))
64
+ .filter((file) => existsSync(file)),
65
+ )
66
+ }
67
+
68
+ const files = findSearchConfigFiles()
69
+
70
+ it('finds the module search configs to check', () => {
71
+ expect(files.length).toBeGreaterThan(5)
72
+ })
73
+
74
+ it.each(files)('%s declares aclFeatures for every searchable entity', (file) => {
75
+ const source = readFileSync(file, 'utf8')
76
+ // Entity configs are the six-space-indented `entityId:` entries of the config's
77
+ // top-level `entities` array. Helper functions above it also build objects with
78
+ // an `entityId` key, so the scan starts at the array and matches on indentation.
79
+ const entitiesArrayStart = source.search(/^ {2}entities: \[$/m)
80
+ expect(entitiesArrayStart).toBeGreaterThan(-1)
81
+ const entitiesArray = source.slice(entitiesArrayStart)
82
+
83
+ const declaredEntities = [...entitiesArray.matchAll(/^ {6}entityId: (.+),$/gm)].map((m) => m[1])
84
+ const declaredAclFeatures = [...entitiesArray.matchAll(/^ {6}aclFeatures: \[/gm)].length
85
+
86
+ expect(declaredEntities.length).toBeGreaterThan(0)
87
+ expect(declaredAclFeatures).toBe(declaredEntities.length)
88
+ })
89
+
90
+ it('uses the offer read-route feature for catalog offer results', () => {
91
+ const offer = readEntityBlock(
92
+ join(repoRoot, 'packages', 'core', 'src', 'modules', 'catalog', 'search.ts'),
93
+ 'catalog:catalog_offer',
94
+ )
95
+
96
+ expect(offer).toContain("aclFeatures: ['sales.channels.manage']")
97
+ })
98
+
99
+ it('keeps record-scoped and polymorphic entities disabled until search can enforce their row access', () => {
100
+ const messages = join(repoRoot, 'packages', 'core', 'src', 'modules', 'messages', 'search.ts')
101
+ const sales = join(repoRoot, 'packages', 'core', 'src', 'modules', 'sales', 'search.ts')
102
+ const unsafeEntities = [
103
+ readEntityBlock(messages, 'messages:message'),
104
+ readEntityBlock(sales, 'sales:sales_note'),
105
+ readEntityBlock(sales, 'sales:sales_document_address'),
106
+ ]
107
+
108
+ for (const entity of unsafeEntities) {
109
+ expect(entity).toContain('enabled: false')
110
+ }
111
+ })
112
+ })
@@ -5,6 +5,11 @@ import type {
5
5
  SearchStrategyId,
6
6
  } from '@open-mercato/shared/modules/search'
7
7
  import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
8
+ import {
9
+ filterSearchResultsByEntityAccess,
10
+ resolveReadableEntityTypes,
11
+ type SearchEntityConfigLookup,
12
+ } from './lib/entity-access'
8
13
 
9
14
  /**
10
15
  * AI Tools definitions for the Search module.
@@ -54,12 +59,11 @@ type AiToolDefinition = {
54
59
 
55
60
  /**
56
61
  * Minimal shape of the `searchIndexer` DI service consumed by the per-entity
57
- * ACL / field-policy resolution below. Kept local to avoid importing the full
58
- * `SearchIndexer` class into the tool module.
62
+ * ACL / field-policy resolution below. Aliased rather than redeclared so the
63
+ * tools and the global-search route cannot drift apart, and still narrow enough
64
+ * to avoid importing the full `SearchIndexer` class into the tool module.
59
65
  */
60
- type SearchIndexerLike = {
61
- getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
62
- }
66
+ type SearchIndexerLike = SearchEntityConfigLookup
63
67
 
64
68
  class SearchToolAuthorizationError extends Error {
65
69
  constructor(message: string) {
@@ -187,14 +191,27 @@ Searches customers, products, orders, deals, and more in one call.`,
187
191
  search: (query: string, options: any) => Promise<SearchResult[]>
188
192
  }>('searchService')
189
193
 
190
- const results = await searchService.search(input.query, {
194
+ // Same rule the HTTP palette applies: `search.global` authorizes using search,
195
+ // not reading every indexed entity type. The query is narrowed to the readable
196
+ // types so `limit` is not spent on records that would be filtered out, and the
197
+ // results are filtered afterwards as defense in depth.
198
+ const searchIndexer = ctx.container.resolve<SearchIndexerLike>('searchIndexer')
199
+ const subject = { grantedFeatures: ctx.userFeatures, isSuperAdmin: ctx.isSuperAdmin }
200
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, input.entityTypes)
201
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
202
+ return { query: input.query, totalResults: 0, results: [] }
203
+ }
204
+
205
+ const rawResults = await searchService.search(input.query, {
191
206
  tenantId: ctx.tenantId,
192
207
  organizationId: ctx.organizationId,
193
- entityTypes: input.entityTypes,
208
+ entityTypes: readableEntityTypes,
194
209
  strategies: input.strategies as SearchStrategyId[],
195
210
  limit: input.limit,
196
211
  })
197
212
 
213
+ const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject)
214
+
198
215
  return {
199
216
  query: input.query,
200
217
  totalResults: results.length,
@@ -70,6 +70,76 @@ function createStrategy(source: SearchStrategyId, recordId: string): SearchStrat
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * The route resolves `rbacService` and `searchIndexer` to drop results whose entity
75
+ * type the caller has no view feature for (issue #5163), so the container mock has
76
+ * to answer for both.
77
+ */
78
+ function createContainer(
79
+ searchService: SearchService,
80
+ configMap: Map<EntityId, SearchEntityConfig>,
81
+ acl: { features: string[]; isSuperAdmin?: boolean },
82
+ ) {
83
+ const registrations: Record<string, unknown> = {
84
+ searchService,
85
+ searchIndexer: {
86
+ getEntityConfig: (entityId: string) => configMap.get(entityId as EntityId),
87
+ getAllEntityConfigs: () => [...configMap.values()],
88
+ },
89
+ rbacService: {
90
+ loadAcl: async () => ({
91
+ isSuperAdmin: acl.isSuperAdmin ?? false,
92
+ features: acl.features,
93
+ organizations: null,
94
+ }),
95
+ },
96
+ }
97
+ return {
98
+ hasRegistration: (name: string) => name in registrations,
99
+ resolve: jest.fn((name: string) => registrations[name]),
100
+ dispose: jest.fn().mockResolvedValue(undefined),
101
+ }
102
+ }
103
+
104
+ function buildDemoSearchService() {
105
+ const rows = ['fulltext-record', 'vector-record', 'tokens-record'].map((recordId) => ({
106
+ entity_type: DEMO_ENTITY_ID,
107
+ entity_id: recordId,
108
+ doc: { id: recordId, title: recordId },
109
+ }))
110
+ const config: SearchEntityConfig = {
111
+ entityId: DEMO_ENTITY_ID,
112
+ enabled: true,
113
+ aclFeatures: ['demo.view'],
114
+ formatResult: async (context) => {
115
+ const { t } = await resolveTranslations()
116
+ return {
117
+ title: String(context.record.title),
118
+ badge: t('demo.search.badge', 'Person'),
119
+ }
120
+ },
121
+ resolveLinks: async (context) => {
122
+ const { t } = await resolveTranslations()
123
+ return [{
124
+ href: `/backend/demo/${String(context.record.id)}`,
125
+ label: t('demo.search.link.open', 'Open person'),
126
+ kind: 'primary',
127
+ }]
128
+ },
129
+ }
130
+ const configMap = new Map<EntityId, SearchEntityConfig>([[DEMO_ENTITY_ID, config]])
131
+ const searchService = new SearchService({
132
+ strategies: [
133
+ createStrategy('fulltext', 'fulltext-record'),
134
+ createStrategy('vector', 'vector-record'),
135
+ createStrategy('tokens', 'tokens-record'),
136
+ ],
137
+ defaultStrategies: ['fulltext', 'vector', 'tokens'],
138
+ presenterEnricher: createPresenterEnricher(createDatabase(rows), configMap),
139
+ })
140
+ return { searchService, configMap }
141
+ }
142
+
73
143
  describe('GET /api/search/search/global presenter localization', () => {
74
144
  beforeAll(() => {
75
145
  registerModules([
@@ -106,46 +176,10 @@ describe('GET /api/search/search/global presenter localization', () => {
106
176
  })
107
177
 
108
178
  it('replaces frozen presenters and links for fulltext, vector, and tokens using Accept-Language', async () => {
109
- const rows = ['fulltext-record', 'vector-record', 'tokens-record'].map((recordId) => ({
110
- entity_type: DEMO_ENTITY_ID,
111
- entity_id: recordId,
112
- doc: { id: recordId, title: recordId },
113
- }))
114
- const config: SearchEntityConfig = {
115
- entityId: DEMO_ENTITY_ID,
116
- enabled: true,
117
- formatResult: async (context) => {
118
- const { t } = await resolveTranslations()
119
- return {
120
- title: String(context.record.title),
121
- badge: t('demo.search.badge', 'Person'),
122
- }
123
- },
124
- resolveLinks: async (context) => {
125
- const { t } = await resolveTranslations()
126
- return [{
127
- href: `/backend/demo/${String(context.record.id)}`,
128
- label: t('demo.search.link.open', 'Open person'),
129
- kind: 'primary',
130
- }]
131
- },
132
- }
133
- const configMap = new Map<EntityId, SearchEntityConfig>([[DEMO_ENTITY_ID, config]])
134
- const presenterEnricher = createPresenterEnricher(createDatabase(rows), configMap)
135
- const searchService = new SearchService({
136
- strategies: [
137
- createStrategy('fulltext', 'fulltext-record'),
138
- createStrategy('vector', 'vector-record'),
139
- createStrategy('tokens', 'tokens-record'),
140
- ],
141
- defaultStrategies: ['fulltext', 'vector', 'tokens'],
142
- presenterEnricher,
143
- })
144
- const container = {
145
- resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
146
- dispose: jest.fn().mockResolvedValue(undefined),
147
- }
148
- mockCreateRequestContainer.mockResolvedValue(container)
179
+ const { searchService, configMap } = buildDemoSearchService()
180
+ mockCreateRequestContainer.mockResolvedValue(
181
+ createContainer(searchService, configMap, { features: ['search.global', 'demo.view'] }),
182
+ )
149
183
 
150
184
  const request = new Request('http://localhost/api/search/search/global?q=person', {
151
185
  headers: { 'accept-language': 'pl-PL' },
@@ -165,3 +199,85 @@ describe('GET /api/search/search/global presenter localization', () => {
165
199
  }
166
200
  })
167
201
  })
202
+
203
+ describe('GET /api/search/search/global per-entity access control', () => {
204
+ beforeEach(() => {
205
+ jest.clearAllMocks()
206
+ mockGetAuthFromRequest.mockResolvedValue({
207
+ tenantId: 'tenant-1',
208
+ orgId: 'org-1',
209
+ sub: 'user-1',
210
+ isSuperAdmin: false,
211
+ })
212
+ mockResolveOrganizationScopeForRequest.mockResolvedValue({
213
+ selectedId: 'org-1',
214
+ filterIds: ['org-1'],
215
+ allowedIds: ['org-1'],
216
+ tenantId: 'tenant-1',
217
+ })
218
+ })
219
+
220
+ async function search(acl: { features: string[]; isSuperAdmin?: boolean }) {
221
+ const { searchService, configMap } = buildDemoSearchService()
222
+ mockCreateRequestContainer.mockResolvedValue(createContainer(searchService, configMap, acl))
223
+ const response = await GET(new Request('http://localhost/api/search/search/global?q=person'))
224
+ return {
225
+ status: response.status,
226
+ body: await response.json() as { results: SearchResult[]; strategiesUsed: SearchStrategyId[] },
227
+ }
228
+ }
229
+
230
+ it('withholds results for entity types the caller cannot view', async () => {
231
+ // `search.global` alone opens the palette; it must not expose presenter titles,
232
+ // subtitles or deep links for records the caller has no view feature for.
233
+ const { status, body } = await search({ features: ['search.global'] })
234
+
235
+ expect(status).toBe(200)
236
+ expect(body.results).toEqual([])
237
+ expect(body.strategiesUsed).toEqual([])
238
+ })
239
+
240
+ it('returns results once the caller holds the entity view feature', async () => {
241
+ const { status, body } = await search({ features: ['search.global', 'demo.view'] })
242
+
243
+ expect(status).toBe(200)
244
+ expect(body.results).toHaveLength(3)
245
+ })
246
+
247
+ it('narrows the query to the readable entity types instead of only filtering afterwards', async () => {
248
+ // Filtering after the fact would spend `limit` on unreadable records and leave
249
+ // the palette looking empty, so the restriction has to reach the strategies.
250
+ const { searchService, configMap } = buildDemoSearchService()
251
+ const searchSpy = jest.spyOn(searchService, 'search')
252
+ mockCreateRequestContainer.mockResolvedValue(
253
+ createContainer(searchService, configMap, { features: ['search.global', 'demo.view'] }),
254
+ )
255
+
256
+ await GET(new Request('http://localhost/api/search/search/global?q=person'))
257
+
258
+ expect(searchSpy).toHaveBeenCalledTimes(1)
259
+ const options = searchSpy.mock.calls[0][1] as { entityTypes?: string[] }
260
+ expect(options.entityTypes).toEqual([DEMO_ENTITY_ID])
261
+ })
262
+
263
+ it('skips the search entirely when the caller can read nothing', async () => {
264
+ const { searchService, configMap } = buildDemoSearchService()
265
+ const searchSpy = jest.spyOn(searchService, 'search')
266
+ mockCreateRequestContainer.mockResolvedValue(
267
+ createContainer(searchService, configMap, { features: ['search.global'] }),
268
+ )
269
+
270
+ const response = await GET(new Request('http://localhost/api/search/search/global?q=person'))
271
+
272
+ expect(response.status).toBe(200)
273
+ expect(searchSpy).not.toHaveBeenCalled()
274
+ expect((await response.json() as { results: SearchResult[] }).results).toEqual([])
275
+ })
276
+
277
+ it('returns results for a superadmin without any explicit grant', async () => {
278
+ const { status, body } = await search({ features: [], isSuperAdmin: true })
279
+
280
+ expect(status).toBe(200)
281
+ expect(body.results).toHaveLength(3)
282
+ })
283
+ })
@@ -93,8 +93,18 @@ describe('Search API organizationId scoping', () => {
93
93
  const searchService = {
94
94
  search: jest.fn().mockResolvedValue([]),
95
95
  }
96
+ // The global route also resolves rbacService + searchIndexer to filter results
97
+ // by per-entity view features (issue #5163).
98
+ const registrations: Record<string, unknown> = {
99
+ searchService,
100
+ searchIndexer: { getEntityConfig: () => undefined, getAllEntityConfigs: () => [] },
101
+ rbacService: {
102
+ loadAcl: jest.fn().mockResolvedValue({ isSuperAdmin: true, features: ['*'], organizations: null }),
103
+ },
104
+ }
96
105
  const container = {
97
- resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
106
+ hasRegistration: (name: string) => name in registrations,
107
+ resolve: jest.fn((name: string) => registrations[name]),
98
108
  dispose: jest.fn(),
99
109
  }
100
110
  mockCreateRequestContainer.mockResolvedValue(container)
@@ -8,11 +8,31 @@ import type { SearchService } from '@open-mercato/search'
8
8
  import type { EmbeddingService } from '../../../../../vector'
9
9
  import { resolveEmbeddingConfig } from '../../../lib/embedding-config'
10
10
  import { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'
11
- import { searchError } from '../../../../../lib/debug'
11
+ import {
12
+ filterSearchResultsByEntityAccess,
13
+ resolveReadableEntityTypes,
14
+ type SearchEntityConfigLookup,
15
+ } from '../../../lib/entity-access'
16
+ import { searchDebug, searchError } from '../../../../../lib/debug'
12
17
  import { globalSearchOpenApi } from '../../openapi'
13
18
 
19
+ /**
20
+ * `search.global` — the same feature the topbar gates the Cmd+K palette on
21
+ * (`BackendHeaderChrome`). It used to be `search.view`, which is the
22
+ * search-administration feature guarding the settings endpoints; the two gates
23
+ * could disagree in either direction, so a role holding only one of them either
24
+ * saw a search box that 403'd on every keystroke or could query the endpoint with
25
+ * no UI. `search.view` stays on `api/settings/**`, where it belongs.
26
+ */
14
27
  export const metadata = {
15
- GET: { requireAuth: true, requireFeatures: ['search.view'] },
28
+ GET: { requireAuth: true, requireFeatures: ['search.global'] },
29
+ }
30
+
31
+ type RbacLike = {
32
+ loadAcl: (
33
+ userId: string,
34
+ scope: { tenantId: string | null; organizationId: string | null },
35
+ ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
16
36
  }
17
37
 
18
38
  function parseLimit(value: string | null): number {
@@ -64,6 +84,21 @@ export async function GET(req: Request) {
64
84
  )
65
85
  }
66
86
 
87
+ // Fail closed: without the RBAC service or the entity registry there is no way
88
+ // to tell which entity types this caller may read, so refuse rather than search.
89
+ if (!container.hasRegistration('rbacService') || !container.hasRegistration('searchIndexer')) {
90
+ searchError('search.api.global', 'entity-acl-unavailable', {
91
+ rbacService: container.hasRegistration('rbacService'),
92
+ searchIndexer: container.hasRegistration('searchIndexer'),
93
+ })
94
+ return NextResponse.json(
95
+ { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
96
+ { status: 503 }
97
+ )
98
+ }
99
+ const rbac = container.resolve('rbacService') as RbacLike
100
+ const searchIndexer = container.resolve('searchIndexer') as SearchEntityConfigLookup
101
+
67
102
  // Fetch saved global search strategies (per-tenant; falls back to the instance default)
68
103
  const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } })
69
104
 
@@ -97,16 +132,50 @@ export async function GET(req: Request) {
97
132
  const scopeFilter = resolveOrganizationScopeFilter(scope, auth)
98
133
  const organizationId =
99
134
  typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined
135
+
136
+ // `search.global` authorizes using the palette, not reading every indexed
137
+ // record. Narrow the query to the entity types this caller may read so the
138
+ // result budget is not spent on records that would only be filtered out.
139
+ const acl = await rbac.loadAcl(auth.sub, {
140
+ tenantId: scope.tenantId ?? auth.tenantId ?? null,
141
+ organizationId: organizationId ?? null,
142
+ })
143
+ const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin }
144
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes)
145
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
146
+ return NextResponse.json({
147
+ results: [],
148
+ strategiesUsed: [],
149
+ strategiesEnabled: strategies,
150
+ timing: Date.now() - startTime,
151
+ query,
152
+ limit,
153
+ })
154
+ }
155
+
100
156
  const searchOptions = {
101
157
  tenantId: auth.tenantId,
102
158
  organizationId,
103
159
  organizationIds: scopeFilter.organizationIds,
104
160
  limit,
105
161
  strategies,
106
- entityTypes,
162
+ entityTypes: readableEntityTypes,
107
163
  }
108
164
 
109
- const results = await searchService.search(query, searchOptions)
165
+ const rawResults = await searchService.search(query, searchOptions)
166
+
167
+ // Defense in depth: a strategy that ignores `entityTypes` must still not leak
168
+ // a presenter title, subtitle or deep link past the per-entity gate.
169
+ const results = filterSearchResultsByEntityAccess(
170
+ rawResults,
171
+ searchIndexer,
172
+ { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin },
173
+ {
174
+ onDeny: (deniedEntityId, reason) => {
175
+ searchDebug('search.api.global', 'entity-filtered', { entityId: deniedEntityId, reason })
176
+ },
177
+ },
178
+ )
110
179
 
111
180
  const timing = Date.now() - startTime
112
181