Repository: fluxcd/notification-controller
Pull request #334 (2022-02-21 12:03:44)

Add basic auth support to grafana provider
Fix: #332 
Signed-off-by: Filipe Sequeira <filipe@weave.works>


Patch:
diff --git a/controllers/provider_controller.go b/controllers/provider_controller.go
index d5de804..3f8e467 100644
--- a/controllers/provider_controller.go
+++ b/controllers/provider_controller.go
@@ -149,6 +149,8 @@ func (r *ProviderReconciler) reconcile(ctx context.Context, obj *v1beta1.Provide
 func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Provider) error {
 	address := provider.Spec.Address
 	proxy := provider.Spec.Proxy
+	username := provider.Spec.Username
+	password := ""
 	token := ""
 	headers := make(map[string]string)
 	if provider.Spec.SecretRef != nil {
@@ -163,6 +165,10 @@ func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Pro
 			address = string(a)
 		}
 
+		if p, ok := secret.Data["password"]; ok {
+			password = string(p)
+		}
+
 		if p, ok := secret.Data["proxy"]; ok {
 			proxy = string(p)
 		}
@@ -171,6 +177,10 @@ func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Pro
 			token = string(t)
 		}
 
+		if u, ok := secret.Data["username"]; ok {
+			username = string(u)
+		}
+
 		if h, ok := secret.Data["headers"]; ok {
 			err := yaml.Unmarshal(h, headers)
 			if err != nil {
@@ -204,7 +214,7 @@ func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Pro
 		}
 	}
 
-	factory := notifier.NewFactory(address, proxy, provider.Spec.Username, provider.Spec.Channel, token, headers, certPool)
+	factory := notifier.NewFactory(address, proxy, username, provider.Spec.Channel, token, headers, certPool, password)
 	if _, err := factory.Notifier(provider.Spec.Type); err != nil {
 		return fmt.Errorf("failed to initialize provider, error: %w", err)
 	}
diff --git a/docs/spec/v1beta1/provider.md b/docs/spec/v1beta1/provider.md
index 74d0360..9f31399 100644
--- a/docs/spec/v1beta1/provider.md
+++ b/docs/spec/v1beta1/provider.md
@@ -431,6 +431,14 @@ kubectl create secret generic grafana-token \
 --from-literal=address=https://<grafana-url>/api/annotations
 ```
 
+Grafana can also use `basic authorization` to authenticate the requests, if both token and 
+username/password are set in the secret, then `API token` takes precedence over `basic auth`.
+```shell
+kubectl create secret generic grafana-token \
+--from-literal=username=<your-grafana-username> \
+--from-literal=password=<your-grafana-password>
+``` 
+
 Then reference the secret in `spec.secretRef`:
 
 ```yaml
diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go
index e94bd20..4de619a 100644
--- a/internal/notifier/factory.go
+++ b/internal/notifier/factory.go
@@ -31,9 +31,10 @@ type Factory struct {
 	Token    string
 	Headers  map[string]string
 	CertPool *x509.CertPool
+	Password string
 }
 
