0

Design permission-aware enterprise search

by · about 3 hours ago· ⑂ 0 remixes

Question: Design permission-aware enterprise search

Companies: Glean

Difficulty: Hard

Problem statement

Design search across every app a company uses (Drive, Confluence, Slack, Jira, GitHub, email and more) from one search box, plus an assistant that answers from the results. Nobody may ever see a result, title, snippet or answer derived from something they cannot open in the source app. Cover connectors, indexing, how permissions are modeled and enforced (groups, nested groups, folder inheritance, identities across apps), how quickly a revoked share disappears, ranking with enterprise signals, and how the assistant stays permission-safe.

Requirements

Search across everything a company's employees use: Google Drive, Confluence, Slack, Jira, GitHub, email, Salesforce and a hundred more apps, from one box, with an assistant that answers questions from the results. The hard constraint: nobody may ever see a result, a title, a snippet or an answer built from a document they cannot open in the source app.

Functional requirements

  • Connect to many SaaS apps per customer; index content, metadata and permissions.
  • Keyword and semantic search with good ranking, filters (app, author, date) and snippets.
  • Results limited to what the searching user can access in the source system, including group membership and folder inheritance.
  • Permission changes take effect quickly; revocations fastest.
  • An assistant answers questions with citations, using only permitted content.

Non-functional requirements

  • Search p95 under 500 ms; first assistant tokens under 2 s.
  • A revoked share disappears from results within minutes; new content is searchable within about 10 minutes.
  • Each customer's data isolated (separate indexes, often a separate cloud project).
  • Zero tolerance for permission leaks: a wrong ranking is a quality bug, a leak is a security incident.

Back-of-the-envelope estimation

  • A 20,000-employee customer: about 50M items (documents, tickets, messages, emails), about 5 chunks each for semantic search: 250M chunks.
  • Keyword index: about 2 KB per item: 100 GB. Embeddings at 768 dimensions in int8: 768 bytes per chunk, 190 GB, plus ANN graph overhead. Sharded across several nodes per large customer.
  • Queries: 20,000 people × 8 searches a day = 160,000 a day, about 2 a second on average and 15 at peak. Each customer is small; the platform runs hundreds of customers.
  • Change volume: about 2% of items change a day (1M updates) and ACL changes come in bursts: re-sharing one folder can change the permissions of 100,000 items at once.

API design

  • GET /search?q=&filters=&cursor= (user identity from SSO) → results {doc_id, title, snippet, url, app, updated_at, why}
  • POST /assistant {question, conversation_id} → streamed answer with {citations}
  • Connector side: POST /ingest/items (content and metadata), POST /ingest/acls (ACL-only changes), POST /ingest/identities (users, groups, memberships)

Data model

  • items(tenant, item_id, app, title, body_ref, author, url, updated_at, acl_principals[], ancestors[])
  • chunks(item_id, chunk_no, text, embedding) in the vector index, carrying the item's acl_principals
  • identities(tenant, person_id, app, app_user_id): one person, many app accounts
  • memberships(tenant, group_id, member_id): users and groups inside groups, per app
  • principal_closure(person_id -> principals[]): cached expansion used at query time

High-level design

Build it one requirement at a time. Each drawing adds to the one before; the shaded boxes are what that step adds.

1) Crawl, index and search

Connectors crawl each app (and subscribe to its change webhooks). Ingest parses, chunks and embeds content into a per-customer search index with keyword and vector parts. The query service runs hybrid retrieval for the user's query.

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"conn","x":0,"y":0,"width":180,"height":70,"label":{"text":"Connectors"}},
 {"type":"rectangle","id":"ingest","x":340,"y":0,"width":180,"height":70,"label":{"text":"Ingest"}},
 {"type":"ellipse","id":"index","x":670,"y":-5,"width":240,"height":80,"label":{"text":"Search index"}},
 {"type":"rectangle","id":"query","x":1060,"y":80,"width":180,"height":70,"label":{"text":"Query service"}},
 {"type":"rectangle","id":"user","x":1400,"y":80,"width":180,"height":70,"label":{"text":"Employee"}},
 {"type":"arrow","id":"a1","x":180,"y":35,"width":160,"height":0,"start":{"id":"conn"},"end":{"id":"ingest"},"label":{"text":"content"}},
 {"type":"arrow","id":"a2","x":520,"y":35,"width":150,"height":0,"start":{"id":"ingest"},"end":{"id":"index"}},
 {"type":"arrow","id":"a3","x":1400,"y":115,"width":-160,"height":0,"start":{"id":"user"},"end":{"id":"query"},"label":{"text":"query"}},
 {"type":"arrow","id":"a4","x":1060,"y":95,"width":-150,"height":-55,"start":{"id":"query"},"end":{"id":"index"}}
]}

