go-duck-cli 1.1.40 → 1.1.43

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.
@@ -273,6 +273,8 @@ import (
273
273
  my_middleware "{{projectName}}/middleware"
274
274
  "gorm.io/gorm"
275
275
  "go.mongodb.org/mongo-driver/mongo"
276
+ "google.golang.org/grpc/codes"
277
+ "google.golang.org/grpc/status"
276
278
  "strings"
277
279
  "fmt"
278
280
  "sort"
@@ -297,13 +299,14 @@ func TenantServerInterceptor(conf *config.Config, db *gorm.DB) middleware.Middle
297
299
  rolesInterface, ok := ra["roles"].([]interface{})
298
300
  if !ok { return handler(ctx, req) }
299
301
 
302
+ superAdminRole := strings.ToLower(conf.GoDuck.Security.SuperAdminRole)
300
303
  // Normalize roles (lowercase)
301
304
  var lowerRoles []string
302
305
  isAdmin := false
303
306
  for _, r := range rolesInterface {
304
307
  roleStr := strings.ToLower(fmt.Sprintf("%v", r))
305
308
  lowerRoles = append(lowerRoles, roleStr)
306
- if roleStr == "admin" || roleStr == "role_admin" { isAdmin = true }
309
+ if roleStr == "admin" || roleStr == "role_admin" || (superAdminRole != "" && roleStr == superAdminRole) { isAdmin = true }
307
310
  }
308
311
 
309
312
  var requestedTenant string
@@ -312,18 +315,12 @@ func TenantServerInterceptor(conf *config.Config, db *gorm.DB) middleware.Middle
312
315
  }
313
316
 
314
317
  var mappings []models.TenantRole
315
- if requestedTenant != "" {
316
- if isAdmin {
317
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE tenant_id = ?", requestedTenant).Scan(&mappings)
318
- } else {
319
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ? AND tenant_id = ?", lowerRoles, requestedTenant).Scan(&mappings)
320
- }
318
+ if isAdmin {
319
+ mappings = []models.TenantRole{}
320
+ } else if requestedTenant != "" {
321
+ db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ? AND tenant_id = ?", lowerRoles, requestedTenant).Scan(&mappings)
321
322
  } else {
322
- if isAdmin {
323
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles").Scan(&mappings)
324
- } else {
325
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ?", lowerRoles).Scan(&mappings)
326
- }
323
+ db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ?", lowerRoles).Scan(&mappings)
327
324
  }
328
325
 
329
326
  siloConnections := make(map[string]*gorm.DB)
@@ -340,6 +337,9 @@ func TenantServerInterceptor(conf *config.Config, db *gorm.DB) middleware.Middle
340
337
  mappings = authorizedMappings
341
338
 
342
339
  if len(mappings) == 0 {
340
+ if !isAdmin {
341
+ return nil, status.Error(codes.PermissionDenied, "Access denied. No tenant database provisioned for your roles.")
342
+ }
343
343
  conn, _ := mgr.GetDB(fallbackDB)
344
344
  siloConnections["fallback"] = conn
345
345
  ctx = context.WithValue(ctx, "tenantDBConn", conn)
@@ -147,12 +147,13 @@ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
147
147
  return
148
148
  }
149
149
 
150
+ superAdminRole := strings.ToLower(cfg.GoDuck.Security.SuperAdminRole)
150
151
  var lowerRoles []string
151
152
  isAdmin := false
152
153
  for _, r := range roles {
153
154
  roleStr := strings.ToLower(fmt.Sprintf("%v", r))
154
155
  lowerRoles = append(lowerRoles, roleStr)
155
- if roleStr == "admin" || roleStr == "role_admin" {
156
+ if roleStr == "admin" || roleStr == "role_admin" || (superAdminRole != "" && roleStr == superAdminRole) {
156
157
  isAdmin = true
157
158
  }
158
159
  }
@@ -173,18 +174,12 @@ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
173
174
  }
174
175
  }
175
176
 
