Zum Inhalt

K-01 & K-02 Implementierungsplan

🎯 Ziel

  • K-01: getUserData() durch Custom Claims ersetzen → $9.000/Monat Einsparung
  • K-02: Race Condition bei Claims-Updates beseitigen → keine verlorenen Zugriffsrechte

K-01: Custom Claims Migration

Schritt 1: Trigger für users/{uid} erweitern

Datei: core/functions/src/triggers/auth-claims.triggers.js

// NACH Zeile 130 (nach onErpUserDocumentCreated) einfügen:

/**
 * Trigger: users/{uid} wird geändert (userRole oder permissions)
 * → Aktualisiert Custom Claims mit neuen Permissions
 */
exports.onUserPermissionsChanged = onDocumentWritten(
  { document: "users/{uid}", region: "europe-west3" },
  async (event) => {
    const uid = event.params.uid;
    const startTime = Date.now();
    let error = null;
    let metadata = {};

    const after = event.data?.after?.data();
    const before = event.data?.before?.data();

    // Keine Daten → User wurde gelöscht (wird von deleteAuthUser behandelt)
    if (!after) return;

    try {
      // Nur triggern wenn userRole oder permissions sich geändert haben
      const roleChanged = before?.userRole !== after?.userRole;
      const permissionsChanged = 
        JSON.stringify(before?.permissions || {}) !== 
        JSON.stringify(after?.permissions || {});

      if (!roleChanged && !permissionsChanged) {
        secureLogger.info("onUserPermissionsChanged: Keine relevanten Änderungen", { uid });
        return;
      }

      // Custom Claims mit userRole & permissions aktualisieren
      await retryWithExponentialBackoff(async () => {
        const existingUser = await admin.auth().getUser(uid);
        const existingClaims = existingUser.customClaims || {};

        await admin.auth().setCustomUserClaims(uid, {
          ...existingClaims,
          appType: 'erp', // ERP-User bleiben ERP
          userRole: after.userRole || 0,
          permissions: after.permissions || {},
        });
      });

      secureLogger.info("Custom Claims aktualisiert", {
        uid,
        userRole: after.userRole,
        permissionCount: Object.keys(after.permissions || {}).length,
      });

      metadata = {
        uid,
        userRole: after.userRole,
        permissions: after.permissions,
      };
    } catch (err) {
      secureLogger.error("onUserPermissionsChanged fehlgeschlagen", {
        uid,
        error: err.message,
      });
      error = err.message;
    }

    await logTriggerExecution({
      triggerId: "onUserPermissionsChanged",
      event: "write",
      collection: "users",
      documentId: uid,
      status: error ? "failed" : "success",
      durationMs: Date.now() - startTime,
      error,
      metadata,
    });
  }
);

Schritt 2: createAuthUser erweitern

Datei: core/functions/src/functions/auth.callable.js

ZEILE 54 ändern von:

await admin.auth().setCustomUserClaims(userRecord.uid, { appType: "erp" });

ZU:

// Claims mit Default-Permissions setzen (User-Dokument wird danach angelegt)
await admin.auth().setCustomUserClaims(userRecord.uid, {
  appType: "erp",
  userRole: 0,      // Default: normaler User (kein Admin)
  permissions: {},  // Default: keine Permissions
});

Schritt 3: Migration Script für existierende User

Neue Datei: core/functions/scripts/migrate_user_claims.js

/**
 * Migration Script: Sync existierender users/{uid} → Custom Claims
 * 
 * Führt für alle ERP-User aus:
 * 1. Lese users/{uid} (userRole, permissions)
 * 2. Setze Custom Claims { appType: 'erp', userRole, permissions }
 * 
 * DRY RUN MODE: Zeigt nur an, was gemacht würde (kein Write)
 */

const admin = require('firebase-admin');

const DRY_RUN = process.argv.includes('--dry-run');
const PROJECT_ID = process.env.FIREBASE_PROJECT || 'ts-easy-sale-core';

