gufi-cli 0.1.49 → 0.1.51
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/commands/docs.js +1 -5
- package/dist/lib/docs-resolver.d.ts +8 -0
- package/dist/lib/docs-resolver.js +27 -0
- package/dist/lib/security.js +5 -0
- package/dist/mcp.js +56 -43
- package/docs/dev-guide/1-01-architecture.md +358 -0
- package/docs/dev-guide/1-02-multi-tenant.md +415 -0
- package/docs/dev-guide/1-03-column-types.md +594 -0
- package/docs/dev-guide/1-04-json-config.md +442 -0
- package/docs/dev-guide/1-05-authentication.md +427 -0
- package/docs/dev-guide/2-01-api-reference.md +564 -0
- package/docs/dev-guide/2-02-automations.md +508 -0
- package/docs/dev-guide/2-03-gufi-cli.md +568 -0
- package/docs/dev-guide/2-04-realtime.md +401 -0
- package/docs/dev-guide/2-05-permissions.md +497 -0
- package/docs/dev-guide/2-06-integrations-overview.md +104 -0
- package/docs/dev-guide/2-07-stripe.md +173 -0
- package/docs/dev-guide/2-08-nayax.md +297 -0
- package/docs/dev-guide/2-09-ourvend.md +226 -0
- package/docs/dev-guide/2-10-tns.md +177 -0
- package/docs/dev-guide/2-11-custom-http.md +268 -0
- package/docs/dev-guide/3-01-custom-views.md +555 -0
- package/docs/dev-guide/3-02-webhooks-api.md +446 -0
- package/docs/mcp/00-overview.md +329 -0
- package/docs/mcp/01-architecture.md +226 -0
- package/docs/mcp/02-modules.md +285 -0
- package/docs/mcp/03-fields.md +357 -0
- package/docs/mcp/04-views.md +613 -0
- package/docs/mcp/05-automations.md +461 -0
- package/docs/mcp/06-api.md +531 -0
- package/docs/mcp/07-packages.md +246 -0
- package/docs/mcp/08-common-errors.md +284 -0
- package/docs/mcp/09-examples.md +453 -0
- package/docs/mcp/README.md +71 -0
- package/docs/mcp/tool-descriptions.json +64 -0
- package/package.json +3 -2
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: multi-tenant
|
|
3
|
+
title: "Multi-Tenant System"
|
|
4
|
+
description: "How Gufi isolates company data"
|
|
5
|
+
icon: Building2
|
|
6
|
+
category: dev
|
|
7
|
+
part: 1
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Multi-Tenant System
|
|
11
|
+
|
|
12
|
+
How Gufi isolates company data
|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
Gufi uses **schema-based multi-tenancy** where each company has its own PostgreSQL schema. This provides complete data isolation with excellent performance.
|
|
17
|
+
|
|
18
|
+
## Schema Architecture
|
|
19
|
+
|
|
20
|
+
### Core Schema
|
|
21
|
+
|
|
22
|
+
The `core` schema contains platform-wide data:
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
-- Users (can belong to multiple companies)
|
|
26
|
+
core.users
|
|
27
|
+
├── id
|
|
28
|
+
├── email
|
|
29
|
+
├── name
|
|
30
|
+
├── password_hash
|
|
31
|
+
├── platform_role -- admin, consultant, client
|
|
32
|
+
└── created_at
|
|
33
|
+
|
|
34
|
+
-- Companies
|
|
35
|
+
core.companies
|
|
36
|
+
├── id
|
|
37
|
+
├── name
|
|
38
|
+
├── settings (jsonb)
|
|
39
|
+
└── created_at
|
|
40
|
+
|
|
41
|
+
-- User-Company membership
|
|
42
|
+
core.company_users
|
|
43
|
+
├── user_id
|
|
44
|
+
├── company_id
|
|
45
|
+
├── roles (jsonb array)
|
|
46
|
+
└── is_active
|
|
47
|
+
|
|
48
|
+
-- Module definitions (templates)
|
|
49
|
+
core.modules
|
|
50
|
+
├── id
|
|
51
|
+
├── company_id
|
|
52
|
+
├── name
|
|
53
|
+
├── config (jsonb)
|
|
54
|
+
└── created_at
|
|
55
|
+
|
|
56
|
+
-- Entity definitions
|
|
57
|
+
core.entities
|
|
58
|
+
├── id
|
|
59
|
+
├── module_id
|
|
60
|
+
├── name
|
|
61
|
+
├── automations (jsonb)
|
|
62
|
+
└── fields (jsonb)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Company Schemas
|
|
66
|
+
|
|
67
|
+
Each company gets a dedicated schema:
|
|
68
|
+
|
|
69
|
+
```sql
|
|
70
|
+
-- Company 146 has schema: company_146
|
|
71
|
+
company_146.m360_t4589 -- Products table
|
|
72
|
+
company_146.m360_t4590 -- Categories table
|
|
73
|
+
company_146.m361_t4600 -- Customers table
|
|
74
|
+
company_146.__audit_log__ -- Audit trail
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Naming Convention
|
|
78
|
+
|
|
79
|
+
Tables follow the pattern `m{moduleId}_t{entityId}`:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
m360_t4589
|
|
83
|
+
│ └── Entity ID 4589
|
|
84
|
+
└── Module ID 360
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This ensures:
|
|
88
|
+
- Unique table names across modules
|
|
89
|
+
- Easy mapping to entity definitions
|
|
90
|
+
- No name collisions between modules
|
|
91
|
+
|
|
92
|
+
## Request Flow
|
|
93
|
+
|
|
94
|
+
### JWT Contains Company
|
|
95
|
+
|
|
96
|
+
Every authenticated request includes company context:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"sub": "user_123",
|
|
101
|
+
"company_id": 146,
|
|
102
|
+
"roles": ["Admin"],
|
|
103
|
+
"exp": 1699999999
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Backend Processing
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
// Middleware extracts and sets context
|
|
111
|
+
async function setTenantContext(req, res, next) {
|
|
112
|
+
const companyId = req.user.company_id;
|
|
113
|
+
|
|
114
|
+
// Get connection from pool
|
|
115
|
+
const client = await pool.connect();
|
|
116
|
+
|
|
117
|
+
// Set schema search path
|
|
118
|
+
await client.query(
|
|
119
|
+
`SET search_path TO company_${companyId}, core, public`
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
req.db = client;
|
|
123
|
+
next();
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Query Execution
|
|
128
|
+
|
|
129
|
+
With search_path set, queries automatically scope to company:
|
|
130
|
+
|
|
131
|
+
```sql
|
|
132
|
+
-- Developer writes:
|
|
133
|
+
SELECT * FROM m360_t4589 WHERE status = 'active';
|
|
134
|
+
|
|
135
|
+
-- PostgreSQL resolves to:
|
|
136
|
+
SELECT * FROM company_146.m360_t4589 WHERE status = 'active';
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Data Isolation Guarantees
|
|
140
|
+
|
|
141
|
+
### Schema Separation
|
|
142
|
+
|
|
143
|
+
| Guarantee | Implementation |
|
|
144
|
+
|---|---|
|
|
145
|
+
| **No cross-company queries** | search_path prevents access |
|
|
146
|
+
| **No data leakage** | Separate schemas, separate tables |
|
|
147
|
+
| **Backup isolation** | Per-schema backups possible |
|
|
148
|
+
| **Performance isolation** | Indexes are per-schema |
|
|
149
|
+
|
|
150
|
+
### Security Layers
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
1. JWT Validation
|
|
154
|
+
└── Company ID extracted from token
|
|
155
|
+
|
|
156
|
+
2. Middleware Check
|
|
157
|
+
└── User belongs to company verified
|
|
158
|
+
|
|
159
|
+
3. Schema Switch
|
|
160
|
+
└── search_path set to company schema
|
|
161
|
+
|
|
162
|
+
4. Query Execution
|
|
163
|
+
└── Only company data visible
|
|
164
|
+
|
|
165
|
+
5. Response
|
|
166
|
+
└── Data returned (already filtered)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### What This Prevents
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
// This is IMPOSSIBLE:
|
|
173
|
+
// User from Company A querying Company B data
|
|
174
|
+
|
|
175
|
+
// Even if an attacker modified the query to:
|
|
176
|
+
SELECT * FROM company_200.products;
|
|
177
|
+
|
|
178
|
+
// They would get:
|
|
179
|
+
ERROR: permission denied for schema company_200
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Company Creation
|
|
183
|
+
|
|
184
|
+
### Schema Provisioning
|
|
185
|
+
|
|
186
|
+
When a new company is created:
|
|
187
|
+
|
|
188
|
+
```javascript
|
|
189
|
+
async function createCompanySchema(companyId) {
|
|
190
|
+
const schemaName = `company_${companyId}`;
|
|
191
|
+
|
|
192
|
+
// Create schema
|
|
193
|
+
await pool.query(`CREATE SCHEMA ${schemaName}`);
|
|
194
|
+
|
|
195
|
+
// Create system tables
|
|
196
|
+
await pool.query(`
|
|
197
|
+
CREATE TABLE ${schemaName}.__audit_log__ (
|
|
198
|
+
id SERIAL PRIMARY KEY,
|
|
199
|
+
table_name TEXT,
|
|
200
|
+
record_id INTEGER,
|
|
201
|
+
action TEXT,
|
|
202
|
+
old_data JSONB,
|
|
203
|
+
new_data JSONB,
|
|
204
|
+
user_id INTEGER,
|
|
205
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
206
|
+
)
|
|
207
|
+
`);
|
|
208
|
+
|
|
209
|
+
// Set up triggers for audit
|
|
210
|
+
// ... (trigger creation)
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Module Installation
|
|
215
|
+
|
|
216
|
+
When a module is installed for a company:
|
|
217
|
+
|
|
218
|
+
```javascript
|
|
219
|
+
async function installModule(companyId, moduleConfig) {
|
|
220
|
+
const schemaName = `company_${companyId}`;
|
|
221
|
+
|
|
222
|
+
for (const entity of moduleConfig.entities) {
|
|
223
|
+
const tableName = `m${moduleConfig.id}_t${entity.id}`;
|
|
224
|
+
|
|
225
|
+
// Create table with fields
|
|
226
|
+
const columns = entity.fields.map(f =>
|
|
227
|
+
`${f.name} ${mapToPostgresType(f.type)}`
|
|
228
|
+
).join(', ');
|
|
229
|
+
|
|
230
|
+
await pool.query(`
|
|
231
|
+
CREATE TABLE ${schemaName}.${tableName} (
|
|
232
|
+
id SERIAL PRIMARY KEY,
|
|
233
|
+
${columns},
|
|
234
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
235
|
+
updated_at TIMESTAMP DEFAULT NOW(),
|
|
236
|
+
created_by INTEGER,
|
|
237
|
+
updated_by INTEGER
|
|
238
|
+
)
|
|
239
|
+
`);
|
|
240
|
+
|
|
241
|
+
// Create indexes, triggers, etc.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Logical vs Physical Names
|
|
247
|
+
|
|
248
|
+
### The Mapping
|
|
249
|
+
|
|
250
|
+
Users see logical names, database uses physical names:
|
|
251
|
+
|
|
252
|
+
| Logical Name | Physical Name | Stored In |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| products | m360_t4589 | core.entities |
|
|
255
|
+
| categories | m360_t4590 | core.entities |
|
|
256
|
+
| customers | m361_t4600 | core.entities |
|
|
257
|
+
|
|
258
|
+
### Resolution Process
|
|
259
|
+
|
|
260
|
+
```javascript
|
|
261
|
+
// Frontend requests using logical name
|
|
262
|
+
GET /api/tables/products
|
|
263
|
+
|
|
264
|
+
// Backend resolves to physical name
|
|
265
|
+
async function resolveTableName(companyId, logicalName) {
|
|
266
|
+
const result = await pool.query(`
|
|
267
|
+
SELECT
|
|
268
|
+
CONCAT('m', m.id, '_t', e.id) as physical_name
|
|
269
|
+
FROM core.entities e
|
|
270
|
+
JOIN core.modules m ON e.module_id = m.id
|
|
271
|
+
WHERE m.company_id = $1 AND e.name = $2
|
|
272
|
+
`, [companyId, logicalName]);
|
|
273
|
+
|
|
274
|
+
return result.rows[0]?.physical_name;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Query executed against physical table
|
|
278
|
+
SELECT * FROM company_146.m360_t4589;
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Benefits
|
|
282
|
+
|
|
283
|
+
1. **No Name Collisions**: Two modules can have "products" entity
|
|
284
|
+
2. **Renaming**: Change display name without migrations
|
|
285
|
+
3. **Module Portability**: Same module works across companies
|
|
286
|
+
4. **Clear Ownership**: Table name shows which module owns it
|
|
287
|
+
|
|
288
|
+
## Cross-Company Operations
|
|
289
|
+
|
|
290
|
+
### Platform Admin Access
|
|
291
|
+
|
|
292
|
+
Platform admins can query across companies:
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
// Only for platform_role = 'admin'
|
|
296
|
+
async function getCrossCompanyReport() {
|
|
297
|
+
// Temporarily use all schemas
|
|
298
|
+
const companies = await pool.query(
|
|
299
|
+
'SELECT id FROM core.companies'
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const results = [];
|
|
303
|
+
for (const company of companies.rows) {
|
|
304
|
+
await pool.query(
|
|
305
|
+
`SET search_path TO company_${company.id}`
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const data = await pool.query(
|
|
309
|
+
'SELECT COUNT(*) FROM m360_t4589'
|
|
310
|
+
);
|
|
311
|
+
results.push({ companyId: company.id, count: data.rows[0].count });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return results;
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Data Migration
|
|
319
|
+
|
|
320
|
+
Moving data between companies (rare):
|
|
321
|
+
|
|
322
|
+
```javascript
|
|
323
|
+
// Export from source
|
|
324
|
+
SET search_path TO company_100;
|
|
325
|
+
COPY products TO '/tmp/products.csv' CSV;
|
|
326
|
+
|
|
327
|
+
// Import to destination
|
|
328
|
+
SET search_path TO company_200;
|
|
329
|
+
COPY products FROM '/tmp/products.csv' CSV;
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Performance Considerations
|
|
333
|
+
|
|
334
|
+
### Schema Benefits
|
|
335
|
+
|
|
336
|
+
- **Index isolation**: Each company's indexes are independent
|
|
337
|
+
- **Statistics per schema**: Query planner optimizes per-company
|
|
338
|
+
- **Vacuum per schema**: Maintenance can be targeted
|
|
339
|
+
|
|
340
|
+
### Pooling Strategy
|
|
341
|
+
|
|
342
|
+
```javascript
|
|
343
|
+
// Connection pool is shared
|
|
344
|
+
const pool = new Pool({
|
|
345
|
+
max: 100, // Total connections
|
|
346
|
+
// ...
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// But each request sets its own search_path
|
|
350
|
+
// No connection affinity needed
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Caching
|
|
354
|
+
|
|
355
|
+
Schema is cached per company:
|
|
356
|
+
|
|
357
|
+
```javascript
|
|
358
|
+
const schemaCache = new Map();
|
|
359
|
+
|
|
360
|
+
async function getCompanySchema(companyId) {
|
|
361
|
+
const cacheKey = `schema:${companyId}`;
|
|
362
|
+
|
|
363
|
+
let schema = schemaCache.get(cacheKey);
|
|
364
|
+
if (!schema) {
|
|
365
|
+
schema = await loadSchemaFromDb(companyId);
|
|
366
|
+
schemaCache.set(cacheKey, schema);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return schema;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Best Practices
|
|
374
|
+
|
|
375
|
+
### Always Use search_path
|
|
376
|
+
|
|
377
|
+
```javascript
|
|
378
|
+
// Good
|
|
379
|
+
await client.query('SET search_path TO company_${id}');
|
|
380
|
+
await client.query('SELECT * FROM products');
|
|
381
|
+
|
|
382
|
+
// Bad - never hardcode schema in queries
|
|
383
|
+
await client.query('SELECT * FROM company_${id}.products');
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Clean Up Connections
|
|
387
|
+
|
|
388
|
+
```javascript
|
|
389
|
+
async function handleRequest(req, res) {
|
|
390
|
+
const client = await pool.connect();
|
|
391
|
+
try {
|
|
392
|
+
await client.query(`SET search_path TO company_${req.companyId}`);
|
|
393
|
+
// ... do work ...
|
|
394
|
+
} finally {
|
|
395
|
+
await client.query('RESET search_path');
|
|
396
|
+
client.release();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Validate Company Access
|
|
402
|
+
|
|
403
|
+
```javascript
|
|
404
|
+
// Before any operation
|
|
405
|
+
async function validateAccess(userId, companyId) {
|
|
406
|
+
const membership = await pool.query(`
|
|
407
|
+
SELECT 1 FROM core.company_users
|
|
408
|
+
WHERE user_id = $1 AND company_id = $2 AND is_active = true
|
|
409
|
+
`, [userId, companyId]);
|
|
410
|
+
|
|
411
|
+
if (membership.rows.length === 0) {
|
|
412
|
+
throw new ForbiddenError('Access denied');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
```
|