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:
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)¶
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