go-duck-cli 1.0.5 → 1.0.7

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.
@@ -33,6 +33,7 @@ export const generateDocumentation = async (config, entities, outputDir, enums =
33
33
  { file: 'gdl', title: 'GDL Reference' },
34
34
  { file: 'cli', title: 'CLI & Code Injection' },
35
35
  { file: 'rest', title: 'REST & Search API' },
36
+ { file: 'multitenancy', title: 'Multi-Tenancy' },
36
37
  { file: 'grpc', title: 'Kratos gRPC API' },
37
38
  { file: 'graphql', title: 'GraphQL Framework' },
38
39
  { file: 'realtime', title: 'WebSockets & MQTT' },
@@ -10,14 +10,76 @@ package middleware
10
10
  import (
11
11
  "fmt"
12
12
  "net/http"
13
+ "sync"
13
14
 
14
15
  "github.com/gin-gonic/gin"
16
+ "gorm.io/driver/postgres"
15
17
  "gorm.io/gorm"
18
+ "{{app_name}}/config"
19
+ )
20
+
21
+ // TenantDBManager handles dynamic connection pooling for all tenants
22
+ type TenantDBManager struct {
23
+ masterDB *gorm.DB
24
+ configs *config.Config
25
+ conns map[string]*gorm.DB
26
+ mu sync.RWMutex
27
+ }
28
+
29
+ var (
30
+ manager *TenantDBManager
31
+ once sync.Once
16
32
  )
17
33
 
18
- func TenantMiddleware(db *gorm.DB) gin.HandlerFunc {
34
+ func GetTenantManager(db *gorm.DB, cfg *config.Config) *TenantDBManager {
35
+ once.Do(func() {
36
+ manager = &TenantDBManager{
37
+ masterDB: db,
38
+ configs: cfg,
39
+ conns: make(map[string]*gorm.DB),
40
+ }
41
+ })
42
+ return manager
43
+ }
44
+
45
+ func (m *TenantDBManager) GetDB(dbName string) (*gorm.DB, error) {
46
+ m.mu.RLock()
47
+ if db, ok := m.conns[dbName]; ok {
48
+ m.mu.RUnlock()
49
+ return db, nil
50
+ }
51
+ m.mu.RUnlock()
52
+
53
+ m.mu.Lock()
54
+ defer m.mu.Unlock()
55
+
56
+ // Double check
57
+ if db, ok := m.conns[dbName]; ok {
58
+ return db, nil
59
+ }
60
+
61
+ // Dynamic Connection Opening
62
+ ds := m.configs.GoDuck.Datasource
63
+ dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=UTC",
64
+ ds.Host, ds.Username, ds.Password, dbName, ds.Port)
65
+
66
+ newDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+
71
+ m.conns[dbName] = newDB
72
+ return newDB, nil
73
+ }
74
+
75
+ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
76
+ mgr := GetTenantManager(db, cfg)
77
+
19
78
  return func(c *gin.Context) {
20
- // 1. Get roles from JWT (previously set by JWTMiddleware)
79
+ // 1. Identification (Hint from Header)
80
+ requestedTenant := c.GetHeader("X-Tenant-ID")
81
+
82
+ // 2. Authorization (Extracted from JWT by JWTMiddleware)
21
83
  userRolesInterface, exists := c.Get("UserRoles")
22
84
  if !exists {
23
85
  c.JSON(http.StatusUnauthorized, gin.H{"error": "No roles found in token"})
@@ -32,17 +94,34 @@ func TenantMiddleware(db *gorm.DB) gin.HandlerFunc {
32
94
  return
33
95
  }
34
96
 
35
- // 2. Lookup DB name for the roles in tenant_roles table
97
+ // 3. Resolution (Which DB is this role authorized to access?)
36
98
  var dbName string
37
99
  err := db.Raw("SELECT db_name FROM tenant_roles WHERE role_name IN ? LIMIT 1", roles).Scan(&dbName).Error
38
100
 
39
101
  if err != nil || dbName == "" {
40
- // Fallback to default DB if no role match (optional behavior)
41
- dbName = "go-duck"
102
+ c.JSON(http.StatusForbidden, gin.H{"error": "Security: No tenant context mapped to user roles"})
103
+ c.Abort()
104
+ return
105
+ }
106
+
107
+ // 4. Security Check (Prevent Cross-Tenant Spoofing)
108
+ if requestedTenant != "" && requestedTenant != dbName {
109
+ c.JSON(http.StatusForbidden, gin.H{"error": "Security Breach: Header/Token tenant mismatch"})
110
+ c.Abort()
111
+ return
112
+ }
113
+
114
+ // 5. Dynamic Switching (Get or Create the DB Connection)
115
+ tenantConn, err := mgr.GetDB(dbName)
116
+ if err != nil {
117
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve tenant database connection"})
118
+ c.Abort()
119
+ return
42
120
  }
43
121
 
44
- // 3. Store tenant info for downstream use
122
+ // 6. Inject Live Connection into Context
45
123
  c.Set("tenantDB", dbName)
124
+ c.Set("tenantDBConn", tenantConn)
46
125
  c.Next()
47
126
  }
48
127
  }
@@ -35,11 +35,30 @@ func (sc *SearchController) GenericSearch(c *gin.Context) {
35
35
  continue
36
36
  }
37
37
 
38
+ // Security: Basic Sanitization for Key (Allowing letters, numbers, _, -, and JSON arrows)
39
+ // We split the key if it contains JSON operators to handle them specifically
40
+ processedKey := key
41
+ if strings.Contains(key, "->") {
42
+ parts := strings.SplitN(key, "->", 2)
43
+ column := parts[0]
44
+ path := parts[1]
45
+ operator := "->"
46
+ if strings.HasPrefix(path, ">") {
47
+ operator = "->>"
48
+ path = path[1:]
49
+ }
50
+ // Wrap column in quotes and path in single quotes for Postgres JSONB safety
51
+ processedKey = fmt.Sprintf("\"%s\"%s'%s'", column, operator, path)
52
+ } else {
53
+ // Standard column: Wrap in quotes for safety
54
+ processedKey = fmt.Sprintf("\"%s\"", key)
55
+ }
56
+
38
57
  for _, val := range values {
39
58
  parts := strings.SplitN(val, ".", 2)
40
59
  if len(parts) < 2 {
41
60
  // Default to equality
42
- query = query.Where(fmt.Sprintf("%s = ?", key), val)
61
+ query = query.Where(processedKey+" = ?", val)
43
62
  continue
44
63
  }
45
64
 
@@ -48,24 +67,27 @@ func (sc *SearchController) GenericSearch(c *gin.Context) {
48
67
 
49
68
  switch op {
50
69
  case "eq":
51
- query = query.Where(fmt.Sprintf("%s = ?", key), target)
70
+ query = query.Where(processedKey+" = ?", target)
52
71
  case "neq":
53
- query = query.Where(fmt.Sprintf("%s <> ?", key), target)
72
+ query = query.Where(processedKey+" <> ?", target)
54
73
  case "gt":
55
- query = query.Where(fmt.Sprintf("%s > ?", key), target)
74
+ query = query.Where(processedKey+" > ?", target)
56
75
  case "gte":
57
- query = query.Where(fmt.Sprintf("%s >= ?", key), target)
76
+ query = query.Where(processedKey+" >= ?", target)
58
77
  case "lt":
59
- query = query.Where(fmt.Sprintf("%s < ?", key), target)
78
+ query = query.Where(processedKey+" < ?", target)
60
79
  case "lte":
61
- query = query.Where(fmt.Sprintf("%s <= ?", key), target)
80
+ query = query.Where(processedKey+" <= ?", target)
62
81
  case "like":
63
- query = query.Where(fmt.Sprintf("%s LIKE ?", key), "%"+target+"%")
82
+ query = query.Where(processedKey+" LIKE ?", "%"+target+"%")
64
83
  case "ilike":
65
- query = query.Where(fmt.Sprintf("%s ILIKE ?", key), "%"+target+"%")
84
+ query = query.Where(processedKey+" ILIKE ?", "%"+target+"%")
66
85
  case "in":
67
86
  list := strings.Split(target, ",")
68
- query = query.Where(fmt.Sprintf("%s IN ?", key), list)
87
+ query = query.Where(processedKey+" IN ?", list)
88
+ default:
89
+ // Fallback to equality if operator is unrecognized
90
+ query = query.Where(processedKey+" = ?", val)
69
91
  }
70
92
  }
71
93
  }
@@ -18,6 +18,19 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
18
18
  ],
