Skip to content
Veristamp
Type to search the engineering journal.Full-text index · ⌘K to open
QQL Gateway: Zero-Trust Multi-Tenancy and AST Filter Injection for QdrantVector databases ignore permissions, risking RAG leaks. Compare RAG post-filtering with Zanzibar-style pre-filtering, and learn how the QQL Gateway injects zero-trust AST filters before Qdrant queries.QdrantSecurityEngineeringAI-AgentsQQL
13 min readSrimon Danguria

QQL Gateway: Zero-Trust Multi-Tenancy and AST Filter Injection for Qdrant

Vector databases ignore permissions, risking RAG leaks. Compare RAG post-filtering with Zanzibar-style pre-filtering, and learn how the QQL Gateway injects zero-trust AST filters before Qdrant queries.

An elegant architectural diagram showing QQL query transformation through JWT validation, rate limiting, and AST-level filter injection before executing on Qdrant.

TL;DR

  • Qdrant supports database-level multi-tenancy: configuring your collection with m = 0 disables the global graph, and setting is_tenant = true on a keyword field builds isolated per-tenant HNSW subgraphs.
  • But Qdrant has no identity control plane. It does not validate JWTs, and it trusts the client to pass the correct tenant filter, making it highly vulnerable to client tampering or prompt-injected AI agents.
  • The QQL Gateway bridges the gap. It intercepts SQL-like QQL queries, validates JWT claims, and mutates the query’s AST (Abstract Syntax Tree) to structurally inject tenant filters before compiling and executing the search on Qdrant.
  • Spin up the entire multi-tenant sandbox (FastAPI auth provider, gateway, and Streamlit playground) from the server-gateway example folder(opens in a new tab) with a single script: docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant && ./start_ui.sh

We spent the last twenty years learning that exposing database connections directly to the client layer is a security failure. We built middleware, API gateways, and ORM layers to act as firewalls between untrusted inputs and our storage engines.

Yet, in the rush to ship vector search and Retrieval-Augmented Generation (RAG) features, many engineering teams are throwing those lessons away.

In a standard RAG setup, a user asks a question, the application converts it into an embedding, queries a vector database, and passes the retrieved text chunks to an LLM. But if a client has the database key, they have access to everything. Pointing an LLM-driven AI agent or a multi-tenant SaaS application directly at Qdrant means you are one prompt injection or modified API call away from a major cross-tenant data leak.

Qdrant does have a powerful native mechanism for multi-tenancy1. You can configure your collection with m = 0 to disable the global HNSW graph, and index a keyword payload field (like org or tenant_id) with is_tenant = true. Qdrant then physically co-locates each tenant’s vectors on disk, bypassing the global graph to search only inside isolated, tenant-specific HNSW subgraphs.

This is brilliant for performance: it eliminates noisy-neighbor issues, saves memory, and accelerates search speeds. But it is not a security control plane:

  1. No Authentication: Qdrant does not validate JWTs, check signatures, or resolve identities.
  2. Client Trust: Qdrant relies entirely on the client to pass the correct tenant key filter. If an AI agent is prompt-injected and omits the tenant filter, or if a user modifies their client-side request to query another company’s ID, Qdrant will execute it without hesitation.
  3. Flat Hierarchy: A tenant index only supports a single field. If you need tiered rules, such as isolating by organization (org), restricting by department (team), and filtering out sensitive files (access != 'confidential'), Qdrant’s native index cannot model them.

We built the QQL Gateway to act as the secure boundary. Instead of letting untrusted callers contact Qdrant directly, the gateway validates JWT claims, parses QQL queries into an Abstract Syntax Tree (AST), and mutates the query structurally to inject tenant filters.


The RAG Security Trap: Pre-Filtering vs. Post-Filtering

In standard Retrieval-Augmented Generation (RAG) applications, vector search operates entirely on semantic similarity. It has no intrinsic concept of access control. An engineer asking your company’s AI assistant about board-level financials will retrieve those chunks if they are semantically close to the query, simply because similarity algorithms ignore your authorization model. Once a restricted chunk is injected into the LLM prompt, it cannot be un-read or redacted.

To fix this, engineering teams typically resort to one of two design patterns:

