--- name: "Fence Glean Search by Datasource" tags: - glean - fence-sensitive-scopes - access-control - datasource - ingress - soc2 - hipaa - gdpr-ccpa publishedAt: 2026-07-12 description: | # glean / fence-datasource-scope **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; explicit allows for non-fenced tools and cleared calls **Package:** `glean.ingress.fence_datasource_scope` ## What it does Glean's `search` tool fans out across every system the tenant has indexed (Drive, Confluence, Slack, Jira, Gmail/Outlook, GitHub, Salesforce, Gong, HR systems…). This policy restricts **which indexed datasource a `search` call may target** by inspecting the call's `app` argument — the datasource enum (`gong`, `salescloud`, `confluence`, `gdrive`, `slack`, `jira`, `github`, `gmail`, `o365sharepoint`, …). Calls targeting a **restricted datasource** are denied unless the caller's IdP groups include the group cleared for that source. The shipped (placeholder) mapping is: | Datasource (`app` value) | What it holds | Required IdP group | |---|---|---| | `gong` | Call recordings / conversation intelligence | `sales` | | `salescloud` | Salesforce CRM | `revops` | | `workday`, `bamboohr` (HR datasources) | HR / people records | `hr` | When the `app` argument is **absent**, the search fans out across every indexed system at once, so an unscoped query is treated like a query that can reach all restricted sources: it is allowed only for a caller cleared for **every** restricted datasource, and otherwise denied with a reason directing the caller to pass an explicit `app`. Every other tool — `chat`, `read_document`, `code_search`, `employee_search`, `gmail_search`, `meeting_lookup`, `memory`, and all non-Glean tools — passes through this policy untouched. Because `chat` accepts only free text with no filterable datasource field, this ingress fence covers `search` only and must be paired with the egress redaction policy to cover `chat` (see Composition). ## Identity gating Clearance is granted per datasource via IdP group membership read from `input.subject.claims.groups` through `object.get(...)` chains, so a missing subject, missing claims, or a missing/malformed `groups` claim **fails closed**: no matching group means no access to the restricted datasource. The `groups` claim must be an array of strings; any other shape yields no memberships. Group names are compared case-insensitively. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Glean's cross-source search path and supports alignment with: - **SOC 2 C1.1, P4.1** — identifies and protects confidential information and limits personal-information use by fencing designated datasources (Gong call data, Salesforce CRM, HR systems) out of agent search unless the caller's role grants it. - **HIPAA §164.502(b)/§164.514(d), §164.308(a)(4)** — minimum-necessary and information-access-management: an agent cannot trawl HR datasources over MCP unless the caller's role clears it; **§164.522(a)** — supports agreed-to restrictions expressed as datasource-level fences. - **PCI DSS 7.2.6** — supports restricting programmatic (agent) query access to stored data that may include account data (e.g. the Salesforce CRM datasource) by IdP role. - **GDPR Art. 9; CPRA §1798.121** — keeps special-category / sensitive personal information held in HR and CRM datasources out of agent result sets; **Art. 5(1)(b)** — supports purpose limitation by keying datasource access to the caller's team. ## Why ingress The target datasource is fully determined by the request alone (tool name, the `app` argument, caller claims), so enforcement happens before the call reaches Glean and restricted content is never fetched into the model context. For defense in depth, pair with an egress redaction policy as a backstop for content reached by paths this policy does not cover (notably `chat`). ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `glean-search`), and the prefix is not standardized, so the policy matches case-insensitively. Glean's remote server names its search tool the bare word `search`, which collides with other servers' `search` tools and with Glean's own `code_search` / `employee_search` / `gmail_search` / `outlook_search`. To avoid mis-matching those, the policy matches **only**: - the exact tool name `search`, or - any name ending in `-search` (e.g. `glean-search`, `glean-mcp-search`). This deliberately excludes the `_search` sibling tools (they take no `app` argument and are governed by companion policies). It also means the policy should be attached to the **Glean gateway/pipeline only** — on a Glean-only pipeline the sole `-search`/`search` tool is Glean's. Verify the exact name your gateway emits with the dump-input debug technique before relying on this in production. The deprecated local server exposed search as `company_search` (a `_search` name, so **not** matched); add it only if a tenant still runs the archived package. ## Argument shape The datasource is read from `input.payload.args.app` (verified from Glean's `search` parameter list). The value is normalized with `lower`/`trim_space` and compared against the restricted set. Both shapes are handled defensively: - a single string (`"app": "gong"`), and - an array of strings (`"app": ["gdrive", "gong"]`) — a call is denied if **any** entry names a restricted datasource the caller is not cleared for. An empty, missing, or non-string `app` (and an array of only empty strings) is treated as an **unscoped** query and fails closed as described above. ## Configuration Edit the `restricted_sources` object at the top of the Rego. The datasource keys (`gong`, `salescloud`, `workday`, `bamboohr`) map to the required IdP group. `gong` and `salescloud` are Glean's documented enum values; the HR entries (`workday`, `bamboohr`) are **placeholders** — replace them with the exact `app` enum values your tenant's HR systems are indexed under, and remap the groups (`sales`, `revops`, `hr`) to your IdP's group names at import time. ## Examples ### Allowed (search scoped to a non-restricted datasource) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "deploy runbook", "app": "confluence" } } } } ``` `allow = true`, no reason. ### Allowed (restricted datasource, caller cleared) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "subject": { "sub": "auth0|rep", "claims": { "groups": ["sales"] } }, "payload": { "name": "glean-search", "args": { "query": "acme renewal", "app": "gong" } } } } ``` `allow = true`, no reason. ### Denied (restricted datasource, caller not cleared) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "subject": { "sub": "auth0|eng", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-search", "args": { "query": "pipeline", "app": "salescloud" } } } } ``` `allow = false`, `reason = "This Glean search targets restricted datasource(s) (salescloud) …"`. ### Denied (unscoped search, caller not cleared for all restricted sources) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "compensation" } } } } ``` `allow = false`, `reason = "This Glean search has no `app` datasource filter, …"`. ## Composition This policy covers the datasource-scoped `search` surface. Useful companions: - An **egress redaction** policy on `search` / `chat` / `read_document` responses so PII/CHD that is reached by paths this fence does not cover (notably free-text `chat`) is masked before it reaches the agent context. - A `meeting_lookup` transcript-extraction fence and a mailbox-search restriction for the other sensitive Glean read tools. - A default-deny-unknown-tools policy (PF-28) for Glean's admin-mutable tool inventory (agents-as-tools, gateway-proxied writes). ## Known limitations - **`chat` is not fenceable here.** `chat` takes only free text with no datasource argument, so it can reach any indexed system regardless of this policy. It is intentionally passed through and must be covered by the egress redaction backstop. - **Sibling read tools are out of scope by design.** `code_search`, `employee_search`, `gmail_search`, `outlook_search`, `meeting_lookup`, `read_document`, and `user_activity` reach sensitive data but do not take the `app` datasource argument, so this policy does not fence them (the tool-name match excludes `_search` names). Govern them with the companion policies listed above. - **`dynamic_search_result_filters` is a bypass residual.** Glean's `search` also accepts a structured result-filter argument that can re-scope results by datasource. A caller could in principle set a benign `app` (or none) and steer results toward a restricted source through that filter. Its per-tenant schema is not documented, so this policy does not parse it; rely on the egress redaction backstop and cap/strip that argument with a separate transform if your tenant exposes it. - **Denylist, not allowlist — an unrecognized `app` value is not fenced.** A scoped call is denied only when `app` names a *restricted* source the caller lacks; any other non-empty string (a real but non-restricted datasource, **or a value the enum does not define**) is treated as a benign scoped query and allowed. Glean's behavior on an unrecognized `app` value is **unverified**: if Glean validates the enum and errors, there is no exposure; but if it silently ignores the value and fans out across all sources, a non-privileged caller could pass a junk `app` (e.g. `app: "everything"`) to reach restricted datasources while still setting `app_present`, sidestepping the unscoped-search guard. The same gap covers **obfuscated look-alikes**: a homoglyph or otherwise-encoded value (e.g. `gong` spelled with a Greek omicron) is a distinct, unrecognized string that `lower`/`trim_space` do not fold to the restricted key, so it is treated as a benign scoped query — though Glean's own enum will not resolve it either, so this yields no exposure beyond the fan-out case above. Do not rely on this fence alone against that case: keep the egress redaction backstop, and if you can enumerate your tenant's datasource enum, convert `restricted_sources` handling to an allowlist (treat any `app` outside the known set as unscoped/fail-closed) at import time. - **Bulk-export flags are not capped here.** `exhaustive` and `num_results` (up to 500) enable bulk pulls; this policy fences *which* datasource, not *how much*. Pair with a transform policy that caps `num_results` and strips `exhaustive` if bulk export is a concern. - **HR datasource names are placeholders.** Only the configured `app` enum values (`workday`, `bamboohr` by default) are treated as HR; a restricted HR system indexed under a different `app` value is not caught until you add it to `restricted_sources`. - **Tool-name portability.** Bare `search` and any `-search` suffix match, so attach this to the Glean pipeline only — a non-Glean server whose tool is named `…-search` would otherwise be fenced too. Confirm the exact gateway tool name with the dump-input technique. The match also depends on the gateway joining the server prefix to the tool with a **hyphen** (`glean-search`, the DTwo convention). If a deployment instead joins with an underscore, the search tool is emitted as `glean_search`, which ends in `_search` and is deliberately excluded (that suffix is how the sibling `code_search`/`gmail_search`/`employee_search` tools are skipped) — so the fence would silently pass the search tool through. Broadening the match to `_search` is not an option (it would blanket-deny every no-`app` sibling search); verify your gateway emits a hyphen-joined name before relying on this fence. - **Identity placeholders.** Group names are placeholders — replace `sales`, `revops`, and `hr` with your IdP's group names at import time. The `groups` claim must be an array of strings; any other shape fails closed. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. direction: ingress apps: - glean industries: [] bundles: - soc2 - hipaa - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package glean.ingress.fence_datasource_scope # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # ----------------------------------------------------------------------------- # CONFIG: restricted Glean datasource (`app` enum value) -> IdP group cleared # to search it. `gong` and `salescloud` are Glean's documented enum values; # the HR entries are PLACEHOLDERS — replace them with the exact `app` values # your HR systems are indexed under, and remap the groups (sales, revops, hr) # to your IdP's group names at import time. Keys must be lowercase. # ----------------------------------------------------------------------------- restricted_sources := { "gong": "sales", "salescloud": "revops", "workday": "hr", "bamboohr": "hr", } # ----------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name. Glean's search tool is the bare word `search`, which collides # with other `search` tools and with Glean's own `code_search` / # `employee_search` / `gmail_search` (all `_search`). We match ONLY the exact # name `search` or a `-search` suffix so those `_search` siblings are excluded. # Attach to the Glean pipeline only. Verify with the dump-input technique. # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) is_glean_search_tool if tool_name == "search" is_glean_search_tool if endswith(tool_name, "-search") # ----------------------------------------------------------------------------- # Identity — caller's IdP groups, read fail-closed: a missing subject, missing # claims, or a missing/malformed groups claim yields no memberships, so the # caller is never treated as cleared by accident. # ----------------------------------------------------------------------------- caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) member_of(group) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == group } # Required to run an UNSCOPED search (no `app`): it fans out across every # indexed system, so the caller must be cleared for ALL restricted sources. caller_in_all_restricted_groups if { every _, group in restricted_sources { member_of(group) } } # ----------------------------------------------------------------------------- # Arguments — the datasource(s) the search targets. Handles both a single # string (`"app": "gong"`) and an array (`"app": ["gdrive", "gong"]`). Empty # or non-string values contribute nothing, so a missing/blank `app` leaves # requested_apps empty and is treated as an unscoped query (fail-closed). # ----------------------------------------------------------------------------- req_args := object.get(object.get(input, "payload", {}), "args", {}) app_raw := object.get(req_args, "app", "") requested_apps contains a if { is_string(app_raw) a := lower(trim_space(app_raw)) a != "" } requested_apps contains a if { is_array(app_raw) some x in app_raw is_string(x) a := lower(trim_space(x)) a != "" } app_present if count(requested_apps) > 0 # Restricted datasources the call targets that the caller is NOT cleared for. denied_apps contains a if { some a in requested_apps group := object.get(restricted_sources, a, "") group != "" not member_of(group) } # ----------------------------------------------------------------------------- # Allow rules # ----------------------------------------------------------------------------- # Any tool this policy does not fence passes through (chat, read_document, # the *_search siblings, memory, all non-Glean tools). allow if { not is_glean_search_tool } # Search scoped to an explicit datasource: allowed unless it names a restricted # source the caller is not cleared for. allow if { is_glean_search_tool app_present count(denied_apps) == 0 } # Unscoped search (no `app`): fans out across every source, so only a caller # cleared for ALL restricted datasources may run one. allow if { is_glean_search_tool not app_present caller_in_all_restricted_groups } # ----------------------------------------------------------------------------- # Reasons # ----------------------------------------------------------------------------- reasons contains msg if { is_glean_search_tool app_present count(denied_apps) > 0 src_list := concat(", ", sort([a | some a in denied_apps])) msg := sprintf("This Glean search targets restricted datasource(s) (%s) your account is not cleared for. Search a datasource you have access to, or contact your InfoSec team if your role requires that source.", [src_list]) } reasons contains "This Glean search has no `app` datasource filter, so it fans out across every indexed system, including restricted ones (Gong call recordings, Salesforce CRM, HR). Pass an explicit `app` naming the datasource you need, or contact your InfoSec team if you need broader search access." if { is_glean_search_tool not app_present not caller_in_all_restricted_groups } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```