1. The Use Case: "CompliAgent" - An Enterprise Compliance Assistant

Imagine a Fortune 500 company with 40,000 employees, 12 regulatory jurisdictions, and a policy library of 25,000 documents (HR handbooks, GDPR playbooks, SOX controls, vendor agreements). Employees, managers, and compliance officers need instant, accurate answers like:

"Can I hire a contractor in Germany for 6 months under our current policy, and what approvals are needed?"

A naive RAG chatbot will hallucinate, leak PII, or quote revoked policies. An enterprise-grade system must:

This article walks through the complete architecture and code for such a system, called CompliAgent.

2. High-Level Architecture

3. Secret Management Strategy

Before a single line of business logic, secrets must be solved correctly. Never commit .env files, never hardcode API keys, and never pass them through environment variables in production containers if you can avoid it.

3.1 The Layered Approach

LayerPurposeTool
L1 — VaultRuntime secrets (DB creds, LLM API keys, signing keys)HashiCorp Vault (KV v2 + dynamic Postgres secrets)
L2 — KMSEnvelope encryption for data at restAWS KMS / GCP KMS
L3 — HSMRoot key materialAWS CloudHSM / on-prem HSM
L4 — AuditEvery secret access is loggedVault audit device → SIEM

3.2 Vault Integration Pattern

# secrets/vault_client.py
import hvac
from functools import lru_cache
from pydantic import SecretStr

class VaultClient:
    def __init__(self, url: str, role: str, jwt_path: str):
        # Auth via Kubernetes service account (or AppRole in non-k8s)
        self.client = hvac.Client(url=url)
        with open(jwt_path) as f:
            jwt = f.read()
        self.client.auth.kubernetes.login(role=role, jwt=jwt)

    def get_secret(self, path: str, key: str) -> SecretStr:
        """Fetches a secret; Vault logs this access."""
        data = self.client.secrets.kv.v2.read_secret_version(path=path)
        return SecretStr(data["data"]["data"][key])

@lru_cache(maxsize=1)
def get_vault() -> VaultClient:
    return VaultClient(
        url=os.environ["VAULT_ADDR"],
        role="compliagent-prod",
        jwt_path="/var/run/secrets/kubernetes.io/serviceaccount/token",
    )

def get_llm_api_key() -> SecretStr:
    return get_vault().get_secret("llm/openai", "api_key")

Why This Matters

4. OAuth 2.0 Implementation

4.1 The Flows We Support

FlowUse Case
Authorization Code + PKCEWeb/mobile end-users
Client CredentialsService-to-service (e.g., audit service calling the LLM gateway)
Resource Owner PasswordNever — deprecated in OAuth 2.1

We use Keycloak (or Auth0/Okta) as the IdP. The FastAPI service is a Resource Server that validates JWTs.

4.2 JWT Validation with RBAC

# auth/oauth2.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import BaseModel

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

class UserContext(BaseModel):
    sub: str                # user id
    email: str
    roles: list[str]        # ["employee", "manager", "compliance_officer"]
    scopes: list[str]       # ["rag:read", "rag:write", "admin:audit"]
    department: str | None

def decode_token(token: str) -> UserContext:
    try:
        # Fetch JWKS from IdP and cache
        payload = jwt.decode(
            token,
            key=JWKS_PUBLIC_KEY,        # rotated via JWKS endpoint
            algorithms=["RS256"],
            audience="compliagent-api",
            issuer="https://idp.company.com/realms/internal",
        )
        return UserContext(
            sub=payload["sub"],
            email=payload.get("email", ""),
            roles=payload.get("realm_access", {}).get("roles", []),
            scopes=payload.get("scope", "").split(),
            department=payload.get("department"),
        )
    except JWTError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Invalid token: {e}"
        )

def get_current_user(token: str = Depends(oauth2_scheme)) -> UserContext:
    return decode_token(token)

def require_scope(*required: str):
    def checker(user: UserContext = Depends(get_current_user)):
        missing = set(required) - set(user.scopes)
        if missing:
            raise HTTPException(
                status_code=403,
                detail=f"Missing scopes: {missing}"
            )
        return user
    return checker

4.3 Policy-Based Access Control on RAG

Not every user should see every document. We enforce document-level ACLs by tagging each vector with allowed roles and filtering at retrieval time.

# retrieval/acl_filter.py
def build_acl_filter(user: UserContext) -> dict:
    """pgvector metadata filter: document must be visible to user's roles."""
    return {"allowed_roles": {"$in": user.roles}}

This is combined with the vector similarity search so that a junior employee never retrieves a document tagged ["compliance_officer", "legal"].

5. Multi-Agent LangGraph RAG with Memory & State

5.1 Why LangGraph Over a Single Chain?

A single LangChain RetrievalQA chain cannot:

LangGraph gives us a state machine where each node is an agent, and the state is a typed dictionary that flows through the graph.

5.2 The State Schema

# agents/state.py
from typing import Annotated, TypedDict, Literal
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage

class CompliState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    user: dict
    intent: Literal[
        "policy_qa",
        "hr_request",
        "legal_review",
        "chitchat"
    ] | None
    retrieved_docs: list[dict]
    analysis: str | None
    response: str | None
    audit_trail: list[dict]
    needs_human_review: bool

5.3 The Agents

# agents/nodes.py
from langchain_openai import AzureChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langgraph.prebuilt import create_react_agent

llm = AzureChatOpenAI(
    azure_deployment="gpt-4o",
    api_key=get_llm_api_key().get_secret_value(),
    temperature=0.1,
)

# --- Supervisor / Router ---
def supervisor(state: CompliState) -> CompliState:
    prompt = ChatPromptTemplate.from_messages([
        (
            "system",
            "Classify the user intent into one of: "
            "policy_qa, hr_request, legal_review, chitchat."
        ),
        ("human", "{input}"),
    ])

    intent = (
        prompt
        | llm.with_structured_output({
            "type": "object",
            "properties": {
                "intent": {
                    "type": "string",
                    "enum": [
                        "policy_qa",
                        "hr_request",
                        "legal_review",
                        "chitchat"
                    ]
                }
            }
        })
    ).invoke({"input": state["messages"][-1].content})

    return {
        **state,
        "intent": intent["intent"],
        "audit_trail": state["audit_trail"] + [
            {"node": "supervisor", "intent": intent["intent"]}
        ]
    }

# --- Retriever (RAG with ACL) ---
def retriever(state: CompliState) -> CompliState:
    user = state["user"]
    query = state["messages"][-1].content

    acl_filter = build_acl_filter(UserContext(**user))

    docs = vectorstore.similarity_search(
        query,
        k=6,
        filter=acl_filter
    )

    # Also check document freshness
    docs = [d for d in docs if is_current_version(d)]

    return {
        **state,
        "retrieved_docs": [d.to_dict() for d in docs],
        "audit_trail": state["audit_trail"] + [{
            "node": "retriever",
            "n_docs": len(docs),
            "sources": [
                d.metadata["source"] for d in docs
            ]
        }]
    }

# --- Analyzer (reasoning over docs) ---
def analyzer(state: CompliState) -> CompliState:
    docs_text = "\n---\n".join(
        d["content"] for d in state["retrieved_docs"]
    )

    prompt = ChatPromptTemplate.from_messages([
        (
            "system",
            "You are a compliance analyst. Given the retrieved policy "
            "excerpts, produce a structured analysis. If the excerpts "
            "do not answer the question, say INSUFFICIENT_EVIDENCE."
        ),
        (
            "human",
            "Question: {q}\n\nExcerpts:\n{docs}"
        ),
    ])

    analysis = (prompt | llm).invoke({
        "q": state["messages"][-1].content,
        "docs": docs_text
    })

    return {
        **state,
        "analysis": analysis.content,
        "audit_trail": state["audit_trail"] + [{
            "node": "analyzer",
            "has_evidence":
                "INSUFFICIENT_EVIDENCE" not in analysis.content
        }]
    }

# --- Writer ---
def writer(state: CompliState) -> CompliState:
    prompt = ChatPromptTemplate.from_messages([
        (
            "system",
            "Write a clear, citation-bearing response. "
            "Cite sources as [1], [2]. If uncertain, disclose it."
        ),
        ("human", "Question: {q}\nAnalysis: {a}"),
    ])

    resp = (prompt | llm).invoke({
        "q": state["messages"][-1].content,
        "a": state["analysis"]
    })

    return {**state, "response": resp.content}

# --- Auditor (compliance + PII scrub + log) ---
def auditor(state: CompliState) -> CompliState:
    response = state["response"]

    # PII scrubbing regex + NER model
    scrubbed = scrub_pii(response)

    needs_human = (
        "INSUFFICIENT_EVIDENCE"
        in (state["analysis"] or "")
    )

    persist_audit_log(
        user_id=state["user"]["sub"],
        query=state["messages"][-1].content,
        response=scrubbed,
        trail=state["audit_trail"],
    )

    return {
        **state,
        "response": scrubbed,
        "needs_human_review": needs_human
    }

5.4 The Graph

# agents/graph.py
from langgraph.graph import StateGraph, START, END

def route_after_supervisor(state: CompliState) -> str:
    if state["intent"] == "chitchat":
        return "writer"
    return "retriever"

def route_after_analyzer(state: CompliState) -> str:
    if "INSUFFICIENT_EVIDENCE" in (state["analysis"] or ""):
        return "writer"
    return "writer"

graph = StateGraph(CompliState)

graph.add_node("supervisor", supervisor)
graph.add_node("retriever", retriever)
graph.add_node("analyzer", analyzer)
graph.add_node("writer", writer)
graph.add_node("auditor", auditor)

graph.add_edge(START, "supervisor")

graph.add_conditional_edges(
    "supervisor",
    route_after_supervisor,
    {
        "retriever": "retriever",
        "writer": "writer"
    }
)

graph.add_edge("retriever", "analyzer")

graph.add_conditional_edges(
    "analyzer",
    route_after_analyzer,
    {
        "writer": "writer"
    }
)

