diff --git a/README.md b/README.md index 94d87fe0..90c8b879 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,15 @@ Other supported formats are listed below. * true - Server certificate is not checked. Default is true if encrypt is not specified. If trust server certificate is true, driver accepts any certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing. * `certificate` - The file that contains the public key certificate of the CA that signed the SQL Server certificate. The specified certificate overrides the go platform specific CA certificates. * `hostNameInCertificate` - Specifies the Common Name (CN) in the server certificate. Default value is the server host. -* `ServerSPN` - The kerberos SPN (Service Principal Name) for the server. Default is MSSQLSvc/host:port. +* `ServerSPN` - The Kerberos SPN (Service Principal Name) for the server. Default is MSSQLSvc/host:port. * `Workstation ID` - The workstation name (default is the host name) * `ApplicationIntent` - Can be given the value `ReadOnly` to initiate a read-only connection to an Availability Group listener. The `database` must be specified when connecting with `Application Intent` set to `ReadOnly`. - +* `FedAuth` - The federated authentication scheme to use. See below for additional setup requirements. + * `ActiveDirectoryApplication` - authenticates using an Azure Active Directory application client ID and client secret or certificate. Set the `user` to `client-ID@tenant-ID` and the `password` to the client secret. If using client certificates, provide the path to the PEM file containing the certificate and RSA private key in the `ClientCertPath` parameter, and set the `password` to the value needed to decrypt the RSA private key (leave blank if unencrypted). + * `ActiveDirectoryMSI` - authenticates using the managed service identity (MSI) attached to the VM, or a specific user-assigned identity if a client ID is specified in the `user` field. + * `ActiveDirectoryPassword` - authenticates an Azure Active Directory user account in the form `user@domain.com` with a password. This method is not recommended for general use and does not support multi-factor authentication for accounts. + * `ActiveDirectoryIntegrated` - configures the connection to request Active Directory Integrated authentication. This method is not fully supported: you must also implement a token provider to obtain the token for the currently logged-in user and supply it in the `ActiveDirectoryTokenProvider` field in the `Connector` as described below. + ### The connection string can be specified in one of three formats: @@ -106,25 +111,68 @@ Other supported formats are listed below. * `odbc:server=localhost;user id=sa;password={foo{bar}` // Literal `{`, password is "foo{bar" * `odbc:server=localhost;user id=sa;password={foo}}bar}` // Escaped `} with `}}`, password is "foo}bar" -### Azure Active Directory authentication - preview +### Azure Active Directory authentication + +Azure Active Directory authentication uses temporary authentication tokens to authenticate. +To have the driver obtain these tokens using the +[Active Directory Authentication Library for Go](https://github.com/Azure/go-autorest/tree/master/autorest/adal), +import the Azure AD module in addition to the normal driver module, and configure the +connection string with a `FedAuth` option and supporting information as described above. + +```golang +import ( + "database/sql" + "net/url" -The configuration of functionality might change in the future. + // Import the Azure AD driver module (also imports the regular driver package) + _ "github.com/denisenkom/go-mssqldb/azuread" +) + +func ConnectWithMSI() (*sql.DB, error) { + return sql.Open("sqlserver", "sqlserver://azuresql.database.windows.net?database=yourdb&fedauth=ActiveDirectoryMSI") +} +``` + +As an alternative, you can select the federated authentication library and Active Directory +using the connection string parameters, but then implement your own routine for obtaining +tokens. + +```golang +import ( + "context" + "database/sql" + "net/url" + + // Import the driver + "github.com/denisenkom/go-mssqldb" +) + +func ConnectWithADToken() (*sql.DB, error) { + conn, err := mssql.NewConnector("sqlserver://azuresql.database.windows.net?database=yourdb&fedauth=ActiveDirectoryApplication") + if err != nil { + // handle errors in DSN + } + + conn.SecurityTokenProvider = func(ctx context.Context) (string, error) { + return "the token", nil + } + + return sql.OpenDB(conn), nil +} + +func ConnectWithADIntegrated() (*sql.DB, error) { + conn, err := mssql.NewConnector("sqlserver://azuresq;.database.windows.net?database=yourdb&fedauth=ActiveDirectoryIntegrated") + if err != nil { + // handle errors in DSN + } + + c.ActiveDirectoryTokenProvider = func(ctx context.Context, serverSPN, stsURL string) (string, error) { + return "the token", nil + } -Azure Active Directory (AAD) access tokens are relatively short lived and need to be -valid when a new connection is made. Authentication is supported using a callback func that -provides a fresh and valid token using a connector: -``` golang -conn, err := mssql.NewAccessTokenConnector( - "Server=test.database.windows.net;Database=testdb", - tokenProvider) -if err != nil { - // handle errors in DSN + return sql.OpenDB(conn), nil } -db := sql.OpenDB(conn) ``` -Where `tokenProvider` is a function that returns a fresh access token or an error. None of these statements -actually trigger the retrieval of a token, this happens when the first statment is issued and a connection -is created. ## Executing Stored Procedures diff --git a/doc/how-to-test-azure-ad-authentication.md b/doc/how-to-test-azure-ad-authentication.md new file mode 100644 index 00000000..20845565 --- /dev/null +++ b/doc/how-to-test-azure-ad-authentication.md @@ -0,0 +1,182 @@ +# How to test Azure AD authentication + +To test Azure AD authentication requires an Azure SQL server configured with an +[Active Directory administrator](https://docs.microsoft.com/en-us/azure/sql-database/sql-database-aad-authentication-configure). +To test managed identity authentication, an Azure virtual machine configured with +[system-assigned and/or user-assigned identities](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/qs-configure-portal-windows-vm) +is also required. + +The necessary resources can be set up through any means including the +[Azure Portal](https://portal.azure.com/), the Azure CLI, the Azure PowerShell cmdlets or +[Terraform](https://terraform.io/). To support these instructions, use the Terraform script at +[examples/azuread/testing.tf](../examples/azuread/testing.tf). + +## Create Azure infrastructure + +Download [Terraform](https://terraform.io/) to a location on your PATH. + +Log in to Azure using the Azure CLI. + +```console +you@workstation:~$ az login +you@workstation:~$ az account show +``` + +If your Azure account has access to multiple subscriptions, use +`az account set --subscription ` to choose the correct one. You will need to have at +least Contributor access to the portal and permissions in Azure Active Directory to create users +and grants. + +Check out this source repository (if you haven't already), change to the `examples/azuread` +directory and run Terraform: + +```console +you@workstation:~$ git clone -b azure-auth https://github.com/wrosenuance/go-mssqldb.git +you@workstation:~$ cd go-mssqldb/examples/azuread +you@workstation:azuread$ terraform init +you@workstation:azuread$ terraform apply +``` + +This will create an Azure resource group, a SQL server with a database, a virtual machine with a +system-assigned identity and user-assigned identity. Resources are named based on a random +prefix: to specify the prefix, use `terraform apply -var prefix=`. + +Upon successful completion, Terraform will display some key details of the infrastructure that has + been created. This includes the SSH key to access the VM, the administrator account and password + for the Azure SQL server, and all the relevant resource names. + +Save the settings to a JSON file: + +```console +you@workstation:azuread$ terraform output -json > settings.json +``` + +Save the SSH private key to a file: + +```console +you@workstation:azuread$ terraform output vm_user_ssh_private_key > ssh-identity +``` + +Copy the `settings.json` to the new VM: + +```console +you@workstation:azuread$ eval "VM_ADMIN_NAME=$(terraform output vm_admin_name)" +you@workstation:azuread$ eval "VM_IP_ADDRESS=$(terraform output vm_ip_address)" +you@workstation:azuread$ scp -i ssh-identity settings.json "${VM_ADMIN_NAME}@${VM_IP_ADDRESS}:" +``` + +## Set up Azure Virtual Machine for testing + +SSH to the new VM to continue setup: + +```console +you@workstation:azuread$ ssh -i ssh-identity "${VM_ADMIN_NAME}@${VM_IP_ADDRESS}" +``` + +Once on the VM, update the system and install some basic packages: + +```console +azureuser@azure-vm:~$ sudo apt update -y +azureuser@azure-vm:~$ sudo apt upgrade -y +azureuser@azure-vm:~$ sudo apt install -y git openssl jq build-essential +azureuser@azure-vm:~$ sudo snap install go --classic +``` + +Install the Azure CLI using the script as shown below, or follow the +[manual install instructions](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-apt): + +```console +azureuser@azure-vm:~$ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash +``` + +## Generate service principal certificate file + +Log in to Azure on the VM and set the subscription: + +```console +azureuser@azure-vm:~$ az login +azureuser@azure-vm:~$ az account set --subscription "$(jq -r '.subscription_id.value' settings.json)" +``` + +Use OpenSSL to create a new certificate and key in PEM format, using the : + +```console +azureuser@azure-vm:~$ openssl rand -writerand ~/.rnd +azureuser@azure-vm:~$ openssl req -x509 -nodes -newkey rsa:4096 -keyout client.key -out client.crt \ + -subj "/C=US/ST=MA/L=Boston/O=Global Security/OU=IT Department/CN=AD-SP" +azureuser@azure-vm:~$ openssl rsa -out client.pem -in client.key -aes256 \ + -passout "pass:$(jq -r '.app_sp_client_secret.value' settings.json)" +azureuser@azure-vm:~$ cat client.crt >> client.pem +azureuser@azure-vm:~$ export APP_SP_CLIENT_CERT="$PWD/client.pem" +``` + +Use the Azure CLI to add the client certificate to the application service principal: + +```console +azureuser@azure-vm:~$ az ad sp credential reset --append --cert @client.crt \ + --name "$(jq -r '.app_sp_client_id.value' settings.json)" +``` + +## Build source code and authorize users in database + +Clone this repository, build and run the `examples/azuread` helper that verifies the database +exists and sets up access for the system-assigned and user-assigned identities. + +```console +azureuser@azure-vm:~$ git clone -b azure-auth https://github.com/wrosenuance/go-mssqldb.git +azureuser@azure-vm:~$ cd go-mssqldb +azureuser@azure-vm:go-mssqldb$ go generate ./... +azureuser@azure-vm:go-mssqldb$ (cd ./examples/azuread; go build -o ../../azuread-example .) +azureuser@azure-vm:go-mssqldb$ eval "$(jq -r -f examples/azuread/environment-settings.jq ../settings.json)" +azureuser@azure-vm:go-mssqldb$ ./azuread-example -fedauth ActiveDirectoryPassword +``` + +For some basic connectivity tests, use the `examples/simple` helper. Run these commands on the +Azure VM so that identity authentication is possible. + +```console +azureuser@azure-vm:go-mssqldb$ go build -o simple ./examples/simple +azureuser@azure-vm:go-mssqldb$ jq -r -f examples/azuread/ad-user-password-dsn.jq ../settings.json | + xargs ./simple -debug -dsn +azureuser@azure-vm:go-mssqldb$ jq -r -f examples/azuread/ad-service-principal-dsn.jq ../settings.json | + xargs ./simple -debug -dsn +azureuser@azure-vm:go-mssqldb$ jq -r --arg certpath "$APP_SP_CLIENT_CERT" \ + -f examples/azuread/ad-service-principal-cert-dsn.jq ../settings.json | + xargs ./simple -debug -dsn +azureuser@azure-vm:go-mssqldb$ jq -r -f examples/azuread/ad-system-assigned-id-dsn.jq ../settings.json | + xargs ./simple -debug -dsn +azureuser@azure-vm:go-mssqldb$ jq -r -f examples/azuread/ad-user-assigned-id-dsn.jq ../settings.json | + xargs ./simple -debug -dsn +``` + +## Running the integration tests + +Now that your environment is configured, you can run `go test`: + +```console +azureuser@azure-vm:go-mssqldb$ export SQLSERVER_DSN="$(jq -r -f examples/azuread/sql-user-password-dsn.jq ../settings.json)" +azureuser@azure-vm:go-mssqldb$ go test -coverprofile=coverage.out ./... +``` + +## Tear down environment + +After you complete your testing, use Terraform to destroy the infrastructure you created. + +```console +you@workstation:azuread$ terraform destroy +``` + +## Troubleshooting + +After Terraform runs you should be able to see resources that were created in the +[Azure Portal](https://portal.azure.com/). + +If the Azure SQL server is successfully created you can connect to it using the AD admin user +and password in SSMS. SSMS will prompt you to create firewall rules if they are missing. You +can read the AD admin user and password from the `settings.json`, or run: + +```console +you@workstation:azuread$ terraform output sql_ad_admin_user +you@workstation:azuread$ terraform output sql_ad_admin_password +``` + diff --git a/examples/azuread/.gitignore b/examples/azuread/.gitignore new file mode 100644 index 00000000..e2a8f423 --- /dev/null +++ b/examples/azuread/.gitignore @@ -0,0 +1,2 @@ +settings.json +ssh-identity diff --git a/examples/azuread/ad-service-principal-cert-dsn.jq b/examples/azuread/ad-service-principal-cert-dsn.jq new file mode 100644 index 00000000..44dfeef6 --- /dev/null +++ b/examples/azuread/ad-service-principal-cert-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.app_sp_client_id.value)%40\(.tenant_id.value):\(.app_sp_client_secret.value)@\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true&fedauth=ActiveDirectoryApplication&clientcertpath=\($certpath)" \ No newline at end of file diff --git a/examples/azuread/ad-service-principal-dsn.jq b/examples/azuread/ad-service-principal-dsn.jq new file mode 100644 index 00000000..b2426037 --- /dev/null +++ b/examples/azuread/ad-service-principal-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.app_sp_client_id.value)%40\(.tenant_id.value):\(.app_sp_client_secret.value)@\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true&fedauth=ActiveDirectoryApplication" \ No newline at end of file diff --git a/examples/azuread/ad-system-assigned-id-dsn.jq b/examples/azuread/ad-system-assigned-id-dsn.jq new file mode 100644 index 00000000..288f2b7c --- /dev/null +++ b/examples/azuread/ad-system-assigned-id-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true&fedauth=ActiveDirectoryMSI" \ No newline at end of file diff --git a/examples/azuread/ad-user-assigned-id-dsn.jq b/examples/azuread/ad-user-assigned-id-dsn.jq new file mode 100644 index 00000000..df31d09d --- /dev/null +++ b/examples/azuread/ad-user-assigned-id-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.user_assigned_identity_client_id.value)@\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true&fedauth=ActiveDirectoryMSI" \ No newline at end of file diff --git a/examples/azuread/ad-user-password-dsn.jq b/examples/azuread/ad-user-password-dsn.jq new file mode 100644 index 00000000..beebc5e1 --- /dev/null +++ b/examples/azuread/ad-user-password-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.sql_ad_admin_user.value):\(.sql_ad_admin_password.value)@\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true&fedauth=ActiveDirectoryPassword" \ No newline at end of file diff --git a/examples/azuread/azuread.go b/examples/azuread/azuread.go new file mode 100644 index 00000000..e8e91cf9 --- /dev/null +++ b/examples/azuread/azuread.go @@ -0,0 +1,144 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "net/url" + "os" + "strings" + "time" + + _ "github.com/denisenkom/go-mssqldb/azuread" +) + +var ( + debug = flag.Bool("debug", false, "enable debugging") + server = flag.String("server", os.Getenv("SQL_SERVER"), "the database server name") + port = flag.Int("port", 1433, "the database port") + database = flag.String("database", os.Getenv("SQL_DATABASE"), "the database name") + user = flag.String("user", os.Getenv("SQL_AD_ADMIN_USER"), "the AD administrator user name") + password = flag.String("password", os.Getenv("SQL_AD_ADMIN_PASSWORD"), "the AD administrator password") + fedauth = flag.String("fedauth", "ActiveDirectoryPassword", "the federated authentication scheme to use") + appName = flag.String("app-name", os.Getenv("APP_NAME"), "the application name to authorize") + vmName = flag.String("vm-name", os.Getenv("VM_NAME"), "the system identity name to authorize for this VM") + uaName = flag.String("ua-name", os.Getenv("UA_NAME"), "the user assigned identity name to authorize for this VM") +) + +func createConnStr(database string) string { + connString := fmt.Sprintf("sqlserver://%s:%s@%s:%d?encrypt=true", + url.QueryEscape(*user), url.QueryEscape(*password), + url.QueryEscape(*server), *port) + + if database != "" && database != "master" { + connString = connString + "&database=" + url.QueryEscape(database) + } + + if *fedauth != "" { + connString = connString + "&fedauth=" + url.QueryEscape(*fedauth) + } + + if *debug { + connString = connString + "&log=127" + } + + return connString +} + +func createDatabaseIfNotExists() error { + // Check database exists by connecting to master on the Azure SQL server + connString := createConnStr("master") + + log.Printf("Open: %s\n", connString) + + conn, err := sql.Open("sqlserver", connString) + if err != nil { + return err + } + + defer conn.Close() + + if err = conn.Ping(); err != nil { + return err + } + + quoted := strings.Replace(*database, "]", "]]", -1) + sql := "IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @p1)\n CREATE DATABASE [" + quoted + "] ( SERVICE_OBJECTIVE = 'S0' )" + log.Printf("Exec: @p1 = '%s'\n%s\n", *database, sql) + _, err = conn.Exec(sql, *database) + + return err +} + +func addExternalUserIfNotExists(user string) error { + connString := createConnStr(*database) + + log.Printf("Open: %s\n", connString) + + var conn *sql.DB + var err error + + for retry := 0; retry < 8; retry++ { + conn, err = sql.Open("sqlserver", connString) + if err == nil { + if err = conn.Ping(); err == nil { + break + } + } + log.Printf("Connection failed: %v", err) + log.Println("Retry in 15 seconds") + time.Sleep(15 * time.Second) + } + if err != nil { + log.Printf("Connection failed: %v", err) + log.Println("No further retries will be attempted") + return err + } + + defer conn.Close() + + quoted := strings.Replace(user, "]", "]]", -1) + sql := "IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = @p1)\n CREATE USER [" + quoted + "] FROM EXTERNAL PROVIDER" + log.Printf("Exec: @p1 = '%s'\n%s\n", user, sql) + _, err = conn.Exec(sql, user) + if err != nil { + return err + } + + sql = "IF IS_ROLEMEMBER('db_owner', @p1) = 0\n ALTER ROLE [db_owner] ADD MEMBER [" + quoted + "]" + log.Printf("Exec: @p1 = '%s'\n%s\n", user, sql) + _, err = conn.Exec(sql, user) + + return err +} + +func main() { + flag.Parse() + + err := createDatabaseIfNotExists() + if err != nil { + log.Fatalf("Unable to create database [%s]: %v", *database, err) + } + + if *vmName != "" { + err = addExternalUserIfNotExists(*vmName) + if err != nil { + log.Fatalf("Unable to create user for system-assigned identity [%s]: %v", *vmName, err) + } + } + + if *appName != "" { + err = addExternalUserIfNotExists(*appName) + if err != nil { + log.Fatalf("Unable to create user for application identity [%s]: %v", *appName, err) + } + } + + if *uaName != "" { + err = addExternalUserIfNotExists(*uaName) + if err != nil { + log.Fatalf("Unable to create user for user-assigned identity [%s]: %v", *uaName, err) + } + } +} diff --git a/examples/azuread/environment-settings.jq b/examples/azuread/environment-settings.jq new file mode 100644 index 00000000..a8c9192a --- /dev/null +++ b/examples/azuread/environment-settings.jq @@ -0,0 +1,20 @@ +# Convert Terraform settings to shell environment exports. +[ + "set -a", + "SQL_SERVER=" + (.sql_server_fqdn.value | @sh), + "SQL_ADMIN_USER=" + (.sql_admin_user.value | @sh), + "SQL_ADMIN_PASSWORD=" + (.sql_admin_password.value | @sh), + "SQL_AD_ADMIN_USER=" + (.sql_ad_admin_user.value | @sh), + "SQL_AD_ADMIN_PASSWORD=" + (.sql_ad_admin_password.value | @sh), + "APP_SP_CLIENT_ID=" + (.app_sp_client_id.value | @sh), + "APP_SP_CLIENT_SECRET=" + (.app_sp_client_secret.value | @sh), + "SQL_DATABASE=" + (.sql_database_name.value | @sh), + "APP_NAME=" + (.app_name.value | @sh), + "VM_NAME=" + (.vm_name.value | @sh), + "VM_CLIENT_ID=" + (.vm_client_id.value | @sh), + "UA_NAME=" + (.user_assigned_identity_name.value | @sh), + "UA_CLIENT_ID=" + (.user_assigned_identity_client_id.value | @sh), + "AZURE_SUBSCRIPTION_ID=" + (.subscription_id.value | @sh), + "AZURE_TENANT_ID=" + (.tenant_id.value | @sh), + "set +a" +] | map([.]) | .[] | @tsv diff --git a/examples/azuread/go.mod b/examples/azuread/go.mod new file mode 100644 index 00000000..217d12b0 --- /dev/null +++ b/examples/azuread/go.mod @@ -0,0 +1,10 @@ +module github.com/denisenkom/go-mssqldb/examples/azuread + +go 1.13 + +require ( + github.com/Azure/go-autorest/autorest/adal v0.8.1 + github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73 +) + +replace github.com/denisenkom/go-mssqldb => ../.. \ No newline at end of file diff --git a/examples/azuread/sql-user-password-dsn.jq b/examples/azuread/sql-user-password-dsn.jq new file mode 100644 index 00000000..86534827 --- /dev/null +++ b/examples/azuread/sql-user-password-dsn.jq @@ -0,0 +1 @@ +@uri "sqlserver://\(.sql_admin_user.value):\(.sql_admin_password.value)@\(.sql_server_fqdn.value)?database=\(.sql_database_name.value)&encrypt=true" \ No newline at end of file diff --git a/examples/azuread/testing.tf b/examples/azuread/testing.tf new file mode 100644 index 00000000..488df9a0 --- /dev/null +++ b/examples/azuread/testing.tf @@ -0,0 +1,506 @@ +# +# Terraform setup for Azure SQL with Azure Active Directory authentication +# + +# +# Set up Terraform provider versions +# + +terraform { + required_providers { + azuread = { + source = "hashicorp/azuread" + version = "=1.1.1" + } + + azurerm = { + source = "hashicorp/azurerm" + version = "=2.40.0" + } + + http = { + source = "hashicorp/http" + version = "=2.0.0" + } + + random = { + version = "=3.0.0" + } + + tls = { + version = "=3.0.0" + } + } +} + +provider "azurerm" { + features {} +} + +# +# Variables +# +# These variables allow limited overrides to control the resource creation. +# To specify, run terraform apply -var name1=value1 [-var name2=value2]... +# E.g. terraform apply -var prefix=my-stuff +# will use "my-stuff" in place of the randomly generated ID that is used by default. +# +variable "prefix" { + description = "Prefix for Azure resource names" + type = string + default = "" +} + +variable "location" { + description = "Azure location for resources" + type = string + default = "East US" +} + +variable "vm_admin_name" { + description = "Name of administrative user on virtual machine" + type = string + default = "azureuser" +} + +variable "ssh_key" { + description = "Path to RSA SSH private key (unencrypted)" + type = string + default = "~/.ssh/id_rsa" +} + +variable "workstation_ip" { + description = "IP address of this workstation to add to SQL server firewall rules" + type = string + default = "" +} + +# +# If the prefix is not specified via the variable, a sixteen character alphanumeric suffix is +# generated and then the prefix is set to "go-mssql-test-" + +# +resource "random_string" "random_prefix" { + length = 16 + lower = true + number = true + upper = false + special = false +} + +# +# Set up a local variable to capture the prefix to use - either the user-specified from the +# variable, or else the generated name using the random string above. +# +# Some resource names (e.g. SQL server) are more restricted than others - e.g. hyphens are +# not permitted - so we create a restricted name prefix as well as a regular name prefix. +# +locals { + regular_name_prefix = var.prefix != "" ? var.prefix : "go-mssql-test-${random_string.random_prefix.result}" + restricted_name_prefix = var.prefix != "" ? lower(replace(var.prefix, "/[^A-Za-z0-9]/", "")) : "gomssqltest${random_string.random_prefix.result}" +} + +# +# SSH Key - generate if not available at the file named in the variable. +# Terraform will complain if var.ssh_key is empty as this is interpreted as referring to the +# current working directory, and that is not a file. Instead, if you want to avoid using an +# existing SSH key, make it a literal "no" or some other string that is not an existing file or +# directory. +# +data "tls_public_key" "file_ssh_key" { + count = fileexists(var.ssh_key) ? 1 : 0 + private_key_pem = fileexists(var.ssh_key) ? file(var.ssh_key) : "" +} + +resource "tls_private_key" "rand_ssh_key" { + algorithm = "ECDSA" +} + +locals { + private_key_pem = fileexists(var.ssh_key) ? data.tls_public_key.file_ssh_key.0.private_key_pem : tls_private_key.rand_ssh_key.private_key_pem + public_key_pem = fileexists(var.ssh_key) ? data.tls_public_key.file_ssh_key.0.public_key_pem : tls_private_key.rand_ssh_key.public_key_pem + public_key_openssh = fileexists(var.ssh_key) ? data.tls_public_key.file_ssh_key.0.public_key_openssh : tls_private_key.rand_ssh_key.public_key_openssh +} + +# +# Retrieve tenant, subscription and default domain information based on the current Azure login. +# +data "azurerm_client_config" "current" { +} + +data "azurerm_subscription" "current" { +} + +data "azuread_domains" "current" { + only_default = "true" +} + +# +# Use ipify.org to determine workstation IP if not provided. +# If this guesses incorrectly, specify your workstation IP with -var worstation_ip= +# when you run terraform apply. +# +data "http" "workstation_ip" { + url = "https://api.ipify.org/" +} + +locals { + workstation_ip = var.workstation_ip != "" ? var.workstation_ip : chomp(data.http.workstation_ip.body) +} + +# +# Set up the Azure resource group for all the test resources. +# +resource "azurerm_resource_group" "rg" { + name = "${local.regular_name_prefix}-rg" + location = var.location +} + +# +# Set up an AD User to use as AD Administrator for the Azure SQL server. +# +# Using a regular user account makes it simpler to log in as the user with SSMS or the Go +# driver when setting up the other permissions for the identities that will be tested. +# It appears to although you can make the AD Administrator a service principal, doing so +# leads to issues during logins that do not occur when the AD Administrator is a normal +# AD User account. +# +resource "random_password" "sql_ad_admin_sp_password" { + length = 32 + special = true +} + +resource "azuread_user" "sql_ad_admin" { + user_principal_name = "SQLAdmin.${local.restricted_name_prefix}@${data.azuread_domains.current.domains[0].domain_name}" + display_name = "SQL Admin for ${local.restricted_name_prefix}" + mail_nickname = "SQLAdmin.${local.restricted_name_prefix}" + password = random_password.sql_ad_admin_sp_password.result +} + +# +# Set up the Azure SQL Server +# +# A normal (non-AD) administrator username and password are also provisioned. However, it is +# not possible to create AD users without logging in via an AD-authenticated account, so this +# non-AD administrator is not able to create new AD user accounts. +# +resource "random_password" "sql_admin_password" { + length = 16 + special = true +} + +resource "azurerm_sql_server" "sql_server" { + name = local.restricted_name_prefix + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + version = "12.0" + administrator_login = "sql-admin" + administrator_login_password = random_password.sql_admin_password.result +} + +resource "azurerm_sql_active_directory_administrator" "sql_server" { + server_name = azurerm_sql_server.sql_server.name + resource_group_name = azurerm_sql_server.sql_server.resource_group_name + login = "sql-ad-admin" + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azuread_user.sql_ad_admin.id +} + +resource "azurerm_sql_firewall_rule" "sql_server_allow_azure" { + name = "AllowAzureAccess" + server_name = azurerm_sql_server.sql_server.name + resource_group_name = azurerm_sql_server.sql_server.resource_group_name + start_ip_address = "0.0.0.0" + end_ip_address = "0.0.0.0" +} + +resource "azurerm_sql_firewall_rule" "sql_server_allow_workstation" { + name = "AllowWorkstationAccess" + server_name = azurerm_sql_server.sql_server.name + resource_group_name = azurerm_sql_server.sql_server.resource_group_name + start_ip_address = local.workstation_ip + end_ip_address = local.workstation_ip +} + +# +# Set up the test database on the Azure SQL server +# +resource "azurerm_sql_database" "sql_db" { + name = "go-mssqldb" + + server_name = azurerm_sql_server.sql_server.name + resource_group_name = azurerm_sql_server.sql_server.resource_group_name + location = azurerm_sql_server.sql_server.location + + requested_service_objective_name = "S0" +} + +# +# Create a service principal that will be granted access to the database, +# representing an application login to the database. +# +resource "azuread_application" "app" { + name = "${local.regular_name_prefix}-app" +} + +resource "azuread_service_principal" "app_sp" { + application_id = azuread_application.app.application_id + app_role_assignment_required = false +} + +resource "random_password" "app_sp_password" { + length = 32 + special = true +} + +resource "azuread_service_principal_password" "app_sp" { + service_principal_id = azuread_service_principal.app_sp.id + value = random_password.app_sp_password.result + end_date_relative = "8760h" +} + + +# +# Create a user-assigned identity that we will add to the VM in addition to the +# system-assigned identity. +# +resource "azurerm_user_assigned_identity" "vm_user_id" { + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + name = "${local.restricted_name_prefix}-user-id" +} + +# +# Create an Azure VM for testing managed identity authentication. +# +# To support the Azure VM, we need a virtual network, a subnet, the public IP, the network +# security group, and the network interface. The network security group allows incoming SSH +# from the anywhere on the internet. +# +resource "azurerm_virtual_network" "vm_vnet" { + name = "${local.regular_name_prefix}-vnet" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + address_space = ["10.0.0.0/16"] +} + +resource "azurerm_subnet" "vm_subnet" { + name = "${local.regular_name_prefix}-vm-sn" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vm_vnet.name + address_prefixes = ["10.0.2.0/24"] +} + +resource "azurerm_public_ip" "vm_ip" { + name = "${local.regular_name_prefix}-vm-ip" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Dynamic" + idle_timeout_in_minutes = 30 +} + +resource "azurerm_network_security_group" "vm_nsg" { + name = "${local.regular_name_prefix}-vm-nsg" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + security_rule { + name = "SSH" + priority = 1001 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +resource "azurerm_network_interface" "vm_nic" { + name = "${local.regular_name_prefix}-vm-nic" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + ip_configuration { + name = "${local.regular_name_prefix}-vm-nic-config" + subnet_id = azurerm_subnet.vm_subnet.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.vm_ip.id + } +} + +resource "azurerm_network_interface_security_group_association" "vm_nic_nsg" { + network_interface_id = azurerm_network_interface.vm_nic.id + network_security_group_id = azurerm_network_security_group.vm_nsg.id +} + +# +# Given the networking setup, now create the Azure VM +# +resource "azurerm_virtual_machine" "vm" { + name = "${local.regular_name_prefix}-vm" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + network_interface_ids = [azurerm_network_interface.vm_nic.id] + vm_size = "Standard_B1s" + + storage_os_disk { + name = "${local.regular_name_prefix}-vm-os" + caching = "ReadWrite" + create_option = "FromImage" + managed_disk_type = "Standard_LRS" + } + + storage_image_reference { + publisher = "Canonical" + offer = "UbuntuServer" + sku = "18.04-LTS" + version = "latest" + } + + os_profile { + computer_name = "${local.regular_name_prefix}-vm" + admin_username = var.vm_admin_name + } + + os_profile_linux_config { + disable_password_authentication = true + ssh_keys { + path = "/home/${var.vm_admin_name}/.ssh/authorized_keys" + key_data = local.public_key_openssh + } + } + + # Configure the VM with both SystemAssigned and a UserAssigned identity + identity { + type = "SystemAssigned, UserAssigned" + identity_ids = [azurerm_user_assigned_identity.vm_user_id.id] + } +} + +# Retrieve the application ID corresponding to the service principal ID assigned to the VM. +data "azuread_service_principal" "vm_sp" { + object_id = azurerm_virtual_machine.vm.identity.0.principal_id +} + +# Wait for public IP to be assigned after VM is created so we can report it in the outputs. +data "azurerm_public_ip" "vm_ip" { + name = azurerm_public_ip.vm_ip.name + resource_group_name = azurerm_virtual_machine.vm.resource_group_name +} + +# +# After provisioning or refreshing, Terraform will populate these outputs. +# These capture the necessary pieces of information to access the new infrastructure. +# +output "tenant_id" { + description = "Azure tenant ID" + value = data.azurerm_client_config.current.tenant_id +} + +output "subscription_id" { + description = "Azure subscription ID" + value = data.azurerm_client_config.current.subscription_id +} + +output "sql_server_name" { + description = "Azure SQL server name" + value = azurerm_sql_server.sql_server.name +} + +output "sql_server_fqdn" { + description = "Azure SQL server domain name" + value = azurerm_sql_server.sql_server.fully_qualified_domain_name +} + +output "sql_ad_admin_user" { + description = "Azure SQL administrator name (AD authentication)" + value = azuread_user.sql_ad_admin.user_principal_name +} + +output "sql_ad_admin_password" { + description = "Azure SQL administrator password (AD authentication)" + value = random_password.sql_ad_admin_sp_password.result + sensitive = true +} + +output "sql_admin_user" { + description = "Azure SQL administrator name (SQL server authentication)" + value = azurerm_sql_server.sql_server.administrator_login +} + +output "sql_admin_password" { + description = "Azure SQL administrator password (SQL server authentication)" + value = random_password.sql_admin_password.result + sensitive = true +} + +output "sql_database_name" { + description = "Azure SQL database name" + value = azurerm_sql_database.sql_db.name +} + +output "vm_name" { + description = "Azure virtual machine name" + value = azurerm_virtual_machine.vm.name +} + +output "vm_client_id" { + description = "Azure VM system-assigned identity client ID" + value = data.azuread_service_principal.vm_sp.application_id +} + +output "vm_principal_id" { + description = "Azure VM system-assigned identity principal ID" + value = azurerm_virtual_machine.vm.identity.0.principal_id +} + +output "vm_ip_address" { + description = "Azure virtual machine public IP" + value = data.azurerm_public_ip.vm_ip.ip_address +} + +output "vm_admin_name" { + description = "Azure virtual machine admin user name" + value = var.vm_admin_name +} + +output "vm_user_ssh_private_key" { + description = "Azure virtual machine admin user private SSH key" + value = local.private_key_pem + sensitive = true +} + +output "vm_user_ssh_openssh_key" { + description = "Azure virtual machine admin user SSH public key" + value = local.public_key_openssh + sensitive = true +} + +output "app_sp_client_id" { + description = "Service principal client ID for application user" + value = azuread_application.app.application_id +} + +output "app_name" { + description = "Service principal name for application user" + value = azuread_application.app.name +} + +output "app_sp_client_secret" { + description = "Service principal client secret for application user" + value = random_password.app_sp_password.result + sensitive = true +} + +output "user_assigned_identity_name" { + description = "User-assigned identity for the Azure virtual machine" + value = azurerm_user_assigned_identity.vm_user_id.name +} + +output "user_assigned_identity_client_id" { + description = "User-assigned identity client ID" + value = azurerm_user_assigned_identity.vm_user_id.client_id +} diff --git a/examples/simple/simple.go b/examples/simple/simple.go index 67f88aa4..a2590502 100644 --- a/examples/simple/simple.go +++ b/examples/simple/simple.go @@ -5,12 +5,16 @@ import ( "flag" "fmt" "log" + "net/url" + "os" - _ "github.com/denisenkom/go-mssqldb" + _ "github.com/denisenkom/go-mssqldb/azuread" ) var ( + database = flag.String("database", "", "the database name") debug = flag.Bool("debug", false, "enable debugging") + dsn = flag.String("dsn", os.Getenv("SQLSERVER_DSN"), "complete SQL DSN") password = flag.String("password", "", "the database password") port *int = flag.Int("port", 1433, "the database port") server = flag.String("server", "", "the database server") @@ -20,24 +24,35 @@ var ( func main() { flag.Parse() - if *debug { - fmt.Printf(" password:%s\n", *password) - fmt.Printf(" port:%d\n", *port) - fmt.Printf(" server:%s\n", *server) - fmt.Printf(" user:%s\n", *user) + var connString string + + if *dsn == "" { + if *debug { + fmt.Printf(" server: %s\n", *server) + fmt.Printf(" port: %d\n", *port) + fmt.Printf(" user: %s\n", *user) + fmt.Printf(" password: %s\n", *password) + fmt.Printf(" database: %s\n", *database) + } + + connString = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s&encrypt=true", + url.QueryEscape(*user), url.QueryEscape(*password), + url.QueryEscape(*server), *port, url.QueryEscape(*database)) + } else { + connString = *dsn } - connString := fmt.Sprintf("server=%s;user id=%s;password=%s;port=%d", *server, *user, *password, *port) if *debug { - fmt.Printf(" connString:%s\n", connString) + fmt.Printf(" dsn: %s\n", connString) } + conn, err := sql.Open("mssql", connString) if err != nil { log.Fatal("Open connection failed:", err.Error()) } defer conn.Close() - stmt, err := conn.Prepare("select 1, 'abc'") + stmt, err := conn.Prepare("select 1, 'abc', suser_name()") if err != nil { log.Fatal("Prepare failed:", err.Error()) } @@ -46,12 +61,14 @@ func main() { row := stmt.QueryRow() var somenumber int64 var somechars string - err = row.Scan(&somenumber, &somechars) + var someuser string + err = row.Scan(&somenumber, &somechars, &someuser) if err != nil { log.Fatal("Scan failed:", err.Error()) } - fmt.Printf("somenumber:%d\n", somenumber) - fmt.Printf("somechars:%s\n", somechars) + fmt.Printf("number: %d\n", somenumber) + fmt.Printf("chars: %s\n", somechars) + fmt.Printf("user: %s\n", someuser) fmt.Printf("bye\n") } diff --git a/examples/tvp/tvp.go b/examples/tvp/tvp.go index a07bb652..eae614ef 100644 --- a/examples/tvp/tvp.go +++ b/examples/tvp/tvp.go @@ -1,3 +1,5 @@ +// +build go1.9 + package main import (