Free lesson · GenAI Security Engineering

Implement row-level access control for vector stores

Build tenant-isolated namespace model for pgvector. Implement query-time access filters with metadata predicates and cross-tenant leak detection.

Course: AI Security Engineering · Chapter 9 · Embedding & Vector Store Security

Free to read — no subscription required.

Introduction

When you store embeddings from multiple tenants in a shared vector database, a single malformed query can silently return vectors belonging to the wrong tenant — not because of an application bug, but because nearest-neighbor search computes distance across all rows before any filter runs. Traditional row-level security policies solve multi-tenancy in relational systems, but vector stores demand that the tenant boundary be enforced before the index scan begins, not after results are returned. By the end of this lesson, you'll be able to implement PostgreSQL Row-Level Security combined with pgvector's IVFFlat indexing to achieve pre-filter tenant isolation, preventing cross-tenant data leakage at the database engine level.

Key terminology

  • Row-Level Security (RLS): A PostgreSQL feature that attaches visibility policies to tables, automatically filtering rows based on session-level variables during query execution — including index scans.
  • Pre-filter isolation: An access control strategy where tenant predicates are evaluated during the index scan phase, preventing the ANN algorithm from ever traversing unauthorized vectors.
  • Post-filter isolation: An insecure pattern where tenant filtering occurs after the ANN search returns candidates, leaking information through timing channels and reducing result quality.
  • IVFFlat: An inverted file index structure used by pgvector that partitions vectors into Voronoi cells. When combined with table partitioning, each tenant's vectors occupy separate index segments.
  • Partial IVFFlat index: A tenant-scoped IVFFlat index built with a WHERE tenant_id = 'X' predicate, so its inverted file lists contain only one tenant's embeddings and the ANN traversal is structurally confined to that tenant's rows.
  • Tenant context variable: A session-scoped PostgreSQL configuration parameter (app.current_tenant) set at connection time and referenced by RLS policies to determine row visibility.
  • Cross-tenant leak: A security violation where a query returns vectors belonging to a tenant other than the authenticated requester, typically caused by misconfigured RLS policies or missing partition boundaries.

Concepts

Why ANN Search Breaks Conventional Row Filtering

In a relational query, a WHERE tenant_id = 'x' predicate is evaluated by the planner before row access: the index navigator descends only into pages that satisfy the predicate, so unauthorized rows are never touched. Approximate nearest-neighbor search with IVFFlat works differently. The algorithm probes a set of inverted file lists — clusters of similar vectors pre-organized at index-build time — and ranks candidates by distance across every vector in those lists. If those lists contain vectors from all tenants, the traversal visits cross-tenant embeddings even if they are discarded before the result set is returned. A tenant filter applied after ANN returns its candidates is therefore a post-filter: structurally too late to prevent unauthorized traversal.

The security implication goes beyond correctness. An adversary with access to query timing can craft probe embeddings that land near a boundary between tenants' clusters and measure response latency to infer properties of vectors they are never shown. Pre-filter isolation eliminates this surface entirely — if unauthorized vectors are absent from the index traversal path, they cannot be inferred from timing.

Partial Index and RLS as Complementary Enforcement Layers

Two mechanisms work in tandem to push the tenant boundary into the index scan itself. A partial IVFFlat index is built with a WHERE tenant_id = 'X' predicate, so its inverted file lists contain only tenant X's embeddings. The planner uses this index for similarity searches by tenant X, and the ANN traversal is structurally confined — cross-tenant vectors are simply absent from the data structure. Row-Level Security adds a second, independent enforcement layer at the engine level: even if a query bypasses the partial index and falls back to a sequential scan, the tenant_isolation policy blocks any row whose tenant_id does not match current_setting('app.current_tenant') from appearing in the result.

Neither mechanism alone is sufficient. A partial index without RLS can be bypassed by a query that forces a seq-scan. RLS without a tenant-scoped index allows an ANN traversal to run across all rows before the policy prunes them. Together they form a defense-in-depth pair: the partial index prevents cross-tenant traversal at the data-structure level, and RLS prevents cross-tenant row visibility at the engine level (see Code Walkthrough).

Loading diagram...

Session Context as the Binding Mechanism

RLS policies evaluate their USING expression on every row access, but they need a runtime value to compare against. TenantVectorStore.query supplies that value by calling set_config('app.current_tenant', tenant_id, TRUE) in the same cursor, before the similarity search executes. The TRUE argument scopes the setting to the current transaction — it is automatically cleared when the transaction ends and cannot be inherited by a concurrent session. The policy expression tenant_id = current_setting('app.current_tenant') then matches only rows whose tenant_id equals the value that was just set.

If set_config is omitted, current_setting raises an error (or returns an empty string with the missing_ok variant), causing the policy to evaluate false for every row — the query returns nothing. This is the fail-safe behavior the lesson's verification step exploits: querying as tenant_b when only tenant_a rows exist must return an empty list. Any non-empty result means the RLS policy is not active and the isolation boundary is broken, making it a reliable correctness check rather than an implementation detail.

Code Walkthrough

Now that you understand why pre-filter isolation must enforce the tenant boundary before the index scan begins, the following implementation applies that model with PostgreSQL RLS and pgvector IVFFlat.