// Init (lokale Service-Account-JSON wenn vorhanden)
if (!admin.apps.length) {
  try {
    const serviceAccount = require(`../../${PROJECT_ID}-firebase-adminsdk.json`);
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
      projectId: PROJECT_ID,
    });
  } catch {
    admin.initializeApp({ projectId: PROJECT_ID });
  }
}

const db = admin.firestore();
const auth = admin.auth();

async function migrateUserClaims() {
  console.log(`\n🔧 User Claims Migration${DRY_RUN ? ' (DRY RUN)' : ''}`);
  console.log(`Project: ${PROJECT_ID}\n`);

  try {
    // 1. Alle users-Dokumente laden
    const usersSnap = await db.collection('users').get();
    console.log(`📊 Gefunden: ${usersSnap.size} User-Dokumente\n`);

    let migratedCount = 0;
    let skippedCount = 0;
    let errorCount = 0;

    for (const doc of usersSnap.docs) {
      const uid = doc.id;
      const data = doc.data();

      try {
        // 2. Auth-User laden
        let authUser;
        try {
          authUser = await auth.getUser(uid);
        } catch (err) {
          console.log(`⚠️  ${uid}: Kein Auth-User (gelöscht?) → Skip`);
          skippedCount++;
          continue;
        }

        // 3. Prüfen ob Claims schon gesetzt sind
        const existingClaims = authUser.customClaims || {};
        const hasUserRole = 'userRole' in existingClaims;
        const hasPermissions = 'permissions' in existingClaims;

        if (hasUserRole && hasPermissions) {
          console.log(`✓ ${uid}: Claims bereits gesetzt → Skip`);
          skippedCount++;
          continue;
        }

        // 4. Claims aus Firestore setzen
        const userRole = data.userRole || 0;
        const permissions = data.permissions || {};

        if (!DRY_RUN) {
          await auth.setCustomUserClaims(uid, {
            ...existingClaims,
            appType: existingClaims.appType || 'erp',
            userRole,
            permissions,
          });
        }

        console.log(`✅ ${uid}: Claims gesetzt (userRole=${userRole}, ${Object.keys(permissions).length} permissions)`);
        migratedCount++;

        // Rate Limiting (max 10/Sekunde wegen Firebase Auth Quota)
        if (migratedCount % 10 === 0) {
          await new Promise(r => setTimeout(r, 1000));
        }

      } catch (err) {
        console.error(`❌ ${uid}: Fehler — ${err.message}`);
        errorCount++;
      }
    }

    console.log(`\n📊 Migration abgeschlossen:`);
    console.log(`   ✅ Migriert:   ${migratedCount}`);
    console.log(`   ⏭️  Übersprungen: ${skippedCount}`);
    console.log(`   ❌ Fehler:      ${errorCount}`);

    if (DRY_RUN) {
      console.log(`\n⚠️  DRY RUN: Keine Änderungen vorgenommen`);
      console.log(`   Zum Ausführen: node scripts/migrate_user_claims.js`);
    }

  } catch (err) {
    console.error(`\n❌ Migration fehlgeschlagen: ${err.message}`);
    process.exit(1);
  }
}

migrateUserClaims()
  .then(() => process.exit(0))
  .catch(err => {
    console.error(err);
    process.exit(1);
  });

Schritt 4: Firestore Rules umstellen

Datei: core/firestore.rules

ERSETZE Zeilen 59-78:

// ALT (DELETE):
function getUserData() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
}

function isSuperAdmin() {
  return isAuthenticated() && getUserData().get('userRole', 0) == 2;
}

function isAdmin() {
  return isAuthenticated() && getUserData().get('userRole', 0) >= 1;
}

function hasPermission(permission) {
  return isAuthenticated() && getUserData().get('permissions', {}).get(permission, false) == true;
}

MIT (NEU):

// ============================================================================
// HELPER FUNCTIONS — BASED ON CUSTOM CLAIMS (NO FIRESTORE READS)
// ============================================================================

// isSuperAdmin: userRole == 2 (höchste Stufe)
function isSuperAdmin() {
  return isAuthenticated() && request.auth.token.get('userRole', 0) == 2;
}

// isAdmin: userRole >= 1 (Admin oder SuperAdmin)
function isAdmin() {
  return isAuthenticated() && request.auth.token.get('userRole', 0) >= 1;
}

// hasPermission: Prüft ob User eine bestimmte Permission im Claim hat
// Permissions-Claim-Struktur: { canCreateCustomers: true, canEditArticles: true, ... }
function hasPermission(permission) {
  return isAuthenticated() && 
         request.auth.token.get('permissions', {}).get(permission, false) == true;
}

WICHTIG: Keine weiteren Änderungen nötig — alle anderen Rules funktionieren weiter!


K-02: Race Condition bei Claims beseitigen

Problem-Analyse

Aktueller Code (Zeile 199-220 in auth-claims.triggers.js):

const [approvedSnap, adminSnap] = await Promise.all([
  db.collection("customerUsers")
    .where("email", "==", email)
    .where("registrationState", "==", 1)
    .get(),
  db.collection("customerUsers")
    .where("email", "==", email)
    .where("registrationState", "==", 1)
    .where("userType", "==", 1)
    .get(),
]);

const customerIds = approvedSnap.docs.map((d) => d.data().customerId).filter(Boolean);
const adminCustomerIds = adminSnap.docs.map((d) => d.data().customerId).filter(Boolean);

// ... später
await admin.auth().setCustomUserClaims(uid, {
  ...existingClaims,
  appType,
  customerIds,
  adminCustomerIds,
});

Race Condition Szenario:

T0: User hat customerIds: ['A']
T1: Admin schaltet Kunde B frei → Trigger 1 startet
T2: Admin schaltet Kunde C frei → Trigger 2 startet
T3: Trigger 1 Query findet: ['A', 'B']
T4: Trigger 2 Query findet: ['A', 'B', 'C']
T5: Trigger 1 schreibt Claims: customerIds: ['A', 'B']
T6: Trigger 2 schreibt Claims: customerIds: ['A', 'B', 'C']
✓ Funktioniert (C gewinnt)

ABER:
T0: User hat customerIds: ['A']
T1: Admin schaltet Kunde B frei → Trigger 1 startet
T2: Trigger 1 Query findet: ['A', 'B']
T3: Admin schaltet Kunde C frei → Trigger 2 startet
T4: Trigger 2 Query findet: ['A', 'B', 'C']
T5: Trigger 2 schreibt Claims: customerIds: ['A', 'B', 'C']
T6: Trigger 1 schreibt Claims: customerIds: ['A', 'B'] ← überschreibt T5!
❌ Kunde C geht verloren

✅ LÖSUNG: Pessimistic Locking via Firestore

ERSETZE onShopUserPermissionChanged (Zeile 155-230):

