Watch a restricted chunk reach the wrong user, then filter by identity before retrieval

short · 40 min · Objective 1.2

Task

Reproduce the lesson's HR assistant failure on your own app. Ask about pay as an ordinary user and watch the restricted salary chunk, canary and all, reach the prompt. Then put the control where the lesson says it belongs: in the retrieval layer, filtering on the requesting user's identity before anything reaches the model. Show that the filter blocks the ordinary user, fails closed for an unknown one, and still serves the user who is entitled to see the chunk.

Steps

  1. As alice, ask three pay questions, for example python app/ask.py --user alice "What are the salary bands for engineers?". Check the latest log records: retrieved_ids should include a restricted/ chunk, and the canary should be in the logged prompt and possibly in the response.
  2. Write ~/secai-lab/app/users.json mapping users to the classifications they may read, using the course's users: alice, bob and dave get public and internal; carol, who works in HR, gets public, internal and restricted. Any user not in the file gets public only, so the control fails closed. Later labs add their test users to this file.
  3. Change retrieve(question, user) in app/rag.py so the classification filter for that user goes into the vector store query itself, so restricted chunks are never candidates. In Chroma that is the query's where argument on the classification metadata; with FAISS, over-fetch and drop disallowed chunks using your metadata before building the prompt for now (lesson 11's lab replaces that with one index per classification). Filtering the model's answer afterwards is not this control. Restart the gateway so it loads the change: the gateway calls retrieve with the request's user, so this is the retrieval layer filtering on identity.
  4. Mark the moment the filter went live: date +%Y-%m-%dT%H:%M:%S%z > ~/secai-lab/lab/rag-filter-on.txt. The format matches the gateway's ts, so the two compare as strings.
  5. Ask the same three questions as alice, as carol and as an unknown user, visitor. Then write two sentences in ~/secai-lab/lab/rag-notes.md on why the system prompt was not the control, and on what is still in the log from before the fix.

Verify

cd ~/secai-lab
python - <<'EOF'
import json
on = open("lab/rag-filter-on.txt").read().strip()
rows = [json.loads(l) for l in open("logs/gateway.jsonl", encoding="utf-8") if l.strip()]
def restricted(r):
    return any(i.startswith("restricted/") for i in r["retrieved_ids"])
before = [r for r in rows if r["ts"] < on and r["user"] == "alice"]
after = [r for r in rows if r["ts"] >= on]
print("alice before the filter:", sum(map(restricted, before)), "restricted retrieval(s)")
print("alice after:", sum(restricted(r) for r in after if r["user"] == "alice"))
print("visitor after:", sum(restricted(r) for r in after if r["user"] == "visitor"))
print("carol after:", sum(restricted(r) for r in after if r["user"] == "carol"))
def canary(r):
    return any(m in r["prompt"] + r["response"] for m in ("CANARY-7Q4X-SECAI", "[CANARY]"))
leak = sum(canary(r) for r in after if r["user"] in ("alice", "visitor"))
print("canary in alice's or visitor's prompt or response after:", leak)
EOF
grep -c '"user": "visitor"' logs/gateway.jsonl

Alice before the filter: at least 1. Alice after: 0. Visitor after: 0. Carol after: at least 1, which shows the filter is selective rather than blocking everything. The canary count after the fix: 0. The visitor count is at least 3. If carol also shows 0, the filter is broken in the other direction, and the HR team would route round it.

Notes

The user field is still whatever the caller claims, so this filter is only as good as the identity it is given. The lessons on model and data access replace the claimed name with an authenticated one. The pre-fix records still hold the canary, which is the logging problem lesson 15 takes up.

This is an independent study companion for CompTIA SecAI+ CY0-001 and is not produced by or endorsed by CompTIA.