diff --git a/astro/public/img/blogs/securing-rag-permify/admin-chargeback-answer.png b/astro/public/img/blogs/securing-rag-permify/admin-chargeback-answer.png new file mode 100644 index 0000000000..63490a11a6 Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/admin-chargeback-answer.png differ diff --git a/astro/public/img/blogs/securing-rag-permify/jane-chargeback-denied.png b/astro/public/img/blogs/securing-rag-permify/jane-chargeback-denied.png new file mode 100644 index 0000000000..27e2268e9e Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/jane-chargeback-denied.png differ diff --git a/astro/public/img/blogs/securing-rag-permify/jane-csat-answer.png b/astro/public/img/blogs/securing-rag-permify/jane-csat-answer.png new file mode 100644 index 0000000000..8647d9f77d Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/jane-csat-answer.png differ diff --git a/astro/public/img/blogs/securing-rag-permify/org-structure.png b/astro/public/img/blogs/securing-rag-permify/org-structure.png new file mode 100644 index 0000000000..a45c410d3c Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/org-structure.png differ diff --git a/astro/public/img/blogs/securing-rag-permify/org-structure.svg b/astro/public/img/blogs/securing-rag-permify/org-structure.svg new file mode 100644 index 0000000000..2bbe8e1bcc --- /dev/null +++ b/astro/public/img/blogs/securing-rag-permify/org-structure.svg @@ -0,0 +1,66 @@ + + + + + + + + CS docs (10) + + + Fraud docs (10) + + + Disputes docs (10) + + + Loan docs (10) + + + + 👤 Admin + org admin + + + + + + + + + + 👤 Jane + CS team lead + + + + + + + + + + + + + + 👤 Stranger + no team, no org role + + + + + + + + + + Team / org access + + + Viewer grant + + + No access + + diff --git a/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.png b/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.png new file mode 100644 index 0000000000..e0fffe934b Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.png differ diff --git a/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.svg b/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.svg new file mode 100644 index 0000000000..a5b8cf509c --- /dev/null +++ b/astro/public/img/blogs/securing-rag-permify/rag-permify-flow.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + Answer + Based on authorized documents only + + + + 👤 + User + + + + + query + + + + 🔍 + Vector Search + + + + + 10 docs + + + + + + + + + Permify + Authorization Service + + + + + 3 permitted + + + + + 7 blocked + Never reach the LLM + + + + 🤖 + LLM + diff --git a/astro/public/img/blogs/securing-rag-permify/stranger-no-access.png b/astro/public/img/blogs/securing-rag-permify/stranger-no-access.png new file mode 100644 index 0000000000..daaa0dbb92 Binary files /dev/null and b/astro/public/img/blogs/securing-rag-permify/stranger-no-access.png differ diff --git a/astro/src/content/blog/securing-rag-with-fine-grained-authorization-using-permify.mdx b/astro/src/content/blog/securing-rag-with-fine-grained-authorization-using-permify.mdx new file mode 100644 index 0000000000..3ed50d88a6 --- /dev/null +++ b/astro/src/content/blog/securing-rag-with-fine-grained-authorization-using-permify.mdx @@ -0,0 +1,229 @@ +--- +publish_date: 2026-04-07 +title: Securing RAG with Fine-Grained Authorization Using FusionAuth FGA By Permify +description: Build a RAG chat application that enforces document-level permissions using FusionAuth FGA By Permify, so unauthorized content never reaches the LLM. +authors: Dan Moore +categories: Tutorial +tags: fine-grained authorization, permify, fga, rag, anthropic, claude, permissions +excerpt_separator: "{/* more */}" +--- +import RemoteCode from 'src/components/RemoteCode.astro'; + +Most RAG systems skip over a critical problem: Should this user actually be allowed to see these documents? + +A typical RAG setup embeds documents, stores them in a vector database, and retrieves the most relevant ones when a user asks a question. The retrieved documents get passed to an LLM, which generates an answer. This works well when every user should have access to every document, but that's rarely the case in a real application. + +Consider a company where the fraud team's investigation notes, the HR team's salary data, and the customer support team's playbooks all live in the same knowledge base. Without access control, any employee asking a question could get answers sourced from documents they were never meant to see. The LLM doesn't know or care about org charts and access policies. It uses whatever context it's given. + +The fix isn't to filter results after the LLM generates an answer. By then, the model has already read the restricted content and may leak it in its response. The solution is to filter _before_ documents reach the LLM, based on who's asking. + +![Diagram showing how FusionAuth FGA By Permify filters documents before they reach the LLM in a RAG pipeline.](/img/blogs/securing-rag-permify/rag-permify-flow.png) + +{/* more */} + +## Building A Demo App + +In this guide, you'll build a RAG chat application that enforces document-level permissions using [FusionAuth FGA By Permify](/docs/extend/fine-grained-authorization), a relationship-based authorization service. When a user asks a question, the system retrieves relevant documents, checks each one against FusionAuth FGA By Permify to confirm the user has the relevant access permission, and only passes permitted documents to the LLM. Unauthorized content never reaches the model. + +The application is a Next.js chat interface backed by three services: + +* **FusionAuth handles authentication:** Users log in via OAuth2 and get a JSON Web Token (JWT) containing their user Id. +* **FusionAuth FGA By Permify handles authorization:** It stores relationships (such as who belongs to which team and who owns which document) and answers questions like, "Can user X view document Y?" +* **Anthropic and Voyage AI handle embeddings and chat:** Voyage AI embeds documents as vectors for semantic search, and Claude generates answers from retrieved context. + +The FusionAuth FGA By Permify authorization model doesn't rely on a static access control list. Instead, it uses four entity types (users, organizations, teams, and documents) to determine access based on the requesting user's position in the org structure. The model grants `view` permission for a document if the user is any of the following: + +* The document owner +* An explicit viewer +* A member or lead of the document's team +* An organization admin + +The demo seeds a fictional company with four teams (Customer Support, Fraud & Security, Disputes & Chargebacks, and Loan Servicing), 12 users, and 40 internal documents spread across those teams. It also includes cross-team viewer grants. For example, the Customer Support team lead can view specific, relevant Dispute documents. + +![Diagram of the fictional company's organization structure showing four teams, their members, and cross-team viewer grants.](/img/blogs/securing-rag-permify/org-structure.svg) + +## Prerequisites For The FusionAuth And FusionAuth FGA By Permify Integration + +To follow this guide, you need: + +* [Docker](https://docs.docker.com/get-docker/) and Docker Compose +* [Node.js](https://nodejs.org/) 18 or higher +* An [Anthropic API key](https://console.anthropic.com) +* A [Voyage AI API key](https://voyageai.com) (used for embeddings) + +## Setting Up FusionAuth, FusionAuth FGA By Permify, And PostgreSQL + +The example runs FusionAuth, FusionAuth FGA By Permify, and PostgreSQL in Docker containers, with a Next.js app connecting to all three. To get everything running, you need to: + +* Clone the example repo and start the Docker containers. +* Configure a few environment variables. +* Seed the demo data. + +### Clone The Repository And Start The Services + +Run the following commands. + +```bash +git clone https://github.com/FusionAuth/fusionauth-example-fga-rag.git +cd fusionauth-example-fga-rag +docker compose up -d +``` + +This starts three services: FusionAuth (on port 9011), FusionAuth FGA By Permify (on port 3476), and PostgreSQL. FusionAuth uses a Kickstart file to configure itself automatically on first boot, creating the OAuth application, an RS256 signing key, and all 12 demo users with the correct Ids (matching the FusionAuth FGA By Permify seed data). + +### Install Dependencies And Configure Environment Variables + +While FusionAuth starts up, install the dependencies and configure environment variables. + +```bash +npm install +cp .env.example .env +``` + +Open `.env` and set `AI_PROVIDER=anthropic`, then add the Anthropic and Voyage AI API keys. You also need the FusionAuth tenant Id, which is generated on first boot. + +```bash +curl -s -H 'Authorization: bf69486b-4733-4470-a592-f1bc7a64f314' \ + http://localhost:9011/api/tenant | python3 -c \ + "import json,sys;print(json.load(sys.stdin)['tenants'][0]['id'])" +``` + +Set `AUTH_FUSIONAUTH_TENANT_ID` in `.env` to the returned tenant Id value. The other FusionAuth values (`AUTH_FUSIONAUTH_CLIENT_ID`, `AUTH_FUSIONAUTH_CLIENT_SECRET`, `AUTH_SECRET`) are already filled in by the example file. + +### Start The Application And Seed The Data + +Now start the Next.js dev server and seed the data. + +```bash +npm run dev +``` + +In a separate terminal, run the seed script. + +```bash +npm run seed:all +``` + +This does three things in order: + +1. Writes the FusionAuth FGA By Permify authorization schema (the entity types and permission rules). +2. Uploads 40 sample documents to the app, which embeds each one via Voyage AI and stores the vectors in memory. +3. Writes 170 relationship tuples to FusionAuth FGA By Permify (including org memberships, team assignments, document ownership, and cross-team viewer grants). + +Open http://localhost:3000 and log in with `admin@example.com` / `password`. + +## How The RAG Pipeline Works + +When a user sends a message, the chat API handler in `app/api/chat/route.ts` extracts the user Id from their JWT and passes both the query and the user Id to the `retrieveWithPermify` function in `lib/rag/retriever.ts`. This function is where the standard RAG pipeline meets authorization. + +First, the app embeds the query in a vector using Voyage AI's `voyage-3-lite` model. The retriever then computes cosine similarity between the query vector and every stored document's embedding, ranking them by relevance. Up to this point, the flow is identical to any other RAG system. + +The difference is what happens next: Instead of passing the top results straight to the LLM, the retriever checks each candidate document against FusionAuth FGA By Permify. + + + +Each `checkPermission` call asks FusionAuth FGA By Permify, "Does this user have `view` permission on this document?" FusionAuth FGA By Permify then traverses its relationship graph to answer. For example, if Jane is a member of the Customer Support team, and a document belongs to that team, FusionAuth FGA By Permify follows the chain `user → team member → team → doc` and returns `true`. If John from Fraud asks about the same document, FusionAuth FGA By Permify finds no path and returns `false`. + +The authorization model only includes documents that pass this check in the LLM's context. The chat handler builds a prompt from the permitted documents and sends it to Claude. If no documents survive the filter, rather than returning a hallucinated answer, the LLM responds with the following message. + +``` +I don't have any relevant documents to answer your question. +``` + +The FusionAuth FGA By Permify schema that defines these rules is written in a DSL. + + + +The `view` rule on the `doc` entity captures five different ways a user may access a document, all resolved by traversing relationships rather than checking a flat permissions table. + +## Testing Permissions With Different Users + +To see how permissions shape the RAG results, try the same question with different users. Log in as `admin@example.com` (password: `password`) and ask the following question. + +``` +What are the chargeback ratios? +``` + +The admin is an organization admin, so the `view` permission is satisfied via the `org.admin` path for every document. The LLM receives chargeback data from the Disputes team's documents and gives a detailed answer citing specific numbers. + +![The admin user receives a detailed answer about chargeback ratios, sourced from all team documents.](/img/blogs/securing-rag-permify/admin-chargeback-answer.png) + +Now log out and log in as `jane@example.com` (password: `password`). Jane leads the Customer Support team. Ask the same question. + +``` +What are the chargeback ratios? +``` + +Jane has no relationship to the Disputes & Chargebacks team's documents. FusionAuth FGA By Permify finds no path from her user Id to those docs, so they're filtered out before reaching the LLM. She gets a response saying the documents don't contain chargeback information, with only her Customer Support team docs as sources. + +![Jane receives a response stating no chargeback information is available in her accessible documents.](/img/blogs/securing-rag-permify/jane-chargeback-denied.png) + +Ask Jane something within her team's scope instead. + +``` +What are our customer satisfaction metrics? +``` + +This time, the retriever finds Customer Support documents that Jane can view (she's a team lead, so `team.lead` satisfies the permission), and the LLM returns a detailed answer with the Q4 CSAT scores. + +![Jane receives detailed Q4 customer satisfaction scores from her Customer Support team's documents.](/img/blogs/securing-rag-permify/jane-csat-answer.png) + +The cross-team viewer grants add a more nuanced layer. Jane has explicit `viewer` access to `dc-doc-6`, the Dispute evidence collection checklist, because Customer Support helps collect dispute evidence. As Jane, ask the following question. + +``` +What's on the dispute evidence collection checklist? +``` + +For Jane, the LLM can answer this one specific question from the Disputes team, but nothing else from that team. + +For the most restrictive case, log in as `stranger@example.com` (password: `password`). This user has no team assignments and no admin role. Although the application automatically adds every user who logs in as an organization member, org membership alone doesn't grant document access. + +The `view` permission requires a more specific relationship: + +* Document owner +* Explicit viewer +* Team member +* Team lead +* Organization admin + +Since the stranger has none of these, every `checkPermission` call returns `false`, and the LLM answers any question the stranger asks with the following message. + +``` +I don't have any relevant documents. +``` + +![The stranger user receives a response stating no relevant documents are available, because they have no team or admin relationships.](/img/blogs/securing-rag-permify/stranger-no-access.png) + +## Managing Permissions At Runtime + +Permissions in FusionAuth FGA By Permify are stored as data points. The application can write and delete relationship tuples at any time, with immediate effect. + +Log in as the admin and go to the Organization page. From here, you can add or remove members and promote or demote admins. Each of these actions maps to a FusionAuth FGA By Permify API call. Promoting a user to admin, for example, writes a single relationship tuple. + + + +Log in as `jane@example.com` again and ask a question about loan servicing. You won't get an answer because Jane is a Customer Support lead and doesn't have access to Loan Servicing documents. + +Now log back in as admin and promote Jane to org admin on the Organization page. Log in as Jane again and ask the same question. + +This time, you get a full answer because the `org.admin` path in the `view` permission now resolves for every document in the organization. No code change or redeployment required. The authorization model stays the same; only the data changes. + +Demoting Jane removes that tuple, and her access immediately shrinks back to her team's documents and her explicit viewer grants. The same pattern applies to adding or removing team members, or granting `view` access to individual documents. + +How easily you can alter permissions is the core advantage of relationship-based authorization for RAG. You define the authorization logic (the schema) once, and then control access entirely by managing relationships. Adding a new team, onboarding a new employee, or granting temporary access to a document for a cross-team project requires only a write to the relationship graph. + +## Moving To Production + +This example stores documents and embeddings in memory, which means they're lost on restart. For a production setup, set the `DOCUMENTS_DATABASE_URL` environment variable to point to a PostgreSQL instance, and the app will persist documents and embeddings there instead. + +The permission checks in the retriever currently run sequentially, calling `checkPermission` once per candidate document. For larger document sets, FusionAuth FGA By Permify's `lookupEntity` API can return all the document Ids a user has access to in a single call (the documents listing page already uses this mechanism). To reduce the number of API calls, switch the retriever to pre-fetch permitted Ids and filter them locally. + +The authorization schema in this example covers a single `view` permission, but FusionAuth FGA By Permify schemas can express more complex rules. For example, you can: + +* Restrict edit permissions to document owners. +* Set team-scoped upload rights. +* Create approval workflows where a document is only visible after a lead signs off. + +The schema DSL supports all these rules without changes to application code. + +The full source code is available on the [FusionAuth FGA + RAG Example](https://github.com/FusionAuth/fusionauth-example-fga-rag) repo. For more on FusionAuth FGA By Permify's authorization model, see the [FusionAuth FGA By Permify docs](/docs/extend/fine-grained-authorization). For FusionAuth authentication configuration, see the [FusionAuth docs](/docs). diff --git a/astro/src/tools/blog/getStaticIndexPaths.ts b/astro/src/tools/blog/getStaticIndexPaths.ts index 2cf8132b36..47b35c9616 100644 --- a/astro/src/tools/blog/getStaticIndexPaths.ts +++ b/astro/src/tools/blog/getStaticIndexPaths.ts @@ -69,10 +69,10 @@ export const getStaticIndexPaths = async ( filteredPosts.sort(sortByDate); - const params = {} as any; + const params: Record = {}; params[paramName] = slug; // Use the deduplicated slug - const props = {} as any; + const props: Record = {}; props[paramName + "Name"] = displayName; // Use the preserved readable name return paginate(filteredPosts, { @@ -106,11 +106,11 @@ export const getStaticCategoryPaths = async (paginate: PaginateFunction): Promis const filteredPosts = blogs.filter((post) => post.data["categories"].includes(target)); // newest first filteredPosts.sort(sortByDate); - const params = {} as any; + const params: Record = {}; params["category"] = target.trim().replaceAll(' ', '-').toLowerCase(); // Put the readable name into the astro props - const props = {} as any; + const props: Record = {}; props["category" + "Name"] = target; return paginate(filteredPosts, {