@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,132 @@
1
+ import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
2
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
+
4
+ /**
5
+ * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL
6
+ * resolution. Kept structural so callers and tests can pass a plain object
7
+ * instead of constructing a full `SearchIndexer`.
8
+ */
9
+ export type SearchEntityConfigLookup = {
10
+ getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
11
+ getAllEntityConfigs: () => SearchEntityConfig[]
12
+ }
13
+
14
+ export type SearchEntityAccessSubject = {
15
+ grantedFeatures: readonly string[]
16
+ isSuperAdmin?: boolean
17
+ }
18
+
19
+ export type SearchEntityDenyReason =
20
+ /** No module declares this entity in a `search.ts` config. */
21
+ | 'unconfigured'
22
+ /** The entity is configured for search but declares no `aclFeatures`. */
23
+ | 'no-acl-features'
24
+ /** The caller does not hold the entity's declared view feature(s). */
25
+ | 'insufficient-features'
26
+
27
+ export type SearchEntityAccessOptions = {
28
+ /**
29
+ * Called once per denied entity type. Exists so a silent drop is diagnosable:
30
+ * results disappearing because a module forgot to declare `aclFeatures` looks
31
+ * identical, from the palette, to results that simply did not match.
32
+ */
33
+ onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void
34
+ }
35
+
36
+ /**
37
+ * Decide whether a caller may see results for one entity type.
38
+ *
39
+ * The single `search.global` gate on the palette only says "this user may use
40
+ * global search"; it says nothing about which records they may read. Each entity
41
+ * declares the owning module's view feature(s) in `aclFeatures`, and those are
42
+ * what actually authorize the read — the same rule the `search_get` /
43
+ * `search_aggregate` AI tools already apply.
44
+ *
45
+ * Fails closed: an entity that is not registered for search, or that declares no
46
+ * `aclFeatures`, is never exposed to a non-superadmin caller.
47
+ */
48
+ export function canReadSearchEntity(
49
+ entityId: string,
50
+ lookup: SearchEntityConfigLookup,
51
+ subject: SearchEntityAccessSubject,
52
+ options: SearchEntityAccessOptions = {},
53
+ ): boolean {
54
+ if (subject.isSuperAdmin) return true
55
+
56
+ const config = lookup.getEntityConfig(entityId)
57
+ if (!config) {
58
+ options.onDeny?.(entityId, 'unconfigured')
59
+ return false
60
+ }
61
+
62
+ const required = config.aclFeatures
63
+ if (!required || required.length === 0) {
64
+ options.onDeny?.(entityId, 'no-acl-features')
65
+ return false
66
+ }
67
+
68
+ const allowed = authorizeFeatures(required, {
69
+ grantedFeatures: subject.grantedFeatures,
70
+ unrestricted: false,
71
+ })
72
+ if (!allowed) options.onDeny?.(entityId, 'insufficient-features')
73
+ return allowed
74
+ }
75
+
76
+ /**
77
+ * The entity types this caller may read, narrowed to `requestedEntityTypes` when
78
+ * the caller asked for specific ones.
79
+ *
80
+ * Restricting the query up front is what keeps `limit` meaningful. Filtering only
81
+ * after the search would spend the whole result budget on records the caller
82
+ * cannot see: an employee granted just `customers.people.view` would get the top
83
+ * 50 hits across every entity type, then watch most of them be dropped, and the
84
+ * palette would look empty even with hundreds of matching people behind it.
85
+ *
86
+ * Returns `undefined` when no restriction applies (superadmin with no explicit
87
+ * request), and an empty array when nothing is readable — callers should
88
+ * short-circuit on that rather than pass it down as "no filter".
89
+ */
90
+ export function resolveReadableEntityTypes(
91
+ lookup: SearchEntityConfigLookup,
92
+ subject: SearchEntityAccessSubject,
93
+ requestedEntityTypes?: string[],
94
+ ): string[] | undefined {
95
+ if (subject.isSuperAdmin) return requestedEntityTypes
96
+
97
+ const readable = lookup
98
+ .getAllEntityConfigs()
99
+ .filter((config) => config.enabled !== false)
100
+ .map((config) => config.entityId)
101
+ .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))
102
+
103
+ if (!requestedEntityTypes) return readable
104
+ const requested = new Set(requestedEntityTypes)
105
+ return readable.filter((entityId) => requested.has(entityId))
106
+ }
107
+
108
+ /**
109
+ * Drop the results whose entity type the caller is not allowed to read.
110
+ *
111
+ * Filtering happens server-side so an under-privileged caller never receives the
112
+ * presenter title, subtitle or deep link of a record they cannot open. Decisions
113
+ * are memoized per entity type because a single response commonly mixes dozens of
114
+ * results across a handful of types.
115
+ */
116
+ export function filterSearchResultsByEntityAccess<T extends { entityId: string }>(
117
+ results: readonly T[],
118
+ lookup: SearchEntityConfigLookup,
119
+ subject: SearchEntityAccessSubject,
120
+ options: SearchEntityAccessOptions = {},
121
+ ): T[] {
122
+ if (subject.isSuperAdmin) return [...results]
123
+
124
+ const decisions = new Map<string, boolean>()
125
+ return results.filter((result) => {
126
+ const cached = decisions.get(result.entityId)
127
+ if (cached !== undefined) return cached
128
+ const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)
129
+ decisions.set(result.entityId, allowed)
130
+ return allowed
131
+ })
132
+ }
@@ -3,7 +3,12 @@ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
3
3
  export const setup: ModuleSetupConfig = {
4
4
  defaultRoleFeatures: {
5
5
  admin: ['search.*', 'vector.*'],
6
- employee: ['vector.*'],
6
+ // `search.global` only unlocks the Cmd+K palette — the mirror image of the
7
+ // `ai_assistant.view` grant that gives employees Cmd+L. It does not widen what
8
+ // they can read: the global-search endpoint drops every result whose entity
9
+ // type the caller has no owning-module view feature for. The administration
10
+ // features (`search.view`, `search.manage`, `search.reindex`) stay admin-only.
11
+ employee: ['search.global', 'vector.*'],
7
12
  },
8
13
  }
9
14