1. The Post-Filter Trap

In a post-filtering architecture, the application executes the semantic vector search first. It retrieves the top-$K$ candidate chunks, checks each document against the company’s permission database (or an authorization engine), and discards any chunks the user is not allowed to see.

While simple to implement, post-filtering introduces major issues:

  • Context Starvation: If a user queries a topic where the top 3 out of 4 retrieved chunks are forbidden, the post-filter drops those 3 chunks. The LLM is left with a starved, incomplete context window containing only 1 chunk, leading to poor generation quality or hallucinations.
  • Latency Overhead: Running authorization checks against a hosted identity provider or a remote relational database after retrieving the vectors introduces a blocking network round-trip for each chunk.
  • Leaked Memory: Forbidden data is still loaded into the application’s memory and scored by the vector engine before being filtered out.

2. Pre-Filtering (Zanzibar and OpenFGA)

The alternative is pre-filtering: resolving the user’s access privileges before performing vector search.

For instance, Lakhan Samani demonstrated a Zanzibar-based approach using Authorizer’s embedded OpenFGA engine(opens in a new tab)2. Under this pattern:

  1. When a user asks a question, the application queries Authorizer/OpenFGA with the user’s token.
  2. The authorization service checks the relationship graph (relationship-based access control, or ReBAC) and returns an allow-list of document IDs that the user has permission to view.
  3. This allow-list is passed to Qdrant as a must metadata payload filter.
  4. Qdrant evaluates this condition during the HNSW graph traversal itself. Forbidden vectors are never scored, fetched, or loaded into the application context.

This solves context starvation (the top-$K$ results are fully filled with allowed documents) and ensures immediate revocation, deleting a single relationship tuple in OpenFGA revokes access on the user’s very next query, with no need to re-index Qdrant.

Pre-filtering ensures that unauthorized documents simply do not exist in the retriever’s search space. The model cannot leak what it never sees.


Unified Pre-Filtering at the Query Gateway Layer

While Zanzibar-style pre-filtering is highly secure, passing large allow-lists of document IDs directly to Qdrant’s payload filters has limitations. If a user has access to thousands of documents, passing an array of 5,000 IDs as a MatchAny filter in every search payload degrades vector search latency and hits network payload limits.

The QQL Gateway (hosted at srimon12/qql-go(opens in a new tab))3 solves this by moving authorization policy enforcement into the query compiler itself.

Instead of forcing your client application to manually call an authorization engine, fetch allowed IDs, and build complex metadata filters, the QQL Gateway acts as a secure, compile-time middleware layer. It intercepts declarative, SQL-like QQL queries from users or autonomous agents, validates the caller’s JWT claims, and mutates the Abstract Syntax Tree (AST) of the query to structurally inject organization, department, and access level filters before compiling them to Qdrant’s gRPC protocol.

To demonstrate this in a live environment, the gateway repository includes a multi-tenant playground. The setup seeds a single Qdrant collection (docs) with documents from four distinct companies (ACME Corp, Globex Corp, Initech, and Umbrella Corp).

The gateway intercepts their queries, validates their tokens, and dynamically injects the appropriate filters.

We can see the visual outcome of this isolation directly in the gateway’s Streamlit playground, which shows the results of running SCROLL FROM docs side-by-side for four different users:

QQL Gateway Tenant Isolation

  • Alice Chen (ACME engineering reader) gets exactly 5 documents. The gateway injected org = 'acme' AND team IN ('engineering', 'all') AND access != 'confidential', directing Qdrant to traverse only ACME’s local HNSW subgraph.
  • Carol Rivera (Globex finance reader) gets exactly 1 public document. The gateway injected org = 'globex' AND team IN ('finance', 'all') AND access != 'confidential', restricting her search to Globex’s finance subgraph.
  • Bob Smith (ACME admin) is granted administrative permissions. The gateway bypasses department filters but keeps him within the tenant boundary (org = 'acme'). He retrieves all 14 ACME documents.
  • Glenn Rossi (Umbrella engineering reader) retrieves exactly 4 documents matching his organization and department permissions.

AST-Level Query Mutation

