Objective
A RAG chatbot answers from whatever the vector search returns. Point it at your company docs and an intern who asks "How much does a senior engineer get paid?" gets the HR salary bands, word for word. The model did nothing wrong: retrieval handed it a document the intern was never allowed to read.
RAG role-based access control fixes this at the retrieval step. Every chunk carries the group
that may read it, users inherit access through nested groups, and the vector search joins
against the user's effective permissions before anything reaches the LLM. In SynapCores the
documents, the embeddings, the group graph, the generation call and the audit log live in one
database, so the permission check is a single JOIN in the same query that ranks by meaning.
It works the same from any framework or agent; see Use it from your agent at the end.
Step 1: Create the knowledge base with an access label on every chunk
Each chunk stores its text, its embedding, and acl_group: the group allowed to read it.
CREATE TABLE IF NOT EXISTS rbac_kb (
chunk_id INTEGER PRIMARY KEY,
doc_ref TEXT,
acl_group TEXT, -- who may read this chunk
content TEXT,
embedding VECTOR(384)
);
Step 2: Load company documents with different sensitivity
Handbook pages for everyone, an engineering wiki, HR salary bands and case notes, and a board deck.
INSERT INTO rbac_kb (chunk_id, doc_ref, acl_group, content) VALUES
(1,'Handbook §2','all_staff','Core collaboration hours are 10am to 3pm Eastern; the rest of the day is flexible.'),
(2,'Handbook §5','all_staff','Engineering career levels run from L1 to L6. Promotion reviews happen every April and October.'),
(3,'Handbook §7','all_staff','Questions about your own pay go to your manager or the HR helpdesk at hr@acme.example.'),
(4,'Eng Wiki: Releases','engineering','Production releases ship through the release pipeline every weekday at 2pm. Hotfixes need on-call approval.'),
(5,'Comp Bands 2026','hr_managers','The 2026 salary band for a Senior Software Engineer (L5) is $182,000 to $228,000 base.'),
(6,'Comp Bands 2026','hr_managers','The 2026 salary band for a Staff Engineer (L6) is $215,000 to $265,000 base.'),
(7,'HR Case Notes','hr_managers','Two engineers on the platform team are on performance improvement plans through December.'),
(8,'Board Deck Q4','finance_exec','The Q4 plan freezes new senior engineering hires until March and cuts the contractor budget by 15%.');
Step 3: Embed the knowledge base
The embedding model runs inside the database. One statement indexes every chunk.
UPDATE rbac_kb SET embedding = EMBED(content);
Step 4: See the leak: plain vector search ignores permissions
This is what most RAG tutorials do. The top two hits for the intern's question are the
hr_managers salary bands.
SELECT chunk_id, doc_ref, acl_group,
COSINE_SIMILARITY(embedding, EMBED('How much does a senior engineer get paid?')) AS relevance
FROM rbac_kb
ORDER BY relevance DESC
LIMIT 3;
Feed those passages to the LLM and it answers with the exact salary range:
CREATE TABLE IF NOT EXISTS rbac_naive_ctx (id INTEGER PRIMARY KEY, sources TEXT);
INSERT INTO rbac_naive_ctx (id, sources)
SELECT 1, GROUP_CONCAT('(' || doc_ref || ') ' || content, ' ')
FROM (SELECT doc_ref, content,
COSINE_SIMILARITY(embedding, EMBED('How much does a senior engineer get paid?')) AS relevance
FROM rbac_kb ORDER BY relevance DESC LIMIT 3) AS top3;
SELECT GENERATE(
'You are the company assistant. Answer using ONLY these sources. If the sources do not contain the answer, say you cannot share that and point to who can help. Sources: ' ||
sources || ' Question: How much does a senior engineer get paid?') AS answer_to_the_intern
FROM rbac_naive_ctx;
Step 5: Model users and nested groups
rbac_membership holds both kinds of edge: a user in a group, and a group inside a parent group.
Priya is an engineering intern, Marcus is an HR manager, Dana is on the finance exec team.
CREATE TABLE IF NOT EXISTS rbac_membership (
member TEXT, -- a user or a group
group_name TEXT
);
INSERT INTO rbac_membership (member, group_name) VALUES
('priya','engineering'),
('marcus','hr_managers'),
('dana','finance_exec'),
('engineering','all_staff'),
('hr_managers','hr'),
('hr','all_staff'),
('finance_exec','finance'),
('finance','all_staff');
Step 6: Resolve each user's effective groups with one recursive query
A recursive CTE walks the group hierarchy, so Marcus inherits hr and all_staff from
hr_managers. The result is stored in rbac_access, the table every retrieval joins against.
CREATE TABLE IF NOT EXISTS rbac_access (
user_id TEXT,
grp TEXT
);
INSERT INTO rbac_access (user_id, grp)
WITH RECURSIVE reach(user_id, grp) AS (
SELECT member, group_name FROM rbac_membership WHERE member IN ('priya','marcus','dana')
UNION
SELECT r.user_id, m.group_name FROM rbac_membership m JOIN reach r ON m.member = r.grp
)
SELECT user_id, grp FROM reach;
SELECT user_id, grp AS can_read FROM rbac_access ORDER BY user_id, grp;
Step 7: Permission-aware retrieval: the same search, one JOIN added
The vector search only ranks chunks whose acl_group the user can read. Run it as Priya, then as Marcus.
SELECT k.chunk_id, k.doc_ref, k.acl_group,
COSINE_SIMILARITY(k.embedding, EMBED('How much does a senior engineer get paid?')) AS relevance
FROM rbac_kb k
JOIN rbac_access a ON k.acl_group = a.grp
WHERE a.user_id = 'priya'
ORDER BY relevance DESC
LIMIT 3;
SELECT k.chunk_id, k.doc_ref, k.acl_group,
COSINE_SIMILARITY(k.embedding, EMBED('How much does a senior engineer get paid?')) AS relevance
FROM rbac_kb k
JOIN rbac_access a ON k.acl_group = a.grp
WHERE a.user_id = 'marcus'
ORDER BY relevance DESC
LIMIT 3;