Guides Markdown

Multi-tenant RAG

Build customer-isolated retrieval with two namespaces and a read-only key, then test search, direct reads, denied writes and cleanup.

Give each customer workspace its own namespace, then make a key that can reach only that namespace. Your trusted backend authenticates the user and chooses the correct key. The retrieval component receives that restricted credential, never your administrative key.

This walkthrough creates two fictional help centers, Cedar and Harbor. Both mention E1042, but have different answers. You will retrieve Cedar's document, then check that the same key cannot search or read Harbor's document and cannot write to Cedar.

Here, both customer workspaces belong to your application's SkimmerDB tenant, which owns keys, usage and billing. A namespace is the workspace's document container. See Keys, scopes and tenants for that distinction and multi-tenant RAG on SkimmerDB for workload fit.

Prepare a test session

Use Bash, curl and jq. Start with an administrative key from the quickstart, exported as ADMIN_KEY. Run the following blocks in the same shell, in order. They create unique example namespaces and remove them at the end. Use a test account; ordinary plan limits and usage charges apply. Never paste a real key into a shared transcript or enable shell tracing while handling it.

Prepare
set -euo pipefail
: "${ADMIN_KEY:?Export your test account admin key first}"
B=${SKIMMERDB_URL:-https://api.skimmerdb.com/v1}
RUN_TAG="$(date +%s)-$$"
CEDAR="seo-cedar-$RUN_TAG"
HARBOR="seo-harbor-$RUN_TAG"

Write the same error code for two customers

The first write creates each namespace. wait: true makes the write searchable on the serving server before it answers. These documents are synthetic; they are not customer data or claims about the meaning of a real error code.

Write the fixtures
curl -fsS "$B/namespaces/$CEDAR/docs" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"wait":true,"docs":[{"id":"cedar-e1042","text":"E1042: retry the webhook after checking the delivery log","meta":{"status":"published"}}]}' >/dev/null

curl -fsS "$B/namespaces/$HARBOR/docs" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"wait":true,"docs":[{"id":"harbor-e1042","text":"E1042: renew the expired integration token","meta":{"status":"published"}}]}' >/dev/null

Issue a read-only key for Cedar

Use an exact namespace name for this example. A prefix such as customer-7-* can be useful when a customer needs several namespaces, but an overly broad prefix grants broader access. The response contains the key once; capture it without printing it.

Create the scoped key
KEY_JSON=$(curl -fsS "$B/keys" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg ns "$CEDAR" '{name:"Cedar example reader",scope:"read",namespaces:[$ns]}')")
CEDAR_KEY=$(printf '%s' "$KEY_JSON" | jq -er '.key')
CEDAR_KEY_ID=$(printf '%s' "$KEY_JSON" | jq -er '.id')
unset KEY_JSON

Verify the allowed result

Search with Cedar's restricted key, not the admin key. Check that the response is complete and contains exactly the expected document. On a distributed deployment, a request routed to a different server can briefly observe older state; the bounded retry below allows for that propagation. It does not turn an incorrect or partial answer into a passing result.

Check Cedar retrieval
FOUND=false
for attempt in 1 2 3 4 5; do
  RESULT=$(curl -fsS "$B/namespaces/$CEDAR/search" \
    -H "Authorization: Bearer $CEDAR_KEY" -H "Content-Type: application/json" \
    -d '{"q":"E1042","filter":{"status":"published"}}')
  if printf '%s' "$RESULT" | jq -e \
    '.partial == false and ([.hits[].id] == ["cedar-e1042"])' >/dev/null; then
    FOUND=true
    break
  fi
  sleep 1
done
[ "$FOUND" = true ]
printf 'PASS: Cedar search returns only cedar-e1042\n'

Verify denied reads and writes

Changing the namespace must not expand the key's reach. An inaccessible namespace returns 404, just like a nonexistent one. A write attempted with a read-only key returns 403. The commands check the expected HTTP statuses; a network failure cannot pass.

Check the boundary
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
  "$B/namespaces/$HARBOR/search" \
  -H "Authorization: Bearer $CEDAR_KEY" -H "Content-Type: application/json" \
  -d '{"q":"E1042"}')
[ "$STATUS" = 404 ]
printf 'PASS: Harbor search denied with 404\n'

STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
  "$B/namespaces/$HARBOR/docs/harbor-e1042" \
  -H "Authorization: Bearer $CEDAR_KEY")
[ "$STATUS" = 404 ]
printf 'PASS: Harbor direct read denied with 404\n'

STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
  "$B/namespaces/$CEDAR/docs" \
  -H "Authorization: Bearer $CEDAR_KEY" -H "Content-Type: application/json" \
  -d '{"docs":[{"id":"must-not-exist","text":"This write must fail"}]}')
[ "$STATUS" = 403 ]
printf 'PASS: write denied with 403\n'

These checks exercise the documented search, direct-read and write boundaries. They do not replace a review of your application's identity mapping, ingestion, logs, exports or other tools available to the agent. Keep tenant identity server-controlled, and repeat the relevant negative tests when credential provisioning or access paths change.

Add semantic retrieval when you need it

The exact query makes the boundary easy to inspect. Once it works, configure embeddings for each workspace and follow vector and hybrid search. Reuse the same scoped credentials.

Text and metadata constraints narrow the vector candidate set. A required identifier such as E1042 is different from an optional descriptive phrase: adding a text constraint can exclude semantically relevant documents that do not contain that text. Test relevance separately from authorization. Inspect partial before treating any result as complete.

Revoke the example key and remove the fixtures

Run cleanup with the admin key. These commands delete only the two uniquely named namespaces created above. If a check stops the walkthrough early, run this block in the same shell to clean up any resources that were created.

Clean up
if [ -n "${CEDAR_KEY_ID:-}" ]; then
  curl -fsS -X DELETE "$B/keys/$CEDAR_KEY_ID" \
    -H "Authorization: Bearer $ADMIN_KEY" >/dev/null
fi
for NS in "$CEDAR" "$HARBOR"; do
  STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE \
    "$B/namespaces/$NS" -H "Authorization: Bearer $ADMIN_KEY")
  case "$STATUS" in 2??|404) ;; *) printf 'Cleanup failed: HTTP %s\n' "$STATUS"; exit 1 ;; esac
done
unset CEDAR_KEY CEDAR_KEY_ID
printf 'PASS: example key revoked and namespaces removed\n'

For production, also decide how customer offboarding removes source documents, generated answers and other application records. Deleting a SkimmerDB namespace does not delete copies held by your application or another service.

Next steps