Exposing a query interface to untrusted clients makes string manipulation a major vulnerability. In SQL, an attacker can bypass queries by injecting comments (--) or logical overrides (OR 1=1). If you try to secure vector search using regular expressions or string replacement, a prompt-injected LLM can write QQL queries that slip past your filters.

The QQL Gateway avoids this by working directly on the parsed query tree:

QQL AST Mutation Flow

When a request arrives, the gateway executes a clean execution flow:

  1. JWT Verification: It validates the bearer token in the Authorization header against the JWKS endpoint.
  2. AST Parsing: The query passes through the lexer and parser, producing a structured tree of Go structs (like SelectStatement or ScrollStatement).
  3. Policy Matching: The gateway matches the JWT claims against policies.yaml.
  4. AST Injection: The injector walks the tree, finds the WHERE clause, and structurally merges the policy filters.
  5. gRPC Compilation: The modified AST is compiled directly into Qdrant’s filter conditions and sent over Connect RPC / gRPC4.

Let’s look at how the injector handles Alice’s request. The incoming query is parsed into a tree. If Alice wrote a simple query without filters:

SCROLL FROM docs LIMIT 20

The AST injector modifies the tree by inserting a WHERE node containing the policy filters:

SCROLL FROM docs
  WHERE org = 'acme'
    AND team IN ('engineering', 'all')
    AND access != 'confidential'
  LIMIT 20

If Alice had already specified a condition (e.g., WHERE year >= 2025), the injector wraps both nodes in a new parent AND node:

SCROLL FROM docs
  WHERE (year >= 2025)
    AND (org = 'acme' AND team IN ('engineering', 'all') AND access != 'confidential')
  LIMIT 20

Even if an attacker attempts a logical bypass by sending:

SCROLL FROM docs WHERE true OR org = 'globex'

The compiler isolates the client’s input, producing a tree equivalent to:

WHERE (true OR org = 'globex') AND org = 'acme'

Under Boolean reduction, this always evaluates to org = 'acme'. The caller cannot escape their tenant boundary because the gateway controls the structure of the query, not just its text.


Policies as Code

Access controls are defined in a central policies.yaml file. The gateway watches this file using fsnotify, hot-reloading rules in memory within milliseconds without dropping active connections or requiring a restart.

Here is the policy ruleset used in our demo:

# policies.yaml
rules:
  # ── Platform admin: bypasses tenant scoping
  - match:
      claims:
        role: platform_admin
    allow: [QUERY, INSERT, UPDATE, DELETE, SCROLL, SELECT, SHOW, CREATE, DROP, EXPLAIN]

  # ── Org admin: tenant isolated, full read/write
  - match:
      claims:
        role: admin
    allow: [QUERY, INSERT, UPDATE, DELETE, SCROLL, SELECT, SHOW, CREATE, EXPLAIN]
    inject:
      where:
        field: org
        from_claim: org_id
        op: "="

  # ── Manager: tenant isolated, read/write, sees confidential docs
  - match:
      claims:
        role: manager
    allow: [QUERY, INSERT, UPDATE, SCROLL, SELECT, SHOW, EXPLAIN]
    inject:
      where:
        field: org
        from_claim: org_id
        op: "="
    limits:
      max_limit: 200

  # ── Reader: tenant + team scoped, hides confidential docs
  - match:
      claims:
        role: reader
    allow: [QUERY, SCROLL, SELECT, SHOW, EXPLAIN]
    collections: [docs]
    inject:
      filters:
        - field: org
          from_claim: org_id
          op: "="
        - field: team
          from_claim: department
          op: "in"
        - field: access
          value: "confidential"
          op: "!="
    limits:
      max_limit: 50

Layered Protection

  • Operation Controls: The gateway checks the parsed query statement against the allow keyword list. If a reader tries to run DELETE FROM docs WHERE id = 1001, the gateway rejects it at the proxy boundary before Qdrant is ever contacted.
  • Limit Capping: If a user attempts to scrape the database by running LIMIT 1000, the gateway updates the AST to match the role’s max_limit (e.g. 50 for readers).
  • Rate Limiting: Per-user rate limiting is handled by an in-memory token-bucket limiter keyed by the JWT’s sub claim.

Structured Audit Logs

