diff --git a/pkg/cli/upgradeassistant/cmd/migrate/430.go b/pkg/cli/upgradeassistant/cmd/migrate/430.go index e02a631fad..9c6c314dfa 100644 --- a/pkg/cli/upgradeassistant/cmd/migrate/430.go +++ b/pkg/cli/upgradeassistant/cmd/migrate/430.go @@ -26,8 +26,10 @@ import ( collaborationmongodb "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/collaboration/repository/mongodb" "github.com/koderover/zadig/v2/pkg/microservice/user/core/repository" usermodels "github.com/koderover/zadig/v2/pkg/microservice/user/core/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/user/core/repository/orm" userorm "github.com/koderover/zadig/v2/pkg/microservice/user/core/repository/orm" permissionservice "github.com/koderover/zadig/v2/pkg/microservice/user/core/service/permission" + "github.com/koderover/zadig/v2/pkg/setting" internalhandler "github.com/koderover/zadig/v2/pkg/shared/handler" "github.com/koderover/zadig/v2/pkg/tool/log" pkgtypes "github.com/koderover/zadig/v2/pkg/types" @@ -39,6 +41,14 @@ type permissionActionSeed430 struct { Name string Action string Resource string + Scope int +} + +var businessDirectoryActionSeeds430 = []permissionActionSeed430{ + {Name: "查看", Action: permissionservice.VerbGetBusinessDirectory, Resource: "BusinessDirectory", Scope: pkgtypes.DBSystemScope}, + {Name: "新建", Action: permissionservice.VerbCreateBusinessDirectory, Resource: "BusinessDirectory", Scope: pkgtypes.DBSystemScope}, + {Name: "编辑", Action: permissionservice.VerbEditBusinessDirectory, Resource: "BusinessDirectory", Scope: pkgtypes.DBSystemScope}, + {Name: "删除", Action: permissionservice.VerbDeleteBusinessDirectory, Resource: "BusinessDirectory", Scope: pkgtypes.DBSystemScope}, } type permissionBackfillRule430 struct { @@ -116,6 +126,7 @@ func V421ToV430() error { return err } + err = migrateGlobalReadOnlyRole(ctx, migrationInfo) err = migrateScalePermissions(migrationInfo) if err != nil { return err @@ -146,6 +157,189 @@ func migrateUserAPITokenEnabledColumn(_ *internalhandler.Context, migrationInfo return nil } +// check global read only role column +func migrateGlobalReadOnlyRole(_ *internalhandler.Context, migrationInfo *internalmodels.Migration) error { + if !migrationInfo.Migration430GlobalReadOnlyRole { + if !repository.DB.Migrator().HasColumn(&usermodels.NewRole{}, "GlobalReadOnly") { + if err := repository.DB.Migrator().AddColumn(&usermodels.NewRole{}, "GlobalReadOnly"); err != nil { + return fmt.Errorf("failed to add global_read_only column for role table, err: %s", err) + } + } + } + + // write globalreadonly role into system roles + err := backfillGlobalReadOnlyRole() + if err != nil { + return err + } + // Ensure business-directory actions exist for upgraded instances. + if err := ensureBusinessDirectoryActions430(); err != nil { + return err + } + // Fallback backfill: + // - if a role already has get_business_directory, append create/edit/delete + if err := backfillBusinessDirectoryRolePermissions430(); err != nil { + return err + } + + _ = internalmongodb.NewMigrationColl().UpdateMigrationStatus(migrationInfo.ID, map[string]interface{}{ + getMigrationFieldBsonTag(migrationInfo, &migrationInfo.Migration430GlobalReadOnlyRole): true, + }) + + return nil +} + +func ensureBusinessDirectoryActions430() error { + tx := repository.DB.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin tx for business directory action migration, err: %s", tx.Error) + } + + for _, seed := range businessDirectoryActionSeeds430 { + action, err := orm.GetActionByVerb(seed.Action, tx) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to query action %s, err: %s", seed.Action, err) + } + if action != nil && action.ID != 0 { + continue + } + + action = &usermodels.Action{ + Name: seed.Name, + Action: seed.Action, + Resource: seed.Resource, + Scope: seed.Scope, + } + if err := orm.CreateAction(action, tx); err != nil { + tx.Rollback() + return fmt.Errorf("failed to create action %s, err: %s", seed.Action, err) + } + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit business directory action migration tx, err: %s", err) + } + return nil +} + +// backfillBusinessDirectoryRolePermissions430 provides a migration fallback for +// historical system roles: +// 1) If a role already has get_business_directory, only append write verbs. +func backfillBusinessDirectoryRolePermissions430() error { + tx := repository.DB.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin tx for business directory permission backfill, err: %s", tx.Error) + } + + actionIDMap := make(map[string]uint, len(businessDirectoryActionSeeds430)) + for _, seed := range businessDirectoryActionSeeds430 { + action, err := orm.GetActionByVerb(seed.Action, tx) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to query action %s for backfill, err: %s", seed.Action, err) + } + if action == nil || action.ID == 0 { + tx.Rollback() + return fmt.Errorf("action %s is missing while backfilling business directory permissions", seed.Action) + } + actionIDMap[seed.Action] = action.ID + } + + roles, err := orm.ListRoleByNamespace(permissionservice.GeneralNamespace, tx) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to list system roles for business directory backfill, err: %s", err) + } + + for _, role := range roles { + if role == nil || role.ID == 0 { + continue + } + // Keep global-read-only role as readonly. + if role.GlobalReadOnly { + continue + } + + actions, err := orm.ListActionByRole(role.ID, tx) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to list actions for role %d during business directory backfill, err: %s", role.ID, err) + } + + existingVerbs := map[string]struct{}{} + for _, action := range actions { + if action == nil { + continue + } + existingVerbs[action.Action] = struct{}{} + } + + // Only backfill write verbs for roles that already have get_business_directory. + if _, hasGet := existingVerbs[permissionservice.VerbGetBusinessDirectory]; !hasGet { + continue + } + targetVerbs := []string{ + permissionservice.VerbCreateBusinessDirectory, + permissionservice.VerbEditBusinessDirectory, + permissionservice.VerbDeleteBusinessDirectory, + } + + missingActionIDs := make([]uint, 0) + for _, verb := range targetVerbs { + if _, ok := existingVerbs[verb]; ok { + continue + } + if actionID, ok := actionIDMap[verb]; ok { + missingActionIDs = append(missingActionIDs, actionID) + } + } + + if len(missingActionIDs) == 0 { + continue + } + if err := orm.BulkCreateRoleActionBindings(role.ID, missingActionIDs, tx); err != nil { + tx.Rollback() + return fmt.Errorf("failed to backfill business directory permissions for role %d, err: %s", role.ID, err) + } + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit business directory permission backfill tx, err: %s", err) + } + return nil +} + +// backfill global read only role +func backfillGlobalReadOnlyRole() error { + tx := repository.DB.Begin() + role := &usermodels.NewRole{ + Name: "global-read-only", + Description: "拥有系统全局只读的权限", + Type: int64(setting.RoleTypeSystem), + Namespace: "*", + GlobalReadOnly: true, + } + + // Check if role already exists + existingRole, err := orm.GetRole("global-read-only", "*", tx) + if err == nil && existingRole != nil && existingRole.ID != 0 { + tx.Commit() + return nil + } + + if err := orm.CreateRole(role, tx); err != nil { + tx.Rollback() + return fmt.Errorf("failed to create global-read-only role in backfill, error: %s", err) + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit tx, err: %s", err) + } + + return nil +} + func migrateScalePermissions(migrationInfo *internalmodels.Migration) error { alreadyMigrated := migrationInfo.Migration430ScalePermission diff --git a/pkg/cli/upgradeassistant/internal/repository/models/migration.go b/pkg/cli/upgradeassistant/internal/repository/models/migration.go index b4202fd34d..7437c2de92 100644 --- a/pkg/cli/upgradeassistant/internal/repository/models/migration.go +++ b/pkg/cli/upgradeassistant/internal/repository/models/migration.go @@ -41,6 +41,7 @@ type Migration struct { Migration421CollaborationRollbackPermission bool `bson:"migration_421_collaboration_rollback_permission"` Migration421WorkflowDeploySpec bool `bson:"migration_421_workflow_deploy_spec"` Migration430UserAPITokenEnabled bool `bson:"migration_430_user_api_token_enabled"` + Migration430GlobalReadOnlyRole bool `bson:"migration_430_global_read_only_role"` Migration430ScalePermission bool `bson:"migration_430_scale_permission"` Migration430CollaborationScalePermission bool `bson:"migration_430_collaboration_scale_permission"` Error string `bson:"error"` diff --git a/pkg/microservice/aslan/core/application/handler/application.go b/pkg/microservice/aslan/core/application/handler/application.go index a9150812c0..f1091b1c04 100644 --- a/pkg/microservice/aslan/core/application/handler/application.go +++ b/pkg/microservice/aslan/core/application/handler/application.go @@ -39,6 +39,10 @@ func CreateApplication(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Create { + ctx.UnAuthorized = true + return + } args := new(commonmodels.Application) data, _ := c.GetRawData() c.Request.Body = io.NopCloser(bytes.NewBuffer(data)) @@ -58,6 +62,11 @@ func BulkCreateApplications(c *gin.Context) { return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Create { + ctx.UnAuthorized = true + return + } + var args []*commonmodels.Application data, _ := c.GetRawData() c.Request.Body = io.NopCloser(bytes.NewBuffer(data)) @@ -74,8 +83,17 @@ func BulkCreateApplications(c *gin.Context) { } func GetApplication(c *gin.Context) { - ctx := internalhandler.NewContext(c) + ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.View { + ctx.UnAuthorized = true + return + } ctx.Resp, ctx.RespErr = service.GetApplication(c.Param("id"), ctx.Logger) } @@ -87,6 +105,10 @@ func UpdateApplication(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Edit { + ctx.UnAuthorized = true + return + } args := new(commonmodels.Application) data, _ := c.GetRawData() if err := json.Unmarshal(data, args); err != nil { @@ -104,12 +126,25 @@ func DeleteApplication(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Delete { + ctx.UnAuthorized = true + return + } ctx.RespErr = service.DeleteApplication(c.Param("id"), ctx.Logger) } func SearchApplications(c *gin.Context) { - ctx := internalhandler.NewContext(c) + ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.View { + ctx.UnAuthorized = true + return + } var req service.SearchApplicationsRequest if err := c.ShouldBindJSON(&req); err != nil { ctx.RespErr = e.ErrInvalidParam.AddErr(err) @@ -124,8 +159,17 @@ func SearchApplications(c *gin.Context) { } func ListApplicationEnvs(c *gin.Context) { - ctx := internalhandler.NewContext(c) + ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.View { + ctx.UnAuthorized = true + return + } resp, err := service.ListApplicationEnvs(c.Param("id"), ctx.Logger) if err != nil { ctx.RespErr = err diff --git a/pkg/microservice/aslan/core/application/handler/field_definition.go b/pkg/microservice/aslan/core/application/handler/field_definition.go index ae3e8efa94..eb410bdf50 100644 --- a/pkg/microservice/aslan/core/application/handler/field_definition.go +++ b/pkg/microservice/aslan/core/application/handler/field_definition.go @@ -39,6 +39,10 @@ func CreateFieldDefinition(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Edit { + ctx.UnAuthorized = true + return + } args := new(commonmodels.ApplicationFieldDefinition) data, _ := c.GetRawData() c.Request.Body = io.NopCloser(bytes.NewBuffer(data)) @@ -50,8 +54,17 @@ func CreateFieldDefinition(c *gin.Context) { } func ListFieldDefinitions(c *gin.Context) { - ctx := internalhandler.NewContext(c) + ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.View { + ctx.UnAuthorized = true + return + } ctx.Resp, ctx.RespErr = service.ListFieldDefinitions(ctx.Logger) } @@ -63,6 +76,10 @@ func UpdateFieldDefinition(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Edit { + ctx.UnAuthorized = true + return + } args := new(commonmodels.ApplicationFieldDefinition) data, _ := c.GetRawData() if err := json.Unmarshal(data, args); err != nil { @@ -80,5 +97,9 @@ func DeleteFieldDefinition(c *gin.Context) { ctx.UnAuthorized = true return } + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.BusinessDirectory.Delete { + ctx.UnAuthorized = true + return + } ctx.RespErr = service.DeleteFieldDefinition(c.Param("id"), ctx.Logger) } diff --git a/pkg/microservice/aslan/core/environment/handler/product.go b/pkg/microservice/aslan/core/environment/handler/product.go index 8daca62f22..ef20c6c5c6 100644 --- a/pkg/microservice/aslan/core/environment/handler/product.go +++ b/pkg/microservice/aslan/core/environment/handler/product.go @@ -98,6 +98,7 @@ func GetInitProduct(c *gin.Context) { if !ctx.Resources.SystemActions.Project.Create && // this api is also used in creating testing env for some reason !(ctx.Resources.ProjectAuthInfo[projectKey].Env.Create || + ctx.Resources.ProjectAuthInfo[projectKey].Env.View || ctx.Resources.ProjectAuthInfo[projectKey].IsProjectAdmin) { ctx.UnAuthorized = true return diff --git a/pkg/microservice/user/core/init/action_initialization.sql b/pkg/microservice/user/core/init/action_initialization.sql index bb8ffdc28d..f78e7a1c38 100644 --- a/pkg/microservice/user/core/init/action_initialization.sql +++ b/pkg/microservice/user/core/init/action_initialization.sql @@ -73,6 +73,9 @@ VALUES ("删除", "delete_release_plan", "ReleasePlan", 2), ("配置", "edit_config_release_plan", "ReleasePlan", 2), ("查看", "get_business_directory", "BusinessDirectory", 2), + ("新建", "create_business_directory", "BusinessDirectory", 2), + ("编辑", "edit_business_directory", "BusinessDirectory", 2), + ("删除", "delete_business_directory", "BusinessDirectory", 2), ("查看", "get_cluster_management", "ClusterManagement", 2), ("新建", "create_cluster_management", "ClusterManagement", 2), ("编辑", "edit_cluster_management", "ClusterManagement", 2), diff --git a/pkg/microservice/user/core/init/dm_action_initialization.sql b/pkg/microservice/user/core/init/dm_action_initialization.sql index 75565fd774..6c8bb04950 100644 --- a/pkg/microservice/user/core/init/dm_action_initialization.sql +++ b/pkg/microservice/user/core/init/dm_action_initialization.sql @@ -70,6 +70,9 @@ VALUES ('编辑', 'edit_release_plan', 'ReleasePlan', 2), ('删除', 'delete_release_plan', 'ReleasePlan', 2), ('查看', 'get_business_directory', 'BusinessDirectory', 2), + ('新建', 'create_business_directory', 'BusinessDirectory', 2), + ('编辑', 'edit_business_directory', 'BusinessDirectory', 2), + ('删除', 'delete_business_directory', 'BusinessDirectory', 2), ('查看', 'get_cluster_management', 'ClusterManagement', 2), ('新建', 'create_cluster_management', 'ClusterManagement', 2), ('编辑', 'edit_cluster_management', 'ClusterManagement', 2), diff --git a/pkg/microservice/user/core/init/dm_mysql.sql b/pkg/microservice/user/core/init/dm_mysql.sql index 9108c58d3a..a06dee9bf2 100644 --- a/pkg/microservice/user/core/init/dm_mysql.sql +++ b/pkg/microservice/user/core/init/dm_mysql.sql @@ -78,6 +78,7 @@ CREATE TABLE IF NOT EXISTS role ( name varchar(32) NOT NULL COMMENT '角色名称', description varchar(64) NOT NULL COMMENT '描述', type int NOT NULL COMMENT '资源范围,1-系统自带, 2-用户自定义', + global_read_only tinyint NOT NULL DEFAULT '0' COMMENT '全局只读开关,开启后可对所有项目扩散只读权限', namespace varchar(32) NOT NULL COMMENT '所属项目,*为全局角色标记', PRIMARY KEY (id) ) ; diff --git a/pkg/microservice/user/core/init/mysql.sql b/pkg/microservice/user/core/init/mysql.sql index 6d53b40b82..68d491b991 100644 --- a/pkg/microservice/user/core/init/mysql.sql +++ b/pkg/microservice/user/core/init/mysql.sql @@ -74,7 +74,9 @@ CREATE TABLE IF NOT EXISTS `role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL COMMENT '角色名称', `description` varchar(64) NOT NULL COMMENT '描述', + `type` int(11) NOT NULL COMMENT '资源范围,1-系统自带, 2-用户自定义', + `global_read_only` tinyint(1) NOT NULL DEFAULT '0' COMMENT '全局只读开关,开启后可对所有项目扩散只读权限', `namespace` varchar(32) NOT NULL COMMENT '所属项目,*为全局角色标记', PRIMARY KEY (`id`), UNIQUE KEY `namespaced_role` (`namespace`, `name`) diff --git a/pkg/microservice/user/core/repository/models/role.go b/pkg/microservice/user/core/repository/models/role.go index 5709c3258c..614bbb02b5 100644 --- a/pkg/microservice/user/core/repository/models/role.go +++ b/pkg/microservice/user/core/repository/models/role.go @@ -36,11 +36,12 @@ func (Role) TableName() string { // NewRole is the schema for role in mysql database, after version 1.7 type NewRole struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"column:name" json:"name"` - Description string `gorm:"column:description" json:"description"` - Type int64 `gorm:"column:type" json:"type"` - Namespace string `gorm:"column:namespace" json:"namespace"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"column:name" json:"name"` + Description string `gorm:"column:description" json:"description"` + Type int64 `gorm:"column:type" json:"type"` + Namespace string `gorm:"column:namespace" json:"namespace"` + GlobalReadOnly bool `gorm:"column:global_read_only" json:"global_read_only"` RoleActionBindings []RoleActionBinding `gorm:"foreignKey:RoleID;constraint:OnDelete:CASCADE;" json:"-"` RoleUserBindings []NewRoleBinding `gorm:"foreignKey:RoleID;constraint:OnDelete:CASCADE;" json:"-"` diff --git a/pkg/microservice/user/core/service.go b/pkg/microservice/user/core/service.go index 219ecbb32b..2c6418b8ef 100644 --- a/pkg/microservice/user/core/service.go +++ b/pkg/microservice/user/core/service.go @@ -231,6 +231,7 @@ func syncUserRoleBinding() { log.Panicf("Failed to count roles in the mysql role table to do the data initialization, error: %s", err) } + // if roleCount > 0, it means that the data has already been initialized, so we can return if roleCount > 0 { return } @@ -253,11 +254,34 @@ func syncUserRoleBinding() { Type: int64(setting.RoleTypeSystem), Namespace: "*", } + // add new role: global read only + globalReadOnlyRole := &models.NewRole{ + Name: "global-read-only", + Description: "拥有系统全局只读的权限", + Type: int64(setting.RoleTypeSystem), + Namespace: "*", + GlobalReadOnly: true, + } + + err := orm.BulkCreateRole([]*models.NewRole{adminRole, globalReadOnlyRole}, tx) + if err != nil { + tx.Rollback() + log.Panicf("failed to initialize system default roles, tearing down user service...") + } - err := orm.CreateRole(adminRole, tx) + actionIDList := make([]uint, 0, len(readOnlyAction)) + for _, verb := range readOnlyAction { + action, err := orm.GetActionByVerb(verb, tx) + if err != nil || action.ID == 0 { + tx.Rollback() + log.Panicf("failed to find action %s for global-read-only role, error: %s", verb, err) + } + actionIDList = append(actionIDList, action.ID) + } + err = orm.BulkCreateRoleActionBindings(globalReadOnlyRole.ID, actionIDList, tx) if err != nil { tx.Rollback() - log.Panicf("failed to initialize admin role for system, tearing down user service...") + log.Panicf("failed to create action binding for role %s in namespace %s, error: %s", globalReadOnlyRole.Name, globalReadOnlyRole.Namespace, err) } } @@ -345,10 +369,13 @@ func syncUserRoleBinding() { RoleLoop: for _, role := range allRoles { // create corresponding mysql role + // GlobalReadOnly is not used in mysql, so it is commented out + //Todo: mysqlRole := &models.NewRole{ Name: role.Name, Description: role.Desc, Namespace: role.Namespace, + // GlobalReadOnly: role.GlobalReadOnly, } if role.Type == setting.ResourceTypeSystem { diff --git a/pkg/microservice/user/core/service/permission/authz.go b/pkg/microservice/user/core/service/permission/authz.go index 2917eab332..22d91f2545 100644 --- a/pkg/microservice/user/core/service/permission/authz.go +++ b/pkg/microservice/user/core/service/permission/authz.go @@ -19,6 +19,7 @@ package permission import ( "database/sql" "fmt" + "slices" "go.uber.org/zap" "k8s.io/apimachinery/pkg/util/sets" @@ -32,6 +33,7 @@ import ( "github.com/koderover/zadig/v2/pkg/types" ) +// GetUserAuthInfo get user auth info func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResources, error) { // system calls if uid == "" { @@ -67,6 +69,7 @@ func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResource systemActions := generateDefaultSystemActions() // we generate a map of namespaced(project) permission projectActionMap := make(map[string]*ProjectActions) + globalReadVerbSet := sets.New[string]() roles, err := ListRoleByUID(uid) if err != nil { @@ -74,12 +77,21 @@ func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResource return nil, fmt.Errorf("failed to list roles for uid: %s, error: %s", uid, err) } + // for _, role := range roles { if role.Namespace != GeneralNamespace { if _, ok := projectActionMap[role.Namespace]; !ok { projectActionMap[role.Namespace] = generateDefaultProjectActions() } } + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + for _, verb := range readOnlyAction { + globalReadVerbSet.Insert(verb) + } + for _, verb := range globalReadOnlySystemAction { + modifySystemAction(systemActions, verb) + } + } // project admin does not have any bindings, it is special if role.Name == ProjectAdminRole { @@ -97,7 +109,11 @@ func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResource for _, action := range actions { switch role.Namespace { case GeneralNamespace: + // inject system actions for global read-only role modifySystemAction(systemActions, action) + if role.GlobalReadOnly && isReadOnlyActionVerb(action) { + globalReadVerbSet.Insert(action) + } default: modifyUserProjectAuth(projectActionMap[role.Namespace], action) } @@ -124,6 +140,17 @@ func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResource projectActionMap[role.Namespace] = generateDefaultProjectActions() } } + // global read-only role has special permission + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + for _, verb := range readOnlyAction { + globalReadVerbSet.Insert(verb) + } + + // 开启 SystemAction read权限 for global read-only role + for _, verb := range globalReadOnlySystemAction { + modifySystemAction(systemActions, verb) + } + } if role.Name == ProjectAdminRole { projectActionMap[role.Namespace].IsProjectAdmin = true @@ -138,15 +165,27 @@ func GetUserAuthInfo(uid string, logger *zap.SugaredLogger) (*AuthorizedResource } for _, action := range actions { + if role.Namespace == GeneralNamespace && role.GlobalReadOnly && !isGlobalReadOnlyRoleActionVerb(action) { + continue + } switch role.Namespace { case GeneralNamespace: + // inject system actions for global read-only role modifySystemAction(systemActions, action) + if role.GlobalReadOnly && isReadOnlyActionVerb(action) { + globalReadVerbSet.Insert(action) + } default: modifyUserProjectAuth(projectActionMap[role.Namespace], action) } } } + //grant global read permission to all projects. + if err := grantGlobalReadAuthToAllProjects(projectActionMap, globalReadVerbSet.UnsortedList()); err != nil { + return nil, err + } + projectInfo := make(map[string]ProjectActions) for proj, actions := range projectActionMap { projectInfo[proj] = *actions @@ -221,10 +260,14 @@ func CheckPermissionGivenByCollaborationMode(uid, projectKey, resource, action s return } +// ListAuthorizedProject list authorized projects for a user +// if user is system admin, return all projects +// if user is not system admin, return projects that user is in func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, error) { tx := repository.DB.Begin(&sql.TxOptions{ReadOnly: true}) - respSet := sets.NewString() + respSet := sets.New[string]() + projectCache := &allProjectCache{} isSystemAdmin, err := checkUserIsSystemAdmin(uid, tx) if err != nil { @@ -234,17 +277,13 @@ func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, err } if isSystemAdmin { - projectList, err := mongodb.NewProjectColl().List() - if err != nil { + if err := projectCache.insertAllProjects(respSet); err != nil { tx.Rollback() logger.Errorf("failed to list project for project admin to return authorized projects, error: %s", err) return nil, fmt.Errorf("failed to list project for project admin to return authorized projects, error: %s", err) } - for _, project := range projectList { - respSet.Insert(project.ProductName) - } tx.Commit() - return respSet.List(), nil + return respSet.UnsortedList(), nil } groupIDList := make([]string, 0) @@ -277,9 +316,17 @@ func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, err } for _, role := range roles { - if role.Namespace != GeneralNamespace { - respSet.Insert(role.Namespace) + if role.Namespace == GeneralNamespace { + if role.GlobalReadOnly { + if err := projectCache.insertAllProjects(respSet); err != nil { + tx.Rollback() + logger.Errorf("failed to list all projects for global read role %s, error: %s", role.Name, err) + return nil, err + } + } + continue } + respSet.Insert(role.Namespace) } groupRoles, err := orm.ListRoleByGroupIDs(groupIDList, tx) @@ -290,9 +337,17 @@ func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, err } for _, role := range groupRoles { - if role.Namespace != GeneralNamespace { - respSet.Insert(role.Namespace) + if role.Namespace == GeneralNamespace { + if role.GlobalReadOnly { + if err := projectCache.insertAllProjects(respSet); err != nil { + tx.Rollback() + logger.Errorf("failed to list all projects for global read role %s, error: %s", role.Name, err) + return nil, err + } + } + continue } + respSet.Insert(role.Namespace) } // TODO: add user group support for collaboration mode @@ -302,7 +357,7 @@ func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, err // given by the role. tx.Commit() logger.Warnf("failed to find user collaboration mode, error: %s", err) - return respSet.List(), nil + return respSet.UnsortedList(), nil } // if user have collaboration mode, they must have access to this project. @@ -311,11 +366,12 @@ func ListAuthorizedProject(uid string, logger *zap.SugaredLogger) ([]string, err } tx.Commit() - return respSet.List(), nil + return respSet.UnsortedList(), nil } func ListAuthorizedProjectByVerb(uid, resource, verb string, logger *zap.SugaredLogger) ([]string, error) { - respSet := sets.NewString() + respSet := sets.New[string]() + projectCache := &allProjectCache{} tx := repository.DB.Begin(&sql.TxOptions{ReadOnly: true}) @@ -327,17 +383,13 @@ func ListAuthorizedProjectByVerb(uid, resource, verb string, logger *zap.Sugared } if isSystemAdmin { - projectList, err := mongodb.NewProjectColl().List() - if err != nil { + if err := projectCache.insertAllProjects(respSet); err != nil { tx.Rollback() logger.Errorf("failed to list project for project admin to return authorized projects, error: %s", err) return nil, fmt.Errorf("failed to list project for project admin to return authorized projects, error: %s", err) } - for _, project := range projectList { - respSet.Insert(project.ProductName) - } tx.Commit() - return respSet.List(), nil + return respSet.UnsortedList(), nil } groupIDList := make([]string, 0) @@ -375,6 +427,28 @@ func ListAuthorizedProjectByVerb(uid, resource, verb string, logger *zap.Sugared } } + // if user has global read only role, we must return all projects. + if isReadOnlyActionVerb(verb) { + systemRoles, err := orm.ListRoleByUID(uid, tx) + if err != nil { + tx.Rollback() + logger.Errorf("failed to list roles for uid: %s, error: %s", uid, err) + return nil, fmt.Errorf("failed to list roles for uid: %s, error: %s", uid, err) + } + + for _, role := range systemRoles { + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + if err := projectCache.insertAllProjects(respSet); err != nil { + tx.Rollback() + logger.Errorf("failed to list all projects for global read role %s, error: %s", role.Name, err) + return nil, err + } + break + } + } + } + + // if user has project admin role, we must return all projects. adminRoles, err := orm.ListProjectAdminRoleByUID(uid, tx) if err != nil { tx.Rollback() @@ -395,12 +469,33 @@ func ListAuthorizedProjectByVerb(uid, resource, verb string, logger *zap.Sugared return nil, fmt.Errorf("failed to list roles for groupid: %+v, error: %s", groupIDList, err) } + // if user has global read only role, we must return all projects. for _, role := range groupRoles { if role.Namespace != GeneralNamespace { respSet.Insert(role.Namespace) } } + // + if isReadOnlyActionVerb(verb) { + systemRoles, err := orm.ListRoleByGroupIDs(groupIDList, tx) + if err != nil { + tx.Rollback() + logger.Errorf("failed to list roles for groupid: %+v, error: %s", groupIDList, err) + return nil, fmt.Errorf("failed to list roles for groupid: %+v, error: %s", groupIDList, err) + } + for _, role := range systemRoles { + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + if err := projectCache.insertAllProjects(respSet); err != nil { + tx.Rollback() + logger.Errorf("failed to list all projects for global read role %s, error: %s", role.Name, err) + return nil, err + } + break + } + } + } + groupAdminRoles, err := orm.ListProjectAdminRoleByGroupIDs(groupIDList, tx) if err != nil { tx.Rollback() @@ -419,7 +514,7 @@ func ListAuthorizedProjectByVerb(uid, resource, verb string, logger *zap.Sugared } tx.Commit() - return respSet.List(), nil + return respSet.UnsortedList(), nil } // ListAuthorizedWorkflow lists all workflows authorized by collaboration mode @@ -576,6 +671,71 @@ func generateAdminRoleResource() *AuthorizedResources { } } +// isReadOnlyActionVerb check if the action is a read-only action. +func isReadOnlyActionVerb(action string) bool { + return slices.Contains(readOnlyAction, action) +} + +// isGlobalReadOnlySystemActionVerb check if the action is a global read-only system action. +func isGlobalReadOnlySystemActionVerb(action string) bool { + return slices.Contains(globalReadOnlySystemAction, action) +} + +// project action 和 system action 的交集 +func isGlobalReadOnlyRoleActionVerb(action string) bool { + return isReadOnlyActionVerb(action) || isGlobalReadOnlySystemActionVerb(action) +} + +// grantGlobalReadAuthToAllProjects grant global read permission to all projects. +func grantGlobalReadAuthToAllProjects(projectActionMap map[string]*ProjectActions, verbs []string) error { + if len(verbs) == 0 { + return nil + } + projectList, err := mongodb.NewProjectColl().List() + if err != nil { + return fmt.Errorf("failed to list projects for global read permission, error: %s", err) + } + + // get project list + for _, project := range projectList { + if _, ok := projectActionMap[project.ProductName]; !ok { + projectActionMap[project.ProductName] = generateDefaultProjectActions() + } + // 对用户所有的project action开启 + for _, verb := range verbs { + modifyUserProjectAuth(projectActionMap[project.ProductName], verb) + } + } + return nil +} + + +// allProjectCache caches all project names (lazy-loaded) +type allProjectCache struct { + loaded bool // whether data has been loaded from DB + projectNames []string // cached project names +} + +// insertAllProjects loads all projects once and inserts them into respSet +func (c *allProjectCache) insertAllProjects(respSet sets.Set[string]) error { + // load from DB only on first call + if !c.loaded { + projectList, err := mongodb.NewProjectColl().List() + if err != nil { + return err + } + for _, project := range projectList { + c.projectNames = append(c.projectNames, project.ProductName) + } + c.loaded = true + } + + // reuse cache + for _, projectName := range c.projectNames { + respSet.Insert(projectName) + } + return nil +} // generateDefaultProjectActions generate an ProjectActions without any authorization info. func generateDefaultProjectActions() *ProjectActions { return &ProjectActions{ @@ -913,6 +1073,12 @@ func modifySystemAction(systemActions *SystemActions, verb string) { systemActions.ReleasePlan.EditConfig = true case VerbGetBusinessDirectory: systemActions.BusinessDirectory.View = true + case VerbCreateBusinessDirectory: + systemActions.BusinessDirectory.Create = true + case VerbEditBusinessDirectory: + systemActions.BusinessDirectory.Edit = true + case VerbDeleteBusinessDirectory: + systemActions.BusinessDirectory.Delete = true case VerbGetClusterManagement: systemActions.ClusterManagement.View = true case VerbCreateClusterManagement: diff --git a/pkg/microservice/user/core/service/permission/internal.go b/pkg/microservice/user/core/service/permission/internal.go index 9fe721164c..6d0c20d8cf 100644 --- a/pkg/microservice/user/core/service/permission/internal.go +++ b/pkg/microservice/user/core/service/permission/internal.go @@ -44,6 +44,22 @@ var readOnlyAction = []string{ VerbGetSprint, } +// globalReadOnlySystemAction defines system-level read-only actions granted by global-read-only role. +// It intentionally excludes system settings and enterprise management actions. +var globalReadOnlySystemAction = []string{ + VerbGetTemplate, + VerbViewTestCenter, + VerbViewReleaseCenter, + VerbDeliveryCenterGetVersions, + VerbDeliveryCenterGetArtifact, + VerbGetDataCenterOverview, + VerbGetDataCenterInsight, + VerbGetBusinessDirectory, + VerbGetReleasePlan, + VerbGetRegistryManagement, + VerbGetS3StorageManagement, +} + func InitializeProjectAuthorization(namespace string, isPublic bool, admins []string, log *zap.SugaredLogger) error { tx := repository.DB.Begin() // First, create default roles diff --git a/pkg/microservice/user/core/service/permission/permission.go b/pkg/microservice/user/core/service/permission/permission.go index 00f03f12b5..d82dd2d2fc 100644 --- a/pkg/microservice/user/core/service/permission/permission.go +++ b/pkg/microservice/user/core/service/permission/permission.go @@ -87,6 +87,11 @@ func GetUserPermissionByProject(uid, projectName string, log *zap.SugaredLogger) } for _, role := range roles { + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + for _, action := range readOnlyAction { + projectVerbSet.Insert(action) + } + } if role.Namespace != projectName { continue } @@ -128,6 +133,11 @@ func GetUserPermissionByProject(uid, projectName string, log *zap.SugaredLogger) } for _, role := range groupRoleMap { + if role.Namespace == GeneralNamespace && role.GlobalReadOnly { + for _, action := range readOnlyAction { + projectVerbSet.Insert(action) + } + } if role.Namespace != projectName { continue } @@ -266,7 +276,13 @@ func GetUserRules(uid string, log *zap.SugaredLogger) (*GetUserRulesResp, error) switch role.Namespace { case GeneralNamespace: + if role.GlobalReadOnly { + systemVerbs = append(systemVerbs, globalReadOnlySystemAction...) + } for _, action := range actions { + if role.GlobalReadOnly && !isGlobalReadOnlyRoleActionVerb(action) { + continue + } systemVerbs = append(systemVerbs, action) } } @@ -311,7 +327,13 @@ func GetUserRules(uid string, log *zap.SugaredLogger) (*GetUserRulesResp, error) switch role.Namespace { case GeneralNamespace: + if role.GlobalReadOnly { + systemVerbs = append(systemVerbs, globalReadOnlySystemAction...) + } for _, action := range actions { + if role.GlobalReadOnly && !isGlobalReadOnlyRoleActionVerb(action) { + continue + } systemVerbs = append(systemVerbs, action) } } diff --git a/pkg/microservice/user/core/service/permission/role.go b/pkg/microservice/user/core/service/permission/role.go index 9328435ece..b1a619efe6 100644 --- a/pkg/microservice/user/core/service/permission/role.go +++ b/pkg/microservice/user/core/service/permission/role.go @@ -41,11 +41,11 @@ const ( RoleActionKeyFormat = "role_action_%d" UIDRoleKeyFormat = "uid_role_%s" - UIDRoleDataFormat = "%d++%s++%s" + UIDRoleDataFormat = "%d++%s++%s++%t" UIDRoleLock = "lock_uid_role_%s" GIDRoleKeyFormat = "gid_role_%s" - GIDRoleDataFormat = "%d++%s++%s" + GIDRoleDataFormat = "%d++%s++%s++%t" GIDRoleLock = "lock_gid_role_%s" ) @@ -55,11 +55,12 @@ const ( var ActionMap = make(map[string]uint) type CreateRoleReq struct { - Name string `json:"name"` - Actions []string `json:"actions"` - Namespace string `json:"namespace"` - Desc string `json:"desc,omitempty"` - Type string `json:"type,omitempty"` + Name string `json:"name"` + Actions []string `json:"actions"` + Namespace string `json:"namespace"` + Desc string `json:"desc,omitempty"` + Type string `json:"type,omitempty"` + GlobalReadOnly bool `json:"global_read_only,omitempty"` } // ListRoleByUID lists all roles by uid with cache. @@ -78,7 +79,7 @@ func ListRoleByUID(uid string) ([]*types.Role, error) { // if we got the data from cache, simply return it\ for _, roleInfo := range resp { roleInfos := strings.Split(roleInfo, "++") - if len(roleInfos) != 3 { + if len(roleInfos) != 3 && len(roleInfos) != 4 { // if the data is corrupted, stop using it. useCache = false break @@ -92,9 +93,10 @@ func ListRoleByUID(uid string) ([]*types.Role, error) { } response = append(response, &types.Role{ - ID: uint(roleID), - Namespace: roleInfos[1], - Name: roleInfos[2], + ID: uint(roleID), + Namespace: roleInfos[1], + Name: roleInfos[2], + GlobalReadOnly: len(roleInfos) == 4 && roleInfos[3] == "true", }) } } else { @@ -119,11 +121,12 @@ func ListRoleByUID(uid string) ([]*types.Role, error) { for _, role := range roles { response = append(response, &types.Role{ - ID: role.ID, - Namespace: role.Namespace, - Name: role.Name, + ID: role.ID, + Namespace: role.Namespace, + Name: role.Name, + GlobalReadOnly: role.GlobalReadOnly, }) - cacheData = append(cacheData, fmt.Sprintf(UIDRoleDataFormat, role.ID, role.Namespace, role.Name)) + cacheData = append(cacheData, fmt.Sprintf(UIDRoleDataFormat, role.ID, role.Namespace, role.Name, role.GlobalReadOnly)) } err = roleCache.Delete(uidRoleKey) @@ -156,7 +159,7 @@ func ListRoleByGID(gid string) ([]*types.Role, error) { // if we got the data from cache, simply return it\ for _, roleInfo := range resp { roleInfos := strings.Split(roleInfo, "++") - if len(roleInfos) != 3 { + if len(roleInfos) != 3 && len(roleInfos) != 4 { // if the data is corrupted, stop using it. useCache = false break @@ -170,9 +173,10 @@ func ListRoleByGID(gid string) ([]*types.Role, error) { } response = append(response, &types.Role{ - ID: uint(roleID), - Namespace: roleInfos[1], - Name: roleInfos[2], + ID: uint(roleID), + Namespace: roleInfos[1], + Name: roleInfos[2], + GlobalReadOnly: len(roleInfos) == 4 && roleInfos[3] == "true", }) } } else { @@ -197,11 +201,12 @@ func ListRoleByGID(gid string) ([]*types.Role, error) { for _, role := range roles { response = append(response, &types.Role{ - ID: role.ID, - Namespace: role.Namespace, - Name: role.Name, + ID: role.ID, + Namespace: role.Namespace, + Name: role.Name, + GlobalReadOnly: role.GlobalReadOnly, }) - cacheData = append(cacheData, fmt.Sprintf(GIDRoleDataFormat, role.ID, role.Namespace, role.Name)) + cacheData = append(cacheData, fmt.Sprintf(GIDRoleDataFormat, role.ID, role.Namespace, role.Name, role.GlobalReadOnly)) } err = roleCache.Delete(gidRoleKey) @@ -270,9 +275,10 @@ func CreateRole(ns string, req *CreateRoleReq, log *zap.SugaredLogger) error { tx := repository.DB.Begin() role := &models.NewRole{ - Name: req.Name, - Description: req.Desc, - Namespace: ns, + Name: req.Name, + Description: req.Desc, + Namespace: ns, + GlobalReadOnly: req.GlobalReadOnly, } if req.Type == string(setting.ResourceTypeSystem) { diff --git a/pkg/microservice/user/core/service/permission/types.go b/pkg/microservice/user/core/service/permission/types.go index 5b8869657b..ce4c331355 100644 --- a/pkg/microservice/user/core/service/permission/types.go +++ b/pkg/microservice/user/core/service/permission/types.go @@ -154,7 +154,10 @@ const ( VerbEditHelmRepoManagement = "edit_helmrepo_management" VerbDeleteHelmRepoManagement = "delete_helmrepo_management" // business directory - VerbGetBusinessDirectory = "get_business_directory" + VerbGetBusinessDirectory = "get_business_directory" + VerbCreateBusinessDirectory = "create_business_directory" + VerbEditBusinessDirectory = "edit_business_directory" + VerbDeleteBusinessDirectory = "delete_business_directory" // dbinstance management VerbGetDBInstanceManagement = "get_dbinstance_management" VerbCreateDBInstanceManagement = "create_dbinstance_management" @@ -351,7 +354,10 @@ type ReleasePlanActions struct { } type BusinessDirectoryActions struct { - View bool + View bool + Create bool + Edit bool + Delete bool } type ClusterManagementActions struct { diff --git a/pkg/shared/client/user/user_auth.go b/pkg/shared/client/user/user_auth.go index 5c4dfa3111..1ad62755ba 100644 --- a/pkg/shared/client/user/user_auth.go +++ b/pkg/shared/client/user/user_auth.go @@ -182,6 +182,10 @@ type ReleasePlanActions struct { type BusinessDirectoryActions struct { View bool + // Edit business directory metadata/configuration. + Edit bool + Create bool + Delete bool } type ClusterManagementActions struct { diff --git a/pkg/types/role.go b/pkg/types/role.go index 29d9ae3050..90e3c2e185 100644 --- a/pkg/types/role.go +++ b/pkg/types/role.go @@ -17,19 +17,21 @@ limitations under the License. package types type Role struct { - ID uint `json:"id"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Description string `json:"desc"` - Type string `json:"type"` + ID uint `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Description string `json:"desc"` + Type string `json:"type"` + GlobalReadOnly bool `json:"global_read_only,omitempty"` } type DetailedRole struct { - ID uint `json:"id"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Description string `json:"desc"` - Type string `json:"type"` + ID uint `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Description string `json:"desc"` + Type string `json:"type"` + GlobalReadOnly bool `json:"global_read_only,omitempty"` // ResourceActions represents a set of verbs with its corresponding resource. // the json response of this field `rules` is used for compatibility. ResourceActions []*ResourceAction `json:"rules"`