MSSQL: Add Windows AD/Kerberos auth (#84742)

* mssql: Add Kerberos/Windows AD auth

* need username for cache file

* account for no port in cc file

* add tests around constring

* remove un-needed port

* add docs

* remove comments

* move defer to same locale as where it begins

* fix linting and spelling

* fix gosec linter

* note lack of grafana cloud support
This commit is contained in:
Adam Simpson
2024-03-20 16:41:57 +02:00
committed by GitHub
parent 04c9f459ec
commit 311aa94fab
10 changed files with 571 additions and 17 deletions
+106
View File
@@ -0,0 +1,106 @@
package kerberos
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
)
type KerberosLookup struct {
User string `json:"user"`
DBName string `json:"database"`
Address string `json:"address"`
CredentialCacheFilename string `json:"credentialCache"`
}
type KerberosAuth struct {
KeytabFilePath string
CredentialCache string
CredentialCacheLookupFile string
ConfigFilePath string
UDPConnectionLimit string
EnableDNSLookupKDC string
}
func GetKerberosSettings(settings backend.DataSourceInstanceSettings) (kerberosAuth KerberosAuth, err error) {
err = json.Unmarshal(settings.JSONData, &kerberosAuth)
return kerberosAuth, err
}
func Krb5ParseAuthCredentials(host string, port string, db string, user string, pass string, kerberosAuth KerberosAuth) string {
//params for driver conn str
//More details: https://github.com/microsoft/go-mssqldb#kerberos-active-directory-authentication-outside-windows
krb5CCLookupFile := kerberosAuth.CredentialCacheLookupFile
krb5CacheCredsFile := kerberosAuth.CredentialCache
// if there is a lookup file specified, use it to find the correct credential cache file and overwrite var
// getCredentialCacheFromLookup implementation taken from mysql kerberos solution - https://github.com/grafana/mysql/commit/b5e73c8d536150c054d310123643683d3b18f0da
if krb5CCLookupFile != "" {
krb5CacheCredsFile = getCredentialCacheFromLookup(krb5CCLookupFile, host, port, db, user)
if krb5CacheCredsFile == "" {
logger.Error("No valid credential cache file found in lookup.")
return ""
}
}
krb5DriverParams := fmt.Sprintf("authenticator=krb5;krb5-configfile=%s;", kerberosAuth.ConfigFilePath)
// There are 3 main connection types:
// - credentials cache
// - user, realm, keytab
// - realm, user, pass
if krb5CacheCredsFile != "" {
krb5DriverParams += fmt.Sprintf("server=%s;database=%s;krb5-credcachefile=%s;", host, db, krb5CacheCredsFile)
} else if kerberosAuth.KeytabFilePath != "" {
krb5DriverParams += fmt.Sprintf("server=%s;database=%s;user id=%s;krb5-keytabfile=%s;", host, db, user, kerberosAuth.KeytabFilePath)
} else if kerberosAuth.KeytabFilePath == "" {
krb5DriverParams += fmt.Sprintf("server=%s;database=%s;user id=%s;password=%s;", host, db, user, pass)
} else {
logger.Error("invalid kerberos configuration")
return ""
}
if kerberosAuth.UDPConnectionLimit != "" {
krb5DriverParams += "krb5-udppreferencelimit=" + kerberosAuth.UDPConnectionLimit + ";"
}
if kerberosAuth.EnableDNSLookupKDC != "" {
krb5DriverParams += "krb5-dnslookupkdc=" + kerberosAuth.EnableDNSLookupKDC + ";"
}
logger.Info(fmt.Sprintf("final krb connstr: %s", krb5DriverParams))
return krb5DriverParams
}
func getCredentialCacheFromLookup(lookupFile string, host string, port string, dbName string, user string) string {
logger.Info(fmt.Sprintf("reading credential cache lookup: %s", lookupFile))
content, err := os.ReadFile(filepath.Clean(lookupFile))
if err != nil {
logger.Error(fmt.Sprintf("error reading: %s, %v", lookupFile, err))
return ""
}
var lookups []KerberosLookup
err = json.Unmarshal(content, &lookups)
if err != nil {
logger.Error(fmt.Sprintf("error parsing: %s, %v", lookupFile, err))
return ""
}
// find cache file
for _, item := range lookups {
if port == "0" {
item.Address = host + ":0"
}
if item.Address == host+":"+port && item.DBName == dbName && item.User == user {
logger.Info(fmt.Sprintf("matched: %+v", item))
return item.CredentialCacheFilename
}
}
logger.Error(fmt.Sprintf("no match found for %s", host+":"+port))
return ""
}
+19 -5
View File
@@ -20,9 +20,11 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
mssql "github.com/microsoft/go-mssqldb"
_ "github.com/microsoft/go-mssqldb/azuread"
_ "github.com/microsoft/go-mssqldb/integratedauth/krb5"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
"github.com/grafana/grafana/pkg/tsdb/sqleng"
"github.com/grafana/grafana/pkg/util"
@@ -34,9 +36,13 @@ type Service struct {
}
const (
azureAuthentication = "Azure AD Authentication"
windowsAuthentication = "Windows Authentication"
sqlServerAuthentication = "SQL Server Authentication"
azureAuthentication = "Azure AD Authentication"
windowsAuthentication = "Windows Authentication"
sqlServerAuthentication = "SQL Server Authentication"
kerberosRaw = "Windows AD: Username + password"
kerberosKeytab = "Windows AD: Keytab"
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
)
func ProvideService(cfg *setting.Cfg) *Service {
@@ -78,6 +84,12 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
if err != nil {
return nil, fmt.Errorf("error reading azure credentials")
}
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
if err != nil {
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
}
err = json.Unmarshal(settings.JSONData, &jsonData)
if err != nil {
return nil, fmt.Errorf("error reading settings: %w", err)
@@ -98,7 +110,7 @@ func newInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
UID: settings.UID,
DecryptedSecureJSONData: settings.DecryptedSecureJSONData,
}
cnnstr, err := generateConnectionString(dsInfo, cfg, azureCredentials, logger)
cnnstr, err := generateConnectionString(dsInfo, cfg, azureCredentials, kerberosAuth, logger)
if err != nil {
return nil, err
}
@@ -184,7 +196,7 @@ func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
}, nil
}
func generateConnectionString(dsInfo sqleng.DataSourceInfo, cfg *setting.Cfg, azureCredentials azcredentials.AzureCredentials, logger log.Logger) (string, error) {
func generateConnectionString(dsInfo sqleng.DataSourceInfo, cfg *setting.Cfg, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
const dfltPort = "0"
var addr util.NetworkAddress
if dsInfo.URL != "" {
@@ -229,6 +241,8 @@ func generateConnectionString(dsInfo sqleng.DataSourceInfo, cfg *setting.Cfg, az
connStr += azureCredentialDSNFragment
case windowsAuthentication:
// No user id or password. We're using windows single sign on.
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
default:
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
}
+107 -4
View File
@@ -3,6 +3,7 @@ package mssql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math/rand"
"os"
@@ -15,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/sqleng"
)
@@ -1331,11 +1333,94 @@ func TestTransformQueryError(t *testing.T) {
}
func TestGenerateConnectionString(t *testing.T) {
kerberosLookup := []kerberos.KerberosLookup{
{
Address: "example.host",
DBName: "testDB",
User: "testUser",
CredentialCacheFilename: "/tmp/cache",
},
}
tmpFile := genTempCacheFile(t, kerberosLookup)
defer func() {
err := os.Remove(tmpFile)
if err != nil {
t.Log(err)
}
}()
testCases := []struct {
desc string
dataSource sqleng.DataSourceInfo
expConnStr string
desc string
kerberosCfg kerberos.KerberosAuth
dataSource sqleng.DataSourceInfo
expConnStr string
}{
{
desc: "Use Kerberos Credential Cache",
kerberosCfg: kerberos.KerberosAuth{
CredentialCache: "/tmp/krb5cc_1000",
ConfigFilePath: "/etc/krb5.conf",
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Credential cache",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
},
{
desc: "Use Kerberos Credential Cache File path",
kerberosCfg: kerberos.KerberosAuth{
CredentialCacheLookupFile: tmpFile,
ConfigFilePath: "/etc/krb5.conf",
},
dataSource: sqleng.DataSourceInfo{
URL: "example.host",
Database: "testDB",
User: "testUser",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Credential cache file",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
},
{
desc: "Use Kerberos Keytab",
kerberosCfg: kerberos.KerberosAuth{
KeytabFilePath: "/foo/bar.keytab",
ConfigFilePath: "/etc/krb5.conf",
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Keytab",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
},
{
desc: "Use Kerberos Username and Password",
kerberosCfg: kerberos.KerberosAuth{
ConfigFilePath: "/etc/krb5.conf",
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
DecryptedSecureJSONData: map[string]string{
"password": "foo",
},
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Username + password",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
},
{
desc: "From URL w/ port",
dataSource: sqleng.DataSourceInfo{
@@ -1463,7 +1548,7 @@ func TestGenerateConnectionString(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
connStr, err := generateConnectionString(tc.dataSource, nil, nil, logger)
connStr, err := generateConnectionString(tc.dataSource, nil, nil, tc.kerberosCfg, logger)
require.NoError(t, err)
assert.Equal(t, tc.expConnStr, connStr)
})
@@ -1504,3 +1589,21 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim
return timeRange
}
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
content, err := json.Marshal(lookups)
if err != nil {
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
}
tmpFile, err := os.CreateTemp("", "lookup*.json")
if err != nil {
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
}
if _, err := tmpFile.Write(content); err != nil {
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
}
return tmpFile.Name()
}