176
- if len(requestedTenants) > 0 {
177
- if isAdmin {
178
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE tenant_id IN ?", requestedTenants).Scan(&mappings)
179
- } else {
180
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ? AND tenant_id IN ?", lowerRoles, requestedTenants).Scan(&mappings)
181
- }
177
+ if isAdmin {
178
+ mappings = []models.TenantRole{}
179
+ } else if len(requestedTenants) > 0 {
180
+ db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ? AND tenant_id IN ?", lowerRoles, requestedTenants).Scan(&mappings)
182
181
  } else {
183
- if isAdmin {
184
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles").Scan(&mappings)
185
- } else {
186
- db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ?", lowerRoles).Scan(&mappings)
187
- }
182
+ db.Raw("SELECT role_name, db_name, is_primary FROM tenant_roles WHERE LOWER(role_name) IN ?", lowerRoles).Scan(&mappings)
188
183
  }
189
184
 
190
185
  // Filter out unauthorized mappings (e.g. admin_db for non-admins)
@@ -198,11 +193,16 @@ func TenantMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
198
193
  mappings = authorizedMappings
199
194
 
200
195
  if len(mappings) == 0 {
196
+ if !isAdmin {
197
+ c.JSON(http.StatusForbidden, gin.H{"error": "Access denied. No tenant database provisioned for your roles."})
198
+ c.Abort()
199
+ return
200
+ }
201
201
  conn, err := mgr.GetDB(fallbackDB)
202
202
  if err != nil || conn == nil {
203
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve tenant fallback database connection"})
204
- c.Abort()
205
- return
203
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve tenant fallback database connection"})
204
+ c.Abort()
205
+ return
206
206
  }
207
207
  siloConnections["fallback"] = conn
208
208
  c.Set("tenantDBConn", conn)
@@ -355,6 +355,7 @@ import (
355
355
  )
356
356
 
357
357
  type DatabaseRequest struct {
358
+ TenantID string \`json:"tenantId" form:"tenantId"\`
358
359
  RoleName string \`json:"roleName" form:"roleName" binding:"required"\`
359
360
  DBName string \`json:"dbName" form:"dbName" binding:"required"\`
360
361
  IsPrimary bool \`json:"isPrimary" form:"isPrimary"\`
@@ -390,15 +391,25 @@ func CreateDatabaseAndMigrate(masterDB *gorm.DB) gin.HandlerFunc {
390
391
  if count == 0 {
391
392
  isPrimary = true
392
393
  }
394
+
395
+ tenantID := req.TenantID
396
+ if tenantID == "" {
397
+ tenantID = uuid.New().String()
398
+ }
399
+
393
400
  if err := masterDB.Where("LOWER(role_name) = LOWER(?) AND db_name = ?", req.RoleName, req.DBName).First(&existing).Error; err == nil {
394
401
  if count <= 1 {
395
402
  isPrimary = true
396
403
  }
397
404
  existing.IsPrimary = isPrimary
405
+ if req.TenantID != "" {
406
+ existing.TenantID = req.TenantID
407
+ }
398
408
  masterDB.Save(&existing)
409
+ tenantID = existing.TenantID
399
410
  } else {
400
411
  masterDB.Create(&models.TenantRole{
401
- TenantID: uuid.New().String(),
412
+ TenantID: tenantID,
402
413
  RoleName: req.RoleName,
403
414
  DBName: req.DBName,
404
415
  IsPrimary: isPrimary,
@@ -414,7 +425,7 @@ func CreateDatabaseAndMigrate(masterDB *gorm.DB) gin.HandlerFunc {
414
425
  migrations.RunGoNativeMigrationsForTenant(tenantDB)
415
426
  }
416
427
 
417
- c.JSON(http.StatusOK, gin.H{"message": "Role silo assigned successfully", "role": req.RoleName, "primary": isPrimary})
428
+ c.JSON(http.StatusOK, gin.H{"message": "Role silo assigned successfully", "role": req.RoleName, "tenantId": tenantID, "primary": isPrimary})
418
429
  }
419
430
  }
420
431
 
@@ -140,11 +140,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
140
140
  { key: "X-Tenant-ID", value: "{{tenant}}" }
141
141
  ],
142
142
  url: {
143
- raw: "http://{{host}}:{{port}}/api/audit",
143
+ raw: "http://{{host}}:{{port}}/api/admin/audit",
144
144
  protocol: "http",
145
145
  host: ["{{host}}"],
146
146
  port: "{{port}}",
147
- path: ["api", "audit"]
147
+ path: ["api", "admin", "audit"]
148
148
  }
149
149
  }
150
150
  },
@@ -305,6 +305,17 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
305
305
  }
306
306
  ];
307
307
 
308
+ if (entity.isSearchable && config.elasticsearch?.enabled) {
309
+ privateItems.push({
310
+ name: `Elasticsearch Search (${capitalized})`,
311
+ request: {
312
+ method: "GET",
313
+ header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
314
+ url: { raw: `http://{{host}}:{{port}}/api/search/${name}?q=Sample`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["api", "search", name], query: [ { key: "q", value: "Sample" } ] }
315
+ }
316
+ });
317
+ }
318
+
308
319
  folder.item.push({ name: "Private (Auth Required) CRUD", item: privateItems });
