@bluealba/platform-cli 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +277 -9
- package/docs/404.mdx +5 -0
- package/docs/architecture/api-explorer.mdx +478 -0
- package/docs/architecture/architecture-diagrams.mdx +12 -0
- package/docs/architecture/authentication-system.mdx +903 -0
- package/docs/architecture/authorization-system.mdx +886 -0
- package/docs/architecture/bootstrap.mdx +1442 -0
- package/docs/architecture/gateway-architecture.mdx +845 -0
- package/docs/architecture/multi-tenancy.mdx +1150 -0
- package/docs/architecture/overview.mdx +776 -0
- package/docs/architecture/scheduler.mdx +818 -0
- package/docs/architecture/shell.mdx +885 -0
- package/docs/architecture/ui-extension-points.mdx +781 -0
- package/docs/architecture/user-states.mdx +794 -0
- package/docs/development/overview.mdx +21 -0
- package/docs/development/workflow.mdx +914 -0
- package/docs/getting-started/core-concepts.mdx +892 -0
- package/docs/getting-started/installation.mdx +780 -0
- package/docs/getting-started/overview.mdx +83 -0
- package/docs/getting-started/quick-start.mdx +940 -0
- package/docs/guides/adding-documentation-sites.mdx +1367 -0
- package/docs/guides/creating-services.mdx +1736 -0
- package/docs/guides/creating-ui-modules.mdx +1860 -0
- package/docs/guides/identity-providers.mdx +1007 -0
- package/docs/guides/mermaid-diagrams.mdx +212 -0
- package/docs/guides/using-feature-flags.mdx +1059 -0
- package/docs/guides/working-with-rooms.mdx +566 -0
- package/docs/index.mdx +57 -0
- package/docs/platform-cli/commands.mdx +604 -0
- package/docs/platform-cli/overview.mdx +195 -0
- package/package.json +5 -2
- package/skills/ba-platform/platform-cli.skill.md +26 -0
- package/skills/ba-platform/platform.skill.md +35 -0
- package/templates/application-monorepo-template/gitignore +95 -0
- package/templates/bootstrap-service-template/gitignore +57 -0
- package/templates/bootstrap-service-template/src/main.ts +6 -16
- package/templates/customization-ui-module-template/gitignore +73 -0
- package/templates/nestjs-service-module-template/gitignore +56 -0
- package/templates/platform-init-template/{{platformName}}-core/gitignore +97 -0
- package/templates/react-ui-module-template/Dockerfile +1 -1
- package/templates/react-ui-module-template/caddy/Caddyfile +1 -1
- package/templates/react-ui-module-template/gitignore +72 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Multi-Tenancy Architecture
|
|
3
|
+
description: Deep dive into the Blue Alba Platform multi-tenancy architecture - tenant isolation, context resolution, URL patterns, and data segregation
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import { Card, CardGrid, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
|
7
|
+
|
|
8
|
+
The Blue Alba Platform is designed with **multi-tenancy** as a core architectural principle, enabling a single platform instance to serve multiple customers or organizations with complete data and configuration isolation.
|
|
9
|
+
|
|
10
|
+
## Multi-Tenancy Overview
|
|
11
|
+
|
|
12
|
+
Multi-tenancy allows the platform to:
|
|
13
|
+
|
|
14
|
+
<CardGrid stagger>
|
|
15
|
+
<Card title="Serve Multiple Customers" icon="codeberg">
|
|
16
|
+
Run multiple isolated customer instances on shared infrastructure
|
|
17
|
+
</Card>
|
|
18
|
+
|
|
19
|
+
<Card title="Data Isolation" icon="seti:lock">
|
|
20
|
+
Complete segregation of tenant data at database and application levels
|
|
21
|
+
</Card>
|
|
22
|
+
|
|
23
|
+
<Card title="Custom Configuration" icon="setting">
|
|
24
|
+
Per-tenant settings, branding, and feature flags
|
|
25
|
+
</Card>
|
|
26
|
+
|
|
27
|
+
<Card title="Cost Efficiency" icon="rocket">
|
|
28
|
+
Shared infrastructure reduces operational costs
|
|
29
|
+
</Card>
|
|
30
|
+
|
|
31
|
+
<Card title="Simplified Operations" icon="seti:config">
|
|
32
|
+
Deploy once, serve many customers
|
|
33
|
+
</Card>
|
|
34
|
+
|
|
35
|
+
<Card title="Scalability" icon="star">
|
|
36
|
+
Add new tenants without infrastructure changes
|
|
37
|
+
</Card>
|
|
38
|
+
</CardGrid>
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Tenant Entity
|
|
43
|
+
|
|
44
|
+
### Tenant Structure
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
interface Tenant {
|
|
48
|
+
id: string; // Unique identifier (UUID)
|
|
49
|
+
code: string; // Short code (e.g., "acme", "contoso")
|
|
50
|
+
name: string; // Display name (e.g., "ACME Corporation")
|
|
51
|
+
status: TenantStatus; // ACTIVE | SUSPENDED | DELETED
|
|
52
|
+
|
|
53
|
+
// Configuration
|
|
54
|
+
config?: {
|
|
55
|
+
mode: 'SINGLE_TENANT' | 'MULTI_TENANT';
|
|
56
|
+
clientSideMode?: 'SUBDOMAIN' | 'PATH' | 'COOKIE' | 'SESSION_STORAGE' | 'QUERY_PARAM';
|
|
57
|
+
features?: Record<string, boolean>;
|
|
58
|
+
settings?: Record<string, any>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Branding
|
|
62
|
+
branding?: {
|
|
63
|
+
logo?: string;
|
|
64
|
+
favicon?: string;
|
|
65
|
+
primaryColor?: string;
|
|
66
|
+
secondaryColor?: string;
|
|
67
|
+
theme?: 'light' | 'dark';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Subscription
|
|
71
|
+
plan?: string;
|
|
72
|
+
subscriptionStartDate?: Date;
|
|
73
|
+
subscriptionEndDate?: Date;
|
|
74
|
+
maxUsers?: number;
|
|
75
|
+
|
|
76
|
+
// Audit
|
|
77
|
+
createdAt: Date;
|
|
78
|
+
createdBy: string;
|
|
79
|
+
updatedAt: Date;
|
|
80
|
+
updatedBy: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'DELETED';
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Example**:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
91
|
+
"code": "acme",
|
|
92
|
+
"name": "ACME Corporation",
|
|
93
|
+
"status": "ACTIVE",
|
|
94
|
+
"config": {
|
|
95
|
+
"mode": "MULTI_TENANT",
|
|
96
|
+
"clientSideMode": "SUBDOMAIN",
|
|
97
|
+
"features": {
|
|
98
|
+
"habits": true,
|
|
99
|
+
"scheduler": true,
|
|
100
|
+
"reports": false
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"branding": {
|
|
104
|
+
"logo": "https://cdn.acme.com/logo.png",
|
|
105
|
+
"primaryColor": "#0066cc",
|
|
106
|
+
"secondaryColor": "#ff6600"
|
|
107
|
+
},
|
|
108
|
+
"plan": "enterprise",
|
|
109
|
+
"maxUsers": 500
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Tenant Modes
|
|
116
|
+
|
|
117
|
+
The platform supports two tenant modes:
|
|
118
|
+
|
|
119
|
+
<Tabs>
|
|
120
|
+
<TabItem label="SINGLE_TENANT">
|
|
121
|
+
**Single Tenant Mode**
|
|
122
|
+
|
|
123
|
+
- Platform serves a single organization
|
|
124
|
+
- No tenant resolution needed
|
|
125
|
+
- Simpler deployment and configuration
|
|
126
|
+
- Tenant context is fixed
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const TENANT_CONFIG = {
|
|
130
|
+
mode: 'SINGLE_TENANT',
|
|
131
|
+
defaultTenantCode: 'acme'
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Use Case**: Dedicated deployment for a single large customer
|
|
136
|
+
</TabItem>
|
|
137
|
+
|
|
138
|
+
<TabItem label="MULTI_TENANT">
|
|
139
|
+
**Multi-Tenant Mode**
|
|
140
|
+
|
|
141
|
+
- Platform serves multiple organizations
|
|
142
|
+
- Tenant must be resolved from request
|
|
143
|
+
- URL patterns, cookies, or headers identify tenant
|
|
144
|
+
- Full tenant isolation enforced
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const TENANT_CONFIG = {
|
|
148
|
+
mode: 'MULTI_TENANT',
|
|
149
|
+
clientSideMode: 'SUBDOMAIN' // or PATH, COOKIE, etc.
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Use Case**: SaaS deployment serving many customers
|
|
154
|
+
</TabItem>
|
|
155
|
+
</Tabs>
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Tenant Context Resolution
|
|
160
|
+
|
|
161
|
+
In multi-tenant mode, the gateway must determine which tenant is making the request.
|
|
162
|
+
|
|
163
|
+
### Resolution Strategies
|
|
164
|
+
|
|
165
|
+
The platform supports multiple strategies for tenant resolution:
|
|
166
|
+
|
|
167
|
+
<Tabs>
|
|
168
|
+
<TabItem label="Subdomain">
|
|
169
|
+
**Subdomain-Based Resolution**
|
|
170
|
+
|
|
171
|
+
Extract tenant from subdomain:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
https://acme.platform.com → tenant: "acme"
|
|
175
|
+
https://contoso.platform.com → tenant: "contoso"
|
|
176
|
+
https://platform.com → no tenant (login page)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Implementation**:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
class SubdomainPatternMatcher implements TenantPatternMatcher {
|
|
183
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
184
|
+
const host = req.headers.host; // "acme.platform.com"
|
|
185
|
+
const parts = host.split('.');
|
|
186
|
+
|
|
187
|
+
if (parts.length < 3) {
|
|
188
|
+
return null; // Not a subdomain
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const subdomain = parts[0]; // "acme"
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
type: 'SUBDOMAIN',
|
|
195
|
+
value: subdomain
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Configuration**:
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
// Tenant URL pattern
|
|
205
|
+
{
|
|
206
|
+
tenantId: 'acme-tenant-id',
|
|
207
|
+
type: 'SUBDOMAIN',
|
|
208
|
+
pattern: 'acme.platform.com',
|
|
209
|
+
priority: 10
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
</TabItem>
|
|
213
|
+
|
|
214
|
+
<TabItem label="Path">
|
|
215
|
+
**Path-Based Resolution**
|
|
216
|
+
|
|
217
|
+
Extract tenant from URL path:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
https://platform.com/tenants/acme/app → tenant: "acme"
|
|
221
|
+
https://platform.com/t/contoso/dashboard → tenant: "contoso"
|
|
222
|
+
https://platform.com/ → no tenant
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Implementation**:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
class PathPatternMatcher implements TenantPatternMatcher {
|
|
229
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
230
|
+
const url = req.url; // "/tenants/acme/app"
|
|
231
|
+
|
|
232
|
+
// Match pattern: /tenants/:tenantCode/*
|
|
233
|
+
const match = url.match(/^\/tenants\/([^\/]+)/);
|
|
234
|
+
|
|
235
|
+
if (!match) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tenantCode = match[1]; // "acme"
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
type: 'PATH',
|
|
243
|
+
value: tenantCode
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Configuration**:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
{
|
|
253
|
+
tenantId: 'acme-tenant-id',
|
|
254
|
+
type: 'PATH',
|
|
255
|
+
pattern: '/tenants/acme/*',
|
|
256
|
+
priority: 10
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
</TabItem>
|
|
260
|
+
|
|
261
|
+
<TabItem label="Custom Domain">
|
|
262
|
+
**Custom Domain Resolution**
|
|
263
|
+
|
|
264
|
+
Map entire domains to tenants:
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
https://acme.com → tenant: "acme"
|
|
268
|
+
https://www.acme.com → tenant: "acme"
|
|
269
|
+
https://contoso.com → tenant: "contoso"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Implementation**:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
class CustomDomainPatternMatcher implements TenantPatternMatcher {
|
|
276
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
277
|
+
const host = req.headers.host; // "acme.com"
|
|
278
|
+
|
|
279
|
+
// Normalize (remove www)
|
|
280
|
+
const domain = host.replace(/^www\./, '');
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
type: 'CUSTOM_DOMAIN',
|
|
284
|
+
value: domain
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Configuration**:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
{
|
|
294
|
+
tenantId: 'acme-tenant-id',
|
|
295
|
+
type: 'CUSTOM_DOMAIN',
|
|
296
|
+
pattern: 'acme.com',
|
|
297
|
+
priority: 20 // Higher priority than subdomain
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
</TabItem>
|
|
301
|
+
|
|
302
|
+
<TabItem label="Header">
|
|
303
|
+
**Header-Based Resolution**
|
|
304
|
+
|
|
305
|
+
Read tenant from custom header:
|
|
306
|
+
|
|
307
|
+
```http
|
|
308
|
+
GET /api/data
|
|
309
|
+
Host: platform.com
|
|
310
|
+
x-tenant-id: acme
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Implementation**:
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
class HeaderPatternMatcher implements TenantPatternMatcher {
|
|
317
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
318
|
+
const tenantCode = req.headers['x-tenant-id'] as string;
|
|
319
|
+
|
|
320
|
+
if (!tenantCode) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
type: 'HEADER',
|
|
326
|
+
value: tenantCode
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Use Case**: Development, testing, API clients
|
|
333
|
+
</TabItem>
|
|
334
|
+
|
|
335
|
+
<TabItem label="Cookie">
|
|
336
|
+
**Cookie-Based Resolution**
|
|
337
|
+
|
|
338
|
+
Read tenant from cookie:
|
|
339
|
+
|
|
340
|
+
```http
|
|
341
|
+
GET /api/data
|
|
342
|
+
Cookie: tenant=acme
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Implementation**:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
class CookiePatternMatcher implements TenantPatternMatcher {
|
|
349
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
350
|
+
const tenantCode = req.cookies['tenant'];
|
|
351
|
+
|
|
352
|
+
if (!tenantCode) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
type: 'COOKIE',
|
|
358
|
+
value: tenantCode
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**Use Case**: After tenant selection by user
|
|
365
|
+
</TabItem>
|
|
366
|
+
|
|
367
|
+
<TabItem label="Query Parameter">
|
|
368
|
+
**Query Parameter Resolution**
|
|
369
|
+
|
|
370
|
+
Read tenant from query string:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
https://platform.com?tenant=acme
|
|
374
|
+
https://platform.com/app?tenant=contoso
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Implementation**:
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
class QueryParamPatternMatcher implements TenantPatternMatcher {
|
|
381
|
+
async match(req: FastifyRequest): Promise<TenantContext | null> {
|
|
382
|
+
const tenantCode = req.query.tenant as string;
|
|
383
|
+
|
|
384
|
+
if (!tenantCode) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
type: 'QUERY_PARAM',
|
|
390
|
+
value: tenantCode
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**Use Case**: Development, testing, explicit tenant switching
|
|
397
|
+
</TabItem>
|
|
398
|
+
</Tabs>
|
|
399
|
+
|
|
400
|
+
### Resolution Priority
|
|
401
|
+
|
|
402
|
+
Multiple patterns can be configured with priority levels. Higher priority patterns are checked first:
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
const urlPatterns = [
|
|
406
|
+
{ type: 'CUSTOM_DOMAIN', pattern: 'acme.com', priority: 100 },
|
|
407
|
+
{ type: 'SUBDOMAIN', pattern: 'acme.platform.com', priority: 50 },
|
|
408
|
+
{ type: 'PATH', pattern: '/tenants/acme/*', priority: 30 },
|
|
409
|
+
{ type: 'HEADER', pattern: '*', priority: 20 },
|
|
410
|
+
{ type: 'COOKIE', pattern: '*', priority: 10 },
|
|
411
|
+
{ type: 'QUERY_PARAM', pattern: '*', priority: 5 }
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
// Sorted by priority (highest first)
|
|
415
|
+
// Checked in order until a match is found
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Tenant Resolution Flow
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
┌──────────┐
|
|
424
|
+
│ Request │
|
|
425
|
+
└────┬─────┘
|
|
426
|
+
│
|
|
427
|
+
▼
|
|
428
|
+
┌─────────────────────────────────────┐
|
|
429
|
+
│ 1. Extract Host/Path/Headers │
|
|
430
|
+
│ host: "acme.platform.com" │
|
|
431
|
+
│ path: "/api/orders" │
|
|
432
|
+
│ headers: { x-tenant: "acme" } │
|
|
433
|
+
└────┬────────────────────────────────┘
|
|
434
|
+
│
|
|
435
|
+
▼
|
|
436
|
+
┌─────────────────────────────────────┐
|
|
437
|
+
│ 2. Get Tenant URL Patterns │
|
|
438
|
+
│ from database, sorted by │
|
|
439
|
+
│ priority │
|
|
440
|
+
└────┬────────────────────────────────┘
|
|
441
|
+
│
|
|
442
|
+
▼
|
|
443
|
+
┌─────────────────────────────────────┐
|
|
444
|
+
│ 3. Try Each Pattern Matcher │
|
|
445
|
+
│ - CustomDomainMatcher │
|
|
446
|
+
│ - SubdomainMatcher │
|
|
447
|
+
│ - PathMatcher │
|
|
448
|
+
│ - HeaderMatcher │
|
|
449
|
+
│ - CookieMatcher │
|
|
450
|
+
│ - QueryParamMatcher │
|
|
451
|
+
└────┬────────────────────────────────┘
|
|
452
|
+
│
|
|
453
|
+
▼
|
|
454
|
+
┌─────────────────────────────────────┐
|
|
455
|
+
│ 4. First Match Found? │
|
|
456
|
+
│ → TenantContext { type, value } │
|
|
457
|
+
└────┬────────────────────────────────┘
|
|
458
|
+
│
|
|
459
|
+
▼
|
|
460
|
+
┌─────────────────────────────────────┐
|
|
461
|
+
│ 5. Query Tenant by Context │
|
|
462
|
+
│ SELECT * FROM tenants │
|
|
463
|
+
│ WHERE code = 'acme' │
|
|
464
|
+
└────┬────────────────────────────────┘
|
|
465
|
+
│
|
|
466
|
+
▼
|
|
467
|
+
┌─────────────────────────────────────┐
|
|
468
|
+
│ 6. Validate Tenant │
|
|
469
|
+
│ - Status = ACTIVE? │
|
|
470
|
+
│ - User has access? │
|
|
471
|
+
│ - Subscription valid? │
|
|
472
|
+
└────┬────────────────────────────────┘
|
|
473
|
+
│
|
|
474
|
+
▼
|
|
475
|
+
┌─────────────────────────────────────┐
|
|
476
|
+
│ 7. Store in Platform Context │
|
|
477
|
+
│ paeContext.setTenant(tenant) │
|
|
478
|
+
└─────────────────────────────────────┘
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
**Implementation**:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// apps/pae-nestjs-gateway-service/src/tenants/tenant-resolver.service.ts
|
|
485
|
+
|
|
486
|
+
@Injectable()
|
|
487
|
+
export class TenantResolverService {
|
|
488
|
+
constructor(
|
|
489
|
+
@Inject('TENANT_STRATEGIES')
|
|
490
|
+
private readonly tenantStrategies: Record<string, TenantStrategy>,
|
|
491
|
+
private readonly tenantService: TenantsService,
|
|
492
|
+
private readonly parametersService: ParametersService
|
|
493
|
+
) {}
|
|
494
|
+
|
|
495
|
+
async getTenantFromRequest(
|
|
496
|
+
request: FastifyRequest,
|
|
497
|
+
response: FastifyReply,
|
|
498
|
+
allowedTenants: Tenant[]
|
|
499
|
+
): Promise<Tenant | null> {
|
|
500
|
+
// Get active strategies based on configuration
|
|
501
|
+
const strategies = await this.resolveStrategies(this.tenantStrategies);
|
|
502
|
+
|
|
503
|
+
// Try each strategy in order
|
|
504
|
+
for (const strategy of strategies) {
|
|
505
|
+
const tenantContext = await strategy.getTenantFromRequest(request);
|
|
506
|
+
|
|
507
|
+
if (tenantContext) {
|
|
508
|
+
// Found tenant context, look up tenant
|
|
509
|
+
const tenant = await this.tenantService.findTenantByContext(tenantContext);
|
|
510
|
+
|
|
511
|
+
if (tenant && allowedTenants.some(t => t.id === tenant.id)) {
|
|
512
|
+
// Valid tenant that user has access to
|
|
513
|
+
return tenant;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return null; // No tenant found
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## Tenant Isolation
|
|
526
|
+
|
|
527
|
+
### Database-Level Isolation
|
|
528
|
+
|
|
529
|
+
All data tables include a `tenant_id` column for isolation:
|
|
530
|
+
|
|
531
|
+
```sql
|
|
532
|
+
-- Example: Orders table
|
|
533
|
+
CREATE TABLE orders (
|
|
534
|
+
id UUID PRIMARY KEY,
|
|
535
|
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
536
|
+
customer_id UUID NOT NULL,
|
|
537
|
+
total DECIMAL(10,2) NOT NULL,
|
|
538
|
+
status VARCHAR(50) NOT NULL,
|
|
539
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
540
|
+
-- ... other columns
|
|
541
|
+
CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
-- Index for fast tenant filtering
|
|
545
|
+
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
|
|
546
|
+
|
|
547
|
+
-- All queries MUST filter by tenant_id
|
|
548
|
+
SELECT * FROM orders WHERE tenant_id = :tenantId;
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
<Aside type="caution" title="Critical: Always Filter by Tenant">
|
|
552
|
+
**Every database query MUST include a `tenant_id` filter.** Failing to do so can expose data across tenant boundaries, which is a serious security breach. Use automated tests to verify tenant isolation.
|
|
553
|
+
</Aside>
|
|
554
|
+
|
|
555
|
+
### Application-Level Isolation
|
|
556
|
+
|
|
557
|
+
Services automatically enforce tenant filtering:
|
|
558
|
+
|
|
559
|
+
<Tabs>
|
|
560
|
+
<TabItem label="Automatic (Recommended)">
|
|
561
|
+
```typescript
|
|
562
|
+
// Use platform context decorator
|
|
563
|
+
import { PlatformContext } from '@bluealba/pae-service-nestjs-sdk';
|
|
564
|
+
|
|
565
|
+
@Injectable()
|
|
566
|
+
export class OrdersService {
|
|
567
|
+
async findAll(@PlatformContext() ctx: PlatformContext) {
|
|
568
|
+
// Automatically filters by ctx.tenantId
|
|
569
|
+
return this.ordersRepository.find({
|
|
570
|
+
where: { tenantId: ctx.tenantId }
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async findOne(id: string, @PlatformContext() ctx: PlatformContext) {
|
|
575
|
+
// Ensure order belongs to tenant
|
|
576
|
+
const order = await this.ordersRepository.findOne({
|
|
577
|
+
where: { id, tenantId: ctx.tenantId }
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (!order) {
|
|
581
|
+
throw new NotFoundException('Order not found');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return order;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
</TabItem>
|
|
589
|
+
|
|
590
|
+
<TabItem label="Manual (Advanced)">
|
|
591
|
+
```typescript
|
|
592
|
+
// Explicitly pass tenant ID
|
|
593
|
+
@Injectable()
|
|
594
|
+
export class OrdersService {
|
|
595
|
+
async findAll(tenantId: string) {
|
|
596
|
+
return this.ordersRepository.find({
|
|
597
|
+
where: { tenantId }
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async findOne(id: string, tenantId: string) {
|
|
602
|
+
const order = await this.ordersRepository.findOne({
|
|
603
|
+
where: { id, tenantId }
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!order) {
|
|
607
|
+
throw new NotFoundException('Order not found');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return order;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
</TabItem>
|
|
615
|
+
|
|
616
|
+
<TabItem label="Query Builder">
|
|
617
|
+
```typescript
|
|
618
|
+
// Using query builder (Knex)
|
|
619
|
+
@Injectable()
|
|
620
|
+
export class OrdersService {
|
|
621
|
+
async findAll(tenantId: string) {
|
|
622
|
+
return this.knex('orders')
|
|
623
|
+
.where('tenant_id', tenantId)
|
|
624
|
+
.select('*');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async create(data: CreateOrderDto, tenantId: string) {
|
|
628
|
+
const [order] = await this.knex('orders')
|
|
629
|
+
.insert({
|
|
630
|
+
...data,
|
|
631
|
+
tenant_id: tenantId, // Always include tenant_id
|
|
632
|
+
created_at: new Date()
|
|
633
|
+
})
|
|
634
|
+
.returning('*');
|
|
635
|
+
|
|
636
|
+
return order;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
</TabItem>
|
|
641
|
+
</Tabs>
|
|
642
|
+
|
|
643
|
+
### Tenant Context Propagation
|
|
644
|
+
|
|
645
|
+
The gateway adds tenant context to forwarded requests:
|
|
646
|
+
|
|
647
|
+
```http
|
|
648
|
+
POST /api/orders HTTP/1.1
|
|
649
|
+
Host: orders-service:4001
|
|
650
|
+
x-pae-user-id: john@acme.com
|
|
651
|
+
x-pae-user: john@acme.com
|
|
652
|
+
x-pae-tenant: eyJ0ZW5hbnRJZCI6ImFjbWUiLCAibmFtZSI6IkFDTUUgQ29ycCJ9
|
|
653
|
+
x-pae-forwarded-tenant-config: eyJtb2RlIjoibXVsdGktdGVuYW50In0=
|
|
654
|
+
|
|
655
|
+
{
|
|
656
|
+
"customerId": "cust-123",
|
|
657
|
+
"total": 99.99
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Services extract tenant context:
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
// NestJS interceptor extracts and decodes headers
|
|
665
|
+
@Injectable()
|
|
666
|
+
export class TenantContextInterceptor implements NestInterceptor {
|
|
667
|
+
intercept(context: ExecutionContext, next: CallHandler) {
|
|
668
|
+
const request = context.switchToHttp().getRequest();
|
|
669
|
+
|
|
670
|
+
// Extract and decode tenant from header
|
|
671
|
+
const tenantHeader = request.headers['x-pae-tenant'];
|
|
672
|
+
const tenant = JSON.parse(base64Decode(tenantHeader));
|
|
673
|
+
|
|
674
|
+
// Store in request
|
|
675
|
+
request.tenant = tenant;
|
|
676
|
+
request.tenantId = tenant.id;
|
|
677
|
+
|
|
678
|
+
return next.handle();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Tenant-Scoped Authorization Rules
|
|
686
|
+
|
|
687
|
+
Authorization rules (the assignments of operations, roles, or applications to users and groups) are scoped to tenants via the `tenant_id` column in the `authz_rules` table.
|
|
688
|
+
|
|
689
|
+
### How It Works
|
|
690
|
+
|
|
691
|
+
- **Tenant-scoped rules** (`tenant_id` is set): Apply only within the specified tenant. All rules created from the Admin UI are automatically associated with the current tenant.
|
|
692
|
+
- **Global rules** (`tenant_id` is `NULL`): Apply across all tenants. These are typically rules created before tenant scoping was introduced.
|
|
693
|
+
|
|
694
|
+
When the authorization service evaluates permissions for a user, it includes:
|
|
695
|
+
1. Rules that match the current tenant (`tenant_id = currentTenantId`)
|
|
696
|
+
2. Global rules (`tenant_id IS NULL`)
|
|
697
|
+
|
|
698
|
+
### Admin UI Behavior
|
|
699
|
+
|
|
700
|
+
In multi-tenant mode, the Admin UI provides visual cues for tenant-scoped rules:
|
|
701
|
+
|
|
702
|
+
- Rules without a `tenant_id` are displayed with a **"Global"** badge to indicate they apply across all tenants.
|
|
703
|
+
- When **creating** new assignments, the current tenant is automatically associated.
|
|
704
|
+
- When **removing** a global rule, a confirmation dialog warns that the deletion will affect all tenants.
|
|
705
|
+
|
|
706
|
+
<Aside type="caution" title="Global Rule Deletion">
|
|
707
|
+
Deleting a global rule removes the assignment across **all** tenants. The Admin UI displays a confirmation warning before proceeding. Consider whether a tenant-scoped override is more appropriate.
|
|
708
|
+
</Aside>
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## Tenant-Scoped Applications
|
|
713
|
+
|
|
714
|
+
Applications can be restricted to specific tenants.
|
|
715
|
+
|
|
716
|
+
### Tenant Application Restrictions
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
interface TenantApplicationRestriction {
|
|
720
|
+
id: string;
|
|
721
|
+
tenantId: string;
|
|
722
|
+
applicationId: string;
|
|
723
|
+
isRestricted: boolean; // If true, app is BLOCKED for tenant
|
|
724
|
+
createdAt: Date;
|
|
725
|
+
createdBy: string;
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
**Database**:
|
|
730
|
+
|
|
731
|
+
```sql
|
|
732
|
+
CREATE TABLE tenant_application_restrictions (
|
|
733
|
+
id UUID PRIMARY KEY,
|
|
734
|
+
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
735
|
+
application_id INTEGER NOT NULL REFERENCES applications(id),
|
|
736
|
+
is_restricted BOOLEAN DEFAULT FALSE,
|
|
737
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
738
|
+
created_by VARCHAR(255) NOT NULL,
|
|
739
|
+
UNIQUE(tenant_id, application_id)
|
|
740
|
+
);
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Usage**:
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
// Check if tenant has access to application
|
|
747
|
+
const hasAccess = await this.tenantApplicationRestrictionsService
|
|
748
|
+
.hasAccessToApplication(tenantId, applicationId);
|
|
749
|
+
|
|
750
|
+
if (!hasAccess) {
|
|
751
|
+
throw new ForbiddenException('Application not available for this tenant');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Filter catalog by tenant
|
|
755
|
+
const catalog = await this.catalogService.getCatalog(tenantId);
|
|
756
|
+
// Returns only applications tenant has access to
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## Tenant Route Restrictions
|
|
762
|
+
|
|
763
|
+
Specific routes can be restricted per tenant.
|
|
764
|
+
|
|
765
|
+
### Route Restriction Configuration
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
interface TenantModuleRestriction {
|
|
769
|
+
id: string;
|
|
770
|
+
tenantId: string;
|
|
771
|
+
moduleId: string;
|
|
772
|
+
routePath: string;
|
|
773
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | '*';
|
|
774
|
+
isBlocked: boolean;
|
|
775
|
+
reason?: string;
|
|
776
|
+
createdAt: Date;
|
|
777
|
+
createdBy: string;
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
**Example**:
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
// Block DELETE operations for tenant "acme"
|
|
785
|
+
{
|
|
786
|
+
tenantId: 'acme-tenant-id',
|
|
787
|
+
moduleId: 'orders-service',
|
|
788
|
+
routePath: '/api/orders/:id',
|
|
789
|
+
method: 'DELETE',
|
|
790
|
+
isBlocked: true,
|
|
791
|
+
reason: 'Tenant plan does not include delete capability'
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Block entire analytics module
|
|
795
|
+
{
|
|
796
|
+
tenantId: 'acme-tenant-id',
|
|
797
|
+
moduleId: 'analytics-service',
|
|
798
|
+
routePath: '*',
|
|
799
|
+
method: '*',
|
|
800
|
+
isBlocked: true,
|
|
801
|
+
reason: 'Analytics not included in tenant plan'
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**Enforcement**:
|
|
806
|
+
|
|
807
|
+
```typescript
|
|
808
|
+
// Gateway checks route restrictions
|
|
809
|
+
const tenant = paeContext.getTenant();
|
|
810
|
+
const targetModule = paeContext.getTargetModule();
|
|
811
|
+
|
|
812
|
+
const isBlocked = await this.tenantRouteRestrictionsService
|
|
813
|
+
.isRouteBlocked(tenant.id, targetModule.id, req.url, req.method);
|
|
814
|
+
|
|
815
|
+
if (isBlocked) {
|
|
816
|
+
throw new ForbiddenException('This route is not available for your tenant');
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
## Tenant Selection Flow
|
|
823
|
+
|
|
824
|
+
For users with access to multiple tenants:
|
|
825
|
+
|
|
826
|
+
```
|
|
827
|
+
┌──────────┐
|
|
828
|
+
│ User │
|
|
829
|
+
│ Logs In │
|
|
830
|
+
└────┬─────┘
|
|
831
|
+
│
|
|
832
|
+
▼
|
|
833
|
+
┌─────────────────────────────────────┐
|
|
834
|
+
│ 1. Authenticate User │
|
|
835
|
+
│ Validate credentials │
|
|
836
|
+
└────┬────────────────────────────────┘
|
|
837
|
+
│
|
|
838
|
+
▼
|
|
839
|
+
┌─────────────────────────────────────┐
|
|
840
|
+
│ 2. Query User's Allowed Tenants │
|
|
841
|
+
│ SELECT tenants FROM │
|
|
842
|
+
│ user_tenant_assignments │
|
|
843
|
+
│ WHERE user_id = :userId │
|
|
844
|
+
└────┬────────────────────────────────┘
|
|
845
|
+
│
|
|
846
|
+
▼
|
|
847
|
+
┌─────────────────────────────────────┐
|
|
848
|
+
│ 3. How Many Tenants? │
|
|
849
|
+
├─────────────┬───────────────────────┤
|
|
850
|
+
│ Single │ Multiple │
|
|
851
|
+
└─────┬───────┴───────────┬───────────┘
|
|
852
|
+
│ │
|
|
853
|
+
▼ ▼
|
|
854
|
+
┌───────────┐ ┌──────────────────┐
|
|
855
|
+
│ Auto │ │ Show Tenant │
|
|
856
|
+
│ Select │ │ Selection Screen │
|
|
857
|
+
└───┬───────┘ └────┬─────────────┘
|
|
858
|
+
│ │
|
|
859
|
+
│ ▼
|
|
860
|
+
│ ┌──────────────────┐
|
|
861
|
+
│ │ User Selects │
|
|
862
|
+
│ │ Tenant │
|
|
863
|
+
│ └────┬─────────────┘
|
|
864
|
+
│ │
|
|
865
|
+
└────────┬────────┘
|
|
866
|
+
▼
|
|
867
|
+
┌─────────────────────────────────┐
|
|
868
|
+
│ 4. Set Tenant Context │
|
|
869
|
+
│ - Cookie │
|
|
870
|
+
│ - Session storage │
|
|
871
|
+
│ - Redirect to subdomain │
|
|
872
|
+
└─────┬───────────────────────────┘
|
|
873
|
+
│
|
|
874
|
+
▼
|
|
875
|
+
┌─────────────────────────────────┐
|
|
876
|
+
│ 5. User Accesses Application │
|
|
877
|
+
└─────────────────────────────────┘
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
**Frontend Implementation**:
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
// React component for tenant selection
|
|
884
|
+
function TenantSelectionPage() {
|
|
885
|
+
const [tenants, setTenants] = useState<Tenant[]>([]);
|
|
886
|
+
const navigate = useNavigate();
|
|
887
|
+
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
// Fetch user's allowed tenants
|
|
890
|
+
fetch('/api/me/tenants')
|
|
891
|
+
.then(res => res.json())
|
|
892
|
+
.then(data => setTenants(data));
|
|
893
|
+
}, []);
|
|
894
|
+
|
|
895
|
+
const selectTenant = async (tenant: Tenant) => {
|
|
896
|
+
// Set tenant cookie
|
|
897
|
+
document.cookie = `tenant=${tenant.code}; path=/; secure; samesite=lax`;
|
|
898
|
+
|
|
899
|
+
// Or redirect to tenant subdomain
|
|
900
|
+
window.location.href = `https://${tenant.code}.platform.com`;
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
return (
|
|
904
|
+
<div>
|
|
905
|
+
<h1>Select Organization</h1>
|
|
906
|
+
{tenants.map(tenant => (
|
|
907
|
+
<button key={tenant.id} onClick={() => selectTenant(tenant)}>
|
|
908
|
+
{tenant.name}
|
|
909
|
+
</button>
|
|
910
|
+
))}
|
|
911
|
+
</div>
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Tenant Provisioning
|
|
919
|
+
|
|
920
|
+
### Creating a New Tenant
|
|
921
|
+
|
|
922
|
+
```typescript
|
|
923
|
+
@Post('tenants')
|
|
924
|
+
async createTenant(
|
|
925
|
+
@Body() dto: CreateTenantDto,
|
|
926
|
+
@User() user: AuthUserWithApplications
|
|
927
|
+
) {
|
|
928
|
+
// 1. Create tenant record
|
|
929
|
+
const tenant = await this.tenantsService.create({
|
|
930
|
+
code: dto.code,
|
|
931
|
+
name: dto.name,
|
|
932
|
+
status: 'ACTIVE',
|
|
933
|
+
config: {
|
|
934
|
+
mode: 'MULTI_TENANT',
|
|
935
|
+
clientSideMode: 'SUBDOMAIN'
|
|
936
|
+
},
|
|
937
|
+
createdBy: user.username
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// 2. Create URL patterns
|
|
941
|
+
await this.tenantUrlPatternsService.create({
|
|
942
|
+
tenantId: tenant.id,
|
|
943
|
+
type: 'SUBDOMAIN',
|
|
944
|
+
pattern: `${dto.code}.platform.com`,
|
|
945
|
+
priority: 50
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// 3. Assign default applications
|
|
949
|
+
const defaultApps = await this.applicationsService.findDefaultApps();
|
|
950
|
+
for (const app of defaultApps) {
|
|
951
|
+
await this.tenantApplicationRestrictionsService.create({
|
|
952
|
+
tenantId: tenant.id,
|
|
953
|
+
applicationId: app.id,
|
|
954
|
+
isRestricted: false
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 4. Create admin user for tenant
|
|
959
|
+
await this.usersService.create({
|
|
960
|
+
email: dto.adminEmail,
|
|
961
|
+
displayName: dto.adminName,
|
|
962
|
+
tenantId: tenant.id,
|
|
963
|
+
roles: ['admin']
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// 5. Provision IDP (Okta, EntraId, etc.)
|
|
967
|
+
if (dto.provisionIDP) {
|
|
968
|
+
await this.idpProvisioningService.provisionTenant(tenant);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return tenant;
|
|
972
|
+
}
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
## Configuration Management
|
|
978
|
+
|
|
979
|
+
### Tenant-Specific Configuration
|
|
980
|
+
|
|
981
|
+
```typescript
|
|
982
|
+
// Get tenant configuration
|
|
983
|
+
const config = await this.tenantsService.getConfig(tenantId);
|
|
984
|
+
|
|
985
|
+
// Check feature flags
|
|
986
|
+
if (config.features?.habits === true) {
|
|
987
|
+
// Habits feature enabled for this tenant
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Get custom settings
|
|
991
|
+
const maxFileSize = config.settings?.maxFileUploadSize || 10 * 1024 * 1024;
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
### Configuration Hierarchy
|
|
995
|
+
|
|
996
|
+
```
|
|
997
|
+
1. Global Configuration (platform-wide defaults)
|
|
998
|
+
↓
|
|
999
|
+
2. Tenant Configuration (tenant-specific overrides)
|
|
1000
|
+
↓
|
|
1001
|
+
3. User Configuration (user preferences)
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
**Example**:
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
// Get effective configuration value
|
|
1008
|
+
function getConfigValue(key: string, tenantId?: string, userId?: string): any {
|
|
1009
|
+
// 1. Try user config
|
|
1010
|
+
if (userId) {
|
|
1011
|
+
const userConfig = getUserConfig(userId);
|
|
1012
|
+
if (userConfig[key] !== undefined) {
|
|
1013
|
+
return userConfig[key];
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// 2. Try tenant config
|
|
1018
|
+
if (tenantId) {
|
|
1019
|
+
const tenantConfig = getTenantConfig(tenantId);
|
|
1020
|
+
if (tenantConfig[key] !== undefined) {
|
|
1021
|
+
return tenantConfig[key];
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// 3. Fall back to global config
|
|
1026
|
+
return getGlobalConfig(key);
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
---
|
|
1031
|
+
|
|
1032
|
+
## Testing Tenant Isolation
|
|
1033
|
+
|
|
1034
|
+
### Unit Tests
|
|
1035
|
+
|
|
1036
|
+
```typescript
|
|
1037
|
+
describe('OrdersService', () => {
|
|
1038
|
+
it('should only return orders for specified tenant', async () => {
|
|
1039
|
+
// Arrange
|
|
1040
|
+
const tenant1Id = 'tenant-1';
|
|
1041
|
+
const tenant2Id = 'tenant-2';
|
|
1042
|
+
|
|
1043
|
+
await ordersRepository.create({ id: '1', tenantId: tenant1Id, total: 100 });
|
|
1044
|
+
await ordersRepository.create({ id: '2', tenantId: tenant2Id, total: 200 });
|
|
1045
|
+
await ordersRepository.create({ id: '3', tenantId: tenant1Id, total: 300 });
|
|
1046
|
+
|
|
1047
|
+
// Act
|
|
1048
|
+
const orders = await ordersService.findAll(tenant1Id);
|
|
1049
|
+
|
|
1050
|
+
// Assert
|
|
1051
|
+
expect(orders).toHaveLength(2);
|
|
1052
|
+
expect(orders.every(o => o.tenantId === tenant1Id)).toBe(true);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('should throw error when accessing other tenant data', async () => {
|
|
1056
|
+
// Arrange
|
|
1057
|
+
const tenant1Id = 'tenant-1';
|
|
1058
|
+
const tenant2Id = 'tenant-2';
|
|
1059
|
+
|
|
1060
|
+
const order = await ordersRepository.create({
|
|
1061
|
+
id: '1',
|
|
1062
|
+
tenantId: tenant1Id,
|
|
1063
|
+
total: 100
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Act & Assert
|
|
1067
|
+
await expect(
|
|
1068
|
+
ordersService.findOne(order.id, tenant2Id)
|
|
1069
|
+
).rejects.toThrow('Order not found');
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
### Integration Tests
|
|
1075
|
+
|
|
1076
|
+
```typescript
|
|
1077
|
+
describe('Orders API (E2E)', () => {
|
|
1078
|
+
it('should enforce tenant isolation', async () => {
|
|
1079
|
+
// Create two tenants
|
|
1080
|
+
const tenant1 = await createTenant('acme');
|
|
1081
|
+
const tenant2 = await createTenant('contoso');
|
|
1082
|
+
|
|
1083
|
+
// Create user for tenant1
|
|
1084
|
+
const user1Token = await authenticateUser('user1@acme.com', tenant1.id);
|
|
1085
|
+
|
|
1086
|
+
// Create order for tenant1
|
|
1087
|
+
const order = await request(app.getHttpServer())
|
|
1088
|
+
.post('/api/orders')
|
|
1089
|
+
.set('Cookie', `auth_token=${user1Token}`)
|
|
1090
|
+
.set('x-tenant-id', 'acme')
|
|
1091
|
+
.send({ total: 100 })
|
|
1092
|
+
.expect(201);
|
|
1093
|
+
|
|
1094
|
+
// Try to access order from tenant2 context
|
|
1095
|
+
const user2Token = await authenticateUser('user2@contoso.com', tenant2.id);
|
|
1096
|
+
|
|
1097
|
+
await request(app.getHttpServer())
|
|
1098
|
+
.get(`/api/orders/${order.body.id}`)
|
|
1099
|
+
.set('Cookie', `auth_token=${user2Token}`)
|
|
1100
|
+
.set('x-tenant-id', 'contoso')
|
|
1101
|
+
.expect(404); // Should not find order
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
## Security Considerations
|
|
1109
|
+
|
|
1110
|
+
<Aside type="caution" title="Multi-Tenancy Security Best Practices">
|
|
1111
|
+
|
|
1112
|
+
**Tenant Isolation**:
|
|
1113
|
+
- ALWAYS filter database queries by `tenant_id`
|
|
1114
|
+
- NEVER trust client-provided tenant identifiers
|
|
1115
|
+
- Validate tenant context on every request
|
|
1116
|
+
- Use foreign key constraints to enforce database-level isolation
|
|
1117
|
+
|
|
1118
|
+
**Data Leakage Prevention**:
|
|
1119
|
+
- Test tenant isolation extensively
|
|
1120
|
+
- Implement automated tests for cross-tenant access
|
|
1121
|
+
- Use database views or row-level security (RLS) for additional protection
|
|
1122
|
+
- Audit logs should include tenant context
|
|
1123
|
+
|
|
1124
|
+
**Tenant Context Validation**:
|
|
1125
|
+
- Verify user has access to requested tenant
|
|
1126
|
+
- Check tenant status (ACTIVE vs SUSPENDED)
|
|
1127
|
+
- Validate subscription and feature access
|
|
1128
|
+
- Handle tenant switching carefully
|
|
1129
|
+
|
|
1130
|
+
**Performance**:
|
|
1131
|
+
- Index `tenant_id` columns for fast filtering
|
|
1132
|
+
- Consider partitioning large tables by tenant
|
|
1133
|
+
- Cache tenant configuration appropriately
|
|
1134
|
+
- Monitor query performance per tenant
|
|
1135
|
+
|
|
1136
|
+
**Compliance**:
|
|
1137
|
+
- Support data residency requirements per tenant
|
|
1138
|
+
- Enable per-tenant data export and deletion
|
|
1139
|
+
- Maintain audit trails per tenant
|
|
1140
|
+
- Support tenant-specific retention policies
|
|
1141
|
+
|
|
1142
|
+
</Aside>
|
|
1143
|
+
|
|
1144
|
+
---
|
|
1145
|
+
|
|
1146
|
+
## Next Steps
|
|
1147
|
+
|
|
1148
|
+
- **[Gateway Architecture](/_/docs/architecture/gateway-architecture/)** - How gateway manages tenant context
|
|
1149
|
+
- **[Authentication System](/_/docs/architecture/authentication-system/)** - Tenant-aware authentication
|
|
1150
|
+
- **[Authorization System](/_/docs/architecture/authorization-system/)** - Tenant-scoped authorization
|