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.
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.
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/nullIssue 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.
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_JSONVerify 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.
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.
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.
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
- Keys, scopes and tenants: exact names, prefixes, caps and key management.
- Durability: visibility, uncertain writes and retries.
- Performance and cost: cold reads, query work and latency.
- Pricing: free allowances and the paid account minimum.