diff --git a/cmd/ocm/gcp/create-wif-config.go b/cmd/ocm/gcp/create-wif-config.go index 600cd228..b4bc2ee2 100644 --- a/cmd/ocm/gcp/create-wif-config.go +++ b/cmd/ocm/gcp/create-wif-config.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" + "google.golang.org/api/cloudresourcemanager/v1" "google.golang.org/api/googleapi" "google.golang.org/api/iam/v1" iamv1 "google.golang.org/api/iam/v1" @@ -32,6 +33,7 @@ var ( } impersonatorServiceAccount = "projects/sda-ccs-3/serviceAccounts/osd-impersonator@sda-ccs-3.iam.gserviceaccount.com" + impersonatorEmail = "osd-impersonator@sda-ccs-3.iam.gserviceaccount.com" ) const ( @@ -84,20 +86,13 @@ func createWorkloadIdentityConfigurationCmd(cmd *cobra.Command, argv []string) { PoolIdentityProviderId: wifConfig.Status.WorkloadIdentityPoolData.IdentityProviderId, } - // TODO: implement scripting for dry run if CreateWorkloadIdentityConfigurationOpts.DryRun { - log.Printf("Dry run option not yet available") - - // identityPoolContent := createIdentityPoolScriptContent(poolSpec) - // identityProviderContent := createIdentityProviderScriptContent(poolSpec) - // serviceAccountContent := createServiceAccountScriptContent(wifConfig) - // err = createScriptFile(CreateWorkloadIdentityConfigurationOpts.TargetDir, - // identityPoolContent, - // identityProviderContent, - // serviceAccountContent) - // if err != nil { - // log.Fatalf("Failed to create script files: %s", err) - // } + log.Printf("Writing script files to %s", CreateWorkloadIdentityConfigurationOpts.TargetDir) + + err := createScript(CreateWorkloadIdentityConfigurationOpts.TargetDir, wifConfig) + if err != nil { + log.Fatalf("Failed to create script files: %s", err) + } return } @@ -258,15 +253,17 @@ func createServiceAccounts(ctx context.Context, gcpClient gcp.GcpClient, wifOutp serviceAccountID := serviceAccount.GetId() fmt.Printf("\t\tBinding roles to %s\n", serviceAccount.Id) + roles := make([]string, 0, len(serviceAccount.Roles)) for _, role := range serviceAccount.Roles { if !role.Predefined { fmt.Printf("Skipping role %q for service account %q as custom roles are not yet supported.", role.Id, serviceAccount.Id) continue } - err := gcpClient.BindRole(serviceAccountID, projectId, fmtRoleResourceId(role)) - if err != nil { - panic(err) - } + roles = append(roles, fmtRoleResourceId(role)) + } + err := EnsurePolicyBindingsForProject(gcpClient, roles, fmt.Sprintf("serviceAccount:%s@%s.iam.gserviceaccount.com", serviceAccountID, projectId), projectId) + if err != nil { + log.Fatalf("Failed to bind roles to service account %s: %s", serviceAccountID, err) } fmt.Printf("\t\tRoles bound to %s\n", serviceAccount.Id) @@ -317,3 +314,78 @@ func generateServiceAccountID(serviceAccount models.ServiceAccount) string { } return serviceAccountID } + +// EnsurePolicyBindingsForProject ensures that given roles and member, appropriate binding is added to project +func EnsurePolicyBindingsForProject(gcpClient gcp.GcpClient, roles []string, member string, projectName string) error { + needPolicyUpdate := false + + policy, err := gcpClient.GetProjectIamPolicy(projectName, &cloudresourcemanager.GetIamPolicyRequest{}) + + if err != nil { + return fmt.Errorf("error fetching policy for project: %v", err) + } + + // Validate that each role exists, and add the policy binding as needed + for _, definedRole := range roles { + // Earlier we've verified that the requested roles already exist. + + // Add policy binding + modified := addPolicyBindingForProject(policy, definedRole, member) + if modified { + needPolicyUpdate = true + } + + } + + if needPolicyUpdate { + return setProjectIamPolicy(gcpClient, policy, projectName) + } + + // If we made it this far there were no updates needed + return nil +} + +func addPolicyBindingForProject(policy *cloudresourcemanager.Policy, roleName, memberName string) bool { + for i, binding := range policy.Bindings { + if binding.Role == roleName { + return addMemberToBindingForProject(memberName, policy.Bindings[i]) + } + } + + // if we didn't find an existing binding entry, then make one + createMemberRoleBindingForProject(policy, roleName, memberName) + + return true +} + +func createMemberRoleBindingForProject(policy *cloudresourcemanager.Policy, roleName, memberName string) { + policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ + Members: []string{memberName}, + Role: roleName, + }) +} + +// adds member to existing binding. returns bool indicating if an entry was made +func addMemberToBindingForProject(memberName string, binding *cloudresourcemanager.Binding) bool { + for _, member := range binding.Members { + if member == memberName { + // already present + return false + } + } + + binding.Members = append(binding.Members, memberName) + return true +} + +func setProjectIamPolicy(gcpClient gcp.GcpClient, policy *cloudresourcemanager.Policy, projectName string) error { + policyRequest := &cloudresourcemanager.SetIamPolicyRequest{ + Policy: policy, + } + + _, err := gcpClient.SetProjectIamPolicy(projectName, policyRequest) + if err != nil { + return fmt.Errorf("error setting project policy: %v", err) + } + return nil +} diff --git a/cmd/ocm/gcp/gcp.go b/cmd/ocm/gcp/gcp.go index 81ade8ec..ea1ac047 100644 --- a/cmd/ocm/gcp/gcp.go +++ b/cmd/ocm/gcp/gcp.go @@ -29,6 +29,7 @@ func NewGcpCmd() *cobra.Command { gcpCmd.AddCommand(NewGetCmd()) gcpCmd.AddCommand(NewListCmd()) gcpCmd.AddCommand(NewDescribeCmd()) + gcpCmd.AddCommand(NewGenerateCommand()) return gcpCmd } diff --git a/cmd/ocm/gcp/generate-wif-script.go b/cmd/ocm/gcp/generate-wif-script.go new file mode 100644 index 00000000..e057f865 --- /dev/null +++ b/cmd/ocm/gcp/generate-wif-script.go @@ -0,0 +1,61 @@ +package gcp + +import ( + "log" + + alphaocm "github.com/openshift-online/ocm-cli/pkg/alpha_ocm" + "github.com/spf13/cobra" +) + +var ( + // CreateWorkloadIdentityPoolOpts captures the options that affect creation of the workload identity pool + GenerateScriptOpts = options{ + TargetDir: "", + } +) + +func NewGenerateCommand() *cobra.Command { + generateScriptCmd := &cobra.Command{ + Use: "generate [wif-config ID]", + Short: "Generate script based on a wif-config", + Args: cobra.ExactArgs(1), + Run: generateCreateScriptCmd, + PersistentPreRun: validationForGenerateCreateScriptCmd, + } + + generateScriptCmd.PersistentFlags().StringVar(&GenerateScriptOpts.TargetDir, "output-dir", "", "Directory to place generated files (defaults to current directory)") + + return generateScriptCmd +} + +func validationForGenerateCreateScriptCmd(cmd *cobra.Command, argv []string) { + if len(argv) != 1 { + log.Fatal( + "Expected exactly one command line parameters containing the id " + + "of the WIF config.", + ) + } +} + +func generateCreateScriptCmd(cmd *cobra.Command, argv []string) { + // Create the client for the OCM API: + ocmClient, err := alphaocm.NewOcmClient() + if err != nil { + log.Fatalf("failed to create backend client: %v", err) + } + + wifConfigId := argv[0] + if wifConfigId == "" { + log.Fatal("WIF config ID is required") + } + + wifConfig, err := ocmClient.GetWifConfig(wifConfigId) + if err != nil { + log.Fatalf("failed to get wif-config: %v", err) + } + + log.Printf("Writing script files to %s", GenerateScriptOpts.TargetDir) + if err := createScript(GenerateScriptOpts.TargetDir, &wifConfig); err != nil { + log.Fatalf("failed to generate script: %v", err) + } +} diff --git a/cmd/ocm/gcp/scripting.go b/cmd/ocm/gcp/scripting.go index 24547cde..1254202a 100644 --- a/cmd/ocm/gcp/scripting.go +++ b/cmd/ocm/gcp/scripting.go @@ -15,12 +15,50 @@ import ( // addPolicyBindingForSvcAcctCmd = "gcloud iam service-accounts add-iam-policy-binding --member=%s --role=%s" // ) +func createScript(targetDir string, wifConfig *models.WifConfigOutput) error { + // Write the script content to the path + scriptContent := generateScriptContent(wifConfig) + err := os.WriteFile(filepath.Join(targetDir, "script.sh"), []byte(scriptContent), 0644) + if err != nil { + return err + } + // Write jwk json file to the path + jwkPath := filepath.Join(targetDir, "jwk.json") + err = os.WriteFile(jwkPath, []byte(wifConfig.Status.WorkloadIdentityPoolData.Jwks), 0644) + if err != nil { + return err + } + return nil +} + +func generateScriptContent(wifConfig *models.WifConfigOutput) string { + poolSpec := gcp.WorkloadIdentityPoolSpec{ + PoolName: wifConfig.Status.WorkloadIdentityPoolData.PoolId, + ProjectId: wifConfig.Status.WorkloadIdentityPoolData.ProjectId, + Jwks: wifConfig.Status.WorkloadIdentityPoolData.Jwks, + IssuerUrl: wifConfig.Status.WorkloadIdentityPoolData.IssuerUrl, + PoolIdentityProviderId: wifConfig.Status.WorkloadIdentityPoolData.IdentityProviderId, + } + + scriptContent := "#!/bin/bash\n" + + // Create a script to create the workload identity pool + scriptContent += createIdentityPoolScriptContent(poolSpec) + + // Append the script to create the identity provider + scriptContent += createIdentityProviderScriptContent(poolSpec) + + // Append the script to create the service accounts + scriptContent += createServiceAccountScriptContent(wifConfig) + + return scriptContent +} + func createIdentityPoolScriptContent(spec gcp.WorkloadIdentityPoolSpec) string { name := spec.PoolName project := spec.ProjectId - return fmt.Sprintf(`#!/bin/bash - + return fmt.Sprintf(` # Create a workload identity pool gcloud iam workload-identity-pools create %s \ --project=%s \ @@ -28,37 +66,21 @@ gcloud iam workload-identity-pools create %s \ --description="Workload Identity Pool for %s" \ --display-name="%s" `, name, project, poolDescription, name) - - // // Create the output directory if it doesn't exist - // err := os.MkdirAll("output", 0755) - // if err != nil { - // return err - // } - - // // Write the script content to the file - // err = ioutil.WriteFile("output/createWorkloadIdentityPool.sh", []byte(scriptContent), 0644) - // if err != nil { - // return err - // } - - // return nil } func createIdentityProviderScriptContent(spec gcp.WorkloadIdentityPoolSpec) string { - return fmt.Sprintf(`#!/bin/bash - + return fmt.Sprintf(` # Create a workload identity provider gcloud iam workload-identity-pools providers create-oidc %s \ --display-name="%s" \ - --description=\"%s\" \ + --description="%s" \ --location=global \ --issuer-uri="%s" \ - --jwk-json-path="path/to/jwk.json" + --jwk-json-path="jwk.json" \ --allowed-audiences="%s" \ - --attribute-mapping=\"google.subject=assertion.sub\" \ + --attribute-mapping="google.subject=assertion.sub" \ --workload-identity-pool=%s `, spec.PoolName, spec.PoolName, poolDescription, spec.IssuerUrl, openShiftAudience, spec.PoolName) - } // This returns the gcloud commands to create a service account, bind roles, and grant access @@ -67,43 +89,56 @@ func createServiceAccountScriptContent(wifConfig *models.WifConfigOutput) string // For each service account, create a service account and bind it to the workload identity pool var sb strings.Builder - sb.WriteString("# Create service accounts:\n") + sb.WriteString("\n# Create service accounts:\n") for _, sa := range wifConfig.Status.ServiceAccounts { - sb.WriteString(fmt.Sprintf("gcloud iam service-accounts create %s --display-name=%s --project=%s\n", - sa.Id, sa.Id, wifConfig.Spec.ProjectId)) + project := wifConfig.Spec.ProjectId + serviceAccountID := sa.GetId() + serviceAccountName := wifConfig.Spec.DisplayName + "-" + serviceAccountID + serviceAccountDesc := poolDescription + " for WIF config " + wifConfig.Spec.DisplayName + sb.WriteString(fmt.Sprintf("gcloud iam service-accounts create %s --display-name=%s --description=\"%s\" --project=%s\n", + serviceAccountID, serviceAccountName, serviceAccountDesc, project)) } - sb.WriteString("# Bind service account roles:\n") + sb.WriteString("\n# Bind service account roles:\n") for _, sa := range wifConfig.Status.ServiceAccounts { for _, role := range sa.Roles { - sb.WriteString(fmt.Sprintf("gcloud projects add-iam-policy-binding %s --member=serviceAccount:%s --role=%s\n", - wifConfig.Spec.ProjectId, sa.Id, role.Id)) + project := wifConfig.Spec.ProjectId + member := fmt.Sprintf("serviceAccount:%s@%s.iam.gserviceaccount.com", sa.GetId(), project) + sb.WriteString(fmt.Sprintf("gcloud projects add-iam-policy-binding %s --member=%s --role=roles/%s\n", + project, member, role.Id)) + } + } + sb.WriteString("\n# Grant access:\n") + for _, sa := range wifConfig.Status.ServiceAccounts { + if sa.AccessMethod == "wif" { + project := wifConfig.Spec.ProjectId + serviceAccount := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", sa.GetId(), project) + members := fmtMembers(sa, wifConfig.Status.WorkloadIdentityPoolData.ProjectNumber, wifConfig.Status.WorkloadIdentityPoolData.PoolId) + for _, member := range members { + sb.WriteString(fmt.Sprintf("gcloud iam service-accounts add-iam-policy-binding %s --member=%s --role=roles/iam.workloadIdentityUser --project=%s\n", + serviceAccount, member, project)) + } + } else if sa.AccessMethod == "impersonate" { + // gcloud iam service-accounts add-iam-policy-binding SERVICE_ACCOUNT_EMAIL \ + // --member='serviceAccount:IMPERSONATOR_EMAIL' \ + // --role='roles/iam.serviceAccountTokenCreator' \ + // --project=PROJECT_ID + project := wifConfig.Spec.ProjectId + serviceAccount := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", sa.GetId(), project) + // saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s@%s.iam.gserviceaccount.com", wifConfig.Spec.ProjectId, sa.Id, wifConfig.Spec.ProjectId) + impersonator := fmt.Sprintf("serviceAccount:%s", impersonatorEmail) + sb.WriteString(fmt.Sprintf("gcloud iam service-accounts add-iam-policy-binding %s --member=%s --role=roles/iam.serviceAccountTokenCreator --project=%s\n", + serviceAccount, impersonator, wifConfig.Spec.ProjectId)) } } - sb.WriteString("# Grant access:\n") - // for _, sa := range wifConfig.Status.ServiceAccounts { - // if sa.AccessMethod == "impersonate" { - // sb.WriteString(fmt.Sprintf("gcloud iam service-accounts add-iam-policy-binding %s --member=serviceAccount:%s --role=roles/iam.workloadIdentityUser\n", - // sa.Id, sa.Id)) - // } else if sa.AccessMethod == "wif" { - // // gcloud iam service-accounts add-iam-policy-binding SERVICE_ACCOUNT_ID \ - // // --member='serviceAccount:PROJECT_ID.svc.id.goog[WORKLOAD_IDENTITY_POOL_ID/WORKLOAD_IDENTITY_PROVIDER_ID]' \ - // // --role='roles/iam.workloadIdentityUser' \ - // // --project=PROJECT_ID - // sb.WriteString(fmt.Sprintf("gcloud iam service-accounts add-iam-policy-binding %s --member='serviceAccount:%s.svc.id.goog[%s/%s]' --role='roles/iam.workloadIdentityUser' --project=%s\n", - // } - // } return sb.String() } -func createScriptFile(targetDir string, content ...string) error { - // Concatenate the content strings - scriptContent := strings.Join(content, "\n") - - // Write the script content to the file - err := os.WriteFile(filepath.Join(targetDir, "script.sh"), []byte(scriptContent), 0644) - if err != nil { - return err +func fmtMembers(sa models.ServiceAccount, projectNum int64, poolId string) []string { + members := []string{} + for _, saName := range sa.GetServiceAccountNames() { + members = append(members, fmt.Sprintf( + "principal://iam.googleapis.com/projects/%d/locations/global/workloadIdentityPools/%s/subject/system:serviceaccount:%s:%s", + projectNum, poolId, sa.GetSecretNamespace(), saName)) } - - return nil + return members } diff --git a/pkg/gcp/client.go b/pkg/gcp/client.go index 6ab6e1bc..c8ae3372 100644 --- a/pkg/gcp/client.go +++ b/pkg/gcp/client.go @@ -31,7 +31,8 @@ type GcpClient interface { DeleteServiceAccount(saName string, project string, allowMissing bool) error - BindRole(saId, projectId, roleResourceId string) error + GetProjectIamPolicy(projectName string, request *cloudresourcemanager.GetIamPolicyRequest) (*cloudresourcemanager.Policy, error) + SetProjectIamPolicy(svcAcctResource string, request *cloudresourcemanager.SetIamPolicyRequest) (*cloudresourcemanager.Policy, error) AttachImpersonator(saId, projectId, impersonatorResourceId string) error AttachWorkloadIdentityPool(sa ServiceAccount, poolId, projectId string) error @@ -130,27 +131,6 @@ func (c *gcpClient) ListServiceAccounts(project string, filter func(string) bool return out, nil } -func (c *gcpClient) BindRole(saId, projectId, roleResourceId string) error { - getReq := c.cloudResourceManager.Projects.GetIamPolicy(projectId, &cloudresourcemanager.GetIamPolicyRequest{}) - policy, err := getReq.Do() - if err != nil { - return err - } - for _, binding := range policy.Bindings { - if binding.Role == roleResourceId { - binding.Members = append(binding.Members, fmt.Sprintf("serviceAccount:%s@%s.iam.gserviceaccount.com", saId, projectId)) - } - } - - setReq := c.cloudResourceManager.Projects.SetIamPolicy(projectId, &cloudresourcemanager.SetIamPolicyRequest{ - Policy: policy, - }) - if _, err := setReq.Do(); err != nil { - return err - } - return nil -} - func (c *gcpClient) AttachImpersonator(saId, projectId string, impersonatorId string) error { saResourceId := fmt.Sprintf("projects/%s/serviceAccounts/%s@%s.iam.gserviceaccount.com", projectId, saId, projectId) policy, err := c.iamClient.GetIamPolicy(context.Background(), &iampb.GetIamPolicyRequest{ @@ -320,3 +300,11 @@ func (c *gcpClient) ProjectNumberFromId(projectId string) (int64, error) { } return project.ProjectNumber, nil } + +func (c *gcpClient) GetProjectIamPolicy(projectName string, request *cloudresourcemanager.GetIamPolicyRequest) (*cloudresourcemanager.Policy, error) { + return c.cloudResourceManager.Projects.GetIamPolicy(projectName, request).Context(context.Background()).Do() +} + +func (c *gcpClient) SetProjectIamPolicy(svcAcctResource string, request *cloudresourcemanager.SetIamPolicyRequest) (*cloudresourcemanager.Policy, error) { + return c.cloudResourceManager.Projects.SetIamPolicy(svcAcctResource, request).Context(context.Background()).Do() +}