package services import ( "context" "fmt" "sync" "time" "github.com/rs/zerolog/log" "google.golang.org/api/cloudresourcemanager/v1" "google.golang.org/api/googleapi" "google.golang.org/api/iam/v1" "google.golang.org/api/option" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/util/retry" edgeiam "edge-infra.dev/pkg/lib/gcp/iam" ) type IAMService interface { CreateServiceAccount(ctx context.Context, projectID, serviceAccountName, displayName, description string) (*iam.ServiceAccount, error) CreatePolicyBinding(ctx context.Context, serviceAccountProjectID, policyProjectID, serviceAccountName string, roles []IAMRole) (*cloudresourcemanager.Policy, error) CreateSAKey(ctx context.Context, projectID string, serviceAccount *iam.ServiceAccount) (*iam.ServiceAccountKey, error) CreateSAandSAKey(ctx context.Context, ProjectID, serviceAccountName, displayName, description string) (*edgeiam.Component, error) CreateOrUpdateEdgeIAM(ctx context.Context, serviceAccountProjectID, policyProjectID, serviceAccountName string, componentIAM *edgeiam.Component, roles []IAMRole) (*edgeiam.Component, error) } type iamService struct { opts []option.ClientOption sync.Mutex } type IAMRole struct { name string roleType string wiNameSpace string wiK8sSA string } const ( WIType = "wiUser" GenericType = "generic" ) func NewIamRole(name string) IAMRole { return IAMRole{ name: name, roleType: GenericType, wiNameSpace: "", wiK8sSA: "", } } func NewWIIamRole(name, ns, k8sSAName string) IAMRole { return IAMRole{ name: name, roleType: WIType, wiNameSpace: ns, wiK8sSA: k8sSAName, } } func (r IAMRole) getMemberName(projectID, name string) string { memberstr := edgeiam.StandardSvcAccountMember(name, projectID) if r.roleType == WIType { memberstr = edgeiam.WorkloadIdentityMember(projectID, r.wiNameSpace, r.wiK8sSA) } return memberstr } var ( defaultRetry = wait.Backoff{ Duration: 500 * time.Millisecond, // every .5 seconds Steps: 10, // 10 times Factor: 1.5, Jitter: 0.1, } ) // CreateServiceAccount calls iam package to create service account. func (s *iamService) CreateServiceAccount(ctx context.Context, projectID, serviceAccountName, displayName, description string) (*iam.ServiceAccount, error) { iamservice, err := edgeiam.NewIAMService(ctx, s.opts...) project := fmt.Sprintf("projects/%s", projectID) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to init iamservice & failed to create configconnectormeta resource name: %s %s %w", project, serviceAccountName, err)). Msg("create service account failed") return nil, err } //create service account serviceAccount := edgeiam.NewServiceAccount(displayName, description) serviceAccountRequest := edgeiam.NewServiceAccountRequest(serviceAccountName, serviceAccount) serviceAccount, err = iamservice.CreateServiceAccount(ctx, project, serviceAccountRequest) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to create service account: %w", err)). Msg("create service account failed") if errorIs409(err) { return handleErrorIs409(ctx, serviceAccountName, projectID, project, iamservice) } return nil, err } return serviceAccount, nil } func handleErrorIs409(ctx context.Context, name, projectID, project string, iamservice *edgeiam.IAMService) (*iam.ServiceAccount, error) { saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, projectID) projectName := fmt.Sprintf("%s/serviceAccounts/%s", project, saEmail) sa, err := iamservice.GetServiceAccount(ctx, projectName) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("SA not found: %s, %w", projectName, err)). Msg("getting service account failed") return nil, err } saKeyProjectID := fmt.Sprintf("%s/serviceAccounts/%s", project, sa.Email) err = cleanupSAKeys(ctx, iamservice, saKeyProjectID) if err != nil { return nil, err } return sa, nil } func cleanupSAKeys(ctx context.Context, iamservice *edgeiam.IAMService, saKeyProjectID string) error { if err := iamservice.DeleteExcessServiceAccountKeys(ctx, saKeyProjectID); err != nil { if errorIs404(err) { log.Ctx(ctx).Err(fmt.Errorf("SA keys not found: %s, %w", saKeyProjectID, err)). Msg("deleting service account key failed") } else { log.Ctx(ctx).Err(err).Msg("deleting service account key failed") return err } } return nil } func (s *iamService) CreateSAKey(ctx context.Context, projectID string, serviceAccount *iam.ServiceAccount) (*iam.ServiceAccountKey, error) { iamservice, err := edgeiam.NewIAMService(ctx, s.opts...) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to init iamservice: %s %w", projectID, err)). Msg("create service account failed") return nil, err } project := fmt.Sprintf("projects/%s", projectID) //create service account api key serviceAccountKeyRequest := edgeiam.NewServiceAccountKeyRequest() //projects/{PROJECT_ID}/serviceAccounts/{ACCOUNT} where account is sa email. saKeyProjectID := fmt.Sprintf("%s/serviceAccounts/%s", project, serviceAccount.Email) serviceAccountKey, err := iamservice.CreateServiceAccountKey(ctx, saKeyProjectID, serviceAccountKeyRequest) if err != nil { return nil, err } return serviceAccountKey, nil } // CreatePolicyBinding calls iam package to create policy binding with provided roles. func (s *iamService) CreatePolicyBinding(ctx context.Context, serviceAccountProjectID, policyProjectID, serviceAccountName string, roles []IAMRole) (*cloudresourcemanager.Policy, error) { modifiedIamPolicy := &cloudresourcemanager.Policy{} if len(roles) > 0 { crmService, err := edgeiam.NewCRMService(ctx, s.opts...) if err != nil { return nil, err } iampolicy, err := crmService.GetPolicy(ctx, policyProjectID) if err != nil { return nil, err } bindingRoles := make(map[string]int) for index, binding := range iampolicy.Bindings { bindingRoles[binding.Role] = index } iampolicy = addBindingsToIAMPolicy(roles, serviceAccountName, serviceAccountProjectID, bindingRoles, iampolicy) modifiedIamPolicy, err = crmService.SetPolicy(ctx, policyProjectID, iampolicy) if err != nil { return nil, err } } return modifiedIamPolicy, nil } func addBindingsToIAMPolicy(roles []IAMRole, name, projectID string, bindingRoles map[string]int, iampolicy *cloudresourcemanager.Policy) *cloudresourcemanager.Policy { for _, role := range roles { memberstr := role.getMemberName(projectID, name) if value, exists := bindingRoles[role.name]; exists { iampolicy.Bindings[value].Members = append(iampolicy.Bindings[value].Members, memberstr) } else { policyBinding := edgeiam.CreatePolicyBinding(role.name, []string{memberstr}) iampolicy.Bindings = append(iampolicy.Bindings, policyBinding) } } return iampolicy } func (s *iamService) CreateSAandSAKey(ctx context.Context, projectID, serviceAccountName, displayName, description string) (*edgeiam.Component, error) { s.Lock() defer s.Unlock() sa, err := s.CreateServiceAccount(ctx, projectID, serviceAccountName, displayName, description) if err != nil { return nil, err } var saKey *iam.ServiceAccountKey // We retry on 409 conflict, 400 bad request, and 404 not found err = retry.OnError(defaultRetry, func(err error) bool { return isErrorCode(err, 409, 400, 404) }, func() error { saKey, err = s.CreateSAKey(ctx, projectID, sa) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to create sa key: %s %v", projectID, sa)). Msg("create service account failed. Will retry.") } return err }) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to create sa key: %s %v", projectID, sa)). Msg("create service account failed. Will NOT retry.") return nil, err } return edgeiam.NewComponent(saKey, sa, nil), nil } func (s *iamService) CreateOrUpdateEdgeIAM(ctx context.Context, serviceAccountProjectID, policyProjectID, serviceAccountName string, componentIAM *edgeiam.Component, roles []IAMRole) (*edgeiam.Component, error) { s.Lock() defer s.Unlock() var ( modifiedIamPolicy *cloudresourcemanager.Policy err error ) // We retry on 409 conflict, 400 bad request, and 404 not found err = retry.OnError(defaultRetry, func(err error) bool { return isErrorCode(err, 409, 400, 404) }, func() error { modifiedIamPolicy, err = s.CreatePolicyBinding(ctx, serviceAccountProjectID, policyProjectID, serviceAccountName, roles) if err != nil { log.Ctx(ctx).Err(fmt.Errorf("failed to update iam policy: %v", err)). Msg("update iam policy failed due to etag conflict. Will retry.") } return err }) if err != nil { return nil, err } return edgeiam.NewComponent(componentIAM.APIKey, componentIAM.ServiceAccount, modifiedIamPolicy), nil } func isErrorCode(err error, codes ...int) bool { if e, ok := err.(*googleapi.Error); ok { for _, code := range codes { if e.Code == code { return true } } } return false } // errorIs409 checks the returned gcp error code and returns true if it is a 409 alreadyExists. func errorIs409(err error) bool { if e, ok := err.(*googleapi.Error); ok { return e.Code == 409 } return false } // errorIs404 checks the returned gcp error code and returns true if it is a 404 not found. func errorIs404(err error) bool { if e, ok := err.(*googleapi.Error); ok { return e.Code == 404 } return false } // NewIAMService creates a new IAM Service. func NewIAMService(opts ...option.ClientOption) IAMService { //nolint return &iamService{ opts: opts, } }