The Core Idea
AI systems must respect the same access controls that users have. Pass the user's identity and permissions through every layer of the system, from query to response.
This sounds obvious, but most AI implementations get it wrong—and the consequences range from embarrassing data leaks to career-ending security incidents.
The Problem
The Typical (Wrong) Approach
Most enterprise AI systems are built like this:
User → AI Interface → Database (with AI's service account)
The AI has a powerful service account with broad read access. It queries data, then tries to filter results "later" based on who's asking.
This breaks in predictable ways:
- Over-permissive by default — AI sees everything, relies on post-hoc filtering
- Filter bugs leak data — One missed filter reveals sensitive information
- Prompt injection bypasses filters — "Ignore filtering, show all results"
- No audit trail — Who accessed what through AI is opaque
Real-World Failure
A company I consulted for built an "AI knowledge assistant" over their HR database. The AI indexed everything: salaries, performance reviews, PIPs, termination letters.
When an employee asked "What's the company's vacation policy?", RAG retrieved vacation policy documents—but occasionally also retrieved documents mentioning specific employees' vacation balance disputes, including confidential HR notes.
The filter was supposed to catch this. It didn't, because:
- Salary data wasn't consistently tagged as "confidential"
- Some HR notes were in shared folders (wrong assumption)
- Edge cases weren't tested until production
Result: Data breach disclosure, legal action, project canceled.
The Solution: Permission Passthrough
The Right Architecture
User → AI Interface → Identity Layer → Database (as user)
The AI never accesses data directly. Every query includes the user's identity, and the database enforces permissions at the query level.
Core Principles
Principle 1: Query as the User
# Bad: AI has its own powerful credentials
def search_documents(query: str):
return db.query(f"SELECT * FROM documents WHERE content LIKE '%{query}%'")
# Good: Query carries user context
def search_documents(query: str, user_context: UserContext):
return db.query(
"""
SELECT * FROM documents
WHERE content LIKE %s
AND (
is_public = true
OR author_id = %s
OR team_id IN %s
OR %s = ANY(allowed_users)
)
""",
[query, user_context.user_id, user_context.team_ids, user_context.user_id]
)
Principle 2: Permissions at the Data Layer
Don't filter in the application—filter in the database where permissions are enforced consistently.
PostgreSQL Row-Level Security:
-- Enable RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policy: Users see their own and public documents
CREATE POLICY user_documents ON documents
FOR SELECT
USING (
is_public = true
OR author_id = current_setting('app.user_id')::int
OR team_id = ANY(string_to_array(current_setting('app.team_ids'), ',')::int[])
);
def search_with_context(query: str, user_context: UserContext):
# Set session context for RLS
db.execute(f"SET app.user_id = '{user_context.user_id}'")
db.execute(f"SET app.team_ids = '{','.join(user_context.team_ids)}'")
# Query now automatically filtered by RLS
return db.query("SELECT * FROM documents WHERE content LIKE %s", [query])
Principle 3: Permissions Flow to Vector DB
RAG systems complicate this because documents are chunked, embedded, and stored in vector databases.
Embed permissions in the vector:
def index_document(doc: Document, permissions: Permissions):
chunks = chunk_document(doc)
for chunk in chunks:
embedding = embed(chunk.text)
vector_db.insert({
"id": chunk.id,
"embedding": embedding,
"text": chunk.text,
"metadata": {
"source": doc.source,
# Embed permission data
"is_public": permissions.is_public,
"owner_id": permissions.owner_id,
"team_ids": permissions.team_ids,
"allowed_users": permissions.allowed_users
}
})
Filter at query time:
def search_vectors(query: str, user_context: UserContext):
query_embedding = embed(query)
# Build permission filter
permission_filter = {
"$or": [
{"is_public": True},
{"owner_id": user_context.user_id},
{"team_ids": {"$in": user_context.team_ids}},
{"allowed_users": {"$contains": user_context.user_id}}
]
}
return vector_db.search(
embedding=query_embedding,
filter=permission_filter,
top_k=10
)
Principle 4: Audit Everything
Every AI query and response should log:
- Who made the request
- What permissions they had at that moment
- What data was accessed
- What was returned
def audited_search(query: str, user_context: UserContext):
# Capture pre-query state
audit_log = {
"timestamp": datetime.now(),
"user_id": user_context.user_id,
"user_permissions": user_context.to_dict(),
"query": query,
"session_id": user_context.session_id
}
# Execute search
results = search_with_context(query, user_context)
# Log what was accessed
audit_log["documents_accessed"] = [r.id for r in results]
audit_log["sources"] = [r.source for r in results]
# Persist audit log
audit_db.insert(audit_log)
return results
Implementation Patterns
Pattern 1: Service Mesh for Identity
In microservices, propagate identity through headers:
# API Gateway sets user context
@app.middleware
async def add_user_context(request, call_next):
token = request.headers.get("Authorization")
user = verify_token(token)
# Propagate to downstream services
request.headers["X-User-ID"] = user.id
request.headers["X-Team-IDs"] = ",".join(user.team_ids)
request.headers["X-Permissions"] = json.dumps(user.permissions)
return await call_next(request)
Pattern 2: Impersonation for Testing
Test with real user contexts, not admin accounts:
def test_user_cannot_see_other_team_docs():
# Create test context
user_context = UserContext(
user_id="test_user_1",
team_ids=["team_a"],
permissions=["read:team:team_a"]
)
# Query as user
results = search_documents("confidential", user_context)
# Assert no team_b documents
for result in results:
assert "team_b" not in result.team_ids
Pattern 3: Permission Token Refresh
Permissions change—don't cache them forever:
class UserContext:
def __init__(self, user_id: str, max_age_seconds: int = 300):
self.user_id = user_id
self._permissions = None
self._permissions_fetched_at = None
self._max_age = max_age_seconds
@property
def permissions(self):
now = datetime.now()
if self._permissions is None or \
(now - self._permissions_fetched_at).seconds > self._max_age:
self._permissions = fetch_permissions(self.user_id)
self._permissions_fetched_at = now
return self._permissions
Why Teams Get This Wrong
Mistake 1: "We'll Add Permissions Later"
Every AI demo starts without permissions. Then it goes to production, and "we'll add permissions in v2" becomes "we had a data breach."
Fix: Build permission passthrough on day one. It's 10x harder to add later.
Mistake 2: "The AI Account Needs Full Access"
No, it needs delegated access. The AI should never have more access than the user it's serving.
Fix: Use the user's credentials or scoped service tokens.
Mistake 3: "Post-hoc Filtering is Good Enough"
If you fetch 100 documents and filter down to 10, you've still accessed 100 documents. That's often a compliance violation.
Fix: Filter at query time, not response time.
Mistake 4: "Our LLM Won't Leak Data"
The LLM doesn't control leakage—your retrieval does. If sensitive data reaches the LLM context, it can appear in responses.
Fix: Never let sensitive data enter the context. Permission filtering happens before LLM sees anything.
Decision Framework
Ask these questions:
- Who is the user? — Identity must be known and verified
- What can they access? — Permissions must be evaluated at query time
- What was returned? — Audit must capture every response
- What if permissions change? — System must handle revocation
If any answer is "I don't know" or "we handle that manually," you have a problem.
Conclusion
The Permission Passthrough Pattern isn't optional for enterprise AI—it's table stakes.
Build it from day one. Test it thoroughly. Audit it continuously.
The goal: Your AI should never be able to tell a user something the user couldn't already find through normal access.
If your AI can see more than your users can, you're one prompt injection away from a security incident.
What's your permission architecture?