diff --git a/CHANGELOG.md b/CHANGELOG.md index e784218..8509abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.6.0 + +* Support BasicAuth for REST APIs and metrics + ## 1.5.1 * Add zap.Logger diff --git a/cmd/root.go b/cmd/root.go index 45adbc1..9424103 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,7 +77,8 @@ func loadConfig(cmd *cobra.Command) (*config.Config, error) { if err := v.BindEnv("leaderElection.podName", "POD_NAME"); err != nil { zap.L().Warn("failed to bind env POD_NAME") } - if err := v.BindEnv("leaderElection.namespace", "POD_NAMESPACE"); err != nil { + + if err := v.BindEnv("namespace", "POD_NAMESPACE"); err != nil { zap.L().Warn("failed to bind env POD_NAMESPACE") } diff --git a/cmd/run.go b/cmd/run.go index cb5e3c9..a5ea7e6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -44,7 +44,7 @@ func newRunCMD() *cobra.Command { return err } - server := resolver.APIServer(policyClient.HasSynced) + server := resolver.APIServer(cmd.Context(), policyClient.HasSynced) if c.REST.Enabled || c.BlockReports.Enabled { resolver.RegisterStoreListener() diff --git a/pkg/api/basic_auth.go b/pkg/api/basic_auth.go new file mode 100644 index 0000000..4c05f41 --- /dev/null +++ b/pkg/api/basic_auth.go @@ -0,0 +1,41 @@ +package api + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" +) + +type BasicAuth struct { + Username string + Password string +} + +func HTTPBasic(auth *BasicAuth, next http.HandlerFunc) http.HandlerFunc { + if auth == nil { + return next + } + + expectedUsernameHash := sha256.Sum256([]byte(auth.Username)) + expectedPasswordHash := sha256.Sum256([]byte(auth.Password)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if ok { + + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) + + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } + } + + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} diff --git a/pkg/api/server.go b/pkg/api/server.go index c2587ec..6c9f5b1 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -32,6 +32,7 @@ type httpServer struct { reports reporting.PolicyReportGenerator http http.Server synced func() bool + auth *BasicAuth } func (s *httpServer) registerHandler() { @@ -39,15 +40,32 @@ func (s *httpServer) registerHandler() { s.mux.HandleFunc("/ready", ReadyHandler()) } +func (s *httpServer) middleware(handler http.HandlerFunc) http.HandlerFunc { + handler = Gzip(handler) + + if s.auth != nil { + handler = HTTPBasic(s.auth, handler) + } + + return handler +} + func (s *httpServer) RegisterMetrics() { - s.mux.Handle("/metrics", promhttp.Handler()) + handler := promhttp.Handler() + + if s.auth != nil { + s.mux.HandleFunc("/metrics", HTTPBasic(s.auth, handler.ServeHTTP)) + return + } + + s.mux.Handle("/metrics", handler) } func (s *httpServer) RegisterREST() { - s.mux.HandleFunc("/policies", Gzip(PolicyHandler(s.store))) - s.mux.HandleFunc("/verify-image-rules", Gzip(VerifyImageRulesHandler(s.store))) - s.mux.HandleFunc("/namespace-details-reporting", Gzip(NamespaceReportingHandler(s.reports, path.Join("templates", "reporting")))) - s.mux.HandleFunc("/policy-details-reporting", Gzip(PolicyReportingHandler(s.reports, path.Join("templates", "reporting")))) + s.mux.HandleFunc("/policies", s.middleware(PolicyHandler(s.store))) + s.mux.HandleFunc("/verify-image-rules", s.middleware(VerifyImageRulesHandler(s.store))) + s.mux.HandleFunc("/namespace-details-reporting", s.middleware(NamespaceReportingHandler(s.reports, path.Join("templates", "reporting")))) + s.mux.HandleFunc("/policy-details-reporting", s.middleware(PolicyReportingHandler(s.reports, path.Join("templates", "reporting")))) } func (s *httpServer) Start() error { @@ -59,7 +77,7 @@ func (s *httpServer) Shutdown(ctx context.Context) error { } // NewServer constructor for a new API Server -func NewServer(pStore *kyverno.PolicyStore, reports reporting.PolicyReportGenerator, port int, synced func() bool, logger *zap.Logger) Server { +func NewServer(pStore *kyverno.PolicyStore, reports reporting.PolicyReportGenerator, port int, synced func() bool, auth *BasicAuth, logger *zap.Logger) Server { mux := http.NewServeMux() s := &httpServer{ @@ -67,6 +85,7 @@ func NewServer(pStore *kyverno.PolicyStore, reports reporting.PolicyReportGenera reports: reports, mux: mux, synced: synced, + auth: auth, http: http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: NewLoggerMiddleware(logger, mux), diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index d86e206..99d9496 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -16,7 +16,7 @@ const port int = 9999 var logger = zap.NewNop() func Test_NewServer(t *testing.T) { - server := api.NewServer(kyverno.NewPolicyStore(), &policyReportGeneratorStub{}, port, func() bool { return true }, logger) + server := api.NewServer(kyverno.NewPolicyStore(), &policyReportGeneratorStub{}, port, func() bool { return true }, nil, logger) server.RegisterMetrics() server.RegisterREST() diff --git a/pkg/config/config.go b/pkg/config/config.go index 34fe916..dc139b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,9 +1,17 @@ package config +// BasicAuth configuration +type BasicAuth struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + SecretRef string `mapstructure:"secretRef"` +} + // API configuration type API struct { - Port int `mapstructure:"port"` - Logging bool `mapstructure:"logging"` + Port int `mapstructure:"port"` + Logging bool `mapstructure:"logging"` + BasicAuth BasicAuth `mapstructure:"basicAuth"` } type Logging struct { @@ -32,7 +40,6 @@ type Results struct { type LeaderElection struct { LockName string `mapstructure:"lockName"` PodName string `mapstructure:"podName"` - Namespace string `mapstructure:"namespace"` LeaseDuration int `mapstructure:"leaseDuration"` RenewDeadline int `mapstructure:"renewDeadline"` RetryPeriod int `mapstructure:"retryPeriod"` @@ -57,4 +64,5 @@ type Config struct { BlockReports BlockReports `mapstructure:"blockReports"` LeaderElection LeaderElection `mapstructure:"leaderElection"` Logging Logging `mapstructure:"logging"` + Namespace string `mapstructure:"namespace"` } diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go index 9c3d65c..cec44fe 100644 --- a/pkg/config/resolver.go +++ b/pkg/config/resolver.go @@ -1,6 +1,7 @@ package config import ( + "context" "time" "go.uber.org/zap" @@ -22,6 +23,7 @@ import ( prk8s "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/policyreport/kubernetes" "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/reporting" rk8s "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/reporting/kubernetes" + "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/secrets" "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/violation" vk8s "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/violation/kubernetes" ) @@ -42,18 +44,45 @@ type Resolver struct { logger *zap.Logger } +// SecretClient resolver method +func (r *Resolver) SecretClient() (secrets.Client, error) { + clientset, err := r.Clientset() + if err != nil { + zap.L().Error("failed to create secret client, secretRefs can not be resolved", zap.Error(err)) + return nil, err + } + + return secrets.NewClient(clientset.CoreV1().Secrets(r.config.Namespace)), nil +} + // APIServer resolver method -func (r *Resolver) APIServer(synced func() bool) api.Server { +func (r *Resolver) APIServer(ctx context.Context, synced func() bool) api.Server { var logger *zap.Logger if r.config.API.Logging { logger, _ = r.Logger() } + authConfig := &r.config.API.BasicAuth + if authConfig.SecretRef != "" { + r.loadSecretRef(ctx, authConfig) + } + + var auth *api.BasicAuth + if authConfig.Username != "" && authConfig.Password != "" { + auth = &api.BasicAuth{ + Username: authConfig.Username, + Password: authConfig.Password, + } + + zap.L().Info("API BasicAuth enabled") + } + return api.NewServer( r.PolicyStore(), r.Reporting(), r.config.API.Port, synced, + auth, logger, ) } @@ -185,7 +214,7 @@ func (r *Resolver) LeaderElectionClient() (*leaderelection.Client, error) { r.leaderClient = leaderelection.New( clientset.CoordinationV1(), r.config.LeaderElection.LockName, - r.config.LeaderElection.Namespace, + r.config.Namespace, r.config.LeaderElection.PodName, time.Duration(r.config.LeaderElection.LeaseDuration)*time.Second, time.Duration(r.config.LeaderElection.RenewDeadline)*time.Second, @@ -305,6 +334,24 @@ func (r *Resolver) RegisterMetricsListener() { r.EventPublisher().RegisterListener(listener.NewPolicyMetricsListener()) } +func (r *Resolver) loadSecretRef(ctx context.Context, auth *BasicAuth) { + client, err := r.SecretClient() + if err != nil { + return + } + values, err := client.Get(ctx, auth.SecretRef) + if err != nil { + zap.L().Error("failed to load basic auth secret", zap.Error(err)) + } + + if values.Username != "" { + auth.Username = values.Username + } + if values.Password != "" { + auth.Password = values.Password + } +} + // NewResolver constructor function func NewResolver(config *Config, k8sConfig *rest.Config) Resolver { return Resolver{ diff --git a/pkg/config/resolver_test.go b/pkg/config/resolver_test.go index 95b8eaf..7af90b7 100644 --- a/pkg/config/resolver_test.go +++ b/pkg/config/resolver_test.go @@ -1,6 +1,7 @@ package config_test import ( + "context" "testing" "k8s.io/client-go/rest" @@ -134,7 +135,7 @@ func Test_ResolvePolicyMapper(t *testing.T) { func Test_ResolveAPIServer(t *testing.T) { resolver := config.NewResolver(testConfig, &rest.Config{}) - server := resolver.APIServer(func() bool { return true }) + server := resolver.APIServer(context.Background(), func() bool { return true }) if server == nil { t.Error("Error: Should return API Server") } diff --git a/pkg/secrets/client.go b/pkg/secrets/client.go new file mode 100644 index 0000000..5541cb2 --- /dev/null +++ b/pkg/secrets/client.go @@ -0,0 +1,72 @@ +package secrets + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/util/retry" +) + +type Values struct { + Username string `json:"username" mapstructure:"username"` + Password string `json:"password" mapstructure:"password"` +} + +type Client interface { + Get(context.Context, string) (Values, error) +} + +type k8sClient struct { + client v1.SecretInterface +} + +func (c *k8sClient) Get(ctx context.Context, name string) (Values, error) { + var secret *corev1.Secret + + err := retry.OnError(retry.DefaultRetry, func(err error) bool { + if _, ok := err.(errors.APIStatus); !ok { + return true + } + + if ok := errors.IsTimeout(err); ok { + return true + } + + if ok := errors.IsServerTimeout(err); ok { + return true + } + + if ok := errors.IsServiceUnavailable(err); ok { + return true + } + + return false + }, func() error { + var err error + secret, err = c.client.Get(ctx, name, metav1.GetOptions{}) + + return err + }) + + values := Values{} + if err != nil { + return values, err + } + + if username, ok := secret.Data["username"]; ok { + values.Username = string(username) + } + + if password, ok := secret.Data["password"]; ok { + values.Password = string(password) + } + + return values, nil +} + +func NewClient(secretClient v1.SecretInterface) Client { + return &k8sClient{secretClient} +} diff --git a/pkg/secrets/client_test.go b/pkg/secrets/client_test.go new file mode 100644 index 0000000..2f2e37e --- /dev/null +++ b/pkg/secrets/client_test.go @@ -0,0 +1,59 @@ +package secrets_test + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + + "github.com/kyverno/policy-reporter-kyverno-plugin/pkg/secrets" +) + +const secretName = "secret-values" + +func newFakeClient() v1.SecretInterface { + return fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "default", + }, + Data: map[string][]byte{ + "api": []byte("http://localhost:9200"), + "kyvernoApi": []byte("http://localhost:9200/kyverno"), + "username": []byte("username"), + "password": []byte("password"), + "skipTLS": []byte("true"), + "certificate": []byte("certs"), + }, + }).CoreV1().Secrets("default") +} + +func Test_Client(t *testing.T) { + client := secrets.NewClient(newFakeClient()) + + t.Run("Get values from existing secret", func(t *testing.T) { + values, err := client.Get(context.Background(), secretName) + if err != nil { + t.Errorf("Unexpected error while fetching secret: %s", err) + } + + if values.Username != "username" { + t.Errorf("Unexpected Username: %s", values.Username) + } + + if values.Password != "password" { + t.Errorf("Unexpected Password: %s", values.Password) + } + }) + + t.Run("Get values from not existing secret", func(t *testing.T) { + _, err := client.Get(context.Background(), "not-exist") + if !errors.IsNotFound(err) { + t.Errorf("Expected not found error") + } + }) +}