Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Webentwicklung  · 7 minuten Lesezeit

Qdrant Multi-Tenancy mit eigener Collection pro Nutzer

Phase 3 bringt echte Datenisolierung. Jeder Nutzer bekommt eine eigene Qdrant-Collection, einen eigenen S3-Pfad und drei neue Endpunkte für DSGVO-Rechte.

Phase 3 bringt echte Datenisolierung. Jeder Nutzer bekommt eine eigene Qdrant-Collection, einen eigenen S3-Pfad und drei neue Endpunkte für DSGVO-Rechte.

Inhalt

Warum eine Collection für alle Nutzer das falsche Modell ist

Bisher gab es eine einzige Qdrant-Collection: instagram_memory. Das Backend rief beim Start ensureCollection() auf, und danach schrieben und lasen alle Nutzer in denselben Vektorrraum. Das funktioniert für einen einzelnen Nutzer gut, aber es hat drei grundlegende Probleme.

Erstens: Es gibt keine Datenisolierung. Ein Fehler in der Filterschicht könnte dazu führen, dass ein Nutzer die Daten eines anderen sieht. Zweitens: Eine Nutzer-Löschung ist operativ komplex. Die Punkte des gelöschten Nutzers müssen einzeln aus der Collection herausgefiltert und gelöscht werden. Drittens: DSGVO Art. 17 (Recht auf Vergessenwerden) verlangt vollständige Löschbarkeit, und ein deleteAll(filter: userId == X) ist weniger zuverlässig als dropCollection("user_X").

Die Lösung ist einfach: Jeder Nutzer bekommt seine eigene Collection.

Dynamische Collection-Namen

Der Collection-Name wird aus der Zitadel-Sub-Claim des JWT abgeleitet:

function getCollectionName(userId: string): string {
  return `user_${userId}_instagram`;
}

Zitadel stellt User-IDs im UUID-Format aus, also 550e8400-e29b-41d4-a716-446655440000. Qdrant akzeptiert Bindestriche in Collection-Namen, daher ist keine Normalisierung nötig.

Die Collection wird nicht beim Server-Start angelegt, sondern beim ersten Ingest des jeweiligen Nutzers:

// ingest-worker.ts
const userId = job.data.userId;
await ensureCollection(userId); // creates "user_{userId}_instagram" if not exists
await storePoint(vector, payload, userId);

Das ensureCollection prüft zuerst, ob die Collection bereits existiert, und legt sie nur dann an, wenn sie fehlt. Ein einmaliger Netzwerk-Round-Trip pro Job, nicht pro Punkt.

Graceful Returns für neue Nutzer

Das erste Problem, das die Rubber-Duck-Review aufgedeckt hat: Leseoperationen auf einer Collection, die noch nicht existiert, liefern von Qdrant einen Fehler. Ein neuer Nutzer, der noch nichts gespeichert hat, würde beim ersten Aufruf von /query oder /exists eine 500 zurückbekommen.

Die Lösung ist ein Helper, der den Qdrant-Fehler als “leer” interpretiert:

function isCollectionNotFound(err: unknown): boolean {
  const msg = err instanceof Error ? err.message : String(err);
  return (
    msg.includes("Not found") ||
    msg.includes("doesn't exist") ||
    msg.includes("404") ||
    msg.includes("Status: 404")
  );
}

Qdrant ist in der Formulierung von Fehlermeldungen nicht vollständig konsistent zwischen Versionen, daher werden mehrere Patterns geprüft. Alle Leseoperationen fangen diesen Fehler ab und geben stattdessen ein leeres Ergebnis zurück:

export async function searchSimilar(
  vector: number[],
  userId: string,
  limit: number
): Promise<ScoredPoint[]> {
  try {
    return await client.search(getCollectionName(userId), { vector, limit });
  } catch (err) {
    if (isCollectionNotFound(err)) return [];
    throw err;
  }
}

Dasselbe Muster gilt für scrollAllPoints, postIdExists und deleteUserCollections.

S3-Pfade pro Nutzer

Auch der Object Storage war bisher nicht isoliert. Alle Bilder landeten unter posts/{uuid}.png. Mit Phase 3 hat jeder Nutzer seinen eigenen Pfad-Prefix:

export async function uploadImage(
  base64: string,
  mimeType: string,
  userId: string
): Promise<string> {
  const extension = mimeType.split("/")[1] ?? "png";
  const key = `${userId}/posts/${randomUUID()}.${extension}`;
  // ...
}

Ein Nutzer hat damit keinen Zugriff auf den Pfad eines anderen Nutzers. Die Löschfunktion kann alle Objekte mit dem Prefix {userId}/ entfernen, ohne eine Liste aller Objekte zu durchsuchen.