The TenantVectorStore class wires three components together: an embeddings table that carries tenant_id on every row, an RLS policy that limits visibility to the current session's tenant context, and a partial IVFFlat index scoped to a single tenant's rows. The setup_schema method creates the table, enables RLS, and installs the isolation policy. The create_tenant_index method builds the per-tenant partial index so approximate nearest-neighbor traversal never crosses into another tenant's vectors. The query method sets app.current_tenant before executing the similarity search, so the policy fires before any distance computation runs.

Code snippetpython
1import psycopg2 2from dataclasses import dataclass 3from typing import Any 4 5@dataclass 6class TenantVectorStore: 7 dsn: str 8 embedding_dim: int = 1536 9 10 def setup_schema(self) -> None: 11 with psycopg2.connect(self.dsn) as conn: 12 with conn.cursor() as cur: 13 cur.execute("CREATE EXTENSION IF NOT EXISTS vector") 14 cur.execute(f""" 15 CREATE TABLE IF NOT EXISTS embeddings ( 16 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 17 tenant_id TEXT NOT NULL, 18 content_hash TEXT NOT NULL, 19 embedding vector({self.embedding_dim}), 20 metadata JSONB DEFAULT '{{}}'::jsonb, 21 created_at TIMESTAMPTZ DEFAULT now() 22 ) 23 """) 24 cur.execute("ALTER TABLE embeddings ENABLE ROW LEVEL SECURITY") 25 cur.execute(""" 26 CREATE POLICY tenant_isolation ON embeddings 27 USING (tenant_id = current_setting('app.current_tenant')) 28 """) 29 conn.commit() 30 31 def create_tenant_index(self, tenant_id: str, lists: int = 100) -> None: 32 idx = f"idx_ivfflat_{tenant_id.replace('-', '_')}" 33 with psycopg2.connect(self.dsn) as conn: 34 with conn.cursor() as cur: 35 cur.execute(f""" 36 CREATE INDEX IF NOT EXISTS {idx} 37 ON embeddings USING ivfflat (embedding vector_cosine_ops) 38 WITH (lists = {lists}) 39 WHERE tenant_id = %s 40 """, (tenant_id,)) 41 conn.commit() 42 43 def query( 44 self, 45 tenant_id: str, 46 embedding: list[float], 47 top_k: int = 5, 48 ) -> list[dict[str, Any]]: 49 with psycopg2.connect(self.dsn) as conn: 50 with conn.cursor() as cur: 51 cur.execute( 52 "SELECT set_config('app.current_tenant', %s, TRUE)", 53 (tenant_id,), 54 ) 55 cur.execute(""" 56 SELECT id, content_hash, metadata, 57 embedding <=> %s::vector AS distance 58 FROM embeddings 59 ORDER BY embedding <=> %s::vector 60 LIMIT %s 61 """, (embedding, embedding, top_k)) 62 return [ 63 {"id": r[0], "content_hash": r[1], "distance": r[3]} 64 for r in cur.fetchall() 65 ]

After calling setup_schema() and inserting rows for two different tenants, use the query method to retrieve results scoped to one of them and confirm every returned row belongs to that tenant.

Code snippetpython
1# TenantVectorStore defined above 2store = TenantVectorStore(dsn="postgresql://user:pass@localhost/mydb") 3store.setup_schema() 4store.create_tenant_index("tenant_a") 5 6probe = [0.0] * 1536 # replace with a real embedding at runtime 7results = store.query("tenant_a", probe, top_k=3) 8for row in results: 9 print(row["id"], row["distance"])

Check that calling store.query("tenant_b", probe) returns an empty list when only tenant_a embeddings exist in the table — if any rows appear, the RLS policy is not active and the isolation boundary is broken.

Do's and Don'ts

Do's

  1. Do use FORCE ROW LEVEL SECURITY — Without this directive, table owners bypass RLS policies entirely. The FORCE keyword ensures the policy applies to all roles, including superusers performing maintenance queries.
  2. Do partition by tenant_id — Table partitioning ensures each tenant's vectors reside in separate physical storage and separate index segments, preventing cross-tenant index traversal even if the RLS policy is temporarily disabled during schema migrations.
  3. Do scope the tenant context to the transaction — Set app.current_tenant with set_config(..., TRUE) so the value is bound to the current transaction and automatically cleared when it ends. This prevents a pooled connection from carrying one tenant's context into the next request, which would let the RLS policy evaluate rows against the wrong tenant.

Don'ts

  1. Don't apply tenant filters in application code only — Application-layer WHERE clauses applied after the query executes leave the database-level ANN search unconstrained. An attacker who bypasses the application layer (direct database access, ORM misconfiguration) gains full cross-tenant visibility.
  2. Don't share connections across tenants — Each connection must have its app.current_tenant variable set before any query executes. Connection pool implementations that reuse connections without resetting the tenant context create a race condition where Tenant B inherits Tenant A's security context.
  3. Don't use top_k values larger than your partition size — If a tenant has 50 vectors and you request top_k=1000, PostgreSQL may spill into other partitions depending on the query planner's cost estimates. Cap top_k at a reasonable maximum and validate it server-side.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Security Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in AI Security Engineering

All free lessons in GenAI Security Engineering