package extsvcaccounts import ( "context" "errors" "strings" "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/satokengen" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/kvstore" sa "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" ) type ExtSvcAccountsService struct { acSvc ac.Service features featuremgmt.FeatureToggles logger log.Logger metrics *metrics saSvc sa.Service skvStore kvstore.SecretsKVStore tracer tracing.Tracer } func ProvideExtSvcAccountsService(acSvc ac.Service, bus bus.Bus, db db.DB, features featuremgmt.FeatureToggles, reg prometheus.Registerer, saSvc *manager.ServiceAccountsService, secretsSvc secrets.Service, tracer tracing.Tracer) *ExtSvcAccountsService { logger := log.New("serviceauth.extsvcaccounts") esa := &ExtSvcAccountsService{ acSvc: acSvc, logger: logger, saSvc: saSvc, features: features, skvStore: kvstore.NewSQLSecretsKVStore(db, secretsSvc, logger), // Using SQL store to avoid a cyclic dependency tracer: tracer, } if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) { // Register the metrics esa.metrics = newMetrics(reg, saSvc, logger) // Register a listener to enable/disable service accounts bus.AddEventListener(esa.handlePluginStateChanged) } return esa } // EnableExtSvcAccount enables or disables the service account associated to an external service func (esa *ExtSvcAccountsService) EnableExtSvcAccount(ctx context.Context, cmd *sa.EnableExtSvcAccountCmd) error { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.EnableExtSvcAccount") defer span.End() saName := sa.ExtSvcPrefix + slugify.Slugify(cmd.ExtSvcSlug) saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, cmd.OrgID, saName) if errRetrieve != nil { return errRetrieve } return esa.saSvc.EnableServiceAccount(ctx, cmd.OrgID, saID, cmd.Enabled) } // HasExternalService returns whether an external service has been saved with that name. func (esa *ExtSvcAccountsService) HasExternalService(ctx context.Context, name string) (bool, error) { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.HasExternalService") defer span.End() saName := sa.ExtSvcPrefix + slugify.Slugify(name) saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, extsvcauth.TmpOrgID, saName) if errRetrieve != nil && !errors.Is(errRetrieve, sa.ErrServiceAccountNotFound) { return false, errRetrieve } return saID > 0, nil } // RetrieveExtSvcAccount fetches an external service account by ID func (esa *ExtSvcAccountsService) RetrieveExtSvcAccount(ctx context.Context, orgID, saID int64) (*sa.ExtSvcAccount, error) { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.RetrieveExtSvcAccount") defer span.End() svcAcc, err := esa.saSvc.RetrieveServiceAccount(ctx, orgID, saID) if err != nil { return nil, err } return &sa.ExtSvcAccount{ ID: svcAcc.Id, Login: svcAcc.Login, Name: svcAcc.Name, OrgID: svcAcc.OrgId, IsDisabled: svcAcc.IsDisabled, Role: roletype.RoleType(svcAcc.Role), }, nil } // GetExternalServiceNames get the names of External Service in store func (esa *ExtSvcAccountsService) GetExternalServiceNames(ctx context.Context) ([]string, error) { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.GetExternalServiceNames") defer span.End() ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("Get external service names from store") sas, err := esa.saSvc.SearchOrgServiceAccounts(ctx, &sa.SearchOrgServiceAccountsQuery{ OrgID: extsvcauth.TmpOrgID, Filter: sa.FilterOnlyExternal, SignedInUser: extsvcuser, }) if err != nil { ctxLogger.Error("Could not fetch external service accounts from store", "error", err.Error()) return nil, err } if sas == nil { return []string{}, nil } res := make([]string, len(sas.ServiceAccounts)) for i := range sas.ServiceAccounts { res[i] = strings.TrimPrefix(sas.ServiceAccounts[i].Name, sa.ExtSvcPrefix) } return res, nil } // SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions. func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { // This is double proofing, we should never reach here anyway the flags have already been checked. if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.FromContext(ctx).Warn("This feature is behind a feature flag, please set it if you want to save external services") return nil, nil } ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.SaveExternalService") defer span.End() ctxLogger := esa.logger.FromContext(ctx) if cmd == nil { ctxLogger.Warn("Received no input") return nil, nil } slug := slugify.Slugify(cmd.Name) saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{ ExtSvcSlug: slug, Enabled: cmd.Self.Enabled, OrgID: extsvcauth.TmpOrgID, Permissions: cmd.Self.Permissions, }) if err != nil { return nil, err } // No need for a token if we don't have a service account if saID <= 0 { ctxLogger.Debug("Skipping service account token creation", "service", slug) return nil, nil } token, err := esa.getExtSvcAccountToken(ctx, extsvcauth.TmpOrgID, saID, slug) if err != nil { ctxLogger.Error("Could not get the external svc token", "service", slug, "saID", saID, "error", err.Error()) return nil, err } return &extsvcauth.ExternalService{Name: slug, ID: slug, Secret: token}, nil } func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error { // This is double proofing, we should never reach here anyway the flags have already been checked. if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") return nil } ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.RemoveExternalService") defer span.End() return esa.RemoveExtSvcAccount(ctx, extsvcauth.TmpOrgID, slugify.Slugify(name)) } func (esa *ExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID int64, extSvcSlug string) error { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.RemoveExtSvcAccount") defer span.End() ctxLogger := esa.logger.FromContext(ctx) saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, orgID, sa.ExtSvcPrefix+extSvcSlug) if errRetrieve != nil && !errors.Is(errRetrieve, sa.ErrServiceAccountNotFound) { return errRetrieve } if saID <= 0 { ctxLogger.Debug("No external service account associated with this service", "service", extSvcSlug, "orgID", orgID) return nil } if err := esa.deleteExtSvcAccount(ctx, orgID, extSvcSlug, saID); err != nil { ctxLogger.Error("Error occurred while deleting service account", "service", extSvcSlug, "saID", saID, "error", err.Error()) return err } esa.logger.Info("Deleted external service account", "service", extSvcSlug, "orgID", orgID) return nil } // ManageExtSvcAccount creates, updates or deletes the service account associated with an external service func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) { // This is double proofing, we should never reach here anyway the flags have already been checked. if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) { esa.logger.FromContext(ctx).Warn("This feature is behind a feature flag, please set it if you want to save external services") return 0, nil } ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.ManageExtSvcAccount") defer span.End() ctxLogger := esa.logger.FromContext(ctx) if cmd == nil { ctxLogger.Warn("Received no input") return 0, nil } saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, cmd.OrgID, sa.ExtSvcPrefix+cmd.ExtSvcSlug) if errRetrieve != nil && !errors.Is(errRetrieve, sa.ErrServiceAccountNotFound) { return 0, errRetrieve } if len(cmd.Permissions) == 0 { if saID > 0 { if err := esa.deleteExtSvcAccount(ctx, cmd.OrgID, cmd.ExtSvcSlug, saID); err != nil { ctxLogger.Error("Error occurred while deleting service account", "service", cmd.ExtSvcSlug, "saID", saID, "error", err.Error()) return 0, err } } ctxLogger.Info("Skipping service account creation, no permission", "service", cmd.ExtSvcSlug, "permission count", len(cmd.Permissions), "saID", saID) return 0, nil } saID, errSave := esa.saveExtSvcAccount(ctx, &saveCmd{ Enabled: cmd.Enabled, ExtSvcSlug: cmd.ExtSvcSlug, OrgID: cmd.OrgID, Permissions: cmd.Permissions, SaID: saID, }) if errSave != nil { ctxLogger.Error("Could not save service account", "service", cmd.ExtSvcSlug, "error", errSave.Error()) return 0, errSave } return saID, nil } // saveExtSvcAccount creates or updates the service account associated with an external service func (esa *ExtSvcAccountsService) saveExtSvcAccount(ctx context.Context, cmd *saveCmd) (int64, error) { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.saveExtSvcAccount") defer span.End() ctxLogger := esa.logger.FromContext(ctx) if cmd.SaID <= 0 { // Create a service account ctxLogger.Info("Create service account", "service", cmd.ExtSvcSlug, "orgID", cmd.OrgID) sa, err := esa.saSvc.CreateServiceAccount(ctx, cmd.OrgID, &sa.CreateServiceAccountForm{ Name: sa.ExtSvcPrefix + cmd.ExtSvcSlug, Role: newRole(roletype.RoleNone), IsDisabled: newBool(false), }) if err != nil { return 0, err } cmd.SaID = sa.Id } // Enable or disable the service account ctxLogger.Debug("Set service account state", "service", cmd.ExtSvcSlug, "saID", cmd.SaID, "enabled", cmd.Enabled) if err := esa.saSvc.EnableServiceAccount(ctx, cmd.OrgID, cmd.SaID, cmd.Enabled); err != nil { return 0, err } // update the service account's permissions ctxLogger.Debug("Update role permissions", "service", cmd.ExtSvcSlug, "saID", cmd.SaID) if err := esa.acSvc.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{ AssignmentOrgID: cmd.OrgID, ExternalServiceID: cmd.ExtSvcSlug, ServiceAccountID: cmd.SaID, Permissions: cmd.Permissions, }); err != nil { return 0, err } esa.metrics.savedCount.Inc() return cmd.SaID, nil } // deleteExtSvcAccount deletes a service account by ID and removes its associated role func (esa *ExtSvcAccountsService) deleteExtSvcAccount(ctx context.Context, orgID int64, slug string, saID int64) error { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.deleteExtSvcAccount") defer span.End() ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Info("Delete service account", "service", slug, "orgID", orgID, "saID", saID) if err := esa.saSvc.DeleteServiceAccount(ctx, orgID, saID); err != nil { return err } if err := esa.acSvc.DeleteExternalServiceRole(ctx, slug); err != nil { return err } if err := esa.DeleteExtSvcCredentials(ctx, orgID, slug); err != nil { return err } esa.metrics.deletedCount.Inc() return nil } // getExtSvcAccountToken get or create the token of an External Service func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, orgID, saID int64, extSvcSlug string) (string, error) { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.getExtSvcAccountToken") defer span.End() ctxLogger := esa.logger.FromContext(ctx) // Get credentials from store credentials, err := esa.GetExtSvcCredentials(ctx, orgID, extSvcSlug) if err != nil && !errors.Is(err, ErrCredentialsNotFound) { if !errors.Is(err, &satokengen.ErrInvalidApiKey{}) { return "", err } ctxLogger.Warn("Invalid token found in store, recovering...", "service", extSvcSlug, "orgID", orgID) if err := esa.removeExtSvcAccountToken(ctx, orgID, saID, extSvcSlug); err != nil { return "", err } } if credentials != nil { return credentials.Secret, nil } // Generate token ctxLogger.Info("Generate new service account token", "service", extSvcSlug, "orgID", orgID) newKeyInfo, err := genTokenWithRetries(ctxLogger, extSvcSlug) if err != nil { return "", err } ctxLogger.Debug("Add service account token", "service", extSvcSlug, "orgID", orgID) if _, err := esa.saSvc.AddServiceAccountToken(ctx, saID, &sa.AddServiceAccountTokenCommand{ Name: tokenName(extSvcSlug), OrgId: orgID, Key: newKeyInfo.HashedKey, }); err != nil { return "", err } if err := esa.SaveExtSvcCredentials(ctx, &SaveCredentialsCmd{ ExtSvcSlug: extSvcSlug, OrgID: orgID, Secret: newKeyInfo.ClientSecret, }); err != nil { return "", err } return newKeyInfo.ClientSecret, nil } func (esa *ExtSvcAccountsService) removeExtSvcAccountToken(ctx context.Context, orgID, saID int64, extSvcSlug string) error { ctx, span := esa.tracer.Start(ctx, "ExtSvcAccountsService.removeExtSvcAccountToken") defer span.End() ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("List service account tokens", "service", extSvcSlug, "orgID", orgID) tokens, err := esa.saSvc.ListTokens(ctx, &sa.GetSATokensQuery{OrgID: &orgID, ServiceAccountID: &saID}) if err != nil { return err } notFound := int64(-1) tknID := notFound for _, token := range tokens { if token.Name == tokenName(extSvcSlug) { ctxLogger.Debug("Found token", "service", extSvcSlug, "orgID", orgID) tknID = token.ID break } } if tknID != notFound { ctxLogger.Debug("Remove token", "service", extSvcSlug, "orgID", orgID) if err := esa.saSvc.DeleteServiceAccountToken(ctx, orgID, saID, tknID); err != nil { return err } } return esa.DeleteExtSvcCredentials(ctx, orgID, extSvcSlug) } // FIXME: If the warning log never appears, we can remove this function func genTokenWithRetries(ctxLogger log.Logger, extSvcSlug string) (satokengen.KeyGenResult, error) { var newKeyInfo satokengen.KeyGenResult var err error retry := 0 for retry < maxTokenGenRetries { newKeyInfo, err = satokengen.New(extSvcSlug) if err != nil { return satokengen.KeyGenResult{}, err } if !strings.Contains(newKeyInfo.ClientSecret, "\x00") { return newKeyInfo, nil } retry++ ctxLogger.Warn("Generated a token containing NUL, retrying", "service", extSvcSlug, "retry", retry, ) // On first retry, log the token parts that contain a nil byte if retry == 1 { logTokenNULParts(ctxLogger, extSvcSlug, newKeyInfo.ClientSecret) } } return satokengen.KeyGenResult{}, ErrCredentialsGenFailed.Errorf("Failed to generate a token for %s", extSvcSlug) } // logTokenNULParts logs a warning if the external service token contains a nil byte // Tokens normally have 3 parts "gl+serviceID_secret_checksum" // Log the part of the generated token that contains a nil byte func logTokenNULParts(ctxLogger log.Logger, extSvcSlug string, token string) { parts := strings.Split(token, "_") for i := range parts { if strings.Contains(parts[i], "\x00") { ctxLogger.Warn("Token contains NUL", "service", extSvcSlug, "part", i, "part_len", len(parts[i]), "parts_count", len(parts), ) } } } // GetExtSvcCredentials get the credentials of an External Service from an encrypted storage func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) (*Credentials, error) { ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("Get service account token from skv", "service", extSvcSlug, "orgID", orgID) token, ok, err := esa.skvStore.Get(ctx, orgID, extSvcSlug, kvStoreType) if err != nil { return nil, err } if !ok { return nil, ErrCredentialsNotFound.Errorf("No credential found for in store %v", extSvcSlug) } if _, err := satokengen.Decode(token); err != nil { ctxLogger.Error("Failed to decode token", "error", err.Error()) return nil, err } return &Credentials{Secret: token}, nil } // SaveExtSvcCredentials stores the credentials of an External Service in an encrypted storage func (esa *ExtSvcAccountsService) SaveExtSvcCredentials(ctx context.Context, cmd *SaveCredentialsCmd) error { ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("Save service account token in skv", "service", cmd.ExtSvcSlug, "orgID", cmd.OrgID) return esa.skvStore.Set(ctx, cmd.OrgID, cmd.ExtSvcSlug, kvStoreType, cmd.Secret) } // DeleteExtSvcCredentials removes the credentials of an External Service from an encrypted storage func (esa *ExtSvcAccountsService) DeleteExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) error { ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("Delete service account token from skv", "service", extSvcSlug, "orgID", orgID) return esa.skvStore.Del(ctx, orgID, extSvcSlug, kvStoreType) } func (esa *ExtSvcAccountsService) handlePluginStateChanged(ctx context.Context, event *pluginsettings.PluginStateChangedEvent) error { ctxLogger := esa.logger.FromContext(ctx) ctxLogger.Debug("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) errEnable := esa.EnableExtSvcAccount(ctx, &sa.EnableExtSvcAccountCmd{ ExtSvcSlug: event.PluginId, Enabled: event.Enabled, OrgID: event.OrgId, }) // Ignore service account not found error if errors.Is(errEnable, sa.ErrServiceAccountNotFound) { ctxLogger.Debug("No ext svc account with this plugin", "pluginId", event.PluginId, "orgId", event.OrgId) return nil } return errEnable } func tokenName(extSvcSlug string) string { return tokenNamePrefix + "-" + extSvcSlug }