# 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](/guides/keys) for that distinction and
[multi-tenant RAG on SkimmerDB](https://skimmerdb.com/multi-tenant-rag) for workload fit.

## Prepare a test session

Use Bash, curl and jq. Start with an administrative key from the [quickstart](/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.

```sh title="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.

```sh title="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.

```sh title="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.

```sh title="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.

```sh title="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](/guides/embeddings) for each workspace and follow
[vector and hybrid search](/guides/vectors). 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.

```sh title="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

- [Keys, scopes and tenants](/guides/keys): exact names, prefixes, caps and key management.
- [Durability](/reference/durability): visibility, uncertain writes and retries.
- [Performance and cost](/guides/performance): cold reads, query work and latency.
- [Pricing](https://skimmerdb.com/pricing): free allowances and the paid account minimum.
