You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2025/08/06 07:00:00 UTC
(skywalking-banyandb) branch main updated: Enable Login with Username/Password in BanyanDB (#704)
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 42ec9df7 Enable Login with Username/Password in BanyanDB (#704)
42ec9df7 is described below
commit 42ec9df7457868926eb80157b36355d94fcd6bba
Author: OmCheeLin <19...@163.com>
AuthorDate: Wed Aug 6 14:59:55 2025 +0800
Enable Login with Username/Password in BanyanDB (#704)
* feat: support login username
---------
Co-authored-by: 吴晟 Wu Sheng <wu...@foxmail.com>
Co-authored-by: Gao Hongtao <ha...@gmail.com>
---
CHANGES.md | 1 +
banyand/liaison/grpc/auth.go | 92 +++++++++
banyand/liaison/grpc/server.go | 21 +++
banyand/liaison/http/auth.go | 128 +++++++++++++
banyand/liaison/http/server.go | 29 ++-
banyand/liaison/pkg/auth/config.go | 105 +++++++++++
bydbctl/internal/cmd/auth_test.go | 164 ++++++++++++++++
bydbctl/internal/cmd/health_check.go | 12 +-
bydbctl/internal/cmd/rest.go | 23 +++
bydbctl/internal/cmd/root.go | 33 +++-
bydbctl/internal/cmd/testdata/.bydbctl.yaml | 3 +
bydbctl/internal/cmd/testdata/.bydbctl1.yaml | 3 +
bydbctl/internal/cmd/testdata/config.yaml | 5 +
docs/operation/security.md | 83 ++++++++
go.mod | 2 +-
pkg/auth/util.go | 31 +++
pkg/cmdsetup/liaison.go | 2 +-
pkg/cmdsetup/standalone.go | 2 +-
pkg/config/config.go | 6 +-
pkg/grpchelper/client.go | 13 +-
pkg/test/helpers/grpc_health.go | 13 +-
pkg/test/helpers/http_health.go | 14 +-
pkg/test/setup/setup.go | 39 +++-
test/cases/measure/data/data.go | 33 +++-
test/integration/standalone/other/auth_test.go | 210 +++++++++++++++++++++
.../standalone/other/testdata/config.yaml | 5 +
26 files changed, 1042 insertions(+), 30 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 62653027..1e2de1ed 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -27,6 +27,7 @@ Release Notes.
- Add chunked data sync to improve memory efficiency and performance during data transfer operations, supporting configurable chunk sizes, retry mechanisms, and out-of-order handling for both measure and stream services.
- Implement comprehensive migration system for both measure and stream data with file-based approach and enhanced progress tracking
- Improve TopN processing by adding "source" tag to track node-specific data, enhancing data handling across distributed nodes
+- Implement Login with Username/Password authentication in BanyanDB
### Bug Fixes
diff --git a/banyand/liaison/grpc/auth.go b/banyand/liaison/grpc/auth.go
new file mode 100644
index 00000000..52ad21a5
--- /dev/null
+++ b/banyand/liaison/grpc/auth.go
@@ -0,0 +1,92 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "context"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+
+ "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+)
+
+func authInterceptor(cfg *auth.Config) grpc.UnaryServerInterceptor {
+ return func(
+ ctx context.Context,
+ req interface{},
+ info *grpc.UnaryServerInfo,
+ handler grpc.UnaryHandler,
+ ) (interface{}, error) {
+ if !cfg.Enabled {
+ return handler(ctx, req)
+ }
+ if info.FullMethod == "/grpc.health.v1.Health/Check" && !cfg.HealthAuthEnabled {
+ return handler(ctx, req)
+ }
+ if err := validateUser(ctx, cfg); err != nil {
+ return nil, err
+ }
+ return handler(ctx, req)
+ }
+}
+
+func authStreamInterceptor(cfg *auth.Config) grpc.StreamServerInterceptor {
+ return func(
+ srv interface{},
+ stream grpc.ServerStream,
+ info *grpc.StreamServerInfo,
+ handler grpc.StreamHandler,
+ ) error {
+ if !cfg.Enabled {
+ return handler(srv, stream)
+ }
+ if info.FullMethod == "/grpc.health.v1.Health/Check" && !cfg.HealthAuthEnabled {
+ return handler(srv, stream)
+ }
+ if err := validateUser(stream.Context(), cfg); err != nil {
+ return err
+ }
+ return handler(srv, stream)
+ }
+}
+
+func validateUser(ctx context.Context, cfg *auth.Config) error {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return status.Errorf(codes.Unauthenticated, "metadata is not provided")
+ }
+
+ usernames := md.Get("username")
+ passwords := md.Get("password")
+
+ if len(usernames) == 0 || len(passwords) == 0 {
+ return status.Errorf(codes.Unauthenticated, "Invalid credentials")
+ }
+
+ username := usernames[0]
+ password := passwords[0]
+
+ if !auth.CheckUsernameAndPassword(cfg, username, password) {
+ return status.Errorf(codes.Unauthenticated, "Invalid credentials")
+ }
+
+ return nil
+}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index f7fccd38..6a55dc65 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -40,6 +40,7 @@ import (
measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
"github.com/apache/skywalking-banyandb/banyand/metadata"
"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
"github.com/apache/skywalking-banyandb/banyand/observability"
@@ -65,6 +66,7 @@ var (
// Server defines the gRPC server.
type Server interface {
run.Unit
+ GetAuthCfg() *auth.Config
GetPort() *uint32
}
@@ -98,6 +100,8 @@ type server struct {
metrics *metrics
certFile string
keyFile string
+ authConfigFile string
+ cfg *auth.Config
host string
addr string
accessLogRootPath string
@@ -161,6 +165,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
schemaRegistry: schemaRegistry,
},
schemaRepo: schemaRegistry,
+ cfg: auth.InitCfg(),
}
s.accessLogRecorders = []accessLogRecorder{streamSVC, measureSVC}
@@ -184,6 +189,11 @@ func (s *server) PreRun(_ context.Context) error {
return err
}
}
+ if s.authConfigFile != "" {
+ if err := auth.LoadConfig(s.cfg, s.authConfigFile); err != nil {
+ return err
+ }
+ }
if s.enableIngestionAccessLog {
for _, alr := range s.accessLogRecorders {
@@ -229,6 +239,11 @@ func (s *server) GetPort() *uint32 {
return &s.port
}
+// GetAuthCfg returns auth cfg (for httpserver).
+func (s *server) GetAuthCfg() *auth.Config {
+ return s.cfg
+}
+
func (s *server) FlagSet() *run.FlagSet {
fs := run.NewFlagSet("grpc")
s.maxRecvMsgSize = defaultRecvSize
@@ -236,6 +251,8 @@ func (s *server) FlagSet() *run.FlagSet {
fs.BoolVar(&s.tls, "tls", false, "connection uses TLS if true, else plain TCP")
fs.StringVar(&s.certFile, "cert-file", "", "the TLS cert file")
fs.StringVar(&s.keyFile, "key-file", "", "the TLS key file")
+ fs.StringVar(&s.authConfigFile, "auth-config-file", "", "Path to the authentication config file (YAML format)")
+ fs.BoolVar(&s.cfg.HealthAuthEnabled, "enable-health-auth", false, "enable authentication for health check")
fs.StringVar(&s.host, "grpc-host", "", "the host of banyand listens")
fs.Uint32Var(&s.port, "grpc-port", 17912, "the port of banyand listens")
fs.BoolVar(&s.enableIngestionAccessLog, "enable-ingestion-access-log", false, "enable ingestion access log")
@@ -297,6 +314,10 @@ func (s *server) Serve() run.StopNotify {
grpc_validator.UnaryServerInterceptor(),
recovery.UnaryServerInterceptor(recovery.WithRecoveryHandler(grpcPanicRecoveryHandler)),
}
+ if s.authConfigFile != "" {
+ streamChain = append(streamChain, authStreamInterceptor(s.cfg))
+ unaryChain = append(unaryChain, authInterceptor(s.cfg))
+ }
opts = append(opts, grpclib.MaxRecvMsgSize(int(s.maxRecvMsgSize)),
grpclib.ChainUnaryInterceptor(unaryChain...),
diff --git a/banyand/liaison/http/auth.go b/banyand/liaison/http/auth.go
new file mode 100644
index 00000000..716e73f4
--- /dev/null
+++ b/banyand/liaison/http/auth.go
@@ -0,0 +1,128 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package http
+
+import (
+ "context"
+ "encoding/base64"
+ "net/http"
+ "strings"
+
+ "github.com/pkg/errors"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+)
+
+func authMiddleware(cfg *auth.Config) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if isStaticPath(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ if !cfg.Enabled {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ if r.URL.Path == "/api/healthz" && !cfg.HealthAuthEnabled {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
+ http.Error(w, "Authorization header is missing", http.StatusUnauthorized)
+ return
+ }
+
+ if !strings.HasPrefix(authHeader, "Basic ") {
+ http.Error(w, "Invalid authorization header format", http.StatusBadRequest)
+ return
+ }
+
+ encodedCredentials := strings.TrimPrefix(authHeader, "Basic ")
+
+ decodedBytes, err := base64.StdEncoding.DecodeString(encodedCredentials)
+ if err != nil {
+ http.Error(w, "Failed to decode authorization header", http.StatusBadRequest)
+ return
+ }
+
+ decodedCredentials := string(decodedBytes)
+
+ parts := strings.SplitN(decodedCredentials, ":", 2)
+ if len(parts) != 2 {
+ http.Error(w, "Invalid authorization header format", http.StatusBadRequest)
+ return
+ }
+
+ username := parts[0]
+ password := parts[1]
+ if !auth.CheckUsernameAndPassword(cfg, username, password) {
+ http.Error(w, `{"error": "invalid credentials"}`, http.StatusUnauthorized)
+ return
+ }
+
+ r.Header.Set("Grpc-Metadata-Username", username)
+ r.Header.Set("Grpc-Metadata-Password", password)
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+var staticPaths = []string{
+ "/favicon.ico",
+ "/banyandb.ico",
+ "/assets/",
+ "index.html",
+}
+
+func isStaticPath(path string) bool {
+ for _, prefix := range staticPaths {
+ if strings.HasPrefix(path, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func buildGRPCContextForHealthCheck(cfg *auth.Config, r *http.Request) (context.Context, error) {
+ ctx := r.Context()
+
+ if cfg.HealthAuthEnabled {
+ username := r.Header.Get("Grpc-Metadata-Username")
+ password := r.Header.Get("Grpc-Metadata-Password")
+
+ if username == "" || password == "" {
+ return nil, errors.New("missing authentication metadata")
+ }
+
+ md := metadata.New(map[string]string{
+ "username": username,
+ "password": password,
+ })
+
+ ctx = metadata.NewOutgoingContext(ctx, md)
+ }
+
+ return ctx, nil
+}
diff --git a/banyand/liaison/http/server.go b/banyand/liaison/http/server.go
index 24d84b3a..b0a88241 100644
--- a/banyand/liaison/http/server.go
+++ b/banyand/liaison/http/server.go
@@ -36,12 +36,14 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health/grpc_health_v1"
commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
"github.com/apache/skywalking-banyandb/pkg/healthcheck"
"github.com/apache/skywalking-banyandb/pkg/logger"
"github.com/apache/skywalking-banyandb/pkg/run"
@@ -58,9 +60,10 @@ var (
)
// NewServer return a http service.
-func NewServer() Server {
+func NewServer(cfg *auth.Config) Server {
return &server{
stopCh: make(chan struct{}),
+ cfg: cfg,
}
}
@@ -87,6 +90,7 @@ type server struct {
grpcAddr string
keyFile string
certFile string
+ cfg *auth.Config
grpcCert string
grpcMu sync.Mutex
port uint32
@@ -338,7 +342,7 @@ func (p *server) initGRPCClient() error {
p.grpcClient.Store(client)
// Create gateway mux with health endpoint
- p.gwMux = runtime.NewServeMux(runtime.WithHealthzEndpoint(p.grpcClient.Load()))
+ p.gwMux = runtime.NewServeMux()
// Register all service handlers
err = multierr.Combine(
@@ -364,6 +368,27 @@ func (p *server) initGRPCClient() error {
// This avoids the conflict when remounting to /api path
newMux := chi.NewRouter()
+ newMux.Use(authMiddleware(p.cfg))
+ newMux.Handle("/api/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, err := buildGRPCContextForHealthCheck(p.cfg, r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusUnauthorized)
+ return
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ resp, err := p.grpcClient.Load().Check(ctx, &grpc_health_v1.HealthCheckRequest{}) //nolint:contextcheck
+ if err != nil {
+ http.Error(w, "gRPC health check failed: "+err.Error(), http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write([]byte(resp.Status.String()))
+ if err != nil {
+ logger.Errorf("Failed to write health check response: %v", err)
+ }
+ }))
+
// Mount the gateway mux to the HTTP server
newMux.Mount("/api", http.StripPrefix("/api", p.gwMux))
diff --git a/banyand/liaison/pkg/auth/config.go b/banyand/liaison/pkg/auth/config.go
new file mode 100644
index 00000000..f595a93d
--- /dev/null
+++ b/banyand/liaison/pkg/auth/config.go
@@ -0,0 +1,105 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package auth provides configuration management and validation logic for authentication.
+package auth
+
+import (
+ "crypto/subtle"
+ "fmt"
+ "os"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+)
+
+// Config AuthConfig.
+type Config struct {
+ Users []User `yaml:"users"`
+ Enabled bool `yaml:"-"`
+ HealthAuthEnabled bool `yaml:"-"`
+}
+
+// User details from config file.
+type User struct {
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+}
+
+// InitCfg returns Config with default values.
+func InitCfg() *Config {
+ return &Config{
+ Enabled: false,
+ HealthAuthEnabled: false,
+ Users: []User{},
+ }
+}
+
+// LoadConfig implements the reading of the authentication configuration.
+func LoadConfig(cfg *Config, filePath string) error {
+ cfg.Enabled = true
+
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return err
+ }
+ perm := info.Mode().Perm()
+ if perm != 0o600 {
+ return fmt.Errorf("config file %s has unsafe permissions: %o (expected 0600)", filePath, perm)
+ }
+
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return err
+ }
+ err = yaml.Unmarshal(data, cfg)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// CheckUsernameAndPassword returns true if the provided username and password match any configured user.
+func CheckUsernameAndPassword(cfg *Config, username, password string) bool {
+ username = strings.TrimSpace(username)
+ password = strings.TrimSpace(password)
+
+ for _, user := range cfg.Users {
+ storedUsername := strings.TrimSpace(user.Username)
+ storedPassword := strings.TrimSpace(user.Password)
+
+ // Convert to []byte
+ usernameBytes := []byte(username)
+ storedUsernameBytes := []byte(storedUsername)
+ passwordBytes := []byte(password)
+ storedPasswordBytes := []byte(storedPassword)
+
+ // Length must match
+ if len(usernameBytes) != len(storedUsernameBytes) || len(passwordBytes) != len(storedPasswordBytes) {
+ continue
+ }
+
+ // Use constant-time comparison
+ usernameMatch := subtle.ConstantTimeCompare(usernameBytes, storedUsernameBytes) == 1
+ passwordMatch := subtle.ConstantTimeCompare(passwordBytes, storedPasswordBytes) == 1
+
+ if usernameMatch && passwordMatch {
+ return true
+ }
+ }
+ return false
+}
diff --git a/bydbctl/internal/cmd/auth_test.go b/bydbctl/internal/cmd/auth_test.go
new file mode 100644
index 00000000..351358a7
--- /dev/null
+++ b/bydbctl/internal/cmd/auth_test.go
@@ -0,0 +1,164 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package cmd_test
+
+import (
+ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "github.com/spf13/cobra"
+ "github.com/zenizh/go-capturer"
+ "sigs.k8s.io/yaml"
+
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ serverAuth "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+ "github.com/apache/skywalking-banyandb/bydbctl/internal/cmd"
+ "github.com/apache/skywalking-banyandb/pkg/test/helpers"
+ "github.com/apache/skywalking-banyandb/pkg/test/setup"
+)
+
+//go:embed testdata/*.yaml
+var configFS embed.FS
+
+var _ = g.Describe("bydbctl test with authentication", func() {
+ var deferFn func()
+ var httpAddr string
+ var rootCmd *cobra.Command
+ var testUser serverAuth.User
+
+ g.BeforeEach(func() {
+ // load server config.yaml
+ cfgBytes, err := configFS.ReadFile("testdata/config.yaml")
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ tempServerCfg := filepath.Join(os.TempDir(), fmt.Sprintf(".bydb-%s.yaml", uuid.New().String()))
+ err = os.WriteFile(tempServerCfg, cfgBytes, 0o600)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ authCfgFile := tempServerCfg
+ info, _ := os.Stat(authCfgFile)
+ gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+ var cfg serverAuth.Config
+ err = yaml.Unmarshal(cfgBytes, &cfg)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ // take the first user as the test user
+ gm.Expect(len(cfg.Users)).Should(gm.BeNumerically(">", 0))
+ testUser = cfg.Users[0]
+ // Username and password must be provided because health checks require authentication when --enable-health-auth=true is enabled.
+ _, httpAddr, deferFn = setup.EmptyStandaloneWithAuth(
+ testUser.Username, testUser.Password,
+ fmt.Sprintf("--auth-config-file=%s", authCfgFile),
+ "--enable-health-auth=true",
+ )
+ httpAddr = httpSchema + httpAddr
+ rootCmd = &cobra.Command{Use: "root"}
+ cmd.RootCmdFlags(rootCmd)
+ })
+
+ g.It("list groups and health check with correct username and password from command line", func() {
+ rootCmd.SetArgs([]string{"group", "list", "-a", httpAddr, "-u", testUser.Username, "-p", testUser.Password})
+ err := rootCmd.Execute()
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+
+ rootCmd.SetArgs([]string{"health", "-a", httpAddr, "-u", testUser.Username, "-p", testUser.Password})
+ err = rootCmd.Execute()
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ })
+
+ g.It("list groups and health check with wrong username and password", func() {
+ // it will send http request with username = "admin" and password = ""
+ rootCmd.SetArgs([]string{"group", "list", "-a", httpAddr, "-u", "admin", "-p", testUser.Password + "wrong"})
+ err := rootCmd.Execute()
+ gm.Expect(err).To(gm.HaveOccurred())
+
+ rootCmd.SetArgs([]string{"health", "-a", httpAddr, "-u", "admin", "-p", testUser.Password + "wrong"})
+ err = rootCmd.Execute()
+ gm.Expect(err).To(gm.HaveOccurred())
+ })
+
+ g.It("create and get a group and health check with correct username and password in bydbctlCfgFile", func() {
+ clientCfgBytes, err := configFS.ReadFile("testdata/.bydbctl.yaml")
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ tempBydbctl := filepath.Join(os.TempDir(), fmt.Sprintf(".bydbctl-%s.yaml", uuid.New().String()))
+ err = os.WriteFile(tempBydbctl, clientCfgBytes, 0o600)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ bydbctlCfgFile := tempBydbctl
+ info, _ := os.Stat(bydbctlCfgFile)
+ gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+ rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "create", "-a", httpAddr, "-f", "-"})
+ rootCmd.SetIn(strings.NewReader(`
+metadata:
+ name: group1
+catalog: CATALOG_STREAM
+resource_opts:
+ shard_num: 2
+ segment_interval:
+ unit: UNIT_DAY
+ num: 1
+ ttl:
+ unit: UNIT_DAY
+ num: 7`))
+ out := capturer.CaptureStdout(func() {
+ err = rootCmd.Execute()
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ })
+ gm.Expect(out).To(gm.ContainSubstring("group group1 is created"))
+
+ rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "-a", httpAddr, "get", "-g", "group1"})
+ out = capturer.CaptureStdout(func() {
+ err = rootCmd.Execute()
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ })
+ resp := new(databasev1.GroupRegistryServiceGetResponse)
+ helpers.UnmarshalYAML([]byte(out), resp)
+ gm.Expect(resp.Group.Metadata.Name).To(gm.Equal("group1"))
+
+ rootCmd.SetArgs([]string{"health", "-a", httpAddr})
+ err = rootCmd.Execute()
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ })
+
+ g.It("list groups and health check with wrong username and password in bydbctlCfgFile", func() {
+ clientCfgBytes, err := configFS.ReadFile("testdata/.bydbctl1.yaml")
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ tempBydbctl := filepath.Join(os.TempDir(), fmt.Sprintf(".bydbctl-%s.yaml", uuid.New().String()))
+ err = os.WriteFile(tempBydbctl, clientCfgBytes, 0o600)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ bydbctlCfgFile := tempBydbctl
+ info, _ := os.Stat(bydbctlCfgFile)
+ gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+ rootCmd.SetArgs([]string{"--config", bydbctlCfgFile, "group", "list", "-a", httpAddr})
+ err = rootCmd.Execute()
+ gm.Expect(err).To(gm.HaveOccurred())
+
+ rootCmd.SetArgs([]string{"health", "-a", httpAddr})
+ err = rootCmd.Execute()
+ gm.Expect(err).To(gm.HaveOccurred())
+ })
+
+ g.AfterEach(func() {
+ deferFn()
+ })
+})
diff --git a/bydbctl/internal/cmd/health_check.go b/bydbctl/internal/cmd/health_check.go
index 2fe2cd0e..de63956f 100644
--- a/bydbctl/internal/cmd/health_check.go
+++ b/bydbctl/internal/cmd/health_check.go
@@ -23,6 +23,7 @@ import (
"github.com/go-resty/resty/v2"
"github.com/spf13/cobra"
+ "github.com/spf13/viper"
"google.golang.org/grpc"
"github.com/apache/skywalking-banyandb/pkg/grpchelper"
@@ -50,7 +51,7 @@ func newHealthCheckCmd() *cobra.Command {
if err != nil {
return err
}
- err = helpers.HealthCheck(grpcAddr, 10*time.Second, 10*time.Second, opts...)()
+ err = healthCheck(grpcAddr, 10*time.Second, 10*time.Second, username, password, opts...)
if err == nil {
fmt.Println("connected")
}
@@ -61,3 +62,12 @@ func newHealthCheckCmd() *cobra.Command {
bindTLSRelatedFlag(healthCheckCmd)
return healthCheckCmd
}
+
+func healthCheck(grpcAddr string, connTimeout, rpcTimeout time.Duration, username, password string, opts ...grpc.DialOption) error {
+ if username != "" {
+ return helpers.HealthCheckWithAuth(grpcAddr, connTimeout, rpcTimeout, username, password, opts...)()
+ }
+ username = viper.GetString("username")
+ password = viper.GetString("password")
+ return helpers.HealthCheckWithAuth(grpcAddr, connTimeout, rpcTimeout, username, password, opts...)()
+}
diff --git a/bydbctl/internal/cmd/rest.go b/bydbctl/internal/cmd/rest.go
index 88ed2dfe..3608128f 100644
--- a/bydbctl/internal/cmd/rest.go
+++ b/bydbctl/internal/cmd/rest.go
@@ -23,6 +23,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "net/http"
"os"
"time"
@@ -37,6 +38,7 @@ import (
"sigs.k8s.io/yaml"
"github.com/apache/skywalking-banyandb/bydbctl/pkg/file"
+ "github.com/apache/skywalking-banyandb/pkg/auth"
)
const (
@@ -337,6 +339,11 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
client.SetTLSClientConfig(&config)
}
req := client.R()
+ // Add req headers.
+ authHeader := getAuthHeader()
+ if authHeader != "" {
+ req.Header.Set("Authorization", authHeader)
+ }
resp, err := fn(request{
reqBody: r,
req: req,
@@ -351,6 +358,9 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
s := status.FromProto(st)
return s.Err()
}
+ if resp.StatusCode() != http.StatusOK {
+ return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode())
+ }
err = printer(i, r, bd)
if err != nil {
return err
@@ -359,3 +369,16 @@ func rest(pfn paramsFn, fn reqFn, printer printer, enableTLS bool, insecure bool
return nil
}
+
+func getAuthHeader() string {
+ if username != "" {
+ return auth.GenerateBasicAuthHeader(username, password)
+ }
+ // If the command line username is not available, then look for it in the configuration file.
+ user := viper.GetString("username")
+ pwd := viper.GetString("password")
+ if user == "" && pwd == "" {
+ return ""
+ }
+ return auth.GenerateBasicAuthHeader(user, pwd)
+}
diff --git a/bydbctl/internal/cmd/root.go b/bydbctl/internal/cmd/root.go
index bb9cde00..c83f2752 100644
--- a/bydbctl/internal/cmd/root.go
+++ b/bydbctl/internal/cmd/root.go
@@ -26,10 +26,14 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
+ "github.com/apache/skywalking-banyandb/pkg/config"
"github.com/apache/skywalking-banyandb/pkg/version"
)
-const pathTemp = "/{group}/{name}"
+const (
+ pathTemp = "/{group}/{name}"
+ envPrefix = "BYDBCTL"
+)
var (
filePath string
@@ -40,6 +44,8 @@ var (
enableTLS bool
insecure bool
cert string
+ username string
+ password string
rootCmd = &cobra.Command{
DisableAutoGenTag: true,
Version: version.Build(),
@@ -65,9 +71,13 @@ func RootCmdFlags(command *cobra.Command) {
command.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bydbctl.yaml)")
command.PersistentFlags().StringP("group", "g", "", "If present, list objects in this group.")
command.PersistentFlags().StringP("addr", "a", "", "Server's address, the format is Schema://Domain:Port")
+ command.PersistentFlags().StringVarP(&username, "username", "u", "", "Username for authentication")
+ command.PersistentFlags().StringVarP(&password, "password", "p", "", "Password for authentication")
_ = viper.BindPFlag("group", command.PersistentFlags().Lookup("group"))
_ = viper.BindPFlag("addr", command.PersistentFlags().Lookup("addr"))
viper.SetDefault("addr", "http://localhost:17913")
+ _ = viper.BindPFlag("username", command.PersistentFlags().Lookup("username"))
+ _ = viper.BindPFlag("password", command.PersistentFlags().Lookup("password"))
command.AddCommand(newGroupCmd(), newUseCmd(), newStreamCmd(), newMeasureCmd(), newTopnCmd(),
newIndexRuleCmd(), newIndexRuleBindingCmd(), newPropertyCmd(), newTraceCmd(), newHealthCheckCmd(), newAnalyzeCmd())
@@ -96,14 +106,27 @@ func initConfig() {
viper.SetConfigName(".bydbctl")
}
+ viper.SetEnvPrefix(envPrefix)
viper.AutomaticEnv()
+ err := config.BindFlags(rootCmd.PersistentFlags(), viper.GetViper(), envPrefix)
+ if err != nil {
+ cobra.CheckErr(err)
+ }
readCfg := func() error {
if err := viper.ReadInConfig(); err != nil {
return err
}
+ configFile := viper.ConfigFileUsed()
+ info, err := os.Stat(configFile)
+ if err != nil {
+ return fmt.Errorf("unable to stat config file: %w", err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ fmt.Printf("config file %s has unsafe permissions: %o (expected 0600)", configFile, info.Mode().Perm())
+ }
// Dump this to stderr in case of mixing up response yaml
- fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
+ fmt.Fprintln(os.Stderr, "Using config file:", configFile)
return nil
}
@@ -112,6 +135,12 @@ func initConfig() {
cobra.CheckErr(err)
}
cobra.CheckErr(viper.SafeWriteConfig())
+ // Reload config to ensure Viper updates ConfigFileUsed(), avoiding empty path for chmod
+ cobra.CheckErr(viper.ReadInConfig())
+ configFile := viper.ConfigFileUsed()
+ if err := os.Chmod(configFile, 0o600); err != nil {
+ cobra.CheckErr(fmt.Errorf("failed to set permissions on config file %s: %w", configFile, err))
+ }
cobra.CheckErr(readCfg())
}
}
diff --git a/bydbctl/internal/cmd/testdata/.bydbctl.yaml b/bydbctl/internal/cmd/testdata/.bydbctl.yaml
new file mode 100644
index 00000000..31a538cb
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/.bydbctl.yaml
@@ -0,0 +1,3 @@
+group: ""
+username: "test"
+password: "123456"
\ No newline at end of file
diff --git a/bydbctl/internal/cmd/testdata/.bydbctl1.yaml b/bydbctl/internal/cmd/testdata/.bydbctl1.yaml
new file mode 100644
index 00000000..f2845ce3
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/.bydbctl1.yaml
@@ -0,0 +1,3 @@
+group: ""
+username: "test"
+password: "wrong-password"
\ No newline at end of file
diff --git a/bydbctl/internal/cmd/testdata/config.yaml b/bydbctl/internal/cmd/testdata/config.yaml
new file mode 100644
index 00000000..6c6d24dd
--- /dev/null
+++ b/bydbctl/internal/cmd/testdata/config.yaml
@@ -0,0 +1,5 @@
+users:
+ - username: admin
+ password: 123456
+ - username: test
+ password: 123456
\ No newline at end of file
diff --git a/docs/operation/security.md b/docs/operation/security.md
index fbddd5ea..748e8dd4 100644
--- a/docs/operation/security.md
+++ b/docs/operation/security.md
@@ -4,6 +4,89 @@ Security is a critical aspect of any application. In this section, we will discu
## Authentication
+BanyanDB supports username and password-based authentication for both gRPC and HTTP endpoints. This guide explains how to configure and use this feature for both the BanyanDB server and the `bydbctl` command-line tool.
+
+### Basic Authentication
+
+To enable authentication on the BanyanDB server, you need to create a **YAML configuration file** that defines your users and their passwords.
+
+#### Create the Configuration File
+
+The configuration file should contain a list of users. For security, it's highly recommended to use strong, unique passwords instead of the examples provided below.
+
+```yaml
+users:
+ - username: admin
+ password: StrongPassword123
+ - username: dev_user
+ password: AnotherStrongPassword456
+```
+
+#### Set File Permissions
+
+To protect your credentials, the configuration file **must** have read/write permissions only for the owner. Set the correct permissions using the following command:
+
+```shell
+chmod 600 /path/to/auth_config.yaml
+```
+
+This command ensures that only the file's owner can read or modify the contents.
+
+#### Start the BanyanDB Server
+
+Finally, start the BanyanDB server with the `--auth-config-file` flag, pointing to the file you just created:
+
+```shell
+banyand liaison --auth-config-file=/path/to/auth_config.yaml
+```
+
+### Authenticating with `bydbctl`
+
+When the BanyanDB server has authentication enabled, you must provide a username and password with your `bydbctl` commands. There are two ways to do this.
+
+#### Option 1: Use Command-Line Flags
+
+You can provide the authentication details directly in your `bydbctl` command using the `-u` and `-p` flags.
+
+```shell
+bydbctl group get -g group1 -a http://localhost:17913 -u admin -p StrongPassword123
+```
+
+> **Note:** The `addr`, `group`, `username`, and `password` parameters will be automatically saved to a file named `.bydbctl.yaml` in your home directory if it doesn't already exist. This file will also have the permissions `0600` for security.
+
+#### Option 2: Use a `bydbctl` Configuration File
+
+You can create a separate configuration file for `bydbctl` to store your connection and authentication details.
+
+#### Create the Configuration File
+
+Create a YAML file containing your connection details. For example:
+
+```yaml
+addr: http://localhost:17913
+group: group1
+username: admin
+password: StrongPassword123
+```
+
+#### Set File Permissions
+
+Just like with the server's authentication file, the `bydbctl` configuration file should have secure permissions:
+
+```shell
+chmod 600 /path/to/bydbctl_config.yaml
+```
+
+#### Run `bydbctl` with the Configuration File
+
+You can then run `bydbctl` by specifying the path to your configuration file with the `--config` flag.
+
+```shell
+bydbctl --config /path/to/bydbctl_config.yaml group list
+```
+
+### External TLS (Client ↔ Server)
+
BanyanDB supports TLS for secure communication between servers. The following flags are used to configure TLS:
- `--tls`: gRPC Connection uses TLS if true, else plain TCP.
diff --git a/go.mod b/go.mod
index 95c21237..a20c0fc3 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
github.com/go-chi/chi/v5 v5.2.1
github.com/go-resty/resty/v2 v2.16.5
github.com/google/go-cmp v0.7.0
+ github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
github.com/hashicorp/golang-lru v1.0.2
@@ -86,7 +87,6 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/kamstrup/intmap v0.5.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/machinebox/graphql v0.2.2 // indirect
diff --git a/pkg/auth/util.go b/pkg/auth/util.go
new file mode 100644
index 00000000..52ca20ae
--- /dev/null
+++ b/pkg/auth/util.go
@@ -0,0 +1,31 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package auth contains authentication helper functions.
+package auth
+
+import (
+ "encoding/base64"
+ "fmt"
+)
+
+// GenerateBasicAuthHeader creates a Basic Auth header from username and password.
+func GenerateBasicAuthHeader(username, password string) string {
+ credentials := fmt.Sprintf("%s:%s", username, password)
+ encoded := base64.StdEncoding.EncodeToString([]byte(credentials))
+ return "Basic " + encoded
+}
diff --git a/pkg/cmdsetup/liaison.go b/pkg/cmdsetup/liaison.go
index 9ad92ab4..97f3f091 100644
--- a/pkg/cmdsetup/liaison.go
+++ b/pkg/cmdsetup/liaison.go
@@ -81,7 +81,7 @@ func newLiaisonCmd(runners ...run.Unit) *cobra.Command {
PropertyNodeRegistry: grpc.NewClusterNodeRegistry(data.TopicPropertyUpdate, tire2Client, propertyNodeSel),
}, metricSvc)
profSvc := observability.NewProfService()
- httpServer := http.NewServer()
+ httpServer := http.NewServer(grpcServer.GetAuthCfg())
var units []run.Unit
units = append(units, runners...)
units = append(units,
diff --git a/pkg/cmdsetup/standalone.go b/pkg/cmdsetup/standalone.go
index 0105011d..70abc4d3 100644
--- a/pkg/cmdsetup/standalone.go
+++ b/pkg/cmdsetup/standalone.go
@@ -76,7 +76,7 @@ func newStandaloneCmd(runners ...run.Unit) *cobra.Command {
PropertyNodeRegistry: nr,
}, metricSvc)
profSvc := observability.NewProfService()
- httpServer := http.NewServer()
+ httpServer := http.NewServer(grpcServer.GetAuthCfg())
var units []run.Unit
units = append(units, runners...)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d16134a2..7e96558f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -79,11 +79,11 @@ func (c *config) initializeConfig(fs *pflag.FlagSet) error {
v.AutomaticEnv()
// Bind the current command's flags to viper
- return bindFlags(fs, v)
+ return BindFlags(fs, v, envPrefix)
}
-// Bind each cobra flag to its associated viper configuration (config file and environment variable).
-func bindFlags(fs *pflag.FlagSet, v *viper.Viper) error {
+// BindFlags bind each cobra flag to its associated viper configuration (config file and environment variable).
+func BindFlags(fs *pflag.FlagSet, v *viper.Viper, envPrefix string) error {
var err error
fs.VisitAll(func(f *pflag.Flag) {
// Environment variables can't have dashes in them, so bind them to their equivalent
diff --git a/pkg/grpchelper/client.go b/pkg/grpchelper/client.go
index db583cfe..085f675a 100644
--- a/pkg/grpchelper/client.go
+++ b/pkg/grpchelper/client.go
@@ -65,6 +65,11 @@ func SecureOptions(dest []grpc.DialOption, enabled, insecure bool, cert string)
// Conn returns a gRPC client connection once connecting the server.
func Conn(addr string, healthCheckTimeout time.Duration, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
+ return ConnWithAuth(addr, healthCheckTimeout, "", "", opts...)
+}
+
+// ConnWithAuth returns a gRPC client connection once connecting the server with Auth.
+func ConnWithAuth(addr string, healthCheckTimeout time.Duration, username, password string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
l := logger.GetLogger("grpc-helper")
conn, err := grpc.NewClient(addr, opts...)
@@ -82,6 +87,11 @@ func Conn(addr string, healthCheckTimeout time.Duration, opts ...grpc.DialOption
}
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
+ md := metadata.Pairs(
+ "username", username,
+ "password", password,
+ )
+ ctx = metadata.NewOutgoingContext(ctx, md)
_, err = healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{Service: ""})
cancel()
if err == nil {
@@ -98,7 +108,8 @@ func Request(ctx context.Context, rpcTimeout time.Duration, fn func(rpcCtx conte
rpcStart := time.Now()
rpcCtx, rpcCancel := context.WithTimeout(ctx, rpcTimeout)
defer rpcCancel()
- rpcCtx = metadata.NewOutgoingContext(rpcCtx, make(metadata.MD))
+ md, _ := metadata.FromOutgoingContext(ctx)
+ rpcCtx = metadata.NewOutgoingContext(rpcCtx, md)
l := logger.GetLogger("grpc-helper")
err := fn(rpcCtx)
diff --git a/pkg/test/helpers/grpc_health.go b/pkg/test/helpers/grpc_health.go
index 4b9274cc..fd049d8e 100644
--- a/pkg/test/helpers/grpc_health.go
+++ b/pkg/test/helpers/grpc_health.go
@@ -24,6 +24,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/metadata"
"github.com/apache/skywalking-banyandb/pkg/grpchelper"
"github.com/apache/skywalking-banyandb/pkg/logger"
@@ -33,14 +34,24 @@ var errServiceUnhealthy = errors.New("service is unhealthy")
// HealthCheck returns a function for ginkgo "Eventually" poll it repeatedly to check whether a gRPC server is ready.
func HealthCheck(addr string, connTimeout time.Duration, rpcTimeout time.Duration, opts ...grpc.DialOption) func() error {
+ return HealthCheckWithAuth(addr, connTimeout, rpcTimeout, "", "", opts...)
+}
+
+// HealthCheckWithAuth returns a function for ginkgo "Eventually" poll it repeatedly to check whether a gRPC server is ready with Auth.
+func HealthCheckWithAuth(addr string, connTimeout time.Duration, rpcTimeout time.Duration, username, password string, opts ...grpc.DialOption) func() error {
return func() error {
- conn, err := grpchelper.Conn(addr, connTimeout, opts...)
+ conn, err := grpchelper.ConnWithAuth(addr, connTimeout, username, password, opts...)
if err != nil {
return err
}
defer conn.Close()
var resp *grpc_health_v1.HealthCheckResponse
if err := grpchelper.Request(context.Background(), rpcTimeout, func(rpcCtx context.Context) (err error) {
+ md := metadata.Pairs(
+ "username", username,
+ "password", password,
+ )
+ rpcCtx = metadata.NewOutgoingContext(rpcCtx, md)
resp, err = grpc_health_v1.NewHealthClient(conn).Check(rpcCtx,
&grpc_health_v1.HealthCheckRequest{
Service: "",
diff --git a/pkg/test/helpers/http_health.go b/pkg/test/helpers/http_health.go
index 80d7d621..78147c7c 100644
--- a/pkg/test/helpers/http_health.go
+++ b/pkg/test/helpers/http_health.go
@@ -27,11 +27,17 @@ import (
"github.com/go-resty/resty/v2"
+ "github.com/apache/skywalking-banyandb/pkg/auth"
"github.com/apache/skywalking-banyandb/pkg/logger"
)
// HTTPHealthCheck returns a function for ginkgo "Eventually" poll it repeatedly to check whether a HTTP server is ready.
func HTTPHealthCheck(addr, cert string) func() error {
+ return HTTPHealthCheckWithAuth(addr, cert, "", "")
+}
+
+// HTTPHealthCheckWithAuth returns a function for ginkgo "Eventually" poll it repeatedly to check whether a HTTP server is ready with Auth.
+func HTTPHealthCheckWithAuth(addr, cert, username, password string) func() error {
return func() error {
client := resty.New()
schema := "http"
@@ -50,9 +56,11 @@ func HTTPHealthCheck(addr, cert string) func() error {
client.SetTLSClientConfig(&config)
schema = "https"
}
- resp, err := client.R().
- SetHeader("Accept", "application/json").
- Get(fmt.Sprintf("%s://%s/api/healthz", schema, addr))
+ req := client.R().SetHeader("Accept", "application/json")
+ if username != "" {
+ req.SetHeader("Authorization", auth.GenerateBasicAuthHeader(username, password))
+ }
+ resp, err := req.Get(fmt.Sprintf("%s://%s/api/healthz", schema, addr))
if err != nil {
time.Sleep(1 * time.Second)
return err
diff --git a/pkg/test/setup/setup.go b/pkg/test/setup/setup.go
index dfb91aa0..cb8a6ac0 100644
--- a/pkg/test/setup/setup.go
+++ b/pkg/test/setup/setup.go
@@ -50,7 +50,15 @@ func Standalone(flags ...string) (string, string, func()) {
return StandaloneWithSchemaLoaders([]SchemaLoader{
&preloadService{name: "stream"},
&preloadService{name: "measure"},
- }, "", "", flags...)
+ }, "", "", "", "", flags...)
+}
+
+// StandaloneWithAuth wires standalone modules to build a testing ready runtime with Auth.
+func StandaloneWithAuth(username, password string, flags ...string) (string, string, func()) {
+ return StandaloneWithSchemaLoaders([]SchemaLoader{
+ &preloadService{name: "stream"},
+ &preloadService{name: "measure"},
+ }, "", "", username, password, flags...)
}
// StandaloneWithTLS wires standalone modules to build a testing ready runtime with TLS enabled.
@@ -58,22 +66,27 @@ func StandaloneWithTLS(certFile, keyFile string, flags ...string) (string, strin
return StandaloneWithSchemaLoaders([]SchemaLoader{
&preloadService{name: "stream"},
&preloadService{name: "measure"},
- }, certFile, keyFile, flags...)
+ }, certFile, keyFile, "", "", flags...)
}
// EmptyStandalone wires standalone modules to build a testing ready runtime.
func EmptyStandalone(flags ...string) (string, string, func()) {
- return StandaloneWithSchemaLoaders(nil, "", "", flags...)
+ return StandaloneWithSchemaLoaders(nil, "", "", "", "", flags...)
+}
+
+// EmptyStandaloneWithAuth wires standalone modules to build a testing ready runtime with Auth.
+func EmptyStandaloneWithAuth(username, password string, flags ...string) (string, string, func()) {
+ return StandaloneWithSchemaLoaders(nil, "", "", username, password, flags...)
}
// StandaloneWithSchemaLoaders wires standalone modules to build a testing ready runtime. It also allows to preload schema.
-func StandaloneWithSchemaLoaders(schemaLoaders []SchemaLoader, certFile, keyFile string, flags ...string) (string, string, func()) {
+func StandaloneWithSchemaLoaders(schemaLoaders []SchemaLoader, certFile, keyFile string, username, password string, flags ...string) (string, string, func()) {
path, deferFn, err := test.NewSpace()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
var ports []int
ports, err = test.AllocateFreePorts(4)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
- addr, httpAddr, closeFn := standaloneServer(path, ports, schemaLoaders, certFile, keyFile, flags...)
+ addr, httpAddr, closeFn := standaloneServerWithAuth(path, ports, schemaLoaders, certFile, keyFile, username, password, flags...)
return addr, httpAddr, func() {
closeFn()
deferFn()
@@ -99,6 +112,12 @@ func EmptyClosableStandalone(path string, ports []int, flags ...string) (string,
}
func standaloneServer(path string, ports []int, schemaLoaders []SchemaLoader, certFile, keyFile string, flags ...string) (string, string, func()) {
+ return standaloneServerWithAuth(path, ports, schemaLoaders, certFile, keyFile, "", "", flags...)
+}
+
+func standaloneServerWithAuth(path string, ports []int, schemaLoaders []SchemaLoader, certFile, keyFile string,
+ username, password string, flags ...string,
+) (string, string, func()) {
addr := fmt.Sprintf("%s:%d", host, ports[0])
httpAddr := fmt.Sprintf("%s:%d", host, ports[1])
endpoint := fmt.Sprintf("http://%s:%d", host, ports[2])
@@ -131,14 +150,16 @@ func standaloneServer(path string, ports []int, schemaLoaders []SchemaLoader, ce
if tlsEnabled {
creds, err := credentials.NewClientTLSFromFile(certFile, "localhost")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
- gomega.Eventually(helpers.HealthCheck(addr, 10*time.Second, 10*time.Second, grpclib.WithTransportCredentials(creds)), testflags.EventuallyTimeout).
+ gomega.Eventually(helpers.HealthCheckWithAuth(addr, 10*time.Second, 10*time.Second,
+ username, password, grpclib.WithTransportCredentials(creds)), testflags.EventuallyTimeout).
Should(gomega.Succeed())
- gomega.Eventually(helpers.HTTPHealthCheck(httpAddr, certFile), testflags.EventuallyTimeout).Should(gomega.Succeed())
+ gomega.Eventually(helpers.HTTPHealthCheckWithAuth(httpAddr, certFile, username, password), testflags.EventuallyTimeout).Should(gomega.Succeed())
} else {
gomega.Eventually(
- helpers.HealthCheck(addr, 10*time.Second, 10*time.Second, grpclib.WithTransportCredentials(insecure.NewCredentials())),
+ helpers.HealthCheckWithAuth(addr, 10*time.Second, 10*time.Second,
+ username, password, grpclib.WithTransportCredentials(insecure.NewCredentials())),
testflags.EventuallyTimeout).Should(gomega.Succeed())
- gomega.Eventually(helpers.HTTPHealthCheck(httpAddr, ""), testflags.EventuallyTimeout).Should(gomega.Succeed())
+ gomega.Eventually(helpers.HTTPHealthCheckWithAuth(httpAddr, "", username, password), testflags.EventuallyTimeout).Should(gomega.Succeed())
}
if schemaLoaders != nil {
diff --git a/test/cases/measure/data/data.go b/test/cases/measure/data/data.go
index f4c78699..77274d08 100644
--- a/test/cases/measure/data/data.go
+++ b/test/cases/measure/data/data.go
@@ -29,6 +29,7 @@ import (
g "github.com/onsi/ginkgo/v2"
gm "github.com/onsi/gomega"
grpclib "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -47,8 +48,7 @@ var inputFS embed.FS
//go:embed want/*.yaml
var wantFS embed.FS
-// VerifyFn verify whether the query response matches the wanted result.
-var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
+func verifyWithContext(ctx context.Context, innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
i, err := inputFS.ReadFile("input/" + args.Input + ".yaml")
innerGm.Expect(err).NotTo(gm.HaveOccurred())
query := &measurev1.QueryRequest{}
@@ -56,7 +56,6 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
query.TimeRange = helpers.TimeRange(args, sharedContext)
query.Stages = args.Stages
c := measurev1.NewMeasureServiceClient(sharedContext.Connection)
- ctx := context.Background()
resp, err := c.Query(ctx, query)
if args.WantErr {
if err == nil {
@@ -111,6 +110,21 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
innerGm.Expect(resp.Trace.GetSpans()).NotTo(gm.BeEmpty())
}
+// VerifyFn verify whether the query response matches the wanted result.
+var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args) {
+ ctx := context.Background()
+ verifyWithContext(ctx, innerGm, sharedContext, args)
+}
+
+// VerifyFnWithAuth verify whether the query response matches the wanted result with Auth.
+var VerifyFnWithAuth = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args helpers.Args, username, password string) {
+ ctx := metadata.AppendToOutgoingContext(context.Background(),
+ "username", username,
+ "password", password,
+ )
+ verifyWithContext(ctx, innerGm, sharedContext, args)
+}
+
//go:embed testdata/*.json
var dataFS embed.FS
@@ -134,18 +148,27 @@ func loadData(md *commonv1.Metadata, measure measurev1.MeasureService_WriteClien
func Write(conn *grpclib.ClientConn, name, group, dataFile string,
baseTime time.Time, interval time.Duration,
) {
+ WriteWithAuth(conn, name, group, dataFile, baseTime, interval, "", "")
+}
+
+// WriteWithAuth data into the server with Auth.
+func WriteWithAuth(conn *grpclib.ClientConn, name, group, dataFile string,
+ baseTime time.Time, interval time.Duration, username, password string,
+) {
+ ctx := context.Background()
+ md := metadata.Pairs("username", username, "password", password)
+ ctx = metadata.NewOutgoingContext(ctx, md)
metadata := &commonv1.Metadata{
Name: name,
Group: group,
}
schema := databasev1.NewMeasureRegistryServiceClient(conn)
- resp, err := schema.Get(context.Background(), &databasev1.MeasureRegistryServiceGetRequest{Metadata: metadata})
+ resp, err := schema.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{Metadata: metadata})
gm.Expect(err).NotTo(gm.HaveOccurred())
metadata = resp.GetMeasure().GetMetadata()
c := measurev1.NewMeasureServiceClient(conn)
- ctx := context.Background()
writeClient, err := c.Write(ctx)
gm.Expect(err).NotTo(gm.HaveOccurred())
loadData(metadata, writeClient, dataFile, baseTime, interval)
diff --git a/test/integration/standalone/other/auth_test.go b/test/integration/standalone/other/auth_test.go
new file mode 100644
index 00000000..20cc06bc
--- /dev/null
+++ b/test/integration/standalone/other/auth_test.go
@@ -0,0 +1,210 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package integration_other_test
+
+import (
+ "embed"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "github.com/onsi/gomega/gleak"
+ grpclib "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+ "sigs.k8s.io/yaml"
+
+ serverAuth "github.com/apache/skywalking-banyandb/banyand/liaison/pkg/auth"
+ "github.com/apache/skywalking-banyandb/pkg/auth"
+ "github.com/apache/skywalking-banyandb/pkg/grpchelper"
+ "github.com/apache/skywalking-banyandb/pkg/test/flags"
+ "github.com/apache/skywalking-banyandb/pkg/test/helpers"
+ "github.com/apache/skywalking-banyandb/pkg/test/setup"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+ casesMeasureData "github.com/apache/skywalking-banyandb/test/cases/measure/data"
+)
+
+//go:embed testdata/config.yaml
+var serverConfigFS embed.FS
+
+var _ = g.Describe("Query service_cpm_minute with authentication", func() {
+ var deferFn func()
+ var baseTime time.Time
+ var interval time.Duration
+ var conn *grpclib.ClientConn
+ var goods []gleak.Goroutine
+ var grpcAddr, httpAddr string
+ var testUser serverAuth.User
+
+ g.BeforeEach(func() {
+ // load server config.yaml
+ cfgBytes, err := serverConfigFS.ReadFile("testdata/config.yaml")
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ tempServerCfg := filepath.Join(os.TempDir(), fmt.Sprintf(".bydb-%s.yaml", uuid.New().String()))
+ err = os.WriteFile(tempServerCfg, cfgBytes, 0o600)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ authCfgFile := tempServerCfg
+ info, _ := os.Stat(authCfgFile)
+ gm.Expect(info.Mode().Perm()).To(gm.Equal(os.FileMode(0o600)))
+
+ var cfg serverAuth.Config
+ err = yaml.Unmarshal(cfgBytes, &cfg)
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ // take the first user as the test user
+ gm.Expect(len(cfg.Users)).Should(gm.BeNumerically(">", 0))
+ testUser = cfg.Users[0]
+ // Username and password must be provided because health checks require authentication when --enable-health-auth=true is enabled.
+ grpcAddr, httpAddr, deferFn = setup.StandaloneWithAuth(
+ testUser.Username, testUser.Password,
+ fmt.Sprintf("--auth-config-file=%s", authCfgFile),
+ "--enable-health-auth=true",
+ )
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ conn, err = grpchelper.ConnWithAuth(grpcAddr, 10*time.Second, testUser.Username, testUser.Password, grpclib.WithTransportCredentials(insecure.NewCredentials()))
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ ns := timestamp.NowMilli().UnixNano()
+ baseTime = time.Unix(0, ns-ns%int64(time.Minute))
+ interval = 500 * time.Millisecond
+ casesMeasureData.WriteWithAuth(conn, "service_cpm_minute", "sw_metric", "service_cpm_minute_data.json", baseTime, interval, testUser.Username, testUser.Password)
+ goods = gleak.Goroutines()
+ })
+
+ g.AfterEach(func() {
+ gm.Expect(conn.Close()).To(gm.Succeed())
+ deferFn()
+ gm.Eventually(gleak.Goroutines, flags.EventuallyTimeout).ShouldNot(gleak.HaveLeaked(goods))
+ })
+
+ g.It("grpc query and healthcheck with correct username and password", func() {
+ gm.Eventually(func(innerGm gm.Gomega) {
+ casesMeasureData.VerifyFnWithAuth(innerGm, helpers.SharedContext{
+ Connection: conn,
+ BaseTime: baseTime,
+ },
+ helpers.Args{Input: "all", Duration: 25 * time.Minute, Offset: -20 * time.Minute},
+ testUser.Username, testUser.Password)
+ }, flags.EventuallyTimeout).Should(gm.Succeed())
+
+ opts := make([]grpclib.DialOption, 0, 1)
+ opts, err := grpchelper.SecureOptions(opts, false, true, "")
+ gm.Expect(err).ToNot(gm.HaveOccurred())
+ gm.Eventually(func() error {
+ return helpers.HealthCheckWithAuth(grpcAddr, 10*time.Second, 10*time.Second, testUser.Username, testUser.Password, opts...)()
+ }, flags.EventuallyTimeout).Should(gm.Succeed())
+ })
+
+ g.It("grpc query and healthcheck with wrong username and password", func() {
+ gm.Eventually(func(innerGm gm.Gomega) {
+ casesMeasureData.VerifyFnWithAuth(innerGm, helpers.SharedContext{
+ Connection: conn,
+ BaseTime: baseTime,
+ },
+ helpers.Args{Input: "all", Duration: 25 * time.Minute, Offset: -20 * time.Minute},
+ testUser.Username, testUser.Password+"wrong")
+ }, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+
+ opts := make([]grpclib.DialOption, 0, 1)
+ opts, err := grpchelper.SecureOptions(opts, false, true, "")
+ gm.Expect(err).ToNot(gm.HaveOccurred())
+ gm.Eventually(func() error {
+ return helpers.HealthCheckWithAuth(grpcAddr, 10*time.Second, 10*time.Second, testUser.Username, testUser.Password+"wrong", opts...)()
+ }, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+ })
+
+ g.It("http query and healthcheck with correct username and password", func() {
+ httpClient := &http.Client{}
+ groupListURL := fmt.Sprintf("http://%s/api/v1/group/schema/lists", httpAddr)
+ gm.Eventually(func() error {
+ req, err := http.NewRequest(http.MethodGet, groupListURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password))
+ resp, respErr := httpClient.Do(req)
+ if respErr != nil {
+ return respErr
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, flags.EventuallyTimeout).Should(gm.Succeed())
+
+ healthCheckURL := fmt.Sprintf("http://%s/api/healthz", httpAddr)
+ gm.Eventually(func() error {
+ req, err := http.NewRequest(http.MethodGet, healthCheckURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password))
+ resp, respErr := httpClient.Do(req)
+ if respErr != nil {
+ return respErr
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, flags.EventuallyTimeout).Should(gm.Succeed())
+ })
+
+ g.It("http query and healthcheck with wrong username and password", func() {
+ httpClient := &http.Client{}
+ groupListURL := fmt.Sprintf("http://%s/api/v1/group/schema/lists", httpAddr)
+ gm.Eventually(func() error {
+ req, err := http.NewRequest(http.MethodGet, groupListURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password+"wrong"))
+ resp, respErr := httpClient.Do(req)
+ if respErr != nil {
+ return respErr
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+
+ healthCheckURL := fmt.Sprintf("http://%s/api/healthz", httpAddr)
+ gm.Eventually(func() error {
+ req, err := http.NewRequest(http.MethodGet, healthCheckURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Authorization", auth.GenerateBasicAuthHeader(testUser.Username, testUser.Password+"wrong"))
+ resp, respErr := httpClient.Do(req)
+ if respErr != nil {
+ return respErr
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, flags.EventuallyTimeout).ShouldNot(gm.Succeed())
+ })
+})
diff --git a/test/integration/standalone/other/testdata/config.yaml b/test/integration/standalone/other/testdata/config.yaml
new file mode 100644
index 00000000..6c6d24dd
--- /dev/null
+++ b/test/integration/standalone/other/testdata/config.yaml
@@ -0,0 +1,5 @@
+users:
+ - username: admin
+ password: 123456
+ - username: test
+ password: 123456
\ No newline at end of file