19
19
  paths: {},
20
20
  components: {
21
+ securitySchemes: {
22
+ BearerAuth: {
23
+ type: 'http',
24
+ scheme: 'bearer',
25
+ bearerFormat: 'JWT'
26
+ },
27
+ TenantID: {
28
+ type: 'apiKey',
29
+ in: 'header',
30
+ name: 'X-Tenant-ID',
31
+ description: 'The unique identifier for the tenant dashboard context'
32
+ }
33
+ },
21
34
  schemas: {
22
35
  Error: {
23
36
  type: 'object',
@@ -26,9 +39,16 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
26
39
  }
27
40
  }
28
41
  }
29
- }
42
+ },
43
+ security: [
44
+ { BearerAuth: [], TenantID: [] }
45
+ ]
30
46
  };
31
47
 
48
+ const commonHeaders = [
49
+ { name: 'X-Tenant-ID', in: 'header', required: true, schema: { type: 'string', default: 'default' }, description: 'Multi-tenancy context identifier' }
50
+ ];
51
+
32
52
  // 1. Add Entity Paths
33
53
  for (const entity of entities) {
34
54
  const name = entity.name.toLowerCase();
@@ -53,6 +73,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
53
73
  post: {
54
74
  tags: [capitalized],
55
75
  summary: `Create a new ${capitalized}`,
76
+ parameters: [...commonHeaders],
56
77
  requestBody: {
57
78
  required: true,
58
79
  content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } }
@@ -65,9 +86,10 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
65
86
  tags: [capitalized],
66
87
  summary: `Get all ${capitalized}s`,
67
88
  parameters: [
68
- { name: 'page', in: 'query', schema: { type: 'integer' } },
69
- { name: 'size', in: 'query', schema: { type: 'integer' } },
70
- { name: 'eager', in: 'query', schema: { type: 'boolean' } }
89
+ ...commonHeaders,
90
+ { name: 'page', in: 'query', schema: { type: 'integer' }, description: 'Zero-based page index' },
91
+ { name: 'size', in: 'query', schema: { type: 'integer' }, description: 'Records per page' },
92
+ { name: 'eager', in: 'query', schema: { type: 'boolean' }, description: 'If true, performs SQL Join to fetch relations' }
71
93
  ],
72
94
  responses: {
73
95
  200: { description: 'OK', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
@@ -80,7 +102,11 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
80
102
  get: {
81
103
  tags: [capitalized],
82
104
  summary: `Get ${capitalized} by ID`,
83
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
105
+ parameters: [
106
+ ...commonHeaders,
107
+ { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
108
+ { name: 'eager', in: 'query', schema: { type: 'boolean' } }
109
+ ],
84
110
  responses: {
85
111
  200: { description: 'OK', content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } } }
86
112
  }
@@ -88,7 +114,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
88
114
  put: {
89
115
  tags: [capitalized],
90
116
  summary: `Update ${capitalized}`,
91
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
117
+ parameters: [...commonHeaders, { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
92
118
  responses: {
93
119
  200: { description: 'Updated', content: { 'application/json': { schema: { $ref: `#/components/schemas/${capitalized}` } } } }
94
120
  }
@@ -96,32 +122,105 @@ export const generateSwaggerDocs = async (config, entities, outputDir) => {
96
122
  delete: {
97
123
  tags: [capitalized],
98
124
  summary: `Delete ${capitalized}`,
99
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
125
+ parameters: [...commonHeaders, { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
100
126
  responses: {
101
127
  204: { description: 'No Content' }
102
128
  }
103
129
  }
104
130
  };
131
+
132
+ // BULK Operations /entities/bulk
133
+ swagger.paths[`/${name}s/bulk`] = {
134
+ post: {
135
+ tags: [capitalized],
136
+ summary: `Bulk Create ${capitalized}s`,
137
+ parameters: [...commonHeaders],
138
+ requestBody: {
139
+ required: true,
140
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } }
141
+ },
142
+ responses: {
143
+ 201: { description: 'Created', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
144
+ }
145
+ },
146
+ put: {
147
+ tags: [capitalized],
148
+ summary: `Bulk Update ${capitalized}s`,
149
+ parameters: [...commonHeaders],
150
+ requestBody: {
151
+ required: true,
152
+ content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } }
153
+ },
154
+ responses: {
155
+ 200: { description: 'Updated', content: { 'application/json': { schema: { type: 'array', items: { $ref: `#/components/schemas/${capitalized}` } } } } }
156
+ }
157
+ },
158
+ patch: {
159
+ tags: [capitalized],
160
+ summary: `Bulk Patch ${capitalized}s`,
161
+ parameters: [...commonHeaders],
162
+ requestBody: {
163
+ required: true,
164
+ content: {
165
+ 'application/json': {
166
+ schema: {
167
+ type: 'array',
168
+ items: {
169
+ type: 'object',
170
+ properties: {
171
+ id: { type: 'integer' },
172
+ changes: { type: 'object' }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ },
179
+ responses: {
180
+ 200: { description: 'Patched' }
181
+ }
182
+ }
183
+ };
105
184
  }
106
185
 
107
186
  // 2. Add System Paths
108
187
  swagger.paths['/rpc/{table}'] = {
109
188
  get: {
110
- tags: ['Search'],
111
- summary: 'Generic PostgREST-like Search',
189
+ tags: ['Search Engine'],
190
+ summary: 'Generic PostgREST RPC Engine',
191
+ description: `Powerful dynamic querying system.
192
+
193
+ ### Dynamic Filtering
194
+ Append any column name as a query parameter using operator notation:
195
+ - \`?age=gt.20\` (Greater Than)
196
+ - \`?name=ilike.John\` (Case-insensitive search)
197
+ - \`?id=in.1,2,3\` (Set containment)
198
+
199
+ ### JSONB Path Querying
200
+ For JSON fields, use arrow notation:
201
+ - \`?metadata->>role=eq.ADMIN\` (Nested text extraction)
202
+ - \`?details->count=gt.5\` (Nested numeric extraction)`,
112
203
  parameters: [
113
- { name: 'table', in: 'path', required: true, schema: { type: 'string' } },
114
- { name: 'order', in: 'query', schema: { type: 'string' } },
115
- { name: 'limit', in: 'query', schema: { type: 'integer' } }
204
+ ...commonHeaders,
205
+ { name: 'table', in: 'path', required: true, schema: { type: 'string' }, description: 'The database table to query' },
206
+ { name: 'order', in: 'query', schema: { type: 'string' }, description: 'Sorting (e.g., id.desc)' },
207
+ { name: 'limit', in: 'query', schema: { type: 'integer' }, description: 'Row limit' },
208
+ { name: 'offset', in: 'query', schema: { type: 'integer' }, description: 'Query offset' }
116
209
  ],
117
- responses: { 200: { description: 'OK' } }
210
+ responses: {
211
+ 200: {
212
+ description: 'OK',
213
+ content: { 'application/json': { schema: { type: 'array', items: { type: 'object' } } } }
214
+ }
215
+ }
118
216
  }
119
217
  };
120
218
 
121
219
  swagger.paths['/audit'] = {
122
220
  get: {
123
- tags: ['Audit'],
124
- summary: 'View Audit Logs',
221
+ tags: ['Observability'],
222
+ summary: 'Fetch Audit Trail',
223
+ parameters: [...commonHeaders],
125
224
  responses: { 200: { description: 'OK' } }
126
225
  }
127
226
  };
@@ -139,7 +238,10 @@ const mapToSwaggerType = (type) => {
139
238
  'Long': 'integer',
140
239
  'BigDecimal': 'number',
141
240
  'LocalDate': 'string',
142
- 'Instant': 'string'
241
+ 'Instant': 'string',
242
+ 'JSON': 'object',
243
+ 'JSONB': 'object',
244
+ 'Text': 'string'
143
245
  };
144
246
  return types[type] || 'string';
145
247
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-duck-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "go function generator",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,83 @@
1
+ <div class="mb-10 text-center lg:text-left border-b border-slate-200 pb-8">
2
+ <div class="inline-flex items-center px-3 py-1 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold tracking-wide uppercase mb-4">
3
+ Enterprise Architecture
4
+ </div>
5
+ <h1 class="text-4xl lg:text-5xl font-extrabold text-slate-900 tracking-tight leading-tight mb-4">Dynamic Multi-Tenancy</h1>
6
+ <p class="text-lg lg:text-xl text-slate-600 max-w-2xl leading-relaxed">Secure, high-performance database-per-tenant isolation with real-time connection hot-swapping.</p>
7
+ </div>
8
+
9
+ <section class="mb-12">
10
+ <h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
11
+ <span class="w-8 h-8 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center mr-3 text-sm">
12
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
13
+ </span>
14
+ The Dual-DB Architecture
15
+ </h2>
16
+ <p class="mb-6 text-slate-600 leading-relaxed">GO-DUCK separates management logic from customer data. Your application maintains a persistent connection to a <strong>Master Database</strong> while dynamically "Relocating" per-request traffic to isolated <strong>Tenant Databases</strong>.</p>
17
+
18
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
19
+ <div class="p-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
20
+ <div class="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mb-4">
21
+ <svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
22
+ </div>
23
+ <h3 class="text-xl font-bold text-slate-900 mb-2">Master Registry</h3>
24
+ <p class="text-slate-600 text-sm leading-relaxed">The <code>tenant_roles</code> table in the Master DB acts as the "Grand Concierge," mapping authenticated user roles to physical database names at runtime.</p>
25
+ </div>
26
+ <div class="p-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
27
+ <div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center mb-4">
28
+ <svg class="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
29
+ </div>
30
+ <h3 class="text-xl font-bold text-slate-900 mb-2">Hot-Swapping Pools</h3>
31
+ <p class="text-slate-600 text-sm leading-relaxed">The <code>TenantDBManager</code> uses lazy loading to open and cache connection pools for tenants only when they are active, ensuring minimal resource overhead.</p>
32
+ </div>
33
+ </div>
34
+ </section>
35
+
36
+ <section class="mb-12">
37
+ <h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
38
+ <span class="w-8 h-8 rounded-lg bg-rose-100 text-rose-600 flex items-center justify-center mr-3 text-sm">
39
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
40
+ </span>
41
+ Security & Anti-Spoofing
42
+ </h2>
43
+ <p class="mb-6 text-slate-600 leading-relaxed">Header-based tenancy (<code>X-Tenant-ID</code>) is convenient but dangerous. GO-DUCK solves this by cryptographically validating the tenant context against the JWT.</p>
44
+
45
+ <div class="bg-slate-900 rounded-2xl p-6 shadow-xl mb-6">
46
+ <h4 class="text-emerald-400 font-mono text-sm mb-4">// Internal Verification Loop</h4>
47
+ <pre class="text-slate-300 font-mono text-sm leading-relaxed">
48
+ 1. Extract Roles from signed Keycloak JWT
49
+ 2. Lookup authorized DB in Master mapping table
50
+ 3. Compare against X-Tenant-ID header
51
+ 4. If Mismatch: Return 403 Forbidden (Security Breach)
52
+ 5. If Match: Activate Dynamic Connection Pool
53
+ </pre>
54
+ </div>
55
+ </section>
56
+
57
+ <section class="mb-12">
58
+ <h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
59
+ <span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center mr-3 text-sm">
60
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path></svg>
61
+ </span>
62
+ Tenant Provisioning API
63
+ </h2>
64
+ <p class="mb-6 text-slate-600 leading-relaxed">Adding a new customer is a single atomic operation. Use the management API to provision a side-by-side database instantly.</p>
65
+
66
+ <div class="bg-[#1e1e1e] rounded-xl overflow-hidden shadow-lg mb-4">
67
+ <div class="bg-[#2d2d2d] px-4 py-2 border-b border-[#404040] flex items-center">
68
+ <span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-[10px] font-bold mr-3">POST</span>
69
+ <span class="text-xs text-slate-300 font-mono">/management/db/create</span>
70
+ </div>
71
+ <div class="p-5 text-sm font-mono text-slate-300">
72
+ {
73
+ "role": "ROLE_ACME_CORP",
74
+ "db_name": "acme_isolated_db"
75
+ }
76
+ </div>
77
+ </div>
78
+ <ul class="space-y-3 text-slate-600 text-sm">
79
+ <li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Executing physical <code>CREATE DATABASE</code></li>
80
+ <li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Mapping role to DB in <code>tenant_roles</code> Master table</li>
81
+ <li class="flex items-center"><svg class="w-4 h-4 text-emerald-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Triggering automated Liquibase migrations on new DB</li>
82
+ </ul>
83
+ </section>
@@ -124,6 +124,84 @@
124
124
  </div>
125
125
  </div>
126
126
  </div>
127
+
128
+ <!-- JSON Querying Section -->
129
+ <div class="bg-white border border-slate-200 rounded-2xl p-8 shadow-sm">
130
+ <h4 class="text-xl font-bold text-slate-900 mb-4 flex items-center">
131
+ <svg class="w-6 h-6 mr-3 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
132
+ Deep JSON/JSONB Querying
133
+ </h4>
134
+ <p class="text-slate-600 text-sm mb-6 leading-relaxed">
135
+ The GO-DUCK generator natively supports PostgreSQL JSONB operators. You can drill down into nested fields directly from the URL using arrow notation.
136
+ </p>
137
+
138
+ <div class="space-y-4">
139
+ <div class="bg-slate-50 p-5 rounded-xl border border-slate-100">
140
+ <div class="flex items-center justify-between mb-2">
141
+ <span class="text-xs font-bold text-indigo-600 uppercase tracking-widest">Text Extraction (->>)</span>
142
+ <span class="px-2 py-0.5 rounded bg-indigo-100 text-indigo-700 text-[10px] font-bold">Standard Use</span>
143
+ </div>
144
+ <code class="text-xs text-slate-800">GET /api/rpc/users?metadata->>role=eq.ADMIN</code>
145
+ <p class="text-[11px] text-slate-500 mt-2">Extracts the value as text. Perfect for equality checks on nested strings.</p>
146
+ </div>
147
+
148
+ <div class="bg-slate-50 p-5 rounded-xl border border-slate-100">
149
+ <div class="flex items-center justify-between mb-2">
150
+ <span class="text-xs font-bold text-purple-600 uppercase tracking-widest">Object Extraction (->)</span>
151
+ </div>
152
+ <code class="text-xs text-slate-800">GET /api/rpc/orders?details->itemsCount=gt.5</code>
153
+ <p class="text-[11px] text-slate-500 mt-2">Treats the extracted value as a JSON object/numeric, allowing for range checks on nested numbers.</p>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="mt-6 p-4 bg-amber-50 rounded-xl border border-amber-100">
158
+ <p class="text-[11px] text-amber-800 leading-relaxed">
159
+ <strong>Pro Tip:</strong> For high-performance JSON querying, ensure you have a <code>GIN</code> index on the JSONB column in your database.
160
+ </p>
161
+ </div>
162
+ </div>
163
+ </section>
164
+
165
+ <section class="mb-12">
166
+ <h2 class="text-2xl font-bold text-slate-800 mb-6 flex items-center">
167
+ <span class="w-8 h-8 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center mr-3 text-sm">
168
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
169
+ </span>
170
+ Bulk Mission Control (High Velocity)
171
+ </h2>
172
+ <p class="text-slate-600 text-sm mb-6 leading-relaxed">
173
+ For batch processing and migrations, avoid the overhead of multiple HTTP calls. Use the specialized <code class="bg-orange-50 px-1 rounded text-orange-700">/bulk</code> endpoints to process hundreds of records in a single transaction.
174
+ </p>
175
+
176
+ <div class="space-y-6">
177
+ <!-- Bulk Create -->
178
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden">
179
+ <div class="bg-slate-50 px-6 py-3 border-b border-slate-200 flex items-center justify-between">
180
+ <span class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Bulk Create Transaction</span>
181
+ <span class="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-[10px] font-bold">POST /api/:entities/bulk</span>
182
+ </div>
183
+ <div class="p-6">
184
+ <pre class="bg-slate-900 rounded-xl p-4 text-xs text-slate-300 font-mono">[
185
+ { "title": "Bulk Article A", "status": "DRAFT" },
186
+ { "title": "Bulk Article B", "status": "PUBLISHED" }
187
+ ]</pre>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Bulk Patch -->
192
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden">
193
+ <div class="bg-slate-50 px-6 py-3 border-b border-slate-200 flex items-center justify-between">
194
+ <span class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Multi-Entity Patch</span>
195
+ <span class="px-2 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-bold">PATCH /api/:entities/bulk</span>
196
+ </div>
197
+ <div class="p-6">
198
+ <pre class="bg-slate-900 rounded-xl p-4 text-xs text-slate-300 font-mono">[
199
+ { "id": 1, "changes": { "status": "ARCHIVED" } },
200
+ { "id": 2, "changes": { "title": "Updated Title via Bulk" } }
201
+ ]</pre>
202
+ </div>
203
+ </div>
204
+ </div>
127
205
  </section>
128
206
 
129
207
  <section class="mb-12">
@@ -9,7 +9,27 @@
9
9
  <p class="text-indigo-900"><strong>Golden Rule:</strong> In <code>application-dev.yml</code>, ensure your Keycloak Realm, ClientID, and Secret are accurately synced with the local running Docker Keycloak image.</p>
10
10
  </div>
11
11
 
12
- <h3 class="font-semibold mb-2 mt-6">2. Burst Protection (Rate Limiting)</h3>
12
+ <h2 class="text-2xl font-bold text-gray-800 mb-4 border-b pb-2">2. Tenant Context Integrity</h2>
13
+ <p class="mb-4">
14
+ GO-DUCK implements <strong>Enterprise-Grade Data Isolation</strong>. While the <code>X-Tenant-ID</code> header is required for request context, the system is immune to "Tenant Spoofing" attacks.
15
+ </p>
16
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
17
+ <div class="bg-slate-50 p-5 rounded-xl border border-slate-200">
18
+ <h4 class="font-bold text-slate-900 mb-2">Internal Cross-Verification</h4>
19
+ <p class="text-xs text-slate-600 leading-relaxed">
20
+ The <code>TenantMiddleware</code> extracts authorized roles from the signed JWT and performs a server-side lookup of the valid DB context. It then <strong>cross-checks</strong> this against the <code>X-Tenant-ID</code> header.
21
+ </p>
22
+ </div>
23
+ <div class="bg-rose-50 p-5 rounded-xl border border-rose-200">
24
+ <h4 class="font-bold text-rose-900 mb-2">Spoof Protection</h4>
25
+ <p class="text-xs text-rose-700 leading-relaxed">
26
+ If a user attempts to access <code>Tenant-B</code> by modifying headers while their JWT is only valid for <code>Tenant-A</code>, the request is immediately aborted with a <code>403 Forbidden - Security Breach</code> error.
27
+ <a href="multitenancy.html" class="text-rose-600 font-bold hover:underline mt-2 inline-block">Learn more about isolation &rarr;</a>
28
+ </p>
29
+ </div>
30
+ </div>
31
+
32
+ <h3 class="font-semibold mb-2 mt-6">3. Burst Protection (Rate Limiting)</h3>
13
33
  <p class="mb-4">Using the standard <code>x/time/rate</code> package, a Token Bucket rate limiter is attached to the Gin Engine globally to mitigate DDOS vectors and abusive scripting.</p>
14
34
  <pre><code class="language-yaml"># Inside your application-prod.yml
15
35
  go-duck:
@@ -21,20 +21,20 @@ Config *config.Config
21
21
  }
22
22
 
23
23
  // Create{{capitalize name}}
24
- func (ctrl *{{capitalize name}}Controller) Create(c *gin.Context) {
25
- tenant, _ := c.Get("tenantDB")
26
- tenantStr := fmt.Sprintf("%v", tenant)
27
- ctx := c.Request.Context()
28
-
29
- var entity models.{{capitalize name}}
30
- if err := c.ShouldBindJSON(&entity); err != nil {
31
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
32
- return
33
- }
34
- if err := ctrl.DB.WithContext(ctx).Create(&entity).Error; err != nil {
35
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
36
- return
37
- }
24
+ db := ctrl.DB
25
+ if tdb, exists := c.Get("tenantDBConn"); exists {
26
+ db = tdb.(*gorm.DB)
27
+ }
28
+
29
+ var entity models.{{capitalize name}}
30
+ if err := c.ShouldBindJSON(&entity); err != nil {
31
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
32
+ return
33
+ }
34
+ if err := db.WithContext(ctx).Create(&entity).Error; err != nil {
35
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
36
+ return
37
+ }
38
38
 
39
39
  // Dynamic Cache Invalidation (Tenant Aware)
40
40
  cache.ClearPattern(tenantStr + ":{{capitalize name}}*")
@@ -50,9 +50,13 @@ c.JSON(http.StatusCreated, entity)
50
50
 
51
51
  // GetAll{{capitalize name}}s (with filtering, pagination, and lazy/eager loading)
52
52
  func (ctrl *{{capitalize name}}Controller) GetAll(c *gin.Context) {
53
- var entities []models.{{capitalize name}}
54
- ctx := c.Request.Context()
55
- query := ctrl.DB.WithContext(ctx)
53
+ db := ctrl.DB
54
+ if tdb, exists := c.Get("tenantDBConn"); exists {
55
+ db = tdb.(*gorm.DB)
56
+ }
57
+ var entities []models.{{capitalize name}}
58
+ ctx := c.Request.Context()
59
+ query := db.WithContext(ctx)
56
60
 
57
61
  // 1. Pagination
58
62
  page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
@@ -104,8 +108,12 @@ c.JSON(http.StatusOK, entity)
104
108
  return
105
109
  }
106
110
 
107
- // 2. Fallback to DB (With Context for Tracing)
108
- query := ctrl.DB.WithContext(ctx)
111
+ // 2. Fallback to DB (With Context for Tracing)
112
+ db := ctrl.DB
113
+ if tdb, exists := c.Get("tenantDBConn"); exists {
114
+ db = tdb.(*gorm.DB)
115
+ }
116
+ query := db.WithContext(ctx)
109
117
  if c.Query("eager") == "true" {
110
118
  {{#each relationships}}
111
119
  query = query.Preload("{{capitalize from.field}}")
@@ -128,14 +136,13 @@ c.JSON(http.StatusOK, entity)
128
136
  }
129
137
 
130
138
  // Update (PUT) - Full Update
131
- func (ctrl *{{capitalize name}}Controller) Update(c *gin.Context) {
132
- id := c.Param("id")
133
- tenant, _ := c.Get("tenantDB")
134
- tenantStr := fmt.Sprintf("%v", tenant)
135
- ctx := c.Request.Context()
139
+ db := ctrl.DB
140
+ if tdb, exists := c.Get("tenantDBConn"); exists {
141
+ db = tdb.(*gorm.DB)
142
+ }
136
143
 
137
- var entity models.{{capitalize name}}
138
- if err := ctrl.DB.WithContext(ctx).First(&entity, id).Error; err != nil {
144
+ var entity models.{{capitalize name}}
145
+ if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
139
146
  c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
140
147
  return
141
148
  }
@@ -148,7 +155,7 @@ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
148
155
  return
149
156
  }
150
157
 
151
- if err := ctrl.DB.WithContext(ctx).Save(&entity).Error; err != nil {
158
+ if err := db.WithContext(ctx).Save(&entity).Error; err != nil {
152
159
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
153
160
  return
154
161
  }
@@ -166,14 +173,13 @@ c.JSON(http.StatusOK, entity)
166
173
  }
167
174
 
168
175
  // Patch (PATCH) - Partial Update
169
- func (ctrl *{{capitalize name}}Controller) Patch(c *gin.Context) {
170
- id := c.Param("id")
171
- tenant, _ := c.Get("tenantDB")
172
- tenantStr := fmt.Sprintf("%v", tenant)
173
- ctx := c.Request.Context()
176
+ db := ctrl.DB
177
+ if tdb, exists := c.Get("tenantDBConn"); exists {
178
+ db = tdb.(*gorm.DB)
179
+ }
174
180
 
175
- var entity models.{{capitalize name}}
176
- if err := ctrl.DB.WithContext(ctx).First(&entity, id).Error; err != nil {
181
+ var entity models.{{capitalize name}}
182
+ if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
177
183
  c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
178
184
  return
179
185
  }
@@ -185,13 +191,13 @@ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
185
191
  return
186
192
  }
187
193
 
188
- if err := ctrl.DB.WithContext(ctx).Model(&entity).Updates(updates).Error; err != nil {
194
+ if err := db.WithContext(ctx).Model(&entity).Updates(updates).Error; err != nil {
189
195
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
190
196
  return
191
197
  }
192
198
 
193
199
  // Fetch updated
194
- ctrl.DB.WithContext(ctx).First(&entity, id)
200
+ db.WithContext(ctx).First(&entity, id)
195
201
 
196
202
  // Cache Invalidation (Tenant Aware)
197
203
  cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%s", tenantStr, id))
@@ -205,20 +211,117 @@ return nil, nil
205
211
  c.JSON(http.StatusOK, gin.H{"message": "Updated successfully", "data": entity})
206
212
  }
207
213
 
214
+ // BulkCreate handles creating multiple entities in one transaction
215
+ db := ctrl.DB
216
+ if tdb, exists := c.Get("tenantDBConn"); exists {
217
+ db = tdb.(*gorm.DB)
218
+ }
219
+
220
+ var entities []models.{{capitalize name}}
221
+ if err := c.ShouldBindJSON(&entities); err != nil {
222
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
223
+ return
224
+ }
225
+
226
+ if err := db.WithContext(ctx).Create(&entities).Error; err != nil {
227
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
228
+ return
229
+ }
230
+
231
+ // Dynamic Cache Invalidation (Tenant Aware)
232
+ cache.ClearPattern(tenantStr + ":{{capitalize name}}*")
233
+
234
+ // MQTT Event (Resilient)
235
+ resilience.Execute(func() (interface{}, error) {
236
+ messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_CREATE", "{{capitalize name}}", entities, nil)
237
+ return nil, nil
238
+ })
239
+
240
+ c.JSON(http.StatusCreated, entities)
241
+ }
242
+
243
+ // BulkUpdate handles updating multiple entities in one transaction
244
+ db := ctrl.DB
245
+ if tdb, exists := c.Get("tenantDBConn"); exists {
246
+ db = tdb.(*gorm.DB)
247
+ }
248
+
249
+ var entities []models.{{capitalize name}}
250
+ if err := c.ShouldBindJSON(&entities); err != nil {
251
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
252
+ return
253
+ }
254
+
255
+ err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
256
+ for _, e := range entities {
257
+ if err := tx.Save(&e).Error; err != nil {
258
+ return err
259
+ }
260
+ cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, e.ID))
261
+ }
262
+ return nil
263
+ })
264
+
265
+ if err != nil {
266
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
267
+ return
268
+ }
269
+
270
+ // MQTT Event (Resilient)
271
+ resilience.Execute(func() (interface{}, error) {
272
+ messaging.PublishEvent(ctrl.Config.GoDuck.Messaging.MQTT.TopicPrefix, "BULK_UPDATE", "{{capitalize name}}", entities, nil)
273
+ return nil, nil
274
+ })
275
+
276
+ c.JSON(http.StatusOK, entities)
277
+ }
278
+
279
+ // BulkPatch handles partial updates for multiple entities
280
+ db := ctrl.DB
281
+ if tdb, exists := c.Get("tenantDBConn"); exists {
282
+ db = tdb.(*gorm.DB)
283
+ }
284
+
285
+ var updates []struct {
286
+ ID uint `json:"id"`
287
+ Changes map[string]interface{} `json:"changes"`
288
+ }
289
+ if err := c.ShouldBindJSON(&updates); err != nil {
290
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
291
+ return
292
+ }
293
+
294
+ err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
295
+ for _, u := range updates {
296
+ if err := tx.Model(&models.{{capitalize name}}{}).Where("id = ?", u.ID).Updates(u.Changes).Error; err != nil {
297
+ return err
298
+ }
299
+ cache.Delete(fmt.Sprintf("%s:{{capitalize name}}:%d", tenantStr, u.ID))
300
+ }
301
+ return nil
302
+ })
303
+
304
+ if err != nil {
305
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
306
+ return
307
+ }
308
+
309
+ c.JSON(http.StatusOK, gin.H{"message": "Bulk patch completed successfully"})
310
+ }
311
+
208
312
  // Delete
209
- func (ctrl *{{capitalize name}}Controller) Delete(c *gin.Context) {
210
- id := c.Param("id")
211
- tenant, _ := c.Get("tenantDB")
212
- tenantStr := fmt.Sprintf("%v", tenant)
213
- ctx := c.Request.Context()
313
+ db := ctrl.DB
314
+ if tdb, exists := c.Get("tenantDBConn"); exists {
315
+ db = tdb.(*gorm.DB)
316
+ }
214
317
 
215
- var entity models.{{capitalize name}}
216
- if err := ctrl.DB.WithContext(ctx).First(&entity, id).Error; err != nil {
318
+ var entity models.{{capitalize name}}
319
+ if err := db.WithContext(ctx).First(&entity, id).Error; err != nil {
217
320
  c.JSON(http.StatusNotFound, gin.H{"error": "Resource not found"})
218
321
  return
219
322
  }
220
323
 
221
- if err := ctrl.DB.WithContext(ctx).Delete(&entity).Error; err != nil {
324
+ if err := db.WithContext(ctx).Delete(&entity).Error; err != nil {
222
325
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
223
326
  return
224
327
  }
@@ -143,7 +143,7 @@ mgmt.POST("/db/create", management.CreateDatabaseAndMigrate(masterDB))
143
143
  // 9. Secured Application APIs
144
144
  api := r.Group("/api")
145
145
  api.Use(middleware.JWTMiddleware())
146
- api.Use(middleware.TenantMiddleware(masterDB))
146
+ api.Use(middleware.TenantMiddleware(masterDB, appConfig))
147
147
  api.Use(middleware.AuditMiddleware(masterDB))
148
148
  api.Use(middleware.MeteringMiddleware(masterDB))
149
149
  {
@@ -163,10 +163,13 @@ api.GET("/rpc/:table", searchCtrl.GenericSearch)
163
163
  // {{name}} Routes
164
164
  {{toLowerCase name}}Ctrl := controllers.{{capitalize name}}Controller{DB: masterDB, Config: appConfig}
165
165
  api.POST("/{{toLowerCase name}}s", {{toLowerCase name}}Ctrl.Create)
166
+ api.POST("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkCreate)
166
167
  api.GET("/{{toLowerCase name}}s", {{toLowerCase name}}Ctrl.GetAll)
167
168
  api.GET("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.GetByID)
168
169
  api.PUT("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Update)
170
+ api.PUT("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkUpdate)
169
171
  api.PATCH("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Patch)
172
+ api.PATCH("/{{toLowerCase name}}s/bulk", {{toLowerCase name}}Ctrl.BulkPatch)
170
173
  api.DELETE("/{{toLowerCase name}}s/:id", {{toLowerCase name}}Ctrl.Delete)
171
174
  {{/each}}
172
175
  // go-duck-needle-add-route