2) Know who can see what

Connectors also sync permissions: each item's ACL (users, groups, "everyone in the company", folder inheritance) and every app's users and groups. An identity service maps one person's accounts across apps and expands nested groups. The index stores each item's allowed principals; the query service expands the user into their principals and filters inside the index query, so forbidden items are never even scored.

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-80,"text":"Shaded: added in this step","fontSize":16},
 {"type":"rectangle","id":"conn","x":0,"y":0,"width":180,"height":70,"label":{"text":"Connectors"}},
 {"type":"rectangle","id":"ingest","x":340,"y":0,"width":180,"height":70,"label":{"text":"Ingest"}},
 {"type":"ellipse","id":"index","x":670,"y":-5,"width":240,"height":80,"label":{"text":"Search index"}},
 {"type":"rectangle","id":"acl","x":340,"y":160,"width":180,"height":70,"label":{"text":"ACL + identity\nsync"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"ellipse","id":"principals","x":670,"y":155,"width":240,"height":80,"label":{"text":"Principals\n+ groups"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"query","x":1060,"y":80,"width":180,"height":70,"label":{"text":"Query service"}},
 {"type":"rectangle","id":"user","x":1400,"y":80,"width":180,"height":70,"label":{"text":"Employee"}},
 {"type":"arrow","id":"a1","x":180,"y":35,"width":160,"height":0,"start":{"id":"conn"},"end":{"id":"ingest"},"label":{"text":"content"}},
 {"type":"arrow","id":"a2","x":520,"y":35,"width":150,"height":0,"start":{"id":"ingest"},"end":{"id":"index"}},
 {"type":"arrow","id":"a5","x":150,"y":70,"width":190,"height":110,"start":{"id":"conn"},"end":{"id":"acl"},"label":{"text":"ACLs"}},
 {"type":"arrow","id":"a6","x":520,"y":195,"width":150,"height":0,"start":{"id":"acl"},"end":{"id":"principals"}},
 {"type":"arrow","id":"a7","x":430,"y":160,"width":1,"height":-90,"start":{"id":"acl"},"end":{"id":"ingest"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"a3","x":1400,"y":115,"width":-160,"height":0,"start":{"id":"user"},"end":{"id":"query"},"label":{"text":"query"}},
 {"type":"arrow","id":"a4","x":1060,"y":95,"width":-150,"height":-55,"start":{"id":"query"},"end":{"id":"index"}},
 {"type":"arrow","id":"a8","x":1060,"y":135,"width":-150,"height":55,"start":{"id":"query"},"end":{"id":"principals"}}
]}

The dashed arrow is the ACL fast path: an ACL-only change updates the index's principal field without re-parsing the content.

3) Rank well, verify the top, answer

