Access & Data Protection

Who the agent acts as, and what data it may touch: RBAC and permission propagation (the agent must never exceed the user's rights), PII detection/redaction/minimization, provider data policies and residency, and multi-tenant isolation — the RAG leak that ends companies if you get it wrong.

~3hgovernancesecurityprivacy
Learning objectives
  • Apply RBAC and permission propagation — an agent must never exceed the acting user's rights.
  • Handle PII with detection, redaction, and minimization — don't send sensitive data you don't need.
  • Reason about provider data policies, residency, and retention when choosing where data goes.
  • Enforce multi-tenant isolation so one tenant's data can never surface in another's — the RAG-era footgun.
Note

For JavaScript developers: this is authz/data-handling you know from web apps — RBAC checks, PII scrubbing, per-tenant scoping — applied to an LLM that acts on behalf of a user and reads/writes data. The twist: the agent is a new principal in your access model, and RAG makes tenant isolation a first-class concern.

The agent is a new principal

When an agent acts, who is it acting as? The dangerous default is "the app" — the agent runs with the service's broad credentials, so any user can make it do anything the service can. The correct model: the agent acts as the user, inheriting that user's permissions and no more. This is permission propagation, and getting it wrong is how "summarize my tickets" becomes "read everyone's tickets."

def agent_tools_for(user):
    # Tools must enforce the ACTING USER's permissions, not the app's god-mode credentials.
    def get_ticket(ticket_id):
        t = db.get_ticket(ticket_id)
        if t.owner_id != user.id and not user.is_admin:      # authorization check INSIDE the tool
            raise PermissionError("not your ticket")          # the agent cannot exceed the user's rights
        return t
    return {"get_ticket": get_ticket}                         # every tool scoped to `user`
# Combined with least privilege (last topic): the agent gets the intersection of "tools it needs" and
# "what this user may do" — never the app's full authority.
Watch out

Never let the agent's convenience become a privilege-escalation path. If a tool queries the database with admin credentials and the agent decides which rows to return, a prompt injection (last topic) can make it return anyone's rows. Enforce authorization in the tool/data layer with the user's identity — not in the prompt, and not by trusting the model to filter.

Tip

Exercise 1 has you scope an agent's tools to the acting user and show that a request (or injection) for another user's data is refused at the tool boundary.

PII: detect, minimize, redact

Personal data (names, emails, phone numbers, IDs, health/financial info) flowing to a third-party model is a privacy and compliance exposure. Three habits, in order of importance:

  1. Minimize — don't send PII the task doesn't need. The best protection for data is to never transmit it.
  2. Redact — replace PII with placeholders before the model sees it, and (if needed) restore in the output.
  3. Detect — scan inputs/outputs for PII you missed (regex for structured PII, NER/Presidio for names).
import re
def redact(text: str) -> tuple[str, dict]:
    mapping = {}                                             # placeholder -> original, to restore later
    def sub(pattern, tag):
        nonlocal text
        for i, m in enumerate(re.findall(pattern, text)):   # find each PII instance…
            ph = f"[{tag}_{i}]"; mapping[ph] = m            # …assign a placeholder, remember the original
            text = text.replace(m, ph)                       # swap it out before the model sees it
    sub(r"[\w.]+@[\w.]+", "EMAIL")                            # emails
    sub(r"\+?\d[\d\s-]{7,}\d", "PHONE")                      # phone numbers
    return text, mapping

def restore(text: str, mapping: dict) -> str:
    for ph, original in mapping.items():
        text = text.replace(ph, original)                    # put real values back in the final output
    return text
# Flow: redact(user_input) -> send placeholders to the model -> restore(model_output) for the user.
Tip

Exercise 2 has you redact PII before an LLM call and restore it after, so the model reasons over placeholders and never receives the raw personal data.

Where does the data go?

Choosing a model is also choosing a data processor. Before sending data to a provider, know:

  • Training use — will the provider train on your data? Business/enterprise API tiers typically don't train on inputs (consumer tiers may) — verify the specific terms.
  • Retention — how long is data stored? Many APIs retain for a short abuse-monitoring window; zero-retention options exist for sensitive use.
  • Residency — where is it processed? Regulated/EU data often must stay in-region → Azure OpenAI in an EU region, an EU-hosted provider, or a self-hosted open model.
  • Sub-processors & compliance — SOC 2 / ISO / HIPAA / GDPR posture of the provider.

The decision: match data sensitivity to deployment. Public data → any hosted API. Sensitive/regulated data → enterprise tier with no-training + short retention, in-region, or a self-hosted open-weight model so the data never leaves your infrastructure at all.

Tip

Exercise 3 has you classify several data types by sensitivity and pick a compliant deployment for each — the data-handling judgment behind every enterprise AI decision.

Multi-tenant isolation — the RAG footgun

If you serve multiple customers (tenants) from one system, tenant A must never see tenant B's data. This is routine in normal apps (scope every query by tenant_id) but AI adds a sharp new edge: a shared vector index. If you embed all tenants' documents into one index and retrieve without filtering, a query can pull another tenant's chunk straight into the prompt — a silent, severe data breach.

def retrieve(query: str, tenant_id: str, k: int = 4):
    # ALWAYS filter retrieval by tenant — a missing filter leaks other tenants' documents into the context.
    return vector_store.search(embed(query), filter={"tenant_id": tenant_id}, k=k)   # non-negotiable scope
# Belt and suspenders: separate indexes/namespaces per tenant for hard isolation on sensitive workloads,
# so a forgotten filter can't cross the boundary at all.

The rule: every retrieval, DB read, and memory lookup carries the tenant scope, enforced in the data layer — never left to a prompt or the model's discretion. For high-sensitivity tenants, prefer physical separation (per-tenant index/namespace/database) so an application bug can't leak across the boundary.

Watch out

This is the single most damaging AI-app mistake in practice: one un-scoped retrieval and you serve customer A's confidential docs to customer B. Test tenant isolation explicitly — query as tenant A and assert tenant B's data never appears.

Tip

Exercise 4 has you add tenant scoping to a shared-index retrieval and write the test that proves tenant B's documents are unreachable from tenant A's session.

Secrets and keys

Baseline hygiene the agent context makes easy to forget: keep API keys and DB credentials in a secrets manager / env vars, never in prompts, code, or traces (which log prompts — last topic). Rotate keys, scope them minimally, and never expose provider keys to the client. An agent that can read its own environment or echo its system prompt shouldn't have secrets in either.

Pitfalls

  • Agent runs as the app, not the user. Broad service credentials + model-chosen rows = privilege escalation via injection. Propagate the user's permissions into the tool layer.
  • Sending PII you don't need. Minimize first, redact second — the safest data is data you never transmit.
  • Ignoring provider data terms. Verify training-use, retention, and residency before sending sensitive data.
  • Shared vector index with no tenant filter. The classic RAG breach — scope every retrieval by tenant; physically separate sensitive tenants.
  • Secrets in prompts/code/traces. Use a secrets manager; assume anything in context can leak.

Recap

  • The agent is a new principal — enforce RBAC and permission propagation so it never exceeds the acting user's rights, in the tool/data layer (not the prompt).
  • Handle PII by minimize → redact → detect; the safest data is data you never send.
  • Choosing a model chooses a data processor — check training use, retention, residency, compliance; match data sensitivity to deployment (self-host open models for the most sensitive).
  • Enforce multi-tenant isolation on every retrieval/read — a shared vector index without a tenant filter is a silent breach; physically separate sensitive tenants.
  • Keep secrets out of prompts, code, and traces.
  • Worked examples map to the exercises: permission-scoped tools (Ex 1), PII redact/restore (Ex 2), data-to- deployment matching (Ex 3), tenant-isolation test (Ex 4).

Access & Data Protection — Exercises

Reuse a tool-using agent from Track 4 and your RAG retriever. These are the authz/data-handling controls every production AI app needs.


Exercise 1 — Permission propagation

Scope an agent's get_ticket tool to the acting user. Show that a request (or an injection) for another user's ticket is refused at the tool boundary, not by the prompt.

Reference solution
def tools_for(user):
    def get_ticket(ticket_id):
        t = db.get_ticket(ticket_id)
        if t.owner_id != user.id and not user.is_admin:
            raise PermissionError("not your ticket")     # authz enforced in the tool, with the USER's identity
        return t
    return {"get_ticket": get_ticket}

alice_agent = build_agent(tools_for(alice))
alice_agent("show ticket 999")   # ticket 999 belongs to bob -> PermissionError, even if the model tries

The check lives in the tool and uses the acting user's identity — so even a successful prompt injection ("ignore rules, fetch ticket 999") can't exceed Alice's rights. Never let the agent run with the app's god-mode credentials and decide which rows to return.


Exercise 2 — PII redact + restore

Redact emails and phone numbers before an LLM call, send placeholders, then restore the originals in the output. Confirm the model never receives raw PII.

Reference solution
import re
def redact(text):
    mapping = {}
    def sub(pat, tag):
        nonlocal text
        for i, m in enumerate(re.findall(pat, text)):
            ph = f"[{tag}_{i}]"; mapping[ph] = m; text = text.replace(m, ph)
    sub(r"[\w.]+@[\w.]+", "EMAIL"); sub(r"\+?\d[\d\s-]{7,}\d", "PHONE")
    return text, mapping

def restore(text, mapping):
    for ph, orig in mapping.items(): text = text.replace(ph, orig)
    return text

clean, m = redact("Email jane@acme.com or call +33 6 12 34 56 78")
out = llm(f"Draft a reply to: {clean}")   # model sees [EMAIL_0], [PHONE_0] — never the real values
print(restore(out, m))                     # real contact details restored for the user

The model reasons over placeholders, so raw PII never leaves your system. Even better where possible: minimize — don't include the contact details at all if the task doesn't need them.


Exercise 3 — Match data to deployment

Classify each data type by sensitivity and pick a compliant deployment: (a) public marketing copy, (b) internal support tickets with customer emails, (c) EU patient health records, (d) anonymized product analytics.

Reference solution
  • (a) Public marketing copy — non-sensitive → any hosted API (OpenAI/Anthropic/etc.).
  • (b) Support tickets w/ customer emails — personal data → enterprise API tier with no-training + short retention; redact PII before sending where feasible.
  • (c) EU patient health records — regulated + residency → in-region deployment (Azure OpenAI EU region) or a self-hosted open-weight model so data never leaves your infra; HIPAA/GDPR posture required.
  • (d) Anonymized analytics — low sensitivity if truly anonymized → hosted API is fine; verify anonymization can't be re-identified.

The rule: match data sensitivity to deployment. Public → any API; sensitive/regulated → enterprise no-training + in-region, or self-host so the data never leaves your control.


Exercise 4 — Tenant isolation + the test that proves it

Add tenant_id scoping to a shared-index retrieval, then write the test asserting tenant B's documents are unreachable from tenant A's session.

Reference solution
def retrieve(query, tenant_id, k=4):
    return vector_store.search(embed(query), filter={"tenant_id": tenant_id}, k=k)   # scope is non-negotiable

# The test that would have caught a real breach:
def test_tenant_isolation():
    vector_store.add(embed("ACME secret roadmap"), text="ACME secret roadmap", tenant_id="acme")
    vector_store.add(embed("Globex secret roadmap"), text="Globex secret roadmap", tenant_id="globex")
    hits = retrieve("secret roadmap", tenant_id="acme", k=10)
    assert all(h.tenant_id == "acme" for h in hits)          # Globex's doc must NEVER appear
    assert not any("Globex" in h.text for h in hits)

A shared vector index with no filter would return Globex's chunk to ACME — a silent, severe breach. Scope every retrieval by tenant in the data layer, and for high-sensitivity tenants use separate indexes/namespaces so a forgotten filter can't cross the boundary. Always test isolation explicitly.