Das Löschen läuft paginiert, weil AWS DeleteObjects maximal 1000 Objekte pro Aufruf akzeptiert:

export async function deleteUserBlobs(userId: string): Promise<void> {
  let continuationToken: string | undefined;
  do {
    const list = await s3.send(
      new ListObjectsV2Command({
        Bucket: BUCKET_NAME,
        Prefix: `${userId}/`,
        ContinuationToken: continuationToken,
      })
    );
    if (list.Contents?.length) {
      await s3.send(
        new DeleteObjectsCommand({
          Bucket: BUCKET_NAME,
          Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key! })) },
        })
      );
    }
    continuationToken = list.NextContinuationToken;
  } while (continuationToken);
}

Drei neue DSGVO-Endpunkte

Phase 3 implementiert die drei datenschutzrechtlich relevanten Nutzeraktionen.

DELETE /account (Art. 17, Recht auf Vergessenwerden): Der Endpunkt löscht in dieser Reihenfolge: ausstehende BullMQ-Jobs, Qdrant-Collection, S3-Blobs und abschließend die Zitadel-Identität. Die Reihenfolge ist bewusst gewählt. Wenn die Qdrant- oder S3-Löschung fehlschlägt, ist die Zitadel-Identität noch vorhanden, und der Nutzer kann erneut versuchen, sein Konto zu löschen.

// Queue cleanup first, before deleting data
const allJobs = await queue.getJobs([
  "waiting", "delayed", "active", "completed", "failed"
]);
const userJobs = allJobs.filter((j) => j.data.userId === userId);
await Promise.allSettled(userJobs.map((j) => j.remove()));

Die Zitadel-Löschung nutzt die Management API v2 mit einem admin PAT, der als Docker-Volume eingebunden wird:

async function deleteZitadelUser(userId: string): Promise<boolean> {
  const pat = await getAdminPat();
  const res = await fetch(`${ZITADEL_URL}/v2/users/${userId}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${pat}` },
  });
  return res.ok;
}

GET /account/export (Art. 20, Datenportabilität): Alle Vektorpunkte des Nutzers werden mit scrollAllPoints gelesen und als JSON zurückgegeben. Für jeden Punkt wird eine Presigned URL für das zugehörige Bild generiert, damit der Nutzer seine Daten vollständig exportieren kann.

POST /account/consent: Zeichnet die Einwilligung mit ToS-Version und Timestamp als strukturierter JSON-Eintrag in stdout auf. Das Format ist Grafana/Loki-kompatibel und wird in Phase 5 mit einem Dashboard abgefragt.

console.log(
  JSON.stringify({
    event: "consent_recorded",
    userId,
    tosVersion,
    consentGivenAt: new Date().toISOString(),
  })
);

Ownership-Check beim Capture-Status

Der Endpunkt GET /captures/:captureId/status lieferte bisher den Job-Status für jeden, der die Job-ID kannte. Mit Phase 3 wird die Ownership geprüft:

// Only enforce when userId is present (backward compat for old jobs)
if (job.data.userId && job.data.userId !== req.auth!.userId) {
  return res.json({ status: "unknown" });
}

Alte Jobs ohne userId-Feld werden nicht zurückgewiesen, damit bestehende Daten weiterhin funktionieren.

Health-Endpunkt ohne Collection-Name

Der Health-Endpunkt rief bisher getCollectionInfo("instagram_memory") auf. Diese Collection existiert nach Phase 3 nicht mehr. Der Endpunkt prüft jetzt die Qdrant-Konnektivität auf System-Ebene:

const qdrantResult = await client.getCollections();
const collectionCount = qdrantResult.collections.length;

Die Response gibt qdrant.collectionCount zurück statt collection.pointCount. Das Frontend muss diese neue Shape kennen.

Architekturdiagramm: Pro-Nutzer-Collections in Qdrant mit userId-Prefix

Das Diagramm zeigt, wie der JWT-Sub-Claim als Routing-Schlüssel durch alle Schichten fließt: vom Ingest über den Worker bis in den Collection-Namen in Qdrant und den S3-Pfad-Prefix. Jeder Nutzer lebt in einem vollständig getrennten Datenpfad.

Was diese Änderungen in der Praxis bedeuten

Der TypeScript-Build läuft durch. Der Ingest-Worker erstellt Collections lazy beim ersten Capture. Neue Nutzer erhalten bei Leseanfragen leere Ergebnisse statt Fehler.

Die DSGVO-Anforderungen sind jetzt technisch vollständig implementiert: Löschung, Export und Einwilligungs-Log. Das ist keine kosmetische Maßnahme. Es ist die Grundlage, auf der das System öffentlich betrieben werden kann.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection (dieser Artikel)
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

Du baust ein RAG-System mit mehreren Nutzern und DSGVO-Anforderungen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen