PII-Safe Multi-Tenant Chatbot: Signed Sessions, Redaction Vaults, Scoped Memory and Erasure
Hands-on lab · IDE in your browser

PII-Safe Multi-Tenant Chatbot: Signed Sessions, Redaction Vaults, Scoped Memory and Erasure

Build a support chatbot that serves several businesses without mixing them: verify signed session tokens, send the model provider a pseudonymous end-user id, redact personal data into a per-user vault and restore it only for its owner, keep memory per tenant and user, run cross-tenant leak probes, and export or erase one customer's data.

Time
60 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Map the attack surface
Query
Retriever
LLM
Poisoned doc
retrieved chunk
Answer
0%
Attack-success rate
Attacks blocked · benign answers pass
graded on real output, not the model's talk

The job

Hollow Desk runs one support chatbot for a dental clinic, a bike shop and an accountancy. Some people are customers of two of them. You make sure no customer's data reaches another business, the model provider or the logs, and that it can be deleted on request.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Who is asking

    Hollow Desk runs one support chatbot for three businesses: a dental clinic, a bike shop and an accountancy.

    You writescope_from_token()provider_user_id()
  2. 2

    Redact before the model, restore for the owner

    Emails, phone numbers, cards and IBANs should not reach the model provider or the logs.

    You writeredact()restore()log_turn()
  3. 3

    Memory that belongs to one tenant and one user

    The bot remembers facts about customers between chats.

    You writeremember()extract_facts()build_messages()
  4. 4

    The chatbot, and the leak probes

    Write chat(token, history, message), one turn: 1.

  5. 5

    Export and forget

    Customers can ask what the bot keeps about them and ask for it to be deleted.

    You writeexport_user()forget_user()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Who is asking

Hollow Desk runs one support chatbot for three businesses: a dental clinic, a bike shop and an accountancy. The same person can be a customer of two of them. Everything the bot keeps is filed under a tenant and a user, so the first job is to know both for certain. They come from a session token the business's website signed, never from anything typed into the chat.

A token is "<tenant>.<user, base64url>.<expiry>.<signature>", and the signature is HMAC-SHA256 over the first three parts with PF.SECRET (see make_token() in hollowdesk.py).

Do this

1. Write scope_from_token(token, now=None) in chatbot.py. Return a Scope(tenant, user), or raise AuthError with the reason, checked in this order:

  • "malformed": not four parts;
  • "bad signature": compare with hmac.compare_digest;
  • "expired": past the expiry (use now if given);
  • "unknown tenant".

2. Write provider_user_id(scope): the id the model provider sees for this end user. Take "u_" plus the first 24 hex characters of HMAC-SHA256, keyed by the tenant's salt, over "<tenant>:<user>".

3. Run. It checks real tokens and five forged or broken ones.

chatbot.py, the file you edit114 lines
"""Hollow Desk's chatbot: one bot, many businesses, and nothing crosses between them."""
import hashlib
import hmac
import json
import re
import time
from dataclasses import dataclass

import harness as H
import hollowdesk as PF


class AuthError(Exception):
    pass


@dataclass(frozen=True)
class Scope:
    tenant: str
    user: str


def first_json(reply, opener):
    """The first JSON value starting with opener ("[" or "{") in a reply, or None."""
    i = reply.find(opener)
    while i != -1:
        try:
            return json.JSONDecoder().raw_decode(reply[i:])[0]
        except ValueError:
            i = reply.find(opener, i + 1)
    return None


# ---------- Step 1: who is asking ----------

def scope_from_token(token, now=None):
    """The Scope a session token proves, or AuthError with a reason: "malformed" (not four dot-separated parts, or a
    user that does not decode), "bad signature" (compare with hmac.compare_digest), "expired", "unknown tenant"."""
    # TODO (Step 1): split into 4 parts, recompute the HMAC over the first three with PF.SECRET,
    # hmac.compare_digest, then expiry, then the tenant; PF.b64decode() the user.
    raise NotImplementedError("Step 1: write scope_from_token()")


def provider_user_id(scope):
    """The end-user id the model provider sees: "u_" + the first 24 hex characters of HMAC-SHA256 keyed by the
    tenant's salt over "<tenant>:<user>". Stable for one user, different across tenants, never the email."""
    # TODO (Step 1): "u_" + HMAC-SHA256(tenant salt, "<tenant>:<user>").hexdigest()[:24].
    raise NotImplementedError("Step 1: write provider_user_id()")


# ---------- Step 2: redact before the model, restore for the owner ----------

def redact(scope, text):
    """text with every PF.detect() span replaced by a placeholder "[KIND_n]". The same value always gets the same
    placeholder for this user; a new value gets the next n for its kind and is stored in the vault under the scope."""
    raise NotImplementedError("redact() arrives in Step 2")


def restore(scope, text):
    """text with each placeholder from this user's vault replaced by its value. Placeholders the vault does not hold
    for this user stay as they are."""
    raise NotImplementedError("restore() arrives in Step 2")


def log_turn(scope, role, text):
    """Append one JSON line to PF.LOG: {"tenant", "user": provider_user_id(scope), "role", "text": redact(...)}.
    The log never holds the user's email or any detected personal data."""
    raise NotImplementedError("log_turn() arrives in Step 2")


# ---------- Step 3: memory that belongs to one tenant and one user ----------

def remember(scope, fact):
    """Store a fact about this user, redacted, under the scope."""
    raise NotImplementedError("remember() arrives in Step 3")


def recall(scope):
    """The facts stored for this tenant and user, oldest first, as stored (redacted)."""
    raise NotImplementedError("recall() arrives in Step 3")


def extract_facts(scope, message):
    """Ask the model which durable facts about the customer the (already redacted) message states, worth keeping for
    later chats (preferences, bookings, conditions). A JSON list of short strings; [] if none or unreadable."""
    raise NotImplementedError("extract_facts() arrives in Step 3")


def build_messages(scope, history, message):
    """The chat for the model: a system message with the tenant's name and FAQ and this user's remembered facts,
    then history (a list of {"role", "content"}), then the message."""
    raise NotImplementedError("build_messages() arrives in Step 3")


# ---------- Step 4: the chatbot ----------

def chat(token, history, message):
    """One turn: scope from the token, redact the message, log it, remember its facts, build the messages, ask the
    model as provider_user_id(scope), log the reply, and return the reply restored for this user. history holds
    earlier turns as sent to the model (redacted)."""
    raise NotImplementedError("chat() arrives in Step 4")


# ---------- Step 5: export and forget ----------

def export_user(scope):
    """Everything kept about one user, restored: {"memory": [facts], "log": [{"role", "text"}]} (the user's log lines,
    in order)."""
    raise NotImplementedError("export_user() arrives in Step 5")


def forget_user(scope):
    """Delete the user's memory, vault and log lines. Returns {"memory": n, "vault": n, "log": n} deleted."""
    raise NotImplementedError("forget_user() arrives in Step 5")
Provided for you:harness.pyhollowdesk.pytenants.jsontry_it.py

Frequently asked questions

How do you isolate tenants in an LLM chatbot?

Derive the tenant and user from a verified session, never from the message, and filter every stored item by both. The model can only reveal what is in its prompt, so another tenant's data must never be put there.

Should end-user ids be sent to the LLM provider?

A stable pseudonym helps the provider trace abuse to one end user. Send a keyed hash of the tenant and user rather than an email, so the provider cannot tell who the person is.

How do you redact PII without breaking the conversation?

Replace each value with a placeholder kept in a per-user vault, let the model work with the placeholders, and restore them in replies to the same user only.

Multi-tenant LLM chatbots and personal data

A chatbot platform that serves several businesses has to keep each business's customers apart, and keep personal data away from the model provider and the logs. You verify HMAC-signed session tokens, derive a pseudonymous end-user id for the provider, redact emails, phones, cards and IBANs into a vault restored only for their owner, scope memory by tenant and user, probe for cross-tenant leaks against a naive bot, and implement data export and erasure.