diff --git a/.changeset/curvy-lizards-report.md b/.changeset/curvy-lizards-report.md new file mode 100644 index 000000000..43bf96e6c --- /dev/null +++ b/.changeset/curvy-lizards-report.md @@ -0,0 +1,5 @@ +--- +'grafana-zabbix': major +--- + +Add the ability to have per-user authentication diff --git a/.github/workflows/compatibility-60.yml b/.github/workflows/compatibility-60.yml index c3201130c..ef3827133 100644 --- a/.github/workflows/compatibility-60.yml +++ b/.github/workflows/compatibility-60.yml @@ -32,6 +32,7 @@ jobs: ZABBIX_URL: 'https://localhost/api_jsonrpc.php' ZABBIX_USER: 'Admin' ZABBIX_PASSWORD: 'zabbix' + ZABBIX_TARGET_USER: 'grafana_test' run: go test -v ./pkg/zabbixapi/... - name: Cleanup diff --git a/.github/workflows/compatibility-70.yml b/.github/workflows/compatibility-70.yml index 6cf0f2401..c0886994f 100644 --- a/.github/workflows/compatibility-70.yml +++ b/.github/workflows/compatibility-70.yml @@ -32,6 +32,7 @@ jobs: ZABBIX_URL: 'https://localhost/api_jsonrpc.php' ZABBIX_USER: 'Admin' ZABBIX_PASSWORD: 'zabbix' + ZABBIX_TARGET_USER: 'grafana_test' run: go test -v ./pkg/zabbixapi/... - name: Cleanup diff --git a/.github/workflows/compatibility-72.yml b/.github/workflows/compatibility-72.yml index 8a9943ef4..cd106a6d3 100644 --- a/.github/workflows/compatibility-72.yml +++ b/.github/workflows/compatibility-72.yml @@ -32,6 +32,7 @@ jobs: ZABBIX_URL: 'http://localhost:8188/api_jsonrpc.php' ZABBIX_USER: 'Admin' ZABBIX_PASSWORD: 'zabbix' + ZABBIX_TARGET_USER: 'grafana_test' run: go test -v ./pkg/zabbixapi/... - name: Cleanup diff --git a/.github/workflows/compatibility-74.yml b/.github/workflows/compatibility-74.yml index 8ad3e2479..2d6ff9eef 100644 --- a/.github/workflows/compatibility-74.yml +++ b/.github/workflows/compatibility-74.yml @@ -32,6 +32,7 @@ jobs: ZABBIX_URL: 'http://localhost:8188/api_jsonrpc.php' ZABBIX_USER: 'Admin' ZABBIX_PASSWORD: 'zabbix' + ZABBIX_TARGET_USER: 'grafana_test' run: go test -v ./pkg/zabbixapi/... - name: Cleanup diff --git a/README.md b/README.md index 054ea158d..4cb32593e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - Transform and shape your data with [metric processing functions](https://grafana.com/docs/plugins/alexanderzobnin-zabbix-app/latest/reference/functions/) (Avg, Median, Min, Max, Multiply, Summarize, Time shift, Alias) - Find problems faster with [Alerting](https://grafana.com/docs/plugins/alexanderzobnin-zabbix-app/latest/reference/alerting/) feature - Mix metrics from multiple data sources in the same dashboard or even graph +- Per user authentication using Bearer tokens, so that Grafana respects the RBAC on Zabbix - Discover and share [dashboards](https://grafana.com/dashboards) in the official library See all features overview and dashboards examples at Grafana-Zabbix [Live demo](http://play.grafana-zabbix.org) site. diff --git a/devenv/zabbix60/bootstrap/Dockerfile b/devenv/zabbix60/bootstrap/Dockerfile index da6457fd1..e852df496 100644 --- a/devenv/zabbix60/bootstrap/Dockerfile +++ b/devenv/zabbix60/bootstrap/Dockerfile @@ -5,6 +5,8 @@ ENV ZBX_API_USER="Admin" ENV ZBX_API_PASSWORD="zabbix" ENV ZBX_CONFIG="zbx_export_hosts.xml" ENV ZBX_BOOTSTRAP_SCRIPT="bootstrap_config.py" +ENV ZBX_TEST_USER="grafana_test" +ENV ZBX_TEST_PASS="grafana_test_pass" RUN pip install pyzabbix diff --git a/devenv/zabbix60/bootstrap/bootstrap_config.py b/devenv/zabbix60/bootstrap/bootstrap_config.py index 9f4a15bfb..806ec30f4 100644 --- a/devenv/zabbix60/bootstrap/bootstrap_config.py +++ b/devenv/zabbix60/bootstrap/bootstrap_config.py @@ -5,7 +5,9 @@ zabbix_url = os.environ['ZBX_API_URL'] zabbix_user = os.environ['ZBX_API_USER'] zabbix_password = os.environ['ZBX_API_PASSWORD'] -print(zabbix_url, zabbix_user, zabbix_password) +zabbix_test_user = os.environ['ZBX_TEST_USER'] +zabbix_test_pass = os.environ['ZBX_TEST_PASS'] +print(zabbix_url, zabbix_user, zabbix_password, zabbix_test_user, zabbix_test_pass) zapi = ZabbixAPI(zabbix_url, timeout=5) @@ -76,5 +78,27 @@ except ZabbixAPIException as e: print e +print("Creating a user for testing per-user auth") +groups = zapi.usergoup.get(output="extend", filter={"name": "Zabbix administrators"}) +if not groups: + groups = zapi.usergroup.get(output="extend") +groupid = groups[0]['usrgrpid'] + +user_create_params = { + "alias": zabbix_test_user, + "passwd": zabbix_test_pass, + "name": "Grafana", + "surname": "Test", + "type": 1, # Zabbix user type + "user_groups": [{"usrgrpid": groupid}], +} + +try: + user = zapi.user.create(**user_create_params) + print("User created successfully: %s" % user['userids'][0]) +except Exception as e: + print("Failed to create user: %s" % e) + + for h in zapi.host.get(output="extend"): print(h['name']) diff --git a/devenv/zabbix60/docker-compose.yml b/devenv/zabbix60/docker-compose.yml index 07cf65d55..d0200cf43 100644 --- a/devenv/zabbix60/docker-compose.yml +++ b/devenv/zabbix60/docker-compose.yml @@ -88,6 +88,8 @@ services: ZBX_API_URL: http://zabbix-web:8080 ZBX_API_USER: Admin ZBX_API_PASSWORD: zabbix + ZBX_TEST_USER: grafana_test + ZBX_TEST_PASS: grafana_test_pass depends_on: - database - zabbix-server diff --git a/devenv/zabbix70/bootstrap/Dockerfile b/devenv/zabbix70/bootstrap/Dockerfile index ac31af8e7..b6c13e9a0 100644 --- a/devenv/zabbix70/bootstrap/Dockerfile +++ b/devenv/zabbix70/bootstrap/Dockerfile @@ -5,6 +5,8 @@ ENV ZBX_API_USER="Admin" ENV ZBX_API_PASSWORD="zabbix" ENV ZBX_CONFIG="zbx_export_hosts.json" ENV ZBX_BOOTSTRAP_SCRIPT="bootstrap_config.py" +ENV ZBX_TEST_USER="grafana_test" +ENV ZBX_TEST_PASS="grafana_test_pass" RUN pip install zabbix_utils diff --git a/devenv/zabbix70/bootstrap/bootstrap_config.py b/devenv/zabbix70/bootstrap/bootstrap_config.py index c75721f50..8a9b8fe05 100644 --- a/devenv/zabbix70/bootstrap/bootstrap_config.py +++ b/devenv/zabbix70/bootstrap/bootstrap_config.py @@ -4,7 +4,9 @@ zabbix_url = os.environ['ZBX_API_URL'] zabbix_user = os.environ['ZBX_API_USER'] zabbix_password = os.environ['ZBX_API_PASSWORD'] -print(zabbix_url, zabbix_user, zabbix_password) +zabbix_test_user = os.environ['ZBX_TEST_USER'] +zabbix_test_pass = os.environ['ZBX_TEST_PASS'] +print(zabbix_url, zabbix_user, zabbix_password, zabbix_test_user, zabbix_test_pass) zapi = ZabbixAPI(zabbix_url) @@ -71,5 +73,26 @@ except Exception as e: print(e) +print("Creating a user for testing per-user auth") +groups = zapi.usergoup.get(output="extend", filter={"name": "Zabbix administrators"}) +if not groups: + groups = zapi.usergroup.get(output="extend") +groupid = groups[0]['usrgrpid'] + +user_create_params = { + "alias": zabbix_test_user, + "passwd": zabbix_test_pass, + "name": "Grafana", + "surname": "Test", + "type": 1, # Zabbix user type + "user_groups": [{"usrgrpid": groupid}], +} + +try: + user = zapi.user.create(**user_create_params) + print("User created successfully: %s" % user['userids'][0]) +except Exception as e: + print("Failed to create user: %s" % e) + for h in zapi.host.get(output="extend"): print(h['name']) diff --git a/devenv/zabbix70/docker-compose.yml b/devenv/zabbix70/docker-compose.yml index f2b5feeb7..b3eb6fac7 100644 --- a/devenv/zabbix70/docker-compose.yml +++ b/devenv/zabbix70/docker-compose.yml @@ -88,6 +88,8 @@ services: ZBX_API_URL: http://zabbix-web:8080 ZBX_API_USER: Admin ZBX_API_PASSWORD: zabbix + ZBX_TEST_USER: grafana_test + ZBX_TEST_PASS: grafana_test_pass depends_on: - database - zabbix-server diff --git a/devenv/zabbix72/bootstrap/Dockerfile b/devenv/zabbix72/bootstrap/Dockerfile index ac31af8e7..b6c13e9a0 100644 --- a/devenv/zabbix72/bootstrap/Dockerfile +++ b/devenv/zabbix72/bootstrap/Dockerfile @@ -5,6 +5,8 @@ ENV ZBX_API_USER="Admin" ENV ZBX_API_PASSWORD="zabbix" ENV ZBX_CONFIG="zbx_export_hosts.json" ENV ZBX_BOOTSTRAP_SCRIPT="bootstrap_config.py" +ENV ZBX_TEST_USER="grafana_test" +ENV ZBX_TEST_PASS="grafana_test_pass" RUN pip install zabbix_utils diff --git a/devenv/zabbix72/bootstrap/bootstrap_config.py b/devenv/zabbix72/bootstrap/bootstrap_config.py index c75721f50..8a9b8fe05 100644 --- a/devenv/zabbix72/bootstrap/bootstrap_config.py +++ b/devenv/zabbix72/bootstrap/bootstrap_config.py @@ -4,7 +4,9 @@ zabbix_url = os.environ['ZBX_API_URL'] zabbix_user = os.environ['ZBX_API_USER'] zabbix_password = os.environ['ZBX_API_PASSWORD'] -print(zabbix_url, zabbix_user, zabbix_password) +zabbix_test_user = os.environ['ZBX_TEST_USER'] +zabbix_test_pass = os.environ['ZBX_TEST_PASS'] +print(zabbix_url, zabbix_user, zabbix_password, zabbix_test_user, zabbix_test_pass) zapi = ZabbixAPI(zabbix_url) @@ -71,5 +73,26 @@ except Exception as e: print(e) +print("Creating a user for testing per-user auth") +groups = zapi.usergoup.get(output="extend", filter={"name": "Zabbix administrators"}) +if not groups: + groups = zapi.usergroup.get(output="extend") +groupid = groups[0]['usrgrpid'] + +user_create_params = { + "alias": zabbix_test_user, + "passwd": zabbix_test_pass, + "name": "Grafana", + "surname": "Test", + "type": 1, # Zabbix user type + "user_groups": [{"usrgrpid": groupid}], +} + +try: + user = zapi.user.create(**user_create_params) + print("User created successfully: %s" % user['userids'][0]) +except Exception as e: + print("Failed to create user: %s" % e) + for h in zapi.host.get(output="extend"): print(h['name']) diff --git a/devenv/zabbix72/docker-compose.yml b/devenv/zabbix72/docker-compose.yml index 99cab9c5e..fc197a180 100644 --- a/devenv/zabbix72/docker-compose.yml +++ b/devenv/zabbix72/docker-compose.yml @@ -69,6 +69,8 @@ services: ZBX_API_URL: http://zabbix-web:8080 ZBX_API_USER: Admin ZBX_API_PASSWORD: zabbix + ZBX_TEST_USER: grafana_test + ZBX_TEST_PASS: grafana_test_pass depends_on: - database - zabbix-server diff --git a/devenv/zabbix74/bootstrap/Dockerfile b/devenv/zabbix74/bootstrap/Dockerfile index bb92a63b1..bc36f4e47 100644 --- a/devenv/zabbix74/bootstrap/Dockerfile +++ b/devenv/zabbix74/bootstrap/Dockerfile @@ -5,6 +5,8 @@ ENV ZBX_API_USER="Admin" ENV ZBX_API_PASSWORD="zabbix" ENV ZBX_CONFIG="zbx_export_hosts.json" ENV ZBX_BOOTSTRAP_SCRIPT="bootstrap_config.py" +ENV ZBX_TEST_USER="grafana_test" +ENV ZBX_TEST_PASS="grafana_test_pass" RUN pip install zabbix_utils diff --git a/devenv/zabbix74/bootstrap/bootstrap_config.py b/devenv/zabbix74/bootstrap/bootstrap_config.py index ff6a975bc..cd1426e2e 100644 --- a/devenv/zabbix74/bootstrap/bootstrap_config.py +++ b/devenv/zabbix74/bootstrap/bootstrap_config.py @@ -4,7 +4,9 @@ zabbix_url = os.environ['ZBX_API_URL'] zabbix_user = os.environ['ZBX_API_USER'] zabbix_password = os.environ['ZBX_API_PASSWORD'] -print(zabbix_url, zabbix_user, zabbix_password) +zabbix_test_user = os.environ['ZBX_TEST_USER'] +zabbix_test_pass = os.environ['ZBX_TEST_PASS'] +print(zabbix_url, zabbix_user, zabbix_password, zabbix_test_user, zabbix_test_pass) zapi = ZabbixAPI(zabbix_url) @@ -71,5 +73,26 @@ except Exception as e: print(e) +print("Creating a user for testing per-user auth") +groups = zapi.usergoup.get(output="extend", filter={"name": "Zabbix administrators"}) +if not groups: + groups = zapi.usergroup.get(output="extend") +groupid = groups[0]['usrgrpid'] + +user_create_params = { + "alias": zabbix_test_user, + "passwd": zabbix_test_pass, + "name": "Grafana", + "surname": "Test", + "type": 1, # Zabbix user type + "user_groups": [{"usrgrpid": groupid}], +} + +try: + user = zapi.user.create(**user_create_params) + print("User created successfully: %s" % user['userids'][0]) +except Exception as e: + print("Failed to create user: %s" % e) + for h in zapi.host.get(output="extend"): print(h['name']) \ No newline at end of file diff --git a/devenv/zabbix74/docker-compose.yml b/devenv/zabbix74/docker-compose.yml index 6c879db7f..c67b2618f 100644 --- a/devenv/zabbix74/docker-compose.yml +++ b/devenv/zabbix74/docker-compose.yml @@ -69,6 +69,8 @@ services: ZBX_API_URL: http://zabbix-web:8080 ZBX_API_USER: Admin ZBX_API_PASSWORD: zabbix + ZBX_TEST_USER: grafana_test + ZBX_TEST_PASS: grafana_test_pass depends_on: - database - zabbix-server diff --git a/pkg/cache/token_cache.go b/pkg/cache/token_cache.go new file mode 100644 index 000000000..febce7b80 --- /dev/null +++ b/pkg/cache/token_cache.go @@ -0,0 +1,57 @@ +package cache + +import ( + "sync" + "time" +) + +type TokenInfo struct { + Token string + ExpiresAt time.Time + UserID string + Username string +} + +type TokenCache struct { + tokens sync.Map // key: "datasourceUID:identity:userID" +} + +func NewTokenCache() *TokenCache { + return &TokenCache{} +} + +func (tc *TokenCache) Get(datasourceUID, identity, userID string) (*TokenInfo, bool) { + key := datasourceUID + ":" + identity + ":" + userID + if val, ok := tc.tokens.Load(key); ok { + tokenInfo := val.(*TokenInfo) + if time.Now().Before(tokenInfo.ExpiresAt) { + return tokenInfo, true + } + tc.tokens.Delete(key) + } + return nil, false +} + +func (tc *TokenCache) Set(datasourceUID, identity, userID, token string, ttl time.Duration) { + key := datasourceUID + ":" + identity + ":" + userID + tokenInfo := &TokenInfo{ + Token: token, + ExpiresAt: time.Now().Add(ttl), + UserID: userID, + Username: identity, + } + tc.tokens.Store(key, tokenInfo) +} + +func (tc *TokenCache) CleanupExpired() int { + count := 0 + tc.tokens.Range(func(key, value interface{}) bool { + tokenInfo := value.(*TokenInfo) + if time.Now().After(tokenInfo.ExpiresAt) { + tc.tokens.Delete(key) + count++ + } + return true + }) + return count +} diff --git a/pkg/datasource/auth.go b/pkg/datasource/auth.go new file mode 100644 index 000000000..c51fb8075 --- /dev/null +++ b/pkg/datasource/auth.go @@ -0,0 +1,136 @@ +package datasource + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +const TokenTTL = 24 * time.Hour + +// applyPerUserAuth applies per-user authentication with token caching +func (ds *ZabbixDatasource) applyPerUserAuth(ctx context.Context, zabbixDS *ZabbixDatasourceInstance, datasourceUID string) error { + if !zabbixDS.Settings.PerUserAuth { + ds.logger.Debug("Per-user authentication is disabled in datasource settings") + return nil + } + + user := backend.UserFromContext(ctx) + if user == nil { + ds.logger.Debug("No user in context (anonymous/guest access), skipping per-user auth") + return errors.New("no Grafana user found in request context") + } + + var identity string + switch zabbixDS.Settings.PerUserAuthField { + case "email": + identity = user.Email + default: + identity = user.Login + } + + // If identity is empty, skip per-user auth + if identity == "" { + ds.logger.Debug("User identity is empty, skipping per-user auth") + return nil + } + + // Check if the user is excluded from per-user auth + excluded := false + exclusionList := zabbixDS.Settings.PerUserAuthExcludeUsers + if exclusionList == nil { + exclusionList = []string{"admin"} + } + for _, excludedUser := range exclusionList { + if strings.EqualFold(identity, excludedUser) { + excluded = true + break + } + } + + if excluded { + ds.logger.Info("User is excluded from per-user authentication, using stored credentials", "user", identity) + return nil + } + + // Check token cache first + if tokenInfo, ok := ds.tokenCache.Get(datasourceUID, identity, identity); ok { + ds.logger.Debug("Using cached token", "user", identity, "expiresIn", time.Until(tokenInfo.ExpiresAt).Round(time.Minute)) + zabbixDS.zabbix.GetAPI().SetAuth(tokenInfo.Token) + return nil + } + + // Staring token generation + ds.logger.Info("Authenticating user with Zabbix", "user", identity) + + // Ensure stored credentials are authenticated + storedAuth := zabbixDS.zabbix.GetAPI().GetAuth() + if storedAuth == "" { + // Stored user not authenticated yet - authenticate now + ds.logger.Debug("Stored user not authenticated, authenticating now") + err := zabbixDS.zabbix.Authenticate(ctx) + if err != nil { + ds.logger.Error("Failed to authenticate with stored credentials", "error", err) + return errors.New("failed to authenticate with stored credentials: " + err.Error()) + } + storedAuth = zabbixDS.zabbix.GetAPI().GetAuth() + if storedAuth == "" { + ds.logger.Error("Stored auth still empty after authentication") + return errors.New("failed to obtain stored user authentication") + } + ds.logger.Debug("Stored user authentication successful") + } + + // Get Zabbix version + zabbixVersion, err := zabbixDS.zabbix.GetVersion(ctx) + if err != nil { + ds.logger.Error("Failed to get Zabbix version", "error", err) + return errors.New("error getting Zabbix version: " + err.Error()) + } + + ds.logger.Debug("Got Zabbix version", "version", zabbixVersion) + + // Validate field + if zabbixDS.Settings.PerUserAuthField == "" { + ds.logger.Error("PerUserAuthField is not configured") + return errors.New("per-user auth field is not configured in datasource settings") + } + + // Query Zabbix for the user (using stored credentials) + ds.logger.Debug("Looking up Zabbix user", "identity", identity, "field", zabbixDS.Settings.PerUserAuthField) + zabbixUser, err := zabbixDS.zabbix.GetAPI().GetUserByIdentity(ctx, zabbixDS.Settings.PerUserAuthField, identity, zabbixVersion) + if err != nil { + ds.logger.Error("Failed to query Zabbix for user", "identity", identity, "error", err) + return errors.New("error querying Zabbix for user: " + err.Error()) + } + if zabbixUser == nil || len(zabbixUser.MustArray()) == 0 { + ds.logger.Error("User not found in Zabbix", "identity", identity) + return errors.New("user " + identity + " not found in Zabbix. Contact your administrator to provision access") + } + + userId := zabbixUser.GetIndex(0).Get("userid").MustString() + userName := zabbixUser.GetIndex(0).Get("username").MustString() + + ds.logger.Debug("Found Zabbix user", "identity", identity, "userId", userId, "userName", userName) + + // Generate token + ds.logger.Debug("Generating token for user", "zabbixUserId", userId, "userName", userName) + token, err := zabbixDS.zabbix.GetAPI().GenerateUserAPIToken(ctx, userId, userName, zabbixVersion) + if err != nil { + ds.logger.Error("Failed to generate token", "userId", userId, "error", err) + return errors.New("failed to generate Zabbix API token for user: " + err.Error()) + } + + ds.logger.Info("Per-user authentication successful", "user", identity, "zabbixUser", userName, "tokenCached", true, "ttl", TokenTTL) + + // Cache the token + ds.tokenCache.Set(datasourceUID, identity, identity, token, TokenTTL) + + // Now switch to the user's token + zabbixDS.zabbix.GetAPI().SetAuth(token) + + return nil +} diff --git a/pkg/datasource/datasource.go b/pkg/datasource/datasource.go index 4fe9a73ee..0d8d76b49 100644 --- a/pkg/datasource/datasource.go +++ b/pkg/datasource/datasource.go @@ -3,7 +3,9 @@ package datasource import ( "context" "errors" + "time" + "github.com/alexanderzobnin/grafana-zabbix/pkg/cache" "github.com/alexanderzobnin/grafana-zabbix/pkg/httpclient" "github.com/alexanderzobnin/grafana-zabbix/pkg/metrics" "github.com/alexanderzobnin/grafana-zabbix/pkg/settings" @@ -20,8 +22,9 @@ var ( ) type ZabbixDatasource struct { - im instancemgmt.InstanceManager - logger log.Logger + im instancemgmt.InstanceManager + logger log.Logger + tokenCache *cache.TokenCache } // ZabbixDatasourceInstance stores state about a specific datasource @@ -34,10 +37,26 @@ type ZabbixDatasourceInstance struct { } func NewZabbixDatasource() *ZabbixDatasource { - im := datasource.NewInstanceManager(newZabbixDatasourceInstance) - return &ZabbixDatasource{ - im: im, - logger: log.New(), + ds := &ZabbixDatasource{ + im: datasource.NewInstanceManager(newZabbixDatasourceInstance), + logger: log.New(), + tokenCache: cache.NewTokenCache(), + } + + go ds.startTokenCleanup() + + return ds +} + +func (ds *ZabbixDatasource) startTokenCleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + cleaned := ds.tokenCache.CleanupExpired() + if cleaned > 0 { + ds.logger.Info("Cleaned up expired Zabbix authentication tokens", "count", cleaned) + } } } @@ -112,6 +131,12 @@ func (ds *ZabbixDatasource) QueryData(ctx context.Context, req *backend.QueryDat return nil, err } + // Apply per-user authentication + err = ds.applyPerUserAuth(ctx, zabbixDS, req.PluginContext.DataSourceInstanceSettings.UID) + if err != nil { + return nil, err + } + for _, q := range req.Queries { res := backend.DataResponse{} query, err := ReadQuery(q) diff --git a/pkg/datasource/resource_handler.go b/pkg/datasource/resource_handler.go index 9f2019e43..8f6a2bbf9 100644 --- a/pkg/datasource/resource_handler.go +++ b/pkg/datasource/resource_handler.go @@ -60,6 +60,14 @@ func (ds *ZabbixDatasource) ZabbixAPIHandler(rw http.ResponseWriter, req *http.R return } + // Apply per-user authentication with caching + err = ds.applyPerUserAuth(ctx, dsInstance, pluginCxt.DataSourceInstanceSettings.UID) + if err != nil { + ds.logger.Error("Per-user authentication failed", "error", err) + writeError(rw, http.StatusForbidden, err) + return + } + apiReq := &zabbix.ZabbixAPIRequest{Method: reqData.Method, Params: reqData.Params} result, err := dsInstance.ZabbixAPIQuery(req.Context(), apiReq) @@ -105,6 +113,14 @@ func (ds *ZabbixDatasource) DBConnectionPostProcessingHandler(rw http.ResponseWr return } + // Apply per-user authentication with caching + err = ds.applyPerUserAuth(ctx, dsInstance, pluginCxt.DataSourceInstanceSettings.UID) + if err != nil { + ds.logger.Error("Per-user authentication failed", "error", err) + writeError(rw, http.StatusForbidden, err) + return + } + reqData.Query.TimeRange.From = time.Unix(reqData.TimeRange.From, 0) reqData.Query.TimeRange.To = time.Unix(reqData.TimeRange.To, 0) diff --git a/pkg/settings/models.go b/pkg/settings/models.go index 60679611e..0ee598a5b 100644 --- a/pkg/settings/models.go +++ b/pkg/settings/models.go @@ -16,8 +16,11 @@ type ZabbixDatasourceSettingsDTO struct { CacheTTL string `json:"cacheTTL"` Timeout interface{} `json:"timeout"` - DisableDataAlignment bool `json:"disableDataAlignment"` - DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` + DisableDataAlignment bool `json:"disableDataAlignment"` + DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` + PerUserAuth bool `json:"perUserAuth"` + PerUserAuthField string `json:"perUserAuthField"` + PerUserAuthExcludeUsers []string `json:"perUserAuthExcludeUsers"` } // ZabbixDatasourceSettings model @@ -29,6 +32,9 @@ type ZabbixDatasourceSettings struct { CacheTTL time.Duration Timeout time.Duration - DisableDataAlignment bool `json:"disableDataAlignment"` - DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` + DisableDataAlignment bool `json:"disableDataAlignment"` + DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` + PerUserAuth bool `json:"perUserAuth"` + PerUserAuthField string `json:"perUserAuthField"` + PerUserAuthExcludeUsers []string `json:"perUserAuthExcludeUsers"` } diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index ac05c5d0c..f53012a92 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -32,6 +32,9 @@ func ReadZabbixSettings(dsInstanceSettings *backend.DataSourceInstanceSettings) if zabbixSettingsDTO.CacheTTL == "" { zabbixSettingsDTO.CacheTTL = "1h" } + if zabbixSettingsDTO.PerUserAuth && zabbixSettingsDTO.PerUserAuthField == "" { + zabbixSettingsDTO.PerUserAuthField = "username" + } //if zabbixSettingsDTO.Timeout == 0 { // zabbixSettingsDTO.Timeout = 30 @@ -79,6 +82,8 @@ func ReadZabbixSettings(dsInstanceSettings *backend.DataSourceInstanceSettings) Timeout: time.Duration(timeout) * time.Second, DisableDataAlignment: zabbixSettingsDTO.DisableDataAlignment, DisableReadOnlyUsersAck: zabbixSettingsDTO.DisableReadOnlyUsersAck, + PerUserAuth: zabbixSettingsDTO.PerUserAuth, + PerUserAuthField: zabbixSettingsDTO.PerUserAuthField, } return zabbixSettings, nil diff --git a/pkg/zabbixapi/zabbix_api.go b/pkg/zabbixapi/zabbix_api.go index 9be440893..31a5c918b 100644 --- a/pkg/zabbixapi/zabbix_api.go +++ b/pkg/zabbixapi/zabbix_api.go @@ -85,6 +85,14 @@ func (api *ZabbixAPI) Request(ctx context.Context, method string, params ZabbixA return api.request(ctx, method, params, api.auth, version) } +func (api *ZabbixAPI) RequestWithArrayParams(ctx context.Context, method string, params []interface{}, version int) (*simplejson.Json, error) { + if api.auth == "" { + return nil, backend.DownstreamError(ErrNotAuthenticated) + } + + return api.requestWithArrayParams(ctx, method, params, api.auth, version) +} + // Request performs API request without authentication token func (api *ZabbixAPI) RequestUnauthenticated(ctx context.Context, method string, params ZabbixAPIParams, version int) (*simplejson.Json, error) { return api.request(ctx, method, params, "", version) @@ -133,6 +141,52 @@ func (api *ZabbixAPI) request(ctx context.Context, method string, params ZabbixA return handleAPIResult(response) } +// requestWithArrayParams performs API request with parameters as an array +// This is used for methods that require an array of parameters instead of a map +// currently `token.generate` method +func (api *ZabbixAPI) requestWithArrayParams(ctx context.Context, method string, params []interface{}, auth string, version int) (*simplejson.Json, error) { + apiRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 2, + "method": method, + "params": params, + } + + // Zabbix v7.2 and later deprecated `auth` parameter and replaced it with using Auth header + // `auth` parameter throws an error in new versions so we need to add it only for older versions + if auth != "" && version < 72 { + apiRequest["auth"] = auth + } + + reqBodyJSON, err := json.Marshal(apiRequest) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, api.url.String(), bytes.NewBuffer(reqBodyJSON)) + if err != nil { + return nil, err + } + + metrics.ZabbixAPIQueryTotal.WithLabelValues(method).Inc() + + if auth != "" && version >= 72 { + if api.dsSettings.BasicAuthEnabled { + return nil, backend.DownstreamErrorf("basic auth is not supported for Zabbix v7.2 and later") + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", auth)) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Grafana/grafana-zabbix") + + response, err := makeHTTPRequest(ctx, api.httpClient, req) + if err != nil { + return nil, err + } + + return handleAPIResult(response) +} + // Login performs API authentication and returns authentication token. func (api *ZabbixAPI) Login(ctx context.Context, username string, password string, version int) (string, error) { params := ZabbixAPIParams{ @@ -189,6 +243,83 @@ func (api *ZabbixAPI) AuthenticateWithToken(ctx context.Context, token string) e return nil } +// GetUserByIdentity queries Zabbix for a user by username or email +func (api *ZabbixAPI) GetUserByIdentity(ctx context.Context, field, value string, version int) (*simplejson.Json, error) { + params := map[string]interface{}{ + "filter": map[string]interface{}{ + field: value, + }, + "output": "extend", + } + return api.Request(ctx, "user.get", params, version) +} + +// GenerateUserAPIToken generates or retrieves a Zabbix API token for a user +func (api *ZabbixAPI) GenerateUserAPIToken(ctx context.Context, userId string, userName string, version int) (string, error) { + // Check for existing token with the desired name + getParams := map[string]interface{}{ + "userids": userId, + "output": "extend", + "filter": map[string]interface{}{ + "name": "Zabbix-Grafana-Token_" + userName, + }, + } + resp, err := api.Request(ctx, "token.get", getParams, version) + if err != nil { + return "", err + } + + var tokenId string + if resp != nil && len(resp.MustArray()) > 0 { + // Token exists, use its tokenid + tokenId = resp.GetIndex(0).Get("tokenid").MustString() + api.logger.Debug("found existing token", "tokenid", tokenId, "username", userName) + } else { + // Token does not exist, create a new one + createParams := map[string]interface{}{ + "userid": userId, + "name": "Zabbix-Grafana-Token_" + userName, + } + + createResp, err := api.Request(ctx, "token.create", createParams, version) + if err != nil { + return "", err + } + + tokenIds := createResp.GetIndex(0).Get("tokenids").MustArray() + if len(tokenIds) == 0 { + return "", errors.New("failed to create Zabbix API token, tokenids array is empty") + } + + tokenId = fmt.Sprintf("%v", tokenIds[0]) + if tokenId == "" { + return "", errors.New("failed to create Zabbix API token, tokenid is empty") + } + api.logger.Debug("created new token", "tokenid", tokenId, "username", userName) + } + + // Generate the actual token value + genParams := []interface{}{ + tokenId, + } + genResp, err := api.RequestWithArrayParams(ctx, "token.generate", genParams, version) + if err != nil { + return "", err + } + + if genResp == nil || len(genResp.MustArray()) == 0 { + return "", errors.New("failed to generate Zabbix API token, response is empty") + } + + token := genResp.GetIndex(0).Get("token").MustString() + if token == "" { + return "", errors.New("failed to generate Zabbix API token, token string is empty") + } + + api.logger.Debug("Generated token successfully", "userName", userName) + return token, nil +} + func isDeprecatedUserParamError(err error) bool { if err == nil { return false diff --git a/pkg/zabbixapi/zabbix_api_60_integration_test.go b/pkg/zabbixapi/zabbix_api_60_integration_test.go index d6bfe4e11..90ebaff69 100644 --- a/pkg/zabbixapi/zabbix_api_60_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_60_integration_test.go @@ -20,7 +20,8 @@ import ( // ZABBIX_URL - URL of the Zabbix server (e.g., http://localhost/zabbix/api_jsonrpc.php) // ZABBIX_USER - Username for authentication // ZABBIX_PASSWORD - Password for authentication -// To run locally, start devenv/zabbix60 and run INTEGRATION_TEST60=true ZABBIX_URL="https://localhost/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" go test -v ./pkg/zabbixapi/... +// ZABBIX_TARGET_USER - Username for per-user authentication +// To run locally, start devenv/zabbix60 and run INTEGRATION_TEST60=true ZABBIX_URL="https://localhost/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" ZABBIX_TARGET_USER="grafana_test" go test -v ./pkg/zabbixapi/... func TestIntegrationZabbixAPI60(t *testing.T) { // Skip if not running integration tests if os.Getenv("INTEGRATION_TEST60") != "true" { @@ -31,12 +32,14 @@ func TestIntegrationZabbixAPI60(t *testing.T) { zabbixURL := os.Getenv("ZABBIX_URL") zabbixUser := os.Getenv("ZABBIX_USER") zabbixPassword := os.Getenv("ZABBIX_PASSWORD") + targetUsername := os.Getenv("ZABBIX_TARGET_USER") zabbixVersion := 60 // Validate required environment variables require.NotEmpty(t, zabbixURL, "ZABBIX_URL environment variable is required") require.NotEmpty(t, zabbixUser, "ZABBIX_USER environment variable is required") require.NotEmpty(t, zabbixPassword, "ZABBIX_PASSWORD environment variable is required") + require.NotEmpty(t, targetUsername, "ZABBIX_TARGET_USER environment variable is required") dsSettings := backend.DataSourceInstanceSettings{ URL: zabbixURL, @@ -144,4 +147,37 @@ func TestIntegrationZabbixAPI60(t *testing.T) { assert.True(t, hasAuth, "Auth parameter should be present in request body for v6.0") assert.Equal(t, api.GetAuth(), auth, "Auth parameter should match the set auth token") }) + + // Test per-user authentication + t.Run("Per-User Authentication", func(t *testing.T) { + // First authenticate + err := api.Authenticate(context.Background(), zabbixUser, zabbixPassword, zabbixVersion) + require.NoError(t, err) + + // Query Zabbix for the target user + zabbixUserResp, err := api.GetUserByIdentity(context.Background(), "username", targetUsername, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, zabbixUserResp) + + if len(zabbixUserResp.MustArray()) == 0 { + t.Skipf("User %s not found in Zabbix. Skipping per-user auth test.", targetUsername) + } + + userId := zabbixUserResp.GetIndex(0).Get("userid").MustString() + userName := zabbixUserResp.GetIndex(0).Get("username").MustString() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, userName, zabbixVersion) + require.NoError(t, err) + assert.NotEmpty(t, token, "Generated token should not be empty") + + api.SetAuth(token) + + // Optionally, perform a simple API call as the user + resp, err := api.Request(context.Background(), "hostgroup.get", map[string]interface{}{"output": "extend"}, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, resp) + + t.Logf("Per-user authentication successful for identity %s (userId %s)", targetUsername, userId) + }) } diff --git a/pkg/zabbixapi/zabbix_api_70_integration_test.go b/pkg/zabbixapi/zabbix_api_70_integration_test.go index c8a06d4bc..ea943923d 100644 --- a/pkg/zabbixapi/zabbix_api_70_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_70_integration_test.go @@ -20,8 +20,8 @@ import ( // ZABBIX_URL - URL of the Zabbix server (e.g., http://localhost/zabbix/api_jsonrpc.php) // ZABBIX_USER - Username for authentication // ZABBIX_PASSWORD - Password for authentication -// ZABBIX_VERSION - Zabbix API version (e.g., 65 for 6.5) -// To run locally, start devenv/zabbix70 and run INTEGRATION_TEST70=true ZABBIX_URL="https://localhost/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" go test -v ./pkg/zabbixapi/... +// ZABBIX_TARGET_USER - Username for per-user authentication +// To run locally, start devenv/zabbix70 and run INTEGRATION_TEST70=true ZABBIX_URL="https://localhost/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" ZABBIX_TARGET_USER="grafana_test" go test -v ./pkg/zabbixapi/... func TestIntegrationZabbixAPI70(t *testing.T) { // Skip if not running integration tests @@ -33,12 +33,14 @@ func TestIntegrationZabbixAPI70(t *testing.T) { zabbixURL := os.Getenv("ZABBIX_URL") zabbixUser := os.Getenv("ZABBIX_USER") zabbixPassword := os.Getenv("ZABBIX_PASSWORD") + targetUsername := os.Getenv("ZABBIX_TARGET_USER") zabbixVersion := 70 // Validate required environment variables require.NotEmpty(t, zabbixURL, "ZABBIX_URL environment variable is required") require.NotEmpty(t, zabbixUser, "ZABBIX_USER environment variable is required") require.NotEmpty(t, zabbixPassword, "ZABBIX_PASSWORD environment variable is required") + require.NotEmpty(t, targetUsername, "ZABBIX_TARGET_USER environment variable is required") dsSettings := backend.DataSourceInstanceSettings{ URL: zabbixURL, @@ -146,4 +148,37 @@ func TestIntegrationZabbixAPI70(t *testing.T) { assert.True(t, hasAuth, "Auth parameter should be present in request body for v7.0") assert.Equal(t, api.GetAuth(), auth, "Auth parameter should match the set auth token") }) + + // Test per-user authentication + t.Run("Per-User Authentication", func(t *testing.T) { + // First authenticate + err := api.Authenticate(context.Background(), zabbixUser, zabbixPassword, zabbixVersion) + require.NoError(t, err) + + // Query Zabbix for the target user + zabbixUserResp, err := api.GetUserByIdentity(context.Background(), "username", targetUsername, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, zabbixUserResp) + + if len(zabbixUserResp.MustArray()) == 0 { + t.Skipf("User %s not found in Zabbix. Skipping per-user auth test.", targetUsername) + } + + userId := zabbixUserResp.GetIndex(0).Get("userid").MustString() + userName := zabbixUserResp.GetIndex(0).Get("username").MustString() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, userName, zabbixVersion) + require.NoError(t, err) + assert.NotEmpty(t, token, "Generated token should not be empty") + + api.SetAuth(token) + + // Optionally, perform a simple API call as the user + resp, err := api.Request(context.Background(), "hostgroup.get", map[string]interface{}{"output": "extend"}, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, resp) + + t.Logf("Per-user authentication successful for identity %s (userId %s)", targetUsername, userId) + }) } diff --git a/pkg/zabbixapi/zabbix_api_72_integration_test.go b/pkg/zabbixapi/zabbix_api_72_integration_test.go index 918343e47..1475f07c8 100644 --- a/pkg/zabbixapi/zabbix_api_72_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_72_integration_test.go @@ -18,7 +18,8 @@ import ( // ZABBIX_URL - URL of the Zabbix server (e.g., http://localhost/zabbix/api_jsonrpc.php) // ZABBIX_USER - Username for authentication // ZABBIX_PASSWORD - Password for authentication -// To run locally, start devenv/zabbix72 and run INTEGRATION_TEST72=true ZABBIX_URL="http://localhost:8188/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" go test -v ./pkg/zabbixapi/... +// ZABBIX_TARGET_USER - Username for per-user authentication +// To run locally, start devenv/zabbix72 and run INTEGRATION_TEST72=true ZABBIX_URL="http://localhost:8188/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" ZABBIX_TARGET_USER="grafana_test" go test -v ./pkg/zabbixapi/... func TestIntegrationZabbixAPI72(t *testing.T) { // Skip if not running integration tests @@ -30,12 +31,14 @@ func TestIntegrationZabbixAPI72(t *testing.T) { zabbixURL := os.Getenv("ZABBIX_URL") zabbixUser := os.Getenv("ZABBIX_USER") zabbixPassword := os.Getenv("ZABBIX_PASSWORD") + targetUsername := os.Getenv("ZABBIX_TARGET_USER") zabbixVersion := 72 // Validate required environment variables require.NotEmpty(t, zabbixURL, "ZABBIX_URL environment variable is required") require.NotEmpty(t, zabbixUser, "ZABBIX_USER environment variable is required") require.NotEmpty(t, zabbixPassword, "ZABBIX_PASSWORD environment variable is required") + require.NotEmpty(t, targetUsername, "ZABBIX_TARGET_USER environment variable is required") // Create new Zabbix API instance dsSettings := backend.DataSourceInstanceSettings{ @@ -158,4 +161,37 @@ func TestIntegrationZabbixAPI72(t *testing.T) { assert.Contains(t, err.Error(), "basic auth is not supported for Zabbix v7.2 and later") assert.True(t, backend.IsDownstreamError(err)) }) + + // Test per-user authentication + t.Run("Per-User Authentication", func(t *testing.T) { + // First authenticate + err := api.Authenticate(context.Background(), zabbixUser, zabbixPassword, zabbixVersion) + require.NoError(t, err) + + // Query Zabbix for the target user + zabbixUserResp, err := api.GetUserByIdentity(context.Background(), "username", targetUsername, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, zabbixUserResp) + + if len(zabbixUserResp.MustArray()) == 0 { + t.Skipf("User %s not found in Zabbix. Skipping per-user auth test.", targetUsername) + } + + userId := zabbixUserResp.GetIndex(0).Get("userid").MustString() + userName := zabbixUserResp.GetIndex(0).Get("username").MustString() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, userName, zabbixVersion) + require.NoError(t, err) + assert.NotEmpty(t, token, "Generated token should not be empty") + + api.SetAuth(token) + + // Optionally, perform a simple API call as the user + resp, err := api.Request(context.Background(), "hostgroup.get", map[string]interface{}{"output": "extend"}, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, resp) + + t.Logf("Per-user authentication successful for identity %s (userId %s)", targetUsername, userId) + }) } diff --git a/pkg/zabbixapi/zabbix_api_74_integration_test.go b/pkg/zabbixapi/zabbix_api_74_integration_test.go index 3ffd361d4..6832cd8f3 100644 --- a/pkg/zabbixapi/zabbix_api_74_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_74_integration_test.go @@ -18,7 +18,8 @@ import ( // ZABBIX_URL - URL of the Zabbix server (e.g., http://localhost/zabbix/api_jsonrpc.php) // ZABBIX_USER - Username for authentication // ZABBIX_PASSWORD - Password for authentication -// To run locally, start devenv/zabbix74 and run INTEGRATION_TEST74=true ZABBIX_URL="http://localhost:8188/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" go test -v ./pkg/zabbixapi/... +// ZABBIX_TARGET_USER - Username for per-user authentication +// To run locally, start devenv/zabbix74 and run INTEGRATION_TEST74=true ZABBIX_URL="http://localhost:8188/api_jsonrpc.php" ZABBIX_USER="Admin" ZABBIX_PASSWORD="zabbix" ZABBIX_TARGET_USER="grafana_test" go test -v ./pkg/zabbixapi/... func TestIntegrationZabbixAPI74(t *testing.T) { // Skip if not running integration tests @@ -30,12 +31,14 @@ func TestIntegrationZabbixAPI74(t *testing.T) { zabbixURL := os.Getenv("ZABBIX_URL") zabbixUser := os.Getenv("ZABBIX_USER") zabbixPassword := os.Getenv("ZABBIX_PASSWORD") + targetUsername := os.Getenv("ZABBIX_TARGET_USER") zabbixVersion := 74 // Validate required environment variables require.NotEmpty(t, zabbixURL, "ZABBIX_URL environment variable is required") require.NotEmpty(t, zabbixUser, "ZABBIX_USER environment variable is required") require.NotEmpty(t, zabbixPassword, "ZABBIX_PASSWORD environment variable is required") + require.NotEmpty(t, targetUsername, "ZABBIX_TARGET_USER environment variable is required") // Create new Zabbix API instance dsSettings := backend.DataSourceInstanceSettings{ @@ -158,4 +161,37 @@ func TestIntegrationZabbixAPI74(t *testing.T) { assert.Contains(t, err.Error(), "basic auth is not supported for Zabbix v7.2 and later") assert.True(t, backend.IsDownstreamError(err)) }) + + // Test per-user authentication + t.Run("Per-User Authentication", func(t *testing.T) { + // First authenticate + err := api.Authenticate(context.Background(), zabbixUser, zabbixPassword, zabbixVersion) + require.NoError(t, err) + + // Query Zabbix for the target user + zabbixUserResp, err := api.GetUserByIdentity(context.Background(), "username", targetUsername, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, zabbixUserResp) + + if len(zabbixUserResp.MustArray()) == 0 { + t.Skipf("User %s not found in Zabbix. Skipping per-user auth test.", targetUsername) + } + + userId := zabbixUserResp.GetIndex(0).Get("userid").MustString() + userName := zabbixUserResp.GetIndex(0).Get("username").MustString() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, userName, zabbixVersion) + require.NoError(t, err) + assert.NotEmpty(t, token, "Generated token should not be empty") + + api.SetAuth(token) + + // Optionally, perform a simple API call as the user + resp, err := api.Request(context.Background(), "hostgroup.get", map[string]interface{}{"output": "extend"}, zabbixVersion) + require.NoError(t, err) + require.NotNil(t, resp) + + t.Logf("Per-user authentication successful for identity %s (userId %s)", targetUsername, userId) + }) } diff --git a/src/datasource/components/ConfigEditor.tsx b/src/datasource/components/ConfigEditor.tsx index 5daee2b47..f1cefa72c 100644 --- a/src/datasource/components/ConfigEditor.tsx +++ b/src/datasource/components/ConfigEditor.tsx @@ -6,6 +6,7 @@ import { Icon, Input, Label, + MultiSelect, SecretInput, SecureSocksProxySettings, Select, @@ -44,6 +45,38 @@ export const ConfigEditor = (props: Props) => { const [selectedDBDatasource, setSelectedDBDatasource] = useState(null); const [currentDSType, setCurrentDSType] = useState(''); + const [grafanaUsers, setGrafanaUsers] = useState[]>([{ label: 'admin', value: 'admin' }]); + const [canEditExcludedUsers, setCanEditExcludedUsers] = useState(true); + const [userFetchWarning, setUserFetchWarning] = useState(null); + + // Fetch Grafana users on mount + useEffect(() => { + const fetchGrafanaUsers = async () => { + try { + const res = await fetch('/api/users'); + if (res.status === 403) { + setUserFetchWarning( + 'You need Grafana Admin permissions to list users. Please contact your Grafana administrator to configure per-user authentication.' + ); + setCanEditExcludedUsers(false); + return; + } + if (!res.ok) throw new Error('Failed to fetch Grafana users'); + const users = await res.json(); + setGrafanaUsers(users.map((u: any) => ({ + label: u.login, + value: u.login, + }))); + setUserFetchWarning(null); + setCanEditExcludedUsers(true); + } catch { + setUserFetchWarning('Failed to fetch Grafana users. Using default user "admin".'); + setCanEditExcludedUsers(false); + } + }; + fetchGrafanaUsers(); + }, []); + // Apply some defaults on initial render useEffect(() => { const { jsonData, secureJsonFields } = options; @@ -378,6 +411,101 @@ export const ConfigEditor = (props: Props) => { onChange={jsonDataSwitchHandler('disableDataAlignment', options, onOptionsChange)} /> + + + + Enable per-user authentication + + Enable this option if you want to use per-user authentication. This will map Grafana users to + Zabbix users respecting the RBAC already setup in Zabbix. + + } + > + + + + + } + > + + + + {options.jsonData.perUserAuth && ( + <> + + + User identity field + + + } + > +