-func NewFactory(url string, proxy string, username string, channel string, token string, headers map[string]string, certPool *x509.CertPool) *Factory {
+func NewFactory(url string, proxy string, username string, channel string, token string, headers map[string]string, certPool *x509.CertPool, password string) *Factory {
 	return &Factory{
 		URL:      url,
 		ProxyURL: proxy,
@@ -42,6 +43,7 @@ func NewFactory(url string, proxy string, username string, channel string, token
 		Token:    token,
 		Headers:  headers,
 		CertPool: certPool,
+		Password: password,
 	}
 }
 
@@ -90,7 +92,7 @@ func (f Factory) Notifier(provider string) (Interface, error) {
 	case v1beta1.AlertManagerProvider:
 		n, err = NewAlertmanager(f.URL, f.ProxyURL, f.CertPool)
 	case v1beta1.GrafanaProvider:
-		n, err = NewGrafana(f.URL, f.ProxyURL, f.Token, f.CertPool)
+		n, err = NewGrafana(f.URL, f.ProxyURL, f.Token, f.CertPool, f.Username, f.Password)
 	default:
 		err = fmt.Errorf("provider %s not supported", provider)
 	}
diff --git a/internal/notifier/grafana.go b/internal/notifier/grafana.go
index 6c6f2c0..a6cc13f 100644
--- a/internal/notifier/grafana.go
+++ b/internal/notifier/grafana.go
@@ -26,24 +26,24 @@ import (
 	"github.com/hashicorp/go-retryablehttp"
 )
 
-// Discord holds the hook URL
 type Grafana struct {
 	URL      string
 	Token    string
 	ProxyURL string
 	CertPool *x509.CertPool
+	Username string
+	Password string
 }
 
 // GraphiteAnnotation represents a Grafana API annotation in Graphite format
 type GraphitePayload struct {
-	//What string   `json:"what"` //optional
 	When int64    `json:"when"` //optional unix timestamp (ms)
 	Text string   `json:"text"`
 	Tags []string `json:"tags,omitempty"`
 }
 
 // NewGrafana validates the Grafana URL and returns a Grafana object
-func NewGrafana(URL string, proxyURL string, token string, certPool *x509.CertPool) (*Grafana, error) {
+func NewGrafana(URL string, proxyURL string, token string, certPool *x509.CertPool, username string, password string) (*Grafana, error) {
 	_, err := url.ParseRequestURI(URL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid Grafana URL %s", URL)
@@ -54,33 +54,36 @@ func NewGrafana(URL string, proxyURL string, token string, certPool *x509.CertPo
 		ProxyURL: proxyURL,
 		Token:    token,
 		CertPool: certPool,
+		Username: username,
+		Password: password,
 	}, nil
 }
 
 // Post annotation
-func (s *Grafana) Post(event events.Event) error {
+func (g *Grafana) Post(event events.Event) error {
 	// Skip any update events
 	if isCommitStatus(event.Metadata, "update") {
 		return nil
 	}
 
 	sfields := make([]string, 0, len(event.Metadata))
-	sfields = append(sfields, "flux")
-	sfields = append(sfields, event.ReportingController)
+	// add tag to filter on grafana
+	sfields = append(sfields, "flux", event.ReportingController)
 	for k, v := range event.Metadata {
 		sfields = append(sfields, fmt.Sprintf("%s: %s", k, v))
 	}
-	// add tag to filter on grafana
-
 	payload := GraphitePayload{
 		When: event.Timestamp.Unix(),
 		Text: fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace),
 		Tags: sfields,
 	}
 
-	err := postMessage(s.URL, s.ProxyURL, s.CertPool, payload, func(request *retryablehttp.Request) {
-		if s.Token != "" {
-			request.Header.Add("Authorization", "Bearer "+s.Token)
+	err := postMessage(g.URL, g.ProxyURL, g.CertPool, payload, func(request *retryablehttp.Request) {
+		if (g.Username != "" && g.Password != "") && g.Token == "" {
+			request.Header.Add("Authorization", "Basic "+basicAuth(g.Username, g.Password))
+		}
+		if g.Token != "" {
+			request.Header.Add("Authorization", "Bearer "+g.Token)
 		}
 	})
 	if err != nil {
diff --git a/internal/notifier/util.go b/internal/notifier/util.go
index 3bd6353..631467f 100644
--- a/internal/notifier/util.go
+++ b/internal/notifier/util.go
@@ -18,6 +18,7 @@ package notifier
 
 import (
 	"crypto/sha1"
+	"encoding/base64"
 	"fmt"
 	"strings"
 	"unicode"
@@ -120,3 +121,8 @@ func sha1String(str string) string {
 	bs := []byte(str)
 	return fmt.Sprintf("%x", sha1.Sum(bs))
 }
+
+func basicAuth(username, password string) string {
+	auth := username + ":" + password
+	return base64.StdEncoding.EncodeToString([]byte(auth))
+}
diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go
index a1abf56..7c2d326 100644
--- a/internal/server/event_handlers.go
+++ b/internal/server/event_handlers.go
@@ -146,8 +146,10 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request)
 			}
 
 			webhook := provider.Spec.Address
+			username := provider.Spec.Username
+			proxy := provider.Spec.Proxy
 			token := ""
-			proxy := provider.Spec.Address
+			password := ""
 			headers := make(map[string]string)
 			if provider.Spec.SecretRef != nil {
 				var secret corev1.Secret
@@ -166,6 +168,10 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request)
 					webhook = string(address)
 				}
 
+				if p, ok := secret.Data["password"]; ok {
+					password = string(p)
+				}
+
 				if p, ok := secret.Data["proxy"]; ok {
 					proxy = string(p)
 				}
@@ -174,6 +180,10 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request)
 					token = string(t)
 				}
 
+				if u, ok := secret.Data["username"]; ok {
+					username = string(u)
+				}
+
 				if h, ok := secret.Data["headers"]; ok {
 					err := yaml.Unmarshal(h, &headers)
 					if err != nil {
@@ -228,7 +238,7 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request)
 				continue
 			}
 
-			factory := notifier.NewFactory(webhook, proxy, provider.Spec.Username, provider.Spec.Channel, token, headers, certPool)
+			factory := notifier.NewFactory(webhook, proxy, username, provider.Spec.Channel, token, headers, certPool, password)
 			sender, err := factory.Notifier(provider.Spec.Type)
 			if err != nil {
 				s.logger.Error(err, "failed to initialize provider",


Test patch:
diff --git a/internal/notifier/grafana_test.go b/internal/notifier/grafana_test.go
index 8705c38..d6eb0e3 100644
--- a/internal/notifier/grafana_test.go
+++ b/internal/notifier/grafana_test.go
@@ -23,27 +23,30 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
 func TestGrafana_Post(t *testing.T) {
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		b, err := io.ReadAll(r.Body)
+	t.Run("Successfully post and expect 200 ok", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			b, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			var payload = GraphitePayload{}
+			err = json.Unmarshal(b, &payload)
+			require.NoError(t, err)
+
+			require.Equal(t, "gitrepository/webapp.gitops-system", payload.Text)
+			require.Equal(t, "flux", payload.Tags[0])
+			require.Equal(t, "source-controller", payload.Tags[1])
+			require.Equal(t, "test: metadata", payload.Tags[2])
+		}))
+		defer ts.Close()
+
+		grafana, err := NewGrafana(ts.URL, "", "", nil, "", "")
 		require.NoError(t, err)
-		var payload = GraphitePayload{}
-		err = json.Unmarshal(b, &payload)
-		require.NoError(t, err)
-
-		require.Equal(t, "gitrepository/webapp.gitops-system", payload.Text)
-		require.Equal(t, "flux", payload.Tags[0])
-		require.Equal(t, "source-controller", payload.Tags[1])
-		require.Equal(t, "test: metadata", payload.Tags[2])
-	}))
-	defer ts.Close()
-
-	grafana, err := NewGrafana(ts.URL, "", "", nil)
-	require.NoError(t, err)
 
-	err = grafana.Post(testEvent())
-	require.NoError(t, err)
+		err = grafana.Post(testEvent())
+		assert.NoError(t, err)
+	})
 }
diff --git a/internal/notifier/util_test.go b/internal/notifier/util_test.go
index c0e248e..397d0f8 100644
--- a/internal/notifier/util_test.go
+++ b/internal/notifier/util_test.go
@@ -116,3 +116,10 @@ func TestUtil_Sha1String(t *testing.T) {
 	s := sha1String(str)
 	require.Equal(t, "12ea142172e98435e16336acbbed8919610922c3", s)
 }
+
+func TestUtil_BasicAuth(t *testing.T) {
+	username := "user"
+	password := "password"
+	s := basicAuth(username, password)
+	require.Equal(t, "dXNlcjpwYXNzd29yZA==", s)
+}