diff --git a/api/restHandler/BatchOperationRestHandler.go b/api/restHandler/BatchOperationRestHandler.go index c1f3adb535..9f5c53caa6 100644 --- a/api/restHandler/BatchOperationRestHandler.go +++ b/api/restHandler/BatchOperationRestHandler.go @@ -100,7 +100,7 @@ func (handler BatchOperationRestHandlerImpl) Operate(w http.ResponseWriter, r *h return } rbacString := handler.enforcerUtil.GetProjectAdminRBACNameBYAppName(*workflow.Destination.App) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreate, rbacString); !ok { + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreatePipeline, rbacString); !ok { common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden) return } diff --git a/api/restHandler/BulkUpdateRestHandler.go b/api/restHandler/BulkUpdateRestHandler.go index 782bddd7bb..8850ffe42b 100644 --- a/api/restHandler/BulkUpdateRestHandler.go +++ b/api/restHandler/BulkUpdateRestHandler.go @@ -461,7 +461,8 @@ func (handler BulkUpdateRestHandlerImpl) HandleCdPipelineBulkAction(w http.Respo //check to avoid same rbac matching multiple times if _, ok := appsHavingRbacChecked[impactedPipeline.App.AppName]; !ok { resourceName := handler.enforcerUtil.GetAppRBACName(impactedPipeline.App.AppName) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionUpdate, resourceName); !ok { + // bulk CD action only performs pipeline deletion (CD_BULK_DELETE); gate it as lifecycle delete + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionDeletePipeline, resourceName); !ok { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) return } else { diff --git a/api/restHandler/CoreAppRestHandler.go b/api/restHandler/CoreAppRestHandler.go index bbe991a5b3..b50d215aff 100644 --- a/api/restHandler/CoreAppRestHandler.go +++ b/api/restHandler/CoreAppRestHandler.go @@ -2277,8 +2277,13 @@ func (handler CoreAppRestHandlerImpl) CreateAppWorkflow(w http.ResponseWriter, r } createAppRequest.AppId = app.AppId object := fmt.Sprintf("%s/%s", app.ProjectName, app.AppName) - // with admin roles, you have to access for all the apps of the project to create new app. (admin or manager with specific app permission can't create app.) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreate, object); !ok { + // Creating a workflow is pipeline lifecycle (createPipeline); a config-only request + // (environmentOverrides with no workflow) stays on create so dev-admin keeps config-edit access. + wfCreateAction := casbin.ActionCreate + if len(createAppRequest.AppWorkflows) > 0 { + wfCreateAction = casbin.ActionCreatePipeline + } + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, wfCreateAction, object); !ok { common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden) return } diff --git a/api/restHandler/app/pipeline/configure/BuildPipelineRestHandler.go b/api/restHandler/app/pipeline/configure/BuildPipelineRestHandler.go index 8cdc04422d..713236f092 100644 --- a/api/restHandler/app/pipeline/configure/BuildPipelineRestHandler.go +++ b/api/restHandler/app/pipeline/configure/BuildPipelineRestHandler.go @@ -141,7 +141,7 @@ func (handler *PipelineConfigRestHandlerImpl) CreateCiConfig(w http.ResponseWrit } token := r.Header.Get("token") - _, authorized := handler.getAppAndCheckAuthForAction(w, createRequest.AppId, token, casbin.ActionCreate) + _, authorized := handler.getAppAndCheckAuthForAction(w, createRequest.AppId, token, casbin.ActionCreatePipeline) if !authorized { return } @@ -405,9 +405,27 @@ func (handler *PipelineConfigRestHandlerImpl) PatchCiPipelines(w http.ResponseWr return } - haveCiPatchAccess := handler.checkCiPatchAccess(token, resourceName, cdPipelines) - if !haveCiPatchAccess { - haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionCreate, resourceName) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionCreate, workflowResourceName) + // Pipeline lifecycle (add/delete a CI pipeline) is gated by the dedicated lifecycle actions, + // independent of config/branch editing which keeps its existing access rules. Switch-CI is a + // creation flow and is treated as create. The jobs fallback is unchanged for all arms. + var haveCiPatchAccess bool + switch { + case patchRequest.Action == bean.DELETE: + haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionDeletePipeline, resourceName) + if !haveCiPatchAccess { + haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionCreate, resourceName) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionCreate, workflowResourceName) + } + case patchRequest.Action == bean.CREATE || patchRequest.IsSwitchCiPipelineRequest(): + haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreatePipeline, resourceName) + if !haveCiPatchAccess { + haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionCreate, resourceName) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionCreate, workflowResourceName) + } + default: + // UPDATE_PIPELINE / UPDATE_SOURCE: config/branch edit — existing access rules unchanged + haveCiPatchAccess = handler.checkCiPatchAccess(token, resourceName, cdPipelines) + if !haveCiPatchAccess { + haveCiPatchAccess = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionCreate, resourceName) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionCreate, workflowResourceName) + } } if !haveCiPatchAccess { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) diff --git a/api/restHandler/app/pipeline/configure/DeploymentPipelineRestHandler.go b/api/restHandler/app/pipeline/configure/DeploymentPipelineRestHandler.go index f0e82c9591..d1e149d894 100644 --- a/api/restHandler/app/pipeline/configure/DeploymentPipelineRestHandler.go +++ b/api/restHandler/app/pipeline/configure/DeploymentPipelineRestHandler.go @@ -233,7 +233,7 @@ func (handler *PipelineConfigRestHandlerImpl) CreateCdPipeline(w http.ResponseWr // RBAC resourceName := handler.enforcerUtil.GetAppRBACName(app.AppName) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreate, resourceName); !ok { + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreatePipeline, resourceName); !ok { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) return } @@ -367,13 +367,23 @@ func (handler *PipelineConfigRestHandlerImpl) PatchCdPipeline(w http.ResponseWri common.WriteJsonResp(w, err, nil, http.StatusBadRequest) return } + // CD_CREATE adds a pipeline (lifecycle create); CD_DELETE/CD_DELETE_PARTIAL delete one (lifecycle + // delete); any other patch (CD_UPDATE / config / strategy) stays on update so config editing works. + appAction := casbin.ActionUpdate + switch cdPipeline.Action { + case bean.CD_CREATE: + appAction = casbin.ActionCreatePipeline + case bean.CD_DELETE, bean.CD_DELETE_PARTIAL: + appAction = casbin.ActionDeletePipeline + } resourceName := handler.enforcerUtil.GetAppRBACName(app.AppName) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionUpdate, resourceName); !ok { + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, appAction, resourceName); !ok { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) return } object := handler.enforcerUtil.GetAppRBACByAppIdAndPipelineId(cdPipeline.AppId, cdPipeline.Pipeline.Id) + // keep environment:update for the env-scoped gate on both delete and config patches if ok := handler.enforcer.Enforce(token, casbin.ResourceEnvironment, casbin.ActionUpdate, object); !ok { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) return diff --git a/api/restHandler/app/pipeline/configure/PipelineConfigRestHandler.go b/api/restHandler/app/pipeline/configure/PipelineConfigRestHandler.go index e683c2cfcb..2f01e9dad1 100644 --- a/api/restHandler/app/pipeline/configure/PipelineConfigRestHandler.go +++ b/api/restHandler/app/pipeline/configure/PipelineConfigRestHandler.go @@ -336,7 +336,11 @@ func (handler *PipelineConfigRestHandlerImpl) DeleteACDAppWithNonCascade(w http. } // rbac enforcer applying resourceName := handler.enforcerUtil.GetAppRBACName(app.AppName) - if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionGet, resourceName); !ok { + appAction := casbin.ActionGet + if forceDelete { // force=true deletes the CD pipeline -> pipeline lifecycle + appAction = casbin.ActionDeletePipeline + } + if ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, appAction, resourceName); !ok { common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden) return } diff --git a/api/restHandler/app/workflow/AppWorkflowRestHandler.go b/api/restHandler/app/workflow/AppWorkflowRestHandler.go index bcad863c97..8669db140d 100644 --- a/api/restHandler/app/workflow/AppWorkflowRestHandler.go +++ b/api/restHandler/app/workflow/AppWorkflowRestHandler.go @@ -102,7 +102,19 @@ func (handler AppWorkflowRestHandlerImpl) CreateAppWorkflow(w http.ResponseWrite token := r.Header.Get("token") //rbac block starts from here resourceName := handler.enforcerUtil.GetAppRBACNameByAppId(request.AppId) - ok := handler.enforcerUtil.CheckAppRbacForAppOrJob(token, resourceName, casbin.ActionCreate) + app, err := handler.pipelineBuilder.GetApp(request.AppId) + if err != nil { + handler.Logger.Errorw("error in fetching app for rbac", "err", err, "appId", request.AppId) + common.WriteJsonResp(w, err, nil, http.StatusBadRequest) + return + } + // apps enforce the applications lifecycle action (createPipeline); jobs enforce the jobs resource only + var ok bool + if app.AppType == helper.Job { + ok = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionCreate, resourceName) + } else { + ok = handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionCreatePipeline, resourceName) + } if !ok { common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden) return @@ -154,9 +166,19 @@ func (handler AppWorkflowRestHandlerImpl) DeleteAppWorkflow(w http.ResponseWrite //rbac block starts from here resourceName := handler.enforcerUtil.GetAppRBACNameByAppId(appId) workflowResourceName := handler.enforcerUtil.GetRbacObjectNameByAppIdAndWorkflow(appId, appWorkflow.Name) - ok := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionDelete, resourceName) - if !ok { + app, err := handler.pipelineBuilder.GetApp(appId) + if err != nil { + handler.Logger.Errorw("service err, GetApp", "err", err, "appId", appId) + common.WriteJsonResp(w, err, nil, http.StatusInternalServerError) + return + } + var ok bool + if app.AppType == helper.Job { + // jobs: enforce the jobs resource only (no applications OR), symmetric with CreateAppWorkflow ok = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionDelete, resourceName) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionDelete, workflowResourceName) + } else { + // devtron apps: workflow delete is pipeline lifecycle + ok = handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionDeletePipeline, resourceName) } if !ok { common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden) diff --git a/pkg/auth/authorisation/casbin/rbacpolicy.go b/pkg/auth/authorisation/casbin/rbacpolicy.go index 1bca4a6418..c4252bfbde 100644 --- a/pkg/auth/authorisation/casbin/rbacpolicy.go +++ b/pkg/auth/authorisation/casbin/rbacpolicy.go @@ -62,8 +62,17 @@ const ( // ActionCreateApp and ActionDeleteApp gate the application-entity lifecycle (create/delete an app), // decoupled from ActionCreate/ActionDelete which also gate pipeline/workflow/config operations. // admin/manager/super-admin keep app lifecycle via their wildcard (`*`) action policies. - ActionCreateApp = "createApp" - ActionDeleteApp = "deleteApp" + // Values are lowercase: the casbin store normalizes policy actions to lowercase (AddPolicy -> + // strings.ToLower), while enforcement passes the action unchanged, so the constant must already be + // lowercase to match a stored grant. + ActionCreateApp = "createapp" + ActionDeleteApp = "deleteapp" + // ActionCreatePipeline and ActionDeletePipeline gate CI/CD pipeline and workflow lifecycle + // (create/delete), decoupled from ActionCreate/ActionUpdate/ActionDelete which continue to gate + // config editing. admin/manager/super-admin keep lifecycle via their wildcard (`*`) action policies. + // Lowercase for the same store-normalization reason as ActionCreateApp above. + ActionCreatePipeline = "createpipeline" + ActionDeletePipeline = "deletepipeline" ClusterResourceRegex = "%s/%s" // {cluster}/{namespace} ClusterObjectRegex = "%s/%s/%s" // {groupName}/{kindName}/{objectName} diff --git a/scripts/sql/37704800_dev_admin_pipeline_lifecycle.down.sql b/scripts/sql/37704800_dev_admin_pipeline_lifecycle.down.sql new file mode 100644 index 0000000000..02aeed7416 --- /dev/null +++ b/scripts/sql/37704800_dev_admin_pipeline_lifecycle.down.sql @@ -0,0 +1,15 @@ +-- Reverse Increment 2: remove pipeline lifecycle actions and restore Release-1 lifecycle actions to +-- their original mixed-case form (as 36504600 left them), so down returns to the pre-migration state. + +-- 1) remove pipeline lifecycle actions +UPDATE rbac_policy_resource_detail +SET allowed_actions = array_remove(array_remove(allowed_actions, 'createpipeline'), 'deletepipeline'), + updated_on = now() +WHERE resource = 'applications'; + +-- 2) restore Release-1 application lifecycle actions to mixed case +UPDATE rbac_policy_resource_detail +SET allowed_actions = array_replace(array_replace(allowed_actions, 'createapp', 'createApp'), 'deleteapp', 'deleteApp'), + updated_on = now() +WHERE resource = 'applications' + AND allowed_actions IS NOT NULL; diff --git a/scripts/sql/37704800_dev_admin_pipeline_lifecycle.up.sql b/scripts/sql/37704800_dev_admin_pipeline_lifecycle.up.sql new file mode 100644 index 0000000000..719e229dc8 --- /dev/null +++ b/scripts/sql/37704800_dev_admin_pipeline_lifecycle.up.sql @@ -0,0 +1,23 @@ +-- Increment 2: add createpipeline / deletepipeline to the applications resource whitelist so custom +-- roles can grant/withhold pipeline lifecycle independently of config actions. dev-admin omits them. +-- +-- Action values are lowercase on purpose: the casbin store normalizes policy actions to lowercase on +-- AddPolicy (strings.ToLower) while enforcement passes the action unchanged, so a mixed-case whitelist +-- label would never match the stored grant. This migration also normalizes the Release-1 lifecycle +-- actions (createApp/deleteApp, added by 36504600) to lowercase here rather than editing that already +-- shipped migration, so the whitelist is internally consistent on both fresh and upgraded installs. + +-- 1) normalize Release-1 application lifecycle actions to lowercase (no-op if already lowercase) +UPDATE rbac_policy_resource_detail +SET allowed_actions = array_replace(array_replace(allowed_actions, 'createApp', 'createapp'), 'deleteApp', 'deleteapp'), + updated_on = now() +WHERE resource = 'applications' + AND allowed_actions IS NOT NULL; + +-- 2) add pipeline lifecycle actions (lowercase) +UPDATE rbac_policy_resource_detail +SET allowed_actions = array_cat(allowed_actions, ARRAY ['createpipeline','deletepipeline']), + updated_on = now() +WHERE resource = 'applications' + AND allowed_actions IS NOT NULL + AND NOT ('createpipeline' = ANY (allowed_actions));