From 7e452e0e8eb53fb9279e8eb5297392a6f0e0b9a0 Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:38:57 +0300 Subject: [PATCH 01/23] add logic for per user authentication --- pkg/datasource/datasource.go | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pkg/datasource/datasource.go b/pkg/datasource/datasource.go index 4fe9a73ee..2b1bae3a6 100644 --- a/pkg/datasource/datasource.go +++ b/pkg/datasource/datasource.go @@ -112,6 +112,49 @@ func (ds *ZabbixDatasource) QueryData(ctx context.Context, req *backend.QueryDat return nil, err } + // --- Per-user authentication logic START --- + if zabbixDS.Settings.PerUserAuth { + user := backend.UserFromContext(ctx) + if user == nil { + return nil, 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 + } + + zabbixVersion, err := zabbixDS.zabbix.GetVersion(ctx) + if err != nil { + return nil, errors.New("error getting Zabbix version: " + err.Error()) + } + + // Query Zabbix for the user + zabbixUser, err := zabbixDS.zabbix.GetAPI().GetUserByIdentity(ctx, zabbixDS.Settings.PerUserAuthField, identity, zabbixVersion) + if err != nil { + return nil, errors.New("error querying Zabbix for user: " + err.Error()) + } + if zabbixUser == nil || len(zabbixUser.MustArray()) == 0 { + return nil, errors.New("user " + identity + " not found in Zabbix. Contact your administrator to provision access") + } + userId := zabbixUser.GetIndex(0).Get("userid").MustString() + + // Generate or retrieve Zabbix API token + token, err := zabbixDS.zabbix.GetAPI().GenerateUserAPIToken(ctx, userId, zabbixVersion) + if err != nil { + return nil, errors.New("failed to generate Zabbix API token for user: " + err.Error()) + } + + zabbixDS.zabbix.GetAPI().SetAuth(token) + + ds.logger.Debug("Per-user authentication enabled", "identity", identity) + } + + // --- Per-user authentication logic END --- + for _, q := range req.Queries { res := backend.DataResponse{} query, err := ReadQuery(q) From 379b71accb631133061d6de9ef6136a1eb1ff591 Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:39:29 +0300 Subject: [PATCH 02/23] extend the models with new fields --- pkg/settings/models.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/settings/models.go b/pkg/settings/models.go index 60679611e..d47f43c85 100644 --- a/pkg/settings/models.go +++ b/pkg/settings/models.go @@ -16,8 +16,10 @@ 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"` // "username" or "email" } // ZabbixDatasourceSettings model @@ -29,6 +31,8 @@ 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"` // "username" } From 2cbc92605c7b08cdb6ca48fa58dfefe3447ff7ae Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:40:07 +0300 Subject: [PATCH 03/23] extend the zabbixSettings with new fields --- pkg/settings/settings.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index ac05c5d0c..e33a09f8c 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -79,6 +79,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 From 89f53b4241b23a5e7fc17cef34ba022e03a416cb Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:40:30 +0300 Subject: [PATCH 04/23] add integration tests for newly created feature --- .../zabbix_api_60_integration_test.go | 34 +++++++++++++++++++ .../zabbix_api_70_integration_test.go | 34 +++++++++++++++++++ .../zabbix_api_72_integration_test.go | 34 +++++++++++++++++++ .../zabbix_api_74_integration_test.go | 34 +++++++++++++++++++ 4 files changed, 136 insertions(+) diff --git a/pkg/zabbixapi/zabbix_api_60_integration_test.go b/pkg/zabbixapi/zabbix_api_60_integration_test.go index d6bfe4e11..090a2f471 100644 --- a/pkg/zabbixapi/zabbix_api_60_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_60_integration_test.go @@ -31,12 +31,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 +146,36 @@ 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() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, 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..7bb11dd51 100644 --- a/pkg/zabbixapi/zabbix_api_70_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_70_integration_test.go @@ -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,36 @@ 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() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, 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 9a8bfec88..2f7b653e1 100644 --- a/pkg/zabbixapi/zabbix_api_72_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_72_integration_test.go @@ -30,12 +30,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 +160,36 @@ 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() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, 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 32516cae2..bcae3c1fe 100644 --- a/pkg/zabbixapi/zabbix_api_74_integration_test.go +++ b/pkg/zabbixapi/zabbix_api_74_integration_test.go @@ -30,12 +30,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 +160,36 @@ 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() + + // Generate or retrieve Zabbix API token for the user + token, err := api.GenerateUserAPIToken(context.Background(), userId, 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) + }) } From d960e0de82895cef068bb181a02d2070a7c34936 Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:41:03 +0300 Subject: [PATCH 05/23] helper functions for getting a user and generating a token for the user --- pkg/zabbixapi/zabbix_api.go | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/pkg/zabbixapi/zabbix_api.go b/pkg/zabbixapi/zabbix_api.go index 7eb56ad44..899716d91 100644 --- a/pkg/zabbixapi/zabbix_api.go +++ b/pkg/zabbixapi/zabbix_api.go @@ -189,6 +189,70 @@ 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, 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-Session-Token", + }, + } + 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() + } else { + // Token does not exist, create a new one + createParams := map[string]interface{}{ + "userid": userId, + "name": "Zabbix-Grafana-Session-Token", + } + + createResp, err := api.Request(ctx, "token.create", createParams, version) + if err != nil { + return "", err + } + + tokenId := createResp.GetIndex(0).Get("tokenid").MustString() + if tokenId == "" { + return "", errors.New("failed to create Zabbix API token, token ID is empty") + } + } + + // Generate the actual token value + + genParams := map[string]interface{}{ + "tokenids": []string{tokenId}, + } + genResp, err := api.Request(ctx, "token.generate", genParams, version) + if err != nil { + return "", err + } + token := genResp.GetIndex(0).Get("token").MustString() + if token == "" { + return "", errors.New("failed to generate Zabbix API token, token is empty") + } + return token, nil +} + func isDeprecatedUserParamError(err error) bool { if err == nil { return false From 134f5da21f4f6c3010e63a73323b0676812ec6cf Mon Sep 17 00:00:00 2001 From: Christos Diamantis Date: Sun, 3 Aug 2025 10:41:41 +0300 Subject: [PATCH 06/23] add new fields on datasource configuration --- src/datasource/components/ConfigEditor.tsx | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/datasource/components/ConfigEditor.tsx b/src/datasource/components/ConfigEditor.tsx index 200ee3a70..89b8a3521 100644 --- a/src/datasource/components/ConfigEditor.tsx +++ b/src/datasource/components/ConfigEditor.tsx @@ -378,6 +378,53 @@ 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 + } > + - + <> + + + User identity field + + + } + > +