From 5f22fa6fcde71e0acb85d00b1a42a65cda51b5ff Mon Sep 17 00:00:00 2001 From: Quentin Bisson Date: Tue, 17 Dec 2024 13:43:27 +0100 Subject: [PATCH] use sso settings api instead of app user-values (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use sso settings api instead of app user-values * add requeue sorting back in case of pod restart * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Hervé Nicol * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Hervé Nicol --------- Co-authored-by: Hervé Nicol --- CHANGELOG.md | 4 + api/v1alpha1/grafanaorganization_types.go | 1 + .../grafanaorganization_controller.go | 92 ++++++++++--------- pkg/grafana/sso_settings.go | 83 +++++++++++++++++ .../grafana-user-values.yaml.template | 8 -- pkg/grafana/templating/templating.go | 77 ---------------- pkg/grafana/types.go | 3 + 7 files changed, 138 insertions(+), 130 deletions(-) create mode 100644 pkg/grafana/sso_settings.go delete mode 100644 pkg/grafana/templating/templates/grafana-user-values.yaml.template delete mode 100644 pkg/grafana/templating/templating.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab378b6..6ac117bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Alertmanager controller +### Changed + +- Change SSO settings configuration to use the Grafana admin API instead of app user-values. + ## [0.10.1] - 2024-12-12 ### Fixed diff --git a/api/v1alpha1/grafanaorganization_types.go b/api/v1alpha1/grafanaorganization_types.go index 293c1340..8d4712dc 100644 --- a/api/v1alpha1/grafanaorganization_types.go +++ b/api/v1alpha1/grafanaorganization_types.go @@ -31,6 +31,7 @@ type GrafanaOrganizationSpec struct { // DisplayName is the name displayed when viewing the organization in Grafana. It can be different from the actual org's name. // +kubebuilder:example="Giant Swarm" // +kubebuilder:validation:MinLength=1 + // +kubebuilder:unique=true DisplayName string `json:"displayName"` // Access rules defines user permissions for interacting with the organization in Grafana. diff --git a/internal/controller/grafanaorganization_controller.go b/internal/controller/grafanaorganization_controller.go index 0fa1b10f..184ed2c0 100644 --- a/internal/controller/grafanaorganization_controller.go +++ b/internal/controller/grafanaorganization_controller.go @@ -38,13 +38,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/giantswarm/observability-operator/pkg/config" - grafanaclient "github.com/giantswarm/observability-operator/pkg/grafana/client" + appv1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" "github.com/giantswarm/observability-operator/api/v1alpha1" "github.com/giantswarm/observability-operator/internal/controller/predicates" + "github.com/giantswarm/observability-operator/pkg/config" "github.com/giantswarm/observability-operator/pkg/grafana" - "github.com/giantswarm/observability-operator/pkg/grafana/templating" + grafanaclient "github.com/giantswarm/observability-operator/pkg/grafana/client" ) // GrafanaOrganizationReconciler reconciles a GrafanaOrganization object @@ -140,7 +140,18 @@ func (r *GrafanaOrganizationReconciler) SetupWithManager(mgr ctrl.Manager) error // Sort organizations by orgID to ensure the order is deterministic. // This is important to prevent incorrect ordering of organizations on grafana restarts. - slices.SortStableFunc(organizations.Items, compareOrganizationsByID) + slices.SortStableFunc(organizations.Items, func(i, j v1alpha1.GrafanaOrganization) int { + // if both orgs have a nil orgID, they are equal + // if one org has a nil orgID, it is higher than the other as it was not created in Grafana yet + if i.Status.OrgID == 0 && j.Status.OrgID == 0 { + return 0 + } else if i.Status.OrgID == 0 { + return 1 + } else if j.Status.OrgID == 0 { + return -1 + } + return cmp.Compare(i.Status.OrgID, j.Status.OrgID) + }) // Reconcile all grafana organizations when the grafana pod is recreated requests := make([]reconcile.Request, 0, len(organizations.Items)) @@ -158,19 +169,6 @@ func (r *GrafanaOrganizationReconciler) SetupWithManager(mgr ctrl.Manager) error Complete(r) } -func compareOrganizationsByID(i, j v1alpha1.GrafanaOrganization) int { - // if both orgs have a nil orgID, they are equal - // if one org has a nil orgID, it is higher than the other as it was not created in Grafana yet - if i.Status.OrgID == 0 && j.Status.OrgID == 0 { - return 0 - } else if i.Status.OrgID == 0 { - return 1 - } else if j.Status.OrgID == 0 { - return -1 - } - return cmp.Compare(i.Status.OrgID, j.Status.OrgID) -} - // reconcileCreate creates the grafanaOrganization. // reconcileCreate ensures the Grafana organization described in grafanaOrganization CR is created in Grafana. // This function is also responsible for: @@ -214,7 +212,7 @@ func (r GrafanaOrganizationReconciler) reconcileCreate(ctx context.Context, graf } // Configure Grafana RBAC - if err := r.configureGrafana(ctx); err != nil { + if err := r.configureGrafanaSSO(ctx); err != nil { return ctrl.Result{}, errors.WithStack(err) } @@ -251,6 +249,9 @@ func newOrganization(grafanaOrganization *v1alpha1.GrafanaOrganization) grafana. ID: grafanaOrganization.Status.OrgID, Name: grafanaOrganization.Spec.DisplayName, TenantIDs: tenantIDs, + Admins: grafanaOrganization.Spec.RBAC.Admins, + Editors: grafanaOrganization.Spec.RBAC.Editors, + Viewers: grafanaOrganization.Spec.RBAC.Viewers, } } @@ -343,7 +344,7 @@ func (r GrafanaOrganizationReconciler) reconcileDelete(ctx context.Context, graf } } - err := r.configureGrafana(ctx) + err := r.configureGrafanaSSO(ctx) if err != nil { return errors.WithStack(err) } @@ -367,7 +368,7 @@ func (r GrafanaOrganizationReconciler) reconcileDelete(ctx context.Context, graf } // configureGrafana ensures the RBAC configuration is set in Grafana. -func (r *GrafanaOrganizationReconciler) configureGrafana(ctx context.Context) error { +func (r *GrafanaOrganizationReconciler) configureGrafanaSSO(ctx context.Context) error { logger := log.FromContext(ctx) organizationList := v1alpha1.GrafanaOrganizationList{} @@ -384,34 +385,35 @@ func (r *GrafanaOrganizationReconciler) configureGrafana(ctx context.Context) er }, } - _, err = controllerutil.CreateOrPatch(ctx, r.Client, grafanaConfig, func() error { - // We always sort the organizations to ensure the order is deterministic and the configmap is stable - // in order to prevent grafana to restarts. - slices.SortStableFunc(organizationList.Items, compareOrganizationsByID) - - config, err := templating.GenerateGrafanaConfiguration(organizationList.Items) - if err != nil { - logger.Error(err, "failed to generate grafana user configmap values.") - return errors.WithStack(err) - } - - // TODO: to be removed for next release - // cleanup owner references from the config map, see https://github.com/giantswarm/observability-operator/pull/183 - for _, organization := range organizationList.Items { - // nolint:errcheck,gosec // ignore errors, owner references are probably already gone - controllerutil.RemoveOwnerReference(&organization, grafanaConfig, r.Scheme) - } - - logger.Info("updating grafana-user-values", "config", config) - - grafanaConfig.Data = make(map[string]string) - grafanaConfig.Data["values"] = config + // TODO remove after next release (current: 0.10.1) + if err = r.Client.Delete(ctx, grafanaConfig); client.IgnoreNotFound(err) != nil { + return errors.WithStack(err) + } - return nil - }) + // Retrieve the app. + var currentApp appv1.App = appv1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana", + Namespace: "giantswarm", + }, + } + err = r.Client.Get(ctx, types.NamespacedName{Name: currentApp.GetName(), Namespace: currentApp.GetNamespace()}, ¤tApp) + if err != nil { + return err + } + currentApp.Spec.UserConfig = appv1.AppSpecUserConfig{} + if err = r.Client.Update(ctx, ¤tApp); err != nil { + return err + } + // TODO end of section to be removed after next release (current: 0.10.1) + // Configure SSO settings in Grafana + organizations := make([]grafana.Organization, len(organizationList.Items)) + for i, organization := range organizationList.Items { + organizations[i] = newOrganization(&organization) + } + err = grafana.ConfigureSSOSettings(ctx, r.GrafanaAPI, organizations) if err != nil { - logger.Error(err, "failed to configure grafana.") return errors.WithStack(err) } diff --git a/pkg/grafana/sso_settings.go b/pkg/grafana/sso_settings.go new file mode 100644 index 00000000..d344e7ec --- /dev/null +++ b/pkg/grafana/sso_settings.go @@ -0,0 +1,83 @@ +package grafana + +import ( + "context" + "fmt" + "strings" + + "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + grafanaAdminRole = "Admin" + grafanaEditorRole = "Editor" + grafanaViewerRole = "Viewer" +) + +func ConfigureSSOSettings(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, organizations []Organization) error { + logger := log.FromContext(ctx) + + provider := "generic_oauth" + resp, err := grafanaAPI.SsoSettings.GetProviderSettings(provider, nil) + if err != nil { + logger.Error(err, "failed to get sso provider settings.") + return errors.WithStack(err) + } + + orgsMapping := generateGrafanaOrgsMapping(organizations) + settings := resp.Payload.Settings.(map[string]interface{}) + settings["role_attribute_path"] = "to_string('Viewer')" + settings["org_attribute_path"] = "groups" + settings["org_mapping"] = orgsMapping + + logger.Info("Configuring Grafana SSO settings", "provider", provider, "settings", settings) + + // Update the provider settings + _, err = grafanaAPI.SsoSettings.UpdateProviderSettings(provider, + &models.UpdateProviderSettingsParamsBody{ + ID: resp.Payload.ID, + Provider: resp.Payload.Provider, + Settings: settings, + }) + + if err != nil { + logger.Error(err, "failed to configure grafana sso.") + return errors.WithStack(err) + } + + return nil +} + +func generateGrafanaOrgsMapping(organizations []Organization) string { + var orgMappings []string + // TODO: We need to be admins to be able to see the private dashboards for now, remove the 2 GS groups once https://github.com/giantswarm/roadmap/issues/3696 is done. + // Grant Admin role to Giantswarm users logging in via azure active directory. + orgMappings = append(orgMappings, buildOrgMapping(SharedOrg.Name, "giantswarm-ad:giantswarm-admins", grafanaAdminRole)) + // Grant Admin role to Giantswarm users logging in via github. + orgMappings = append(orgMappings, buildOrgMapping(SharedOrg.Name, "giantswarm-github:giantswarm:giantswarm-admins", grafanaAdminRole)) + // Grant Editor role to every other users. + orgMappings = append(orgMappings, fmt.Sprintf(`"*:%s:%s"`, SharedOrg.Name, grafanaEditorRole)) + for _, organization := range organizations { + for _, adminOrgAttribute := range organization.Admins { + orgMappings = append(orgMappings, buildOrgMapping(organization.Name, adminOrgAttribute, grafanaAdminRole)) + } + for _, editorOrgAttribute := range organization.Editors { + orgMappings = append(orgMappings, buildOrgMapping(organization.Name, editorOrgAttribute, grafanaEditorRole)) + } + for _, viewerOrgAttribute := range organization.Viewers { + orgMappings = append(orgMappings, buildOrgMapping(organization.Name, viewerOrgAttribute, grafanaViewerRole)) + } + } + + return strings.Join(orgMappings, " ") +} + +func buildOrgMapping(organizationName, userOrgAttribute, role string) string { + // We need to escape the colon in the userOrgAttribute + u := strings.ReplaceAll(userOrgAttribute, ":", "\\:") + // We add double quotes to the org mapping to support spaces in display names + return fmt.Sprintf(`"%s:%s:%s"`, u, organizationName, role) +} diff --git a/pkg/grafana/templating/templates/grafana-user-values.yaml.template b/pkg/grafana/templating/templates/grafana-user-values.yaml.template deleted file mode 100644 index 213e4e11..00000000 --- a/pkg/grafana/templating/templates/grafana-user-values.yaml.template +++ /dev/null @@ -1,8 +0,0 @@ -grafana: - grafana.ini: - auth: - disable_signout_menu: false - auth.generic_oauth: - role_attribute_path: to_string('Viewer') - org_attribute_path: groups - org_mapping: '{{ .OrgMapping }}' diff --git a/pkg/grafana/templating/templating.go b/pkg/grafana/templating/templating.go deleted file mode 100644 index 33e64096..00000000 --- a/pkg/grafana/templating/templating.go +++ /dev/null @@ -1,77 +0,0 @@ -package templating - -import ( - "bytes" - _ "embed" - "fmt" - "strings" - "text/template" - - "github.com/pkg/errors" - - "github.com/giantswarm/observability-operator/api/v1alpha1" - "github.com/giantswarm/observability-operator/pkg/grafana" -) - -const ( - grafanaAdminRole = "Admin" - grafanaEditorRole = "Editor" - grafanaViewerRole = "Viewer" -) - -var ( - //go:embed templates/grafana-user-values.yaml.template - grafanaUserConfig string - grafanaUserConfigTemplate *template.Template -) - -func init() { - grafanaUserConfigTemplate = template.Must(template.New("grafana-user-values.yaml").Parse(grafanaUserConfig)) -} - -func GenerateGrafanaConfiguration(organizations []v1alpha1.GrafanaOrganization) (string, error) { - var orgMappings []string - // TODO: We need to be admins to be able to see the private dashboards for now, remove the 2 GS groups once https://github.com/giantswarm/roadmap/issues/3696 is done. - // Grant Admin role to Giantswarm users logging in via azure active directory. - orgMappings = append(orgMappings, buildOrgMapping(grafana.SharedOrg.Name, "giantswarm-ad:giantswarm-admins", grafanaAdminRole)) - // Grant Admin role to Giantswarm users logging in via github. - orgMappings = append(orgMappings, buildOrgMapping(grafana.SharedOrg.Name, "giantswarm-github:giantswarm:giantswarm-admins", grafanaAdminRole)) - // Grant Editor role to every other users. - orgMappings = append(orgMappings, fmt.Sprintf(`"*:%s:%s"`, grafana.SharedOrg.Name, grafanaEditorRole)) - for _, organization := range organizations { - rbac := organization.Spec.RBAC - organizationName := organization.Spec.DisplayName - for _, adminOrgAttribute := range rbac.Admins { - orgMappings = append(orgMappings, buildOrgMapping(organizationName, adminOrgAttribute, grafanaAdminRole)) - } - for _, editorOrgAttribute := range rbac.Editors { - orgMappings = append(orgMappings, buildOrgMapping(organizationName, editorOrgAttribute, grafanaEditorRole)) - } - for _, viewerOrgAttribute := range rbac.Viewers { - orgMappings = append(orgMappings, buildOrgMapping(organizationName, viewerOrgAttribute, grafanaViewerRole)) - } - } - - orgMapping := strings.Join(orgMappings, " ") - - data := struct { - OrgMapping string - }{ - OrgMapping: orgMapping, - } - - var values bytes.Buffer - err := grafanaUserConfigTemplate.Execute(&values, data) - if err != nil { - return "", errors.WithStack(err) - } - - return values.String(), nil -} - -func buildOrgMapping(organizationName, userOrgAttribute, role string) string { - // We need to escape the colon in the userOrgAttribute - u := strings.ReplaceAll(userOrgAttribute, ":", "\\:") - // We add double quotes to the org mapping to support spaces in display names - return fmt.Sprintf(`"%s:%s:%s"`, u, organizationName, role) -} diff --git a/pkg/grafana/types.go b/pkg/grafana/types.go index 634ff396..bb42f8eb 100644 --- a/pkg/grafana/types.go +++ b/pkg/grafana/types.go @@ -10,6 +10,9 @@ type Organization struct { ID int64 Name string TenantIDs []string + Admins []string + Editors []string + Viewers []string } type Datasource struct {