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.
- package/generators/kratos.js +12 -12
- package/generators/multitenancy.js +28 -17
- package/generators/postman.js +13 -2
- package/generators/swagger.js +64 -2
- package/package.json +1 -1
- package/templates/go/router.go.hbs +7 -0
package/generators/kratos.js
CHANGED
|
@@ -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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
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
|
|
package/generators/postman.js
CHANGED
|
@@ -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
|
}
|
package/generators/swagger.js
CHANGED
|
@@ -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
|
@@ -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
|
{
|