To maintain visibility, the gateway outputs structured JSON logs for every query. This log records who executed the query, which policy matched, which filters were injected, and the Qdrant execution latency:

{
  "ts": "2026-06-20T05:12:04Z",
  "subject": "acme_alice",
  "email": "[email protected]",
  "tenant_id": "acme",
  "roles": ["reader"],
  "operation": "SCROLL",
  "collection": "docs",
  "query": "SCROLL FROM docs LIMIT 20",
  "filters_injected": ["org = 'acme'", "team IN ('engineering', 'all')", "access != 'confidential'"],
  "limit_capped": 50,
  "allowed": true,
  "denied": false,
  "latency_ms": 3,
  "status": "allowed"
}

This ensures security teams can monitor and audit access boundaries across a shared database cluster.


Run the Playground in Two Steps

The gateway demo runner bundles the FastAPI identity provider (to sign JWTs and serve JWKS keys), the Go-based QQL gateway, and the Streamlit UI dashboard into a single, one-command stack.

Step 1: Start Qdrant

Ensure a local Qdrant container is running:

docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest

Step 2: Start the Stack

Run the startup script in the server-gateway(opens in a new tab) folder5. The script builds the gateway with m = 0 sharding, starts the services, seeds the 64 multi-org documents with is_tenant = true keyword indexes, and launches the dashboard:

cd examples/server-gateway
./start_ui.sh

Open http://localhost:8501 to access the Streamlit UI, where you can log in as different users (such as [email protected] / alice123 or [email protected] / carol123) and inspect the query plans and transformations.


FAQ

Does the gateway require re-indexing Qdrant when permissions change?

No. The gateway rewrites queries dynamically at runtime based on the JWT claims. The index, points, and payloads in Qdrant remain untouched.

What happens if the gateway cannot reach the auth server?

The gateway fails closed. If it cannot retrieve public keys to verify the JWT signature, the request is rejected immediately, and no database queries are executed.

Can a prompt-injected agent bypass the AST filter?

No. Because the agent does not hold the raw Qdrant API keys, it must communicate through the gateway using the user’s JWT. The gateway extracts the identity and role from the validated token and forces the filters directly onto the AST before compilation. The agent cannot modify the filter structure.


Conclusion

Vector databases like Qdrant are highly optimized search engines. When configured with m = 0 HNSW graphs and is_tenant = true payload indexing, they build isolated, high-performance tenant subgraphs. But they lack access controls, JWT validation, and tiered policy layers.

The QQL Gateway solves this by shifting access controls to the compiler. By combining JWT validation with AST-level filter injection, you can turn a flat-trust Qdrant cluster into a secure, multi-tenant database.

If you are coordinating AI agents with tool-calling capabilities, pair the gateway with our governed code mode runtime to execute code in zero-trust sandboxes. To learn more about standard query commands, read our retrieval operations guide, or see our schema-first LLM wiki design to learn how to structure knowledge bases.

To dive deeper into setting up and using QQL, explore the QQL Go Official Documentation(opens in a new tab)6 or inspect the gateway compiler implementation on GitHub(opens in a new tab)7.


References

Footnotes

  1. Qdrant. “Multitenancy.” https://qdrant.tech/documentation/manage-data/multitenancy/(opens in a new tab)

  2. Lakhan Samani. “Permission-Aware RAG with Authorizer, OpenFGA, and Qdrant.” Authorizer Dev Blog. https://blog.authorizer.dev/permission-aware-rag-authorizer-openfga-qdrant(opens in a new tab)

  3. Srimon Danguria. “qql-go repository.” GitHub. https://github.com/srimon12/qql-go(opens in a new tab)

  4. Connect RPC. “Connect, A simpler gRPC.” https://connectrpc.com/(opens in a new tab)

  5. Srimon Danguria. “qql-go server-gateway example.” GitHub. https://github.com/srimon12/qql-go/tree/main/examples/server-gateway(opens in a new tab)

  6. Srimon Danguria. “qql-go official documentation.” Veristamp. https://qql-go.veristamp.in/docs(opens in a new tab)

  7. Srimon Danguria. “qql-go gateway compiler code.” GitHub. https://github.com/srimon12/qql-go/tree/main/server(opens in a new tab)