309
320
  restFolder.item.push(folder);
310
321
  }
@@ -208,7 +208,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
208
208
  }
209
209
 
210
210
  // 2. Add System Paths
211
- swagger.paths['/rpc/{table}'] = {
211
+ swagger.paths['/api/rpc/{table}'] = {
212
212
  get: {
213
213
  tags: ['Search Engine'],
214
214
  summary: 'Generic PostgREST RPC Engine',
@@ -240,7 +240,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
240
240
  }
241
241
  };
242
242
 
243
- swagger.paths['/audit'] = {
243
+ swagger.paths['/api/admin/audit'] = {
244
244
  get: {
245
245
  tags: ['Observability'],
246
246
  summary: 'Fetch Audit Trail',
@@ -249,6 +249,68 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
249
249
  }
250
250
  };
251
251
 
252
+ swagger.paths['/management/tenant/assign'] = {
253
+ post: {
254
+ tags: ['Management'],
255
+ summary: 'Provision and Assign Tenant Database',
256
+ description: 'Creates a dedicated database for the specified tenant and runs all migrations.',
257
+ requestBody: {
258
+ required: true,
259
+ content: {
260
+ 'application/json': {
261
+ schema: {
262
+ type: 'object',
263
+ properties: {
264
+ roleName: { type: 'string', example: 'tenant_1_admin', description: 'The realm role name mapped to this database context' },
265
+ dbName: { type: 'string', example: 'tenant_1', description: 'The physical name of the database to create and provision' }
266
+ },
267
+ required: ['roleName', 'dbName']
268
+ }
269
+ }
270
+ }
271
+ },
272
+ parameters: [
273
+ { name: 'X-Tenant-ID', in: 'header', required: true, schema: { type: 'string', default: 'master_internal' }, description: 'SuperAdmin internal master bypass token' }
274
+ ],
275
+ responses: {
276
+ 200: { description: 'Success' },
277
+ 401: { description: 'Unauthorized' },
278
+ 403: { description: 'Forbidden (Requires SuperAdmin role)' }
279
+ }
280
+ }
281
+ };
282
+
283
+ if (config.elasticsearch?.enabled) {
284
+ swagger.paths['/api/search/{entity}'] = {
285
+ get: {
286
+ tags: ['Search Engine'],
287
+ summary: 'Elasticsearch Global Search',
288
+ description: 'High-performance full-text search with fuzzy matching across federated silos using Elasticsearch.',
289
+ parameters: [
290
+ ...commonHeaders,
291
+ { name: 'entity', in: 'path', required: true, schema: { type: 'string' }, description: 'The entity name to search (e.g., institute)' },
292
+ { name: 'q', in: 'query', schema: { type: 'string' }, description: 'The search query (Spring-style fuzzy match)' }
293
+ ],
294
+ responses: {
295
+ 200: {
296
+ description: 'OK',
297
+ content: {
298
+ 'application/json': {
299
+ schema: {
300
+ type: 'object',
301
+ properties: {
302
+ total: { type: 'integer', description: 'Total search hits matching query' },
303
+ hits: { type: 'array', items: { type: 'object' }, description: 'Search hits (matching source documents)' }
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ };
312
+ }
313
+
252
314
  await fs.writeJson(path.join(docsDir, 'swagger.json'), swagger, { spaces: 2 });
253
315
  console.log(chalk.gray(' Generated Swagger Documentation: swagger.json'));
254
316
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-duck-cli",
3
- "version": "1.1.40",
3
+ "version": "1.1.43",
4
4
  "description": "The Ultimate Evolutionary Go Microservice Scaffolder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -230,6 +230,13 @@ func SetupRouter(appConfig *config.Config) *gin.Engine {
230
230
  api := r.Group("/api")
231
231
  api.Use(middleware.JWTMiddleware())
232
232
  api.Use(middleware.TenantMiddleware(masterDB, appConfig))
233
+ // Log request DB resolution for debugging
234
+ api.Use(func(c *gin.Context) {
235
+ if dbName, ok := c.Get("resolvedDB"); ok {
236
+ fmt.Printf("[LOG] %s %s using DB: %s\n", c.Request.Method, c.Request.URL.Path, dbName.(string))
237
+ }
238
+ c.Next()
239
+ })
233
240
  api.Use(middleware.AuditMiddleware(masterDB))
234
241
  api.Use(middleware.MeteringMiddleware(masterDB))
235
242
  {