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:
@@ -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
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user