Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
354 lines
8.6 KiB
TypeScript
354 lines
8.6 KiB
TypeScript
// Tenant management utilities
|
|
import { Tenant, TenantCreateRequest } from '@gt2/types';
|
|
import { generateEncryptionKey, deriveTenantKey } from './crypto';
|
|
|
|
/**
|
|
* Generate Kubernetes namespace name for tenant
|
|
*/
|
|
export function generateTenantNamespace(domain: string): string {
|
|
return `gt-${domain}`;
|
|
}
|
|
|
|
/**
|
|
* Generate tenant subdomain
|
|
*/
|
|
export function generateTenantSubdomain(domain: string): string {
|
|
return domain; // For now, subdomain matches domain
|
|
}
|
|
|
|
/**
|
|
* Generate OS user ID for tenant isolation
|
|
*/
|
|
export function generateTenantUserId(tenantId: number): number {
|
|
const baseUserId = 10000; // Start user IDs from 10000
|
|
return baseUserId + tenantId;
|
|
}
|
|
|
|
/**
|
|
* Generate OS group ID for tenant isolation
|
|
*/
|
|
export function generateTenantGroupId(tenantId: number): number {
|
|
return generateTenantUserId(tenantId); // Use same ID for group
|
|
}
|
|
|
|
/**
|
|
* Get tenant data directory path
|
|
*/
|
|
export function getTenantDataPath(domain: string, baseDataDir: string = '/data'): string {
|
|
return `${baseDataDir}/${domain}`;
|
|
}
|
|
|
|
/**
|
|
* Get default resource limits based on template
|
|
*/
|
|
export function getTemplateResourceLimits(template: string): {
|
|
cpu: string;
|
|
memory: string;
|
|
storage: string;
|
|
} {
|
|
switch (template) {
|
|
case 'basic':
|
|
return {
|
|
cpu: '500m',
|
|
memory: '1Gi',
|
|
storage: '5Gi'
|
|
};
|
|
case 'professional':
|
|
return {
|
|
cpu: '1000m',
|
|
memory: '2Gi',
|
|
storage: '20Gi'
|
|
};
|
|
case 'enterprise':
|
|
return {
|
|
cpu: '2000m',
|
|
memory: '4Gi',
|
|
storage: '100Gi'
|
|
};
|
|
default:
|
|
return {
|
|
cpu: '500m',
|
|
memory: '1Gi',
|
|
storage: '5Gi'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get default max users based on template
|
|
*/
|
|
export function getTemplateMaxUsers(template: string): number {
|
|
switch (template) {
|
|
case 'basic':
|
|
return 10;
|
|
case 'professional':
|
|
return 100;
|
|
case 'enterprise':
|
|
return 1000;
|
|
default:
|
|
return 10;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate tenant domain availability (placeholder - would check database in real implementation)
|
|
*/
|
|
export function isDomainAvailable(domain: string): boolean {
|
|
// In real implementation, this would check the database
|
|
// For now, just check format
|
|
const reservedDomains = ['admin', 'api', 'www', 'mail', 'ftp', 'localhost', 'gt2'];
|
|
return !reservedDomains.includes(domain.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Generate complete tenant configuration from create request
|
|
*/
|
|
export function generateTenantConfig(
|
|
request: TenantCreateRequest,
|
|
masterEncryptionKey: string
|
|
): Partial<Tenant> {
|
|
const template = request.template || 'basic';
|
|
const resourceLimits = request.resource_limits || getTemplateResourceLimits(template);
|
|
const maxUsers = request.max_users || getTemplateMaxUsers(template);
|
|
|
|
return {
|
|
name: request.name.trim(),
|
|
domain: request.domain.toLowerCase(),
|
|
template,
|
|
max_users: maxUsers,
|
|
resource_limits: resourceLimits,
|
|
namespace: generateTenantNamespace(request.domain),
|
|
subdomain: generateTenantSubdomain(request.domain),
|
|
status: 'pending'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate Kubernetes deployment YAML for tenant
|
|
*/
|
|
export function generateTenantDeploymentYAML(tenant: Tenant, tenantUserId: number): string {
|
|
return `
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${tenant.namespace}
|
|
labels:
|
|
gt.tenant: ${tenant.domain}
|
|
gt.template: ${tenant.template}
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: ${tenant.domain}-isolation
|
|
namespace: ${tenant.namespace}
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes: ["Ingress", "Egress"]
|
|
ingress:
|
|
- from:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
name: gt-admin
|
|
egress:
|
|
- to:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
name: gt-resource
|
|
---
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: ${tenant.domain}-config
|
|
namespace: ${tenant.namespace}
|
|
data:
|
|
TENANT_ID: "${tenant.id}"
|
|
TENANT_DOMAIN: "${tenant.domain}"
|
|
TENANT_NAME: "${tenant.name}"
|
|
DATABASE_PATH: "/data/${tenant.domain}/app.db"
|
|
CHROMA_COLLECTION: "gt2_${tenant.domain.replace(/-/g, '_')}_documents"
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${tenant.domain}-app
|
|
namespace: ${tenant.namespace}
|
|
labels:
|
|
app: ${tenant.domain}-app
|
|
tenant: ${tenant.domain}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: ${tenant.domain}-app
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: ${tenant.domain}-app
|
|
tenant: ${tenant.domain}
|
|
spec:
|
|
securityContext:
|
|
runAsUser: ${tenantUserId}
|
|
runAsGroup: ${tenantUserId}
|
|
fsGroup: ${tenantUserId}
|
|
containers:
|
|
- name: frontend
|
|
image: gt2/tenant-frontend:latest
|
|
ports:
|
|
- containerPort: 3000
|
|
name: frontend
|
|
env:
|
|
- name: NEXT_PUBLIC_API_URL
|
|
value: "http://localhost:8000"
|
|
- name: NEXT_PUBLIC_WS_URL
|
|
value: "ws://localhost:8000"
|
|
resources:
|
|
requests:
|
|
cpu: "100m"
|
|
memory: "128Mi"
|
|
limits:
|
|
cpu: "${tenant.resource_limits.cpu}"
|
|
memory: "${tenant.resource_limits.memory}"
|
|
volumeMounts:
|
|
- name: tenant-data
|
|
mountPath: /data/${tenant.domain}
|
|
- name: backend
|
|
image: gt2/tenant-backend:latest
|
|
ports:
|
|
- containerPort: 8000
|
|
name: backend
|
|
envFrom:
|
|
- configMapRef:
|
|
name: ${tenant.domain}-config
|
|
env:
|
|
- name: ENCRYPTION_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${tenant.domain}-secrets
|
|
key: encryption-key
|
|
resources:
|
|
requests:
|
|
cpu: "200m"
|
|
memory: "256Mi"
|
|
limits:
|
|
cpu: "${tenant.resource_limits.cpu}"
|
|
memory: "${tenant.resource_limits.memory}"
|
|
volumeMounts:
|
|
- name: tenant-data
|
|
mountPath: /data/${tenant.domain}
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: 8000
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /ready
|
|
port: 8000
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 5
|
|
volumes:
|
|
- name: tenant-data
|
|
persistentVolumeClaim:
|
|
claimName: ${tenant.domain}-data
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: ${tenant.domain}-data
|
|
namespace: ${tenant.namespace}
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
resources:
|
|
requests:
|
|
storage: ${tenant.resource_limits.storage}
|
|
---
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: ${tenant.domain}-secrets
|
|
namespace: ${tenant.namespace}
|
|
type: Opaque
|
|
data:
|
|
encryption-key: ${Buffer.from(tenant.encryption_key || '').toString('base64')}
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${tenant.domain}-service
|
|
namespace: ${tenant.namespace}
|
|
spec:
|
|
selector:
|
|
app: ${tenant.domain}-app
|
|
ports:
|
|
- name: frontend
|
|
port: 3000
|
|
targetPort: 3000
|
|
- name: backend
|
|
port: 8000
|
|
targetPort: 8000
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: Ingress
|
|
metadata:
|
|
name: ${tenant.domain}-ingress
|
|
namespace: ${tenant.namespace}
|
|
annotations:
|
|
nginx.ingress.kubernetes.io/rewrite-target: /
|
|
spec:
|
|
rules:
|
|
- host: ${tenant.subdomain}.gt2.local
|
|
http:
|
|
paths:
|
|
- path: /api
|
|
pathType: Prefix
|
|
backend:
|
|
service:
|
|
name: ${tenant.domain}-service
|
|
port:
|
|
number: 8000
|
|
- path: /
|
|
pathType: Prefix
|
|
backend:
|
|
service:
|
|
name: ${tenant.domain}-service
|
|
port:
|
|
number: 3000
|
|
`.trim();
|
|
}
|
|
|
|
/**
|
|
* Calculate tenant usage costs
|
|
*/
|
|
export function calculateTenantCosts(
|
|
cpuUsage: number, // CPU hours
|
|
memoryUsage: number, // Memory GB-hours
|
|
storageUsage: number, // Storage GB-hours
|
|
aiTokens: number // AI tokens used
|
|
): {
|
|
cpu_cost_cents: number;
|
|
memory_cost_cents: number;
|
|
storage_cost_cents: number;
|
|
ai_cost_cents: number;
|
|
total_cost_cents: number;
|
|
} {
|
|
// Pricing (example rates)
|
|
const CPU_COST_PER_HOUR = 5; // 5 cents per CPU hour
|
|
const MEMORY_COST_PER_GB_HOUR = 1; // 1 cent per GB-hour
|
|
const STORAGE_COST_PER_GB_HOUR = 0.1; // 0.1 cents per GB-hour
|
|
const AI_COST_PER_1K_TOKENS = 0.5; // 0.5 cents per 1K tokens
|
|
|
|
const cpu_cost_cents = Math.round(cpuUsage * CPU_COST_PER_HOUR);
|
|
const memory_cost_cents = Math.round(memoryUsage * MEMORY_COST_PER_GB_HOUR);
|
|
const storage_cost_cents = Math.round(storageUsage * STORAGE_COST_PER_GB_HOUR);
|
|
const ai_cost_cents = Math.round((aiTokens / 1000) * AI_COST_PER_1K_TOKENS);
|
|
|
|
return {
|
|
cpu_cost_cents,
|
|
memory_cost_cents,
|
|
storage_cost_cents,
|
|
ai_cost_cents,
|
|
total_cost_cents: cpu_cost_cents + memory_cost_cents + storage_cost_cents + ai_cost_cents
|
|
};
|
|
} |