graph.add_edge("writer", "auditor")
graph.add_edge("auditor", END)

app = graph.compile(
    checkpointer=RedisSaver.from_url(
        os.environ["REDIS_URL"]
    )
)

The RedisSaver checkpointer means every invocation is keyed by thread_id. The next message from the same user with the same thread_id resumes the exact state — that is your conversation memory.

5.5 Long-Term Memory (User Profile)

Beyond conversation memory, we store a user profile in Postgres: preferred department, past queries, and acknowledged policies. This is injected into the state at START by a tiny pre-node.

def inject_user_profile(state: CompliState) -> CompliState:
    profile = user_profile_repo.get(state["user"]["sub"])

    return {
        **state,
        "user": {
            **state["user"],
            "profile": profile
        }
    }

6. FastAPI Integration

# api/main.py
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.util import get_remote_address

from auth.oauth2 import (
    get_current_user,
    require_scope,
    UserContext
)

from agents.graph import app as graph_app
from agents.state import CompliState

app = FastAPI(
    title="CompliAgent API",
    version="1.4.0"
)

app.state.limiter = Limiter(
    key_func=get_remote_address
)

@app.post("/v1/chat", response_model=ChatResponse)
async def chat(
    req: ChatRequest,
    user: UserContext = Depends(
        require_scope("rag:read")
    ),
):
    config = {
        "configurable": {
            "thread_id": req.thread_id
        }
    }

    # Build initial state
    initial_state: CompliState = {
        "messages": req.messages,
        "user": user.model_dump(),
        "intent": None,
        "retrieved_docs": [],
        "analysis": None,
        "response": None,
        "audit_trail": [],
        "needs_human_review": False,
    }

    final = await graph_app.ainvoke(
        initial_state,
        config=config
    )

    return ChatResponse(
        response=final["response"],
        citations=[
            d["source"]
            for d in final["retrieved_docs"]
        ],
        needs_human_review=final["needs_human_review"],
        audit_id=persist_audit(
            final["audit_trail"]
        ),
    )

6.1 What This Endpoint Gives You, for Free

7. End-to-End Request Flow (Real-Time Walkthrough)

User (a German HR manager) sends:

"Can I hire a contractor in Germany for 6 months under our current policy, and what approvals are needed?"

  1. FastAPI validates the JWT → UserContext(roles=["manager","hr"], department="EMEA").

  2. Scope check passes (rag:read).

  3. Supervisor classifies intent → hr_request.

  4. Retriever runs vector search with filter:

{
  "allowed_roles": {
    "$in": ["manager", "hr"]
  }
}

and country: "DE".

Returns 5 docs:

  1. Analyzer produces structured analysis:

"Yes, ≤6 months is permitted without works council approval; >6 months requires it. Required forms: HR-DE-101, vendor due-diligence."

  1. Writer emits a response with citations [1], [2].

  2. Auditor scrubs any accidental PII, logs the full trail to the audit table, and flags needs_human_review=false.

  3. Response returned to user. The LangGraph state is checkpointed in Redis under thread_id, so the next question ("What's the form HR-DE-101?") has full context.

8. Production Hardening Checklist

ConcernMitigation
Prompt injectionSystem prompt hardening + output classifier in auditor node
HallucinationINSUFFICIENT_EVIDENCE escape hatch + mandatory citations
Stale policiesVersion metadata + is_current_version gate
PII leakageNER scrubber + regex in auditor
Secrets leakVault dynamic secrets + short TTLs + audit device
Token replayJWT short TTL (5 min) + refresh tokens + rotation
DDoSRate limit + WAF + per-user quotas
ObservabilityOpenTelemetry traces per graph node → Jaeger
ComplianceAudit table immutable (append-only), replicated to cold storage
FailoverLangGraph checkpointer on Redis Cluster; Postgres primary/replica

9. Deployment Sketch

# k8s deployment (abbreviated)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: compliagent

spec:
  template:
    spec:
      serviceAccountName: compliagent-sa

      containers:
      - name: api
        image: compliagent:1.4.0

        env:
        - name: VAULT_ADDR
          value: "https://vault.internal:8200"

        # NO secrets here — fetched at runtime from Vault

        resources:
          requests:
            cpu: "2"
            memory: "4Gi"

          limits:
            cpu: "4"
            memory: "8Gi"

        livenessProbe:
          httpGet:
            path: /healthz
            port: 8000

The pod authenticates to Vault via its Kubernetes service account JWT — no secrets ever touch the manifest or the environment. Build it this way, and you can stand in front of a regulator and show them exactly who asked what, which documents were used, which agent reasoned over them, and why the answer was what it was — with cryptographic proof at every step.

Summary

CompliAgent demonstrates how to build an enterprise-grade compliance assistant that goes far beyond a basic RAG chatbot. By combining OAuth 2.0 authentication, RBAC authorization, Vault-based secret management, ACL-aware retrieval, LangGraph multi-agent orchestration, persistent memory, audit logging, PII protection, and regulatory-grade observability, organizations can deliver accurate, secure, explainable, and compliant AI-powered decision support at enterprise scale.