feat: multi-tenant credential isolation + architecture docs
- Add src/multitenancy/ with AES-256-GCM credential store, WhatsApp webhook router (phone_number_id -> customerId), and per-customer audit log (90-day Redis TTL) - Add src/billing/ with plan definitions and meterMiddleware that resolves API key -> Customer object with getCredential() closure - Refactor all src/clients/* to accept optional customer param, falling back to env vars for backward compat with single-user mode - Thread customer through handleToolCall(name, args, customer?) - Add customers table to MySQL schema initDatabase() - Add /webhook/whatsapp (immediate 200 + async routing) and /api/connect/* onboarding endpoints to index.ts - Add Redis 7 to docker-compose.yml; add REDIS_URL and CREDENTIAL_ENCRYPTION_KEY to hermes-k8s.yaml - Add product/incubation/ with architecture write-up and PlantUML diagrams (system architecture + 5 user flows) - Extend OpenAPI spec in manifest.ts with all platform endpoints Verification: 3 isolation tests (credential, webhook routing, audit log) passed against live Redis. Deployed to hermes.squaremcp.com. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
81
src/multitenancy/audit-log.ts
Normal file
81
src/multitenancy/audit-log.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[audit-log] Redis connect error:', err));
|
||||
|
||||
export interface AuditEntry {
|
||||
customerId: string;
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
result: 'success' | 'error';
|
||||
errorMessage?: string;
|
||||
durationMs: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Retain 90 days of logs per customer. Key: logs:{customerId}:{ISO-date}:{seq}
|
||||
export async function logToolCall(entry: AuditEntry): Promise<void> {
|
||||
const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
|
||||
const seqKey = `log_seq:${entry.customerId}:${date}`;
|
||||
const seq = await redis.incr(seqKey);
|
||||
// Expire the sequence key after 95 days so it cleans up
|
||||
if (seq === 1) await redis.expire(seqKey, 60 * 60 * 24 * 95);
|
||||
|
||||
const logKey = `logs:${entry.customerId}:${date}:${String(seq).padStart(8, '0')}`;
|
||||
await redis.set(logKey, JSON.stringify(entry), { EX: 60 * 60 * 24 * 90 });
|
||||
}
|
||||
|
||||
// Retrieve logs for one customer for a date range — customerId is REQUIRED
|
||||
export async function getCustomerLogs(
|
||||
customerId: string,
|
||||
fromDate: string, // YYYY-MM-DD
|
||||
toDate: string // YYYY-MM-DD
|
||||
): Promise<AuditEntry[]> {
|
||||
const dates = getDatesInRange(fromDate, toDate);
|
||||
const entries: AuditEntry[] = [];
|
||||
|
||||
for (const date of dates) {
|
||||
const pattern = `logs:${customerId}:${date}:*`;
|
||||
const keys = await redis.keys(pattern);
|
||||
for (const key of keys) {
|
||||
const raw = await redis.get(key);
|
||||
if (raw) entries.push(JSON.parse(raw) as AuditEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
}
|
||||
|
||||
function getDatesInRange(from: string, to: string): string[] {
|
||||
const dates: string[] = [];
|
||||
const current = new Date(from);
|
||||
const end = new Date(to);
|
||||
while (current <= end) {
|
||||
dates.push(current.toISOString().slice(0, 10));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Convenience wrapper for tool handlers — call before and after
|
||||
export function createToolAudit(customerId: string, tool: string) {
|
||||
const start = Date.now();
|
||||
return {
|
||||
success: async (args: Record<string, unknown>) => {
|
||||
await logToolCall({
|
||||
customerId, tool, args,
|
||||
result: 'success',
|
||||
durationMs: Date.now() - start,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
error: async (args: Record<string, unknown>, errorMessage: string) => {
|
||||
await logToolCall({
|
||||
customerId, tool, args,
|
||||
result: 'error', errorMessage,
|
||||
durationMs: Date.now() - start,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
84
src/multitenancy/credential-store.ts
Normal file
84
src/multitenancy/credential-store.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[credential-store] Redis connect error:', err));
|
||||
|
||||
const ENCRYPTION_KEY = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY ?? '0'.repeat(64), 'hex');
|
||||
// CREDENTIAL_ENCRYPTION_KEY must be a 64-char hex string (32 bytes)
|
||||
// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
function decrypt(ciphertext: string): string {
|
||||
const [ivHex, tagHex, encHex] = ciphertext.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const encrypted = Buffer.from(encHex, 'hex');
|
||||
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
|
||||
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'obsidian';
|
||||
|
||||
export interface EmailCredentials {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
}
|
||||
|
||||
export interface OAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface WhatsAppCredentials {
|
||||
phoneNumberId: string;
|
||||
accessToken: string;
|
||||
businessAccountId: string;
|
||||
}
|
||||
|
||||
export type PlatformCredentials = EmailCredentials | OAuthCredentials | WhatsAppCredentials | Record<string, string>;
|
||||
|
||||
export async function storeCredential(
|
||||
customerId: string,
|
||||
platform: Platform,
|
||||
credentials: PlatformCredentials
|
||||
): Promise<void> {
|
||||
const key = `creds:${customerId}:${platform}`;
|
||||
const encrypted = encrypt(JSON.stringify(credentials));
|
||||
// Credentials don't expire — they persist until explicitly revoked
|
||||
await redis.set(key, encrypted);
|
||||
}
|
||||
|
||||
export async function getCredential<T extends PlatformCredentials>(
|
||||
customerId: string,
|
||||
platform: Platform
|
||||
): Promise<T | null> {
|
||||
const key = `creds:${customerId}:${platform}`;
|
||||
const encrypted = await redis.get(key);
|
||||
if (!encrypted) return null;
|
||||
return JSON.parse(decrypt(encrypted)) as T;
|
||||
}
|
||||
|
||||
export async function revokeCredential(customerId: string, platform: Platform): Promise<void> {
|
||||
await redis.del(`creds:${customerId}:${platform}`);
|
||||
}
|
||||
|
||||
export async function revokeAllCredentials(customerId: string): Promise<void> {
|
||||
const pattern = `creds:${customerId}:*`;
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length > 0) await redis.del(keys);
|
||||
}
|
||||
104
src/multitenancy/webhook-router.ts
Normal file
104
src/multitenancy/webhook-router.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createClient } from 'redis';
|
||||
import { getCredential, WhatsAppCredentials } from './credential-store.js';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err));
|
||||
|
||||
// Call this at customer onboarding when they connect their WhatsApp Business number
|
||||
export async function registerWhatsAppNumber(
|
||||
customerId: string,
|
||||
phoneNumberId: string
|
||||
): Promise<void> {
|
||||
await redis.set(`wa_phone_id:${phoneNumberId}`, customerId);
|
||||
}
|
||||
|
||||
export async function unregisterWhatsAppNumber(phoneNumberId: string): Promise<void> {
|
||||
await redis.del(`wa_phone_id:${phoneNumberId}`);
|
||||
}
|
||||
|
||||
export async function resolveCustomerFromPhoneNumberId(
|
||||
phoneNumberId: string
|
||||
): Promise<string | null> {
|
||||
return redis.get(`wa_phone_id:${phoneNumberId}`);
|
||||
}
|
||||
|
||||
// WhatsApp Cloud API webhook payload types
|
||||
interface WhatsAppWebhookEntry {
|
||||
id: string;
|
||||
changes: Array<{
|
||||
value: {
|
||||
messaging_product: string;
|
||||
metadata: {
|
||||
display_phone_number: string;
|
||||
phone_number_id: string;
|
||||
};
|
||||
messages?: WhatsAppInboundMessage[];
|
||||
statuses?: WhatsAppStatus[];
|
||||
};
|
||||
field: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WhatsAppInboundMessage {
|
||||
from: string;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
text?: { body: string };
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface WhatsAppStatus {
|
||||
id: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
recipient_id: string;
|
||||
}
|
||||
|
||||
export interface RoutedWebhookEvent {
|
||||
customerId: string;
|
||||
phoneNumberId: string;
|
||||
message: WhatsAppInboundMessage;
|
||||
credentials: WhatsAppCredentials;
|
||||
}
|
||||
|
||||
// Parse the incoming webhook and return one routed event per message
|
||||
export async function routeWhatsAppWebhook(
|
||||
body: Record<string, unknown>
|
||||
): Promise<RoutedWebhookEvent[]> {
|
||||
const events: RoutedWebhookEvent[] = [];
|
||||
|
||||
if (body.object !== 'whatsapp_business_account') return events;
|
||||
|
||||
const entries = (body.entry as WhatsAppWebhookEntry[]) ?? [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const change of entry.changes) {
|
||||
if (change.field !== 'messages') continue;
|
||||
|
||||
const { phone_number_id } = change.value.metadata;
|
||||
const messages = change.value.messages ?? [];
|
||||
|
||||
if (messages.length === 0) continue;
|
||||
|
||||
// Resolve which customer owns this phone number
|
||||
const customerId = await resolveCustomerFromPhoneNumberId(phone_number_id);
|
||||
if (!customerId) {
|
||||
console.warn(`[webhook-router] Unroutable WhatsApp message to phone_number_id=${phone_number_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load that customer's WhatsApp credentials
|
||||
const credentials = await getCredential<WhatsAppCredentials>(customerId, 'whatsapp');
|
||||
if (!credentials) {
|
||||
console.error(`[webhook-router] No WhatsApp credentials for customerId=${customerId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
events.push({ customerId, phoneNumberId: phone_number_id, message, credentials });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
Reference in New Issue
Block a user