exports.onShopUserPermissionChanged = onDocumentWritten(
  { document: "customerUsers/{docId}", region: "europe-west3" },
  async (event) => {
    const docId = event.params.docId;
    const startTime = Date.now();
    let error = null;
    let metadata = {};

    const after = event.data?.after?.data();
    const before = event.data?.before?.data();

    const email = after?.email ?? before?.email;
    if (!email) return;

    try {
      const stateBefore = before?.registrationState;
      const stateAfter = after?.registrationState;

      const isNewDoc = stateBefore === undefined && stateAfter !== undefined;
      const isDeleted = stateAfter === undefined;
      const stateChanged = stateBefore !== stateAfter;
      const userTypeChanged = before?.userType !== after?.userType;

      if (!isNewDoc && !isDeleted && !stateChanged && !userTypeChanged) return;

      let userRecord;
      try {
        userRecord = await admin.auth().getUserByEmail(email);
      } catch (err) {
        secureLogger.warn("onShopUserPermissionChanged: kein Auth-User", {
          email,
          error: err.message,
        });
        return;
      }

      const uid = userRecord.uid;

      // ═══════════════════════════════════════════════════════════════
      // 🔒 CRITICAL FIX K-02: Transaction-based Claims Update
      // ═══════════════════════════════════════════════════════════════
      // Problem: Parallele Trigger können sich gegenseitig überschreiben
      // Lösung: Distributed Lock via Firestore Document
      //
      // Lock-Mechanismus:
      // 1. Versuche Lock zu erwerben (atomic create)
      // 2. Lese aktuelle customerUsers-Dokumente
      // 3. Update Claims atomar
      // 4. Release Lock
      //
      // Bei Konflikt: Exponential Backoff Retry (max 5× mit Jitter)
      // ═══════════════════════════════════════════════════════════════

      const db = admin.firestore();
      const lockRef = db.collection('_claimsUpdateLocks').doc(`shop_${uid}`);

      let lockAcquired = false;
      let retries = 0;
      const maxRetries = 5;

      while (retries < maxRetries) {
        try {
          // Versuche Lock zu erwerben (fails wenn bereits existiert)
          await lockRef.create({
            acquiredAt: admin.firestore.FieldValue.serverTimestamp(),
            acquiredBy: docId, // welcher customerUsers-Trigger hat gelockt
            email,
            expiresAt: admin.firestore.Timestamp.fromMillis(Date.now() + 30000), // 30s TTL
          });

          lockAcquired = true;
          break;

        } catch (err) {
          if (err.code !== 6) throw err; // 6 = ALREADY_EXISTS

          // Lock existiert bereits → Exponential Backoff mit Jitter
          retries++;
          if (retries >= maxRetries) {
            throw new Error(`Lock acquisition failed after ${maxRetries} retries`);
          }

          const baseDelay = 50; // 50ms
          const maxDelay = baseDelay * Math.pow(2, retries);
          const jitter = Math.random() * (maxDelay / 2);
          const delay = Math.ceil(maxDelay / 2 + jitter);

          secureLogger.info("Claims Lock collision, retry", {
            uid,
            email,
            retry: retries,
            delayMs: delay,
          });

          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }

      if (!lockAcquired) {
        throw new Error('Failed to acquire claims update lock');
      }

      try {
        // Lock acquired → jetzt atomare Queries & Claims Update

        // 1. Lese ALLE customerUsers für diese Email (innerhalb Lock)
        const [approvedSnap, adminSnap] = await Promise.all([
          db.collection("customerUsers")
            .where("email", "==", email)
            .where("registrationState", "==", 1)
            .get(),
          db.collection("customerUsers")
            .where("email", "==", email)
            .where("registrationState", "==", 1)
            .where("userType", "==", 1)
            .get(),
        ]);

        const customerIds = approvedSnap.docs
          .map((d) => d.data().customerId)
          .filter(Boolean);

        const adminCustomerIds = adminSnap.docs
          .map((d) => d.data().customerId)
          .filter(Boolean);

        const existingClaims = userRecord.customClaims || {};

        // ERP-User behalten ihren appType
        const appType = existingClaims.appType === "erp" ? "erp" : "shop";

        // 2. Claims Update (mit Retry falls Firebase Auth Rate Limit)
        await retryWithExponentialBackoff(async () => {
          await admin.auth().setCustomUserClaims(uid, {
            ...existingClaims,
            appType,
            customerIds,
            adminCustomerIds,
          });
        });

        secureLogger.info("Shop User Claims aktualisiert (locked)", {
          uid,
          email,
          customerCount: customerIds.length,
          adminCustomerCount: adminCustomerIds.length,
        });

        metadata = {
          uid,
          email,
          appType,
          customerIds,
          adminCustomerIds,
        };

      } finally {
        // 3. Lock IMMER freigeben (auch bei Fehler)
        try {
          await lockRef.delete();
        } catch (deleteErr) {
          secureLogger.warn("Lock deletion failed (non-critical)", {
            uid,
            error: deleteErr.message,
          });
        }
      }

    } catch (err) {
      secureLogger.error("onShopUserPermissionChanged fehlgeschlagen", {
        email,
        error: err.message,
        stack: err.stack,
      });
      error = err.message;
    }

    await logTriggerExecution({
      triggerId: "onShopUserPermissionChanged",
      event: "write",
      collection: "customerUsers",
      documentId: docId,
      status: error ? "failed" : "success",
      durationMs: Date.now() - startTime,
      error,
      metadata,
    });
  }
);

Schritt 5: Lock-Cleanup Job (expired Locks löschen)

Neue Datei: core/functions/src/jobs/cleanup_claims_locks.js

/**
 * Scheduled Job: Cleanup abgelaufener Claims Update Locks
 * 
 * Läuft alle 5 Minuten und löscht Locks älter als 1 Minute.
 * Verhindert, dass crashed Triggers Locks dauerhaft halten.
 */

const { onSchedule } = require('firebase-functions/v2/scheduler');
const admin = require('firebase-admin');
const secureLogger = require('../utils/secure_logger');

exports.cleanupClaimsLocks = onSchedule(
  {
    schedule: 'every 5 minutes',
    region: 'europe-west3',
    timeoutSeconds: 120,
  },
  async () => {
    const db = admin.firestore();
    const now = Date.now();
    const oneMinuteAgo = now - 60000;

    try {
      const expiredSnap = await db
        .collection('_claimsUpdateLocks')
        .where('expiresAt', '<', admin.firestore.Timestamp.fromMillis(oneMinuteAgo))
        .get();

      if (expiredSnap.empty) {
        secureLogger.info('cleanupClaimsLocks: Keine expired Locks');
        return;
      }

      const batch = db.batch();
      expiredSnap.docs.forEach(doc => batch.delete(doc.ref));
      await batch.commit();

      secureLogger.info('cleanupClaimsLocks: Expired Locks gelöscht', {
        count: expiredSnap.size,
      });

    } catch (err) {
      secureLogger.error('cleanupClaimsLocks fehlgeschlagen', {
        error: err.message,
      });
      throw err;
    }
  }
);

In core/functions/index.js registrieren:

// NACH den anderen Cleanup-Jobs einfügen:
const { cleanupClaimsLocks } = require('./src/jobs/cleanup_claims_locks');
exports.cleanupClaimsLocks = cleanupClaimsLocks;

🚀 Rollout-Strategie

Phase 1: Vorbereitung (1 Tag)

  • [ ] Trigger onUserPermissionsChanged erstellen
  • [ ] createAuthUser erweitern (Claims mit userRole/permissions)
  • [ ] Migration Script testen (Dry-Run auf Dev)
  • [ ] Lock-Mechanismus in onShopUserPermissionChanged implementieren
  • [ ] Lock-Cleanup-Job erstellen

Phase 2: Deployment auf Dev (1 Tag)

  • [ ] Functions deployen
  • [ ] Migration Script ausführen (--dry-run)
  • [ ] Migration Script ausführen (live)
  • [ ] Validierung: alle User haben Claims mit userRole/permissions
  • [ ] Test: User anlegen/ändern → Claims werden korrekt gesetzt
  • [ ] Test: Parallel 10 Shop-User für denselben User freischalten → keine Race Condition

Phase 3: Firestore Rules Update (1 Tag)

  • [ ] Rules umstellen (getUserData() → Custom Claims)
  • [ ] Rules deployen auf Dev
  • [ ] Tests durchführen (alle 47 verwendeten Stellen)
  • [ ] Performance-Monitoring: Firestore-Read-Count sinkt um ~50%

Phase 4: Production Rollout (2 Tage)

  • [ ] Migration Script auf Production (erst --dry-run)
  • [ ] Migration Script auf Production (live)
  • [ ] Functions deployen auf Production
  • [ ] Rules deployen auf Production
  • [ ] Monitoring 24h: Keine Auth-Errors, Read-Count sinkt

Phase 5: Cleanup (1 Tag)

  • [ ] getUserData() Funktion aus Rules entfernen (dead code)
  • [ ] Documentation aktualisieren
  • [ ] Audit-Findings K-01 & K-02 als ✅ BEHOBEN markieren

✅ Erfolgskriterien

K-01: Read-Amplification beseitigt

  • [ ] Firestore Read Count sinkt um 40-50%
  • [ ] Keine getUserData() Calls mehr in Rules
  • [ ] Performance-Verbesserung messbar (avg. Rule-Check < 5ms)
  • [ ] Kosten-Einsparung: ~$9.000/Monat bei 1M Usern

K-02: Race Condition beseitigt

  • [ ] 100 parallele Shop-User-Approvals → keine verlorenen customerIds
  • [ ] Lock-Cleanup läuft stabil (avg. 0-2 expired Locks pro Run)
  • [ ] Keine "User hat keinen Zugriff"-Fehler nach Freigabe

⚠️ Rollback-Plan

Falls nach Rules-Deployment auf Production Probleme auftreten:

Sofort-Rollback (< 5 Minuten)

# Rules auf vorherige Version zurücksetzen
firebase deploy --only firestore:rules --project PROD

Function-Rollback (< 10 Minuten)

# Functions auf vorherige Version zurücksetzen
firebase functions:delete onUserPermissionsChanged --project PROD
firebase deploy --only functions --project PROD --force

WICHTIG: Claims-Migration ist NICHT rollback-fähig. Einmal gesetzte Claims bleiben. Das ist OK, weil: - Alte Rules funktionieren mit neuen Claims (Fallback auf getUserData()) - Neue Rules funktionieren mit alten Claims (userRole/permissions default 0/{})


📊 Testing Checklist

Unit Tests

  • [ ] onUserPermissionsChanged Trigger
  • [ ] userRole-Änderung → Claims updated
  • [ ] permissions-Änderung → Claims updated
  • [ ] Keine Änderung → kein Claims-Update (Skip)
  • [ ] User existiert nicht → graceful Failure

  • [ ] onShopUserPermissionChanged (mit Lock)

  • [ ] Single Approval → Claims korrekt
  • [ ] Parallel 10 Approvals → alle customerIds vorhanden
  • [ ] Lock Timeout → Retry erfolgreich
  • [ ] Lock Cleanup → expired Locks gelöscht

Integration Tests

  • [ ] ERP: User anlegen → Claims mit userRole=0, permissions={}
  • [ ] ERP: User zu Admin machen → Claims mit userRole=1
  • [ ] ERP: Permission hinzufügen → Claims updated
  • [ ] Shop: Kunde freischalten → customerIds enthält Kunde
  • [ ] Shop: Parallel 5 Kunden freischalten → alle in customerIds

Performance Tests

  • [ ] Firestore Read Count Baseline (vor Migration)
  • [ ] Firestore Read Count nach Migration (-40-50%)
  • [ ] Rule Execution Time Baseline
  • [ ] Rule Execution Time nach Migration (-30-40%)

💰 ROI-Berechnung

Kosten VORHER

  • getUserData() Calls: 50M/Tag bei 1M Usern
  • Kosten: $0.06 per 100k Reads = $9.000/Monat

Kosten NACHHER

  • Custom Claims: 0 extra Reads
  • Lock-Dokumente: ~1000/Tag = $0.06/Monat
  • Einsparung: $8.994/Monat

Implementierungs-Aufwand

  • 6 Entwicklertage × $500/Tag = $3.000
  • Break-Even: nach 10 Tagen bei 1M Usern
  • Bei 100k Usern: Break-Even nach 3 Monaten

Hochprofitabler Fix, selbst bei kleiner User-Basis