A ranker combines text relevance with enterprise signals (who wrote it, how recently and widely it was used, the user's team), learned from clicks. Before results are returned, the top few are re-checked against the source app's permission API when the index's ACL might be stale. The assistant answers only from those verified results.

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-80,"text":"Shaded: added in this step","fontSize":16},
 {"type":"rectangle","id":"conn","x":0,"y":0,"width":180,"height":70,"label":{"text":"Connectors"}},
 {"type":"rectangle","id":"ingest","x":340,"y":0,"width":180,"height":70,"label":{"text":"Ingest"}},
 {"type":"ellipse","id":"index","x":670,"y":-5,"width":240,"height":80,"label":{"text":"Search index"}},
 {"type":"rectangle","id":"acl","x":340,"y":160,"width":180,"height":70,"label":{"text":"ACL + identity\nsync"}},
 {"type":"ellipse","id":"principals","x":670,"y":155,"width":240,"height":80,"label":{"text":"Principals\n+ groups"}},
 {"type":"ellipse","id":"signals","x":670,"y":315,"width":240,"height":80,"label":{"text":"Activity\nsignals"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"query","x":1060,"y":80,"width":180,"height":70,"label":{"text":"Query service"}},
 {"type":"rectangle","id":"rank","x":1060,"y":320,"width":180,"height":70,"label":{"text":"Ranker"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"verify","x":1400,"y":320,"width":180,"height":70,"label":{"text":"Verify top\nresults"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"assist","x":1400,"y":480,"width":180,"height":70,"label":{"text":"Assistant"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"user","x":1400,"y":80,"width":180,"height":70,"label":{"text":"Employee"}},
 {"type":"arrow","id":"a1","x":180,"y":35,"width":160,"height":0,"start":{"id":"conn"},"end":{"id":"ingest"},"label":{"text":"content"}},
 {"type":"arrow","id":"a2","x":520,"y":35,"width":150,"height":0,"start":{"id":"ingest"},"end":{"id":"index"}},
 {"type":"arrow","id":"a5","x":150,"y":70,"width":190,"height":110,"start":{"id":"conn"},"end":{"id":"acl"},"label":{"text":"ACLs"}},
 {"type":"arrow","id":"a6","x":520,"y":195,"width":150,"height":0,"start":{"id":"acl"},"end":{"id":"principals"}},
 {"type":"arrow","id":"a7","x":430,"y":160,"width":1,"height":-90,"start":{"id":"acl"},"end":{"id":"ingest"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"a3","x":1400,"y":115,"width":-160,"height":0,"start":{"id":"user"},"end":{"id":"query"},"label":{"text":"query"}},
 {"type":"arrow","id":"a4","x":1060,"y":95,"width":-150,"height":-55,"start":{"id":"query"},"end":{"id":"index"}},
 {"type":"arrow","id":"a8","x":1060,"y":135,"width":-150,"height":55,"start":{"id":"query"},"end":{"id":"principals"}},
 {"type":"arrow","id":"a9","x":1150,"y":150,"width":1,"height":170,"start":{"id":"query"},"end":{"id":"rank"},"label":{"text":"top 200"}},
 {"type":"arrow","id":"a10","x":910,"y":355,"width":150,"height":0,"start":{"id":"signals"},"end":{"id":"rank"}},
 {"type":"arrow","id":"a11","x":1240,"y":355,"width":160,"height":0,"start":{"id":"rank"},"end":{"id":"verify"}},
 {"type":"arrow","id":"a12","x":1490,"y":390,"width":1,"height":90,"start":{"id":"verify"},"end":{"id":"assist"}}
]}

Final design

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-80,"text":"Dashed: ACL-only updates skip re-parsing.","fontSize":16},
 {"type":"rectangle","id":"conn","x":0,"y":0,"width":180,"height":70,"label":{"text":"Connectors"}},
 {"type":"rectangle","id":"ingest","x":340,"y":0,"width":180,"height":70,"label":{"text":"Ingest"}},
 {"type":"ellipse","id":"index","x":670,"y":-5,"width":240,"height":80,"label":{"text":"Search index"}},
 {"type":"rectangle","id":"acl","x":340,"y":160,"width":180,"height":70,"label":{"text":"ACL + identity\nsync"}},
 {"type":"ellipse","id":"principals","x":670,"y":155,"width":240,"height":80,"label":{"text":"Principals\n+ groups"}},
 {"type":"ellipse","id":"signals","x":670,"y":315,"width":240,"height":80,"label":{"text":"Activity\nsignals"}},
 {"type":"rectangle","id":"query","x":1060,"y":80,"width":180,"height":70,"label":{"text":"Query service"}},
 {"type":"rectangle","id":"rank","x":1060,"y":320,"width":180,"height":70,"label":{"text":"Ranker"}},
 {"type":"rectangle","id":"verify","x":1400,"y":320,"width":180,"height":70,"label":{"text":"Verify top\nresults"}},
 {"type":"rectangle","id":"assist","x":1400,"y":480,"width":180,"height":70,"label":{"text":"Assistant"}},
 {"type":"rectangle","id":"user","x":1400,"y":80,"width":180,"height":70,"label":{"text":"Employee"}},
 {"type":"arrow","id":"a1","x":180,"y":35,"width":160,"height":0,"start":{"id":"conn"},"end":{"id":"ingest"},"label":{"text":"content"}},
 {"type":"arrow","id":"a2","x":520,"y":35,"width":150,"height":0,"start":{"id":"ingest"},"end":{"id":"index"}},
 {"type":"arrow","id":"a5","x":150,"y":70,"width":190,"height":110,"start":{"id":"conn"},"end":{"id":"acl"},"label":{"text":"ACLs"}},
 {"type":"arrow","id":"a6","x":520,"y":195,"width":150,"height":0,"start":{"id":"acl"},"end":{"id":"principals"}},
 {"type":"arrow","id":"a7","x":430,"y":160,"width":1,"height":-90,"start":{"id":"acl"},"end":{"id":"ingest"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"a3","x":1400,"y":115,"width":-160,"height":0,"start":{"id":"user"},"end":{"id":"query"},"label":{"text":"query"}},
 {"type":"arrow","id":"a4","x":1060,"y":95,"width":-150,"height":-55,"start":{"id":"query"},"end":{"id":"index"}},
 {"type":"arrow","id":"a8","x":1060,"y":135,"width":-150,"height":55,"start":{"id":"query"},"end":{"id":"principals"}},
 {"type":"arrow","id":"a9","x":1150,"y":150,"width":1,"height":170,"start":{"id":"query"},"end":{"id":"rank"},"label":{"text":"top 200"}},
 {"type":"arrow","id":"a10","x":910,"y":355,"width":150,"height":0,"start":{"id":"signals"},"end":{"id":"rank"}},
 {"type":"arrow","id":"a11","x":1240,"y":355,"width":160,"height":0,"start":{"id":"rank"},"end":{"id":"verify"}},
 {"type":"arrow","id":"a12","x":1490,"y":390,"width":1,"height":90,"start":{"id":"verify"},"end":{"id":"assist"}}
]}

Deep dives

Early binding versus late binding

  • Early binding: store each item's allowed principals in the index and filter inside the query. Fast (the filter is just another posting-list intersection) and it never scores forbidden items, so result counts, facets and snippets cannot leak. Its weakness is staleness: the index is only as fresh as the last ACL sync.
  • Late binding: fetch candidates without a filter, then ask each source app "can this user open this item?" before showing it. Always current, but slow (an API call per item, rate-limited), and it breaks pagination and counts: you may filter 190 of 200 candidates away.

Use early binding as the main mechanism and late binding as a safety net on the few results actually shown (top 10, and every item fed to the assistant), for apps whose ACL sync is slow or whose permissions are complex. If a recheck fails, drop the result and queue a fast ACL refresh for that item.

Modeling permissions

  • Principals. An ACL is a set of principals: users, groups, "everyone in the domain", and app-specific ones (a Slack channel's members, a Jira project role). The searching user's principals are their identities in every app plus the transitive closure of their group memberships. Cache this closure per user (a few hundred principals is typical) and invalidate it on membership changes.
  • Identity mapping. One person is ana@corp.com in Google, U02ABC in Slack and ana.k in Jira. Map accounts to one person through email, SSO and the apps' admin APIs. An unmapped account sees nothing: when unsure, deny.
  • Inheritance. A Drive item inherits its folder's sharing. Re-sharing a folder with 100,000 descendants would mean 100,000 index updates. Store the item's ancestors and resolve folder-level grants at query time as principals ("member of a folder share"), or process the fan-out as a bulk ACL-only job with high priority. Pick per app based on how deep and how volatile its hierarchy is.
  • Link sharing. "Anyone with the link" does not mean "discoverable by everyone". Treat such items as visible only to the people the app reports as having opened them, or to explicit principals.

Freshness and revocation

Connectors use webhooks where apps have them and incremental polling (with change cursors) otherwise. Three queues with different priorities: ACL changes and deletions first (security), then new and changed content, then the periodic full re-crawl that catches anything the incremental path missed. Track per connector the lag between a change in the app and the index reflecting it, and alert when revocation lag exceeds the target.

Ranking

Enterprise search has little text redundancy (often exactly one right document) and strong social signals. Combine:

  • text relevance: BM25 on title and body plus embedding similarity, merged with reciprocal rank fusion;
  • authority and usage: views, edits, links and shares, decayed over time;
  • personalization: the user's team, people they work with, their own recent documents;
  • freshness, weighted by app (a Slack message ages in days, a design doc in months).

Train a learning-to-rank model per customer segment on click data (with position-bias correction), with a global model as the fallback for small customers. Evaluate with a labeled query set per customer plus online metrics (click-through on the first result, time to first click, reformulation rate).

A runnable core

The early-binding filter, nested groups and an ACL-only update path in miniature:

import re
from collections import defaultdict
from typing import Dict, Iterable, List, Set, Tuple

TOKEN = re.compile(r"[a-z0-9]+")


class PermissionAwareIndex:
    """Inverted index whose documents carry ACL principals (early binding).

    A user's effective principals are the user id plus every group that
    contains them, directly or through nested groups, plus "domain" for
    content shared with the whole company. Link-shared documents carry no
    principal for the domain, so they are found only by people listed on them.
    """

    def __init__(self) -> None:
        self.postings: Dict[str, Set[str]] = defaultdict(set)
        self.doc_terms: Dict[str, Dict[str, int]] = {}
        self.doc_acl: Dict[str, Set[str]] = {}
        self.parents: Dict[str, Set[str]] = defaultdict(set)   # member -> groups it is in
        self._closure_cache: Dict[str, Set[str]] = {}

    # ---- documents ------------------------------------------------------------
    def upsert(self, doc_id: str, text: str, principals: Iterable[str]) -> None:
        self.delete(doc_id)
        counts: Dict[str, int] = defaultdict(int)
        for t in TOKEN.findall(text.lower()):
            counts[t] += 1
        self.doc_terms[doc_id] = dict(counts)
        self.doc_acl[doc_id] = set(principals)
        for t in counts:
            self.postings[t].add(doc_id)

    def set_acl(self, doc_id: str, principals: Iterable[str]) -> None:
        """ACL-only update: no re-parsing, so it can run on the fast path."""
        self.doc_acl[doc_id] = set(principals)

    def delete(self, doc_id: str) -> None:
        for t in self.doc_terms.pop(doc_id, {}):
            self.postings[t].discard(doc_id)
        self.doc_acl.pop(doc_id, None)

    # ---- identity -------------------------------------------------------------
    def add_member(self, group: str, member: str) -> None:
        self.parents[member].add(group)
        self._closure_cache.clear()

    def remove_member(self, group: str, member: str) -> None:
        self.parents[member].discard(group)
        self._closure_cache.clear()

    def principals_of(self, user: str) -> Set[str]:
        if user not in self._closure_cache:
            seen, stack = {user, "domain"}, [user]
            while stack:                          # walk up nested groups; cycles are fine
                for g in self.parents.get(stack.pop(), ()):
                    if g not in seen:
                        seen.add(g)
                        stack.append(g)
            self._closure_cache[user] = seen
        return self._closure_cache[user]

    # ---- query ----------------------------------------------------------------
    def search(self, user: str, query: str, k: int = 10) -> List[Tuple[str, int]]:
        terms = TOKEN.findall(query.lower())
        if not terms:
            return []
        allowed = self.principals_of(user)
        candidates = set.intersection(*(self.postings.get(t, set()) for t in terms))
        scored = [
            (doc, sum(self.doc_terms[doc][t] for t in terms))
            for doc in candidates
            if self.doc_acl[doc] & allowed                       # the permission filter
        ]
        scored.sort(key=lambda x: (-x[1], x[0]))
        return scored[:k]
def _index():
    idx = PermissionAwareIndex()
    idx.add_member("eng", "ana")
    idx.add_member("eng-leads", "bo")
    idx.add_member("eng", "eng-leads")              # nested group
    idx.upsert("d1", "Q3 roadmap for search ranking", ["eng"])
    idx.upsert("d2", "Ranking offsite notes", ["bo"])
    idx.upsert("d3", "Company holiday ranking calendar", ["domain"])
    idx.upsert("d4", "Board deck ranking", ["board"])
    return idx


def test_results_respect_acls_and_nested_groups():
    idx = _index()
    assert [d for d, _ in idx.search("ana", "ranking")] == ["d1", "d3"]
    assert [d for d, _ in idx.search("bo", "ranking")] == ["d1", "d2", "d3"]   # eng via eng-leads
    assert [d for d, _ in idx.search("cy", "ranking")] == ["d3"]


def test_revocation_and_acl_only_update():
    idx = _index()
    idx.remove_member("eng", "ana")
    assert [d for d, _ in idx.search("ana", "roadmap")] == []
    idx.set_acl("d4", ["board", "ana"])
    assert [d for d, _ in idx.search("ana", "board deck")] == ["d4"]
    idx.delete("d4")
    assert idx.search("ana", "board") == []
def test_group_cycles_and_empty_query():
    idx = PermissionAwareIndex()
    idx.add_member("a", "b")
    idx.add_member("b", "a")                         # a cycle must not hang
    idx.add_member("a", "zoe")
    assert idx.principals_of("zoe") == {"zoe", "domain", "a", "b"}
    idx.upsert("x", "secret plan", ["b"])
    assert idx.search("zoe", "plan") == [("x", 1)]
    assert idx.search("zoe", "   ") == []

The assistant

The assistant is retrieval-augmented generation over the same permission-filtered, verified results, so it can only quote what the user could open. Two extra rules: never answer from a cached response computed for another user (cache keys include the user's principal set, or do not cache), and cite every claim so the user can click through to the source, which also re-checks permission in the app itself.

Trade-offs

  • Early versus late binding. Early is fast and leak-proof for counts and facets but can be stale; late is current but slow and breaks pagination. Early binding plus verification of what is shown gets most of both.
  • Index per customer versus shared. Per-customer indexes give hard isolation and per-customer ranking, at the cost of many small indexes to operate. Shared multi-tenant indexes are cheaper but one filter bug becomes a cross-customer leak. For enterprise data, isolation wins.
  • Folder-level grants at query time versus fan-out. Query-time resolution makes re-shares instant but adds work to every query; fan-out keeps queries simple but makes large re-shares slow to apply.
  • Freshness versus API quotas. Apps rate-limit crawlers. Spend the quota on ACL changes and recently active content first.

Interviewer expectations

Mid-level (L4)

  • Connectors, an index and a query path, with ACLs stored on documents and filtered at query time.
  • Expands the user's groups and handles "everyone in the company".
  • Explains why filtering after retrieval (late) is slow and can leak counts.

Senior (L5)

  • Compares early and late binding and combines them; handles nested groups, identity mapping across apps and link sharing.
  • Prioritizes ACL changes and revocations in the sync pipeline, with a measured revocation lag.
  • Designs ranking with enterprise signals and click-trained models, with numbers for index size and query load.

Staff (L6+)

  • Handles inheritance fan-out (100,000 descendants) with a deliberate choice between query-time grants and bulk ACL jobs.
  • Makes the assistant permission-safe: verified retrieval, no cross-user caching, citations.
  • Argues tenant isolation, failure modes ("when unsure, deny") and how leaks are detected and audited.

Sign in to comment, vote or remix.