Skip to content

Commit 63b8f6c

Browse files
authored
Add azidentity-based AAD auth to sqlcmd (#15)
* basic azure auth support * add tests for AAD auth * pipeline fixes * pipeline fix * fix variable cyclic reference * fix file name in pipeline * merge coverage data * fix missing quote * remove unneeded scope * fix test for azure auth
1 parent 44e5b52 commit 63b8f6c

File tree

11 files changed

+466
-95
lines changed

11 files changed

+466
-95
lines changed

.pipelines/TestSql2017.yml

Lines changed: 47 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,51 @@
1+
variables:
2+
# AZURE_CLIENT_SECRET and SQLPASSWORD must be defined as secret variables in the pipeline.
3+
# AZURE_TENANT_ID and AZURE_CLIENT_ID are not expected to be secret variables, just regular variables
4+
AZURECLIENTSECRET: $(AZURE_CLIENT_SECRET)
5+
PASSWORD: $(SQLPASSWORD)
16
pool:
27
vmImage: 'ubuntu-latest'
38

4-
steps:
5-
- task: GoTool@0
6-
inputs:
7-
version: '1.16.5'
8-
- task: Go@0
9-
displayName: 'Go: get dependencies'
10-
inputs:
11-
command: 'get'
12-
arguments: '-d'
13-
workingDirectory: '$(Build.SourcesDirectory)/cmd/sqlcmd'
14-
15-
16-
- task: Go@0
17-
displayName: 'Go: install gotest.tools/gotestsum'
18-
inputs:
19-
command: 'custom'
20-
customCommand: 'install'
21-
arguments: 'gotest.tools/gotestsum@latest'
22-
workingDirectory: '$(System.DefaultWorkingDirectory)'
23-
24-
- task: Go@0
25-
displayName: 'Go: install github.com/axw/gocov/gocov'
26-
inputs:
27-
command: 'custom'
28-
customCommand: 'install'
29-
arguments: 'github.com/axw/gocov/gocov@latest'
30-
workingDirectory: '$(System.DefaultWorkingDirectory)'
31-
32-
- task: Go@0
33-
displayName: 'Go: install github.com/axw/gocov/gocov'
34-
inputs:
35-
command: 'custom'
36-
customCommand: 'install'
37-
arguments: 'github.com/AlekSi/gocov-xml@latest'
38-
workingDirectory: '$(System.DefaultWorkingDirectory)'
39-
40-
#Your build pipeline references an undefined variables named SQLPASSWORD.
41-
#Create or edit the build pipeline for this YAML file, define the variable on the Variables tab. See https://go.microsoft.com/fwlink/?linkid=865972
42-
43-
- task: Docker@2
44-
displayName: 'Run SQL 2017 docker image'
45-
inputs:
46-
command: run
47-
arguments: '-m 2GB -e ACCEPT_EULA=1 -d --name sql2017 -p:1433:1433 -e SA_PASSWORD=$(SQLPASSWORD) mcr.microsoft.com/mssql/server:2017-latest'
48-
49-
- script: |
50-
~/go/bin/gotestsum --junitfile testresults.xml -- ./... -coverprofile=coverage.txt -covermode count
51-
~/go/bin/gocov convert coverage.txt > coverage.json
52-
~/go/bin/gocov-xml < coverage.json > coverage.xml
53-
mkdir coverage
54-
workingDirectory: '$(Build.SourcesDirectory)'
55-
displayName: 'run tests'
56-
env:
57-
SQLPASSWORD: $(SQLPASSWORD)
58-
SQLCMDUSER: sa
59-
SQLCMDPASSWORD: $(SQLPASSWORD)
60-
continueOnError: true
61-
- task: PublishTestResults@2
62-
displayName: "Publish junit-style results"
63-
inputs:
64-
testResultsFiles: 'testresults.xml'
65-
testResultsFormat: JUnit
66-
searchFolder: '$(Build.SourcesDirectory)'
67-
testRunTitle: 'SQL 2017 - $(Build.SourceBranchName)'
68-
condition: always()
69-
continueOnError: true
70-
71-
- task: PublishCodeCoverageResults@1
72-
inputs:
73-
codeCoverageTool: Cobertura
74-
pathToSources: '$(Build.SourcesDirectory)'
75-
summaryFileLocation: $(Build.SourcesDirectory)/**/coverage.xml
76-
reportDirectory: $(Build.SourcesDirectory)/**/coverage
77-
failIfCoverageEmpty: true
78-
condition: always()
79-
continueOnError: true
9+
steps:
10+
- template: include-install-go-tools.yml
11+
12+
- task: Docker@2
13+
displayName: 'Run SQL 2017 docker image'
14+
inputs:
15+
command: run
16+
arguments: '-m 2GB -e ACCEPT_EULA=1 -d --name sql2017 -p:1433:1433 -e SA_PASSWORD=$(PASSWORD) mcr.microsoft.com/mssql/server:2017-latest'
17+
18+
- template: include-runtests-linux.yml
19+
parameters:
20+
RunName: 'SQL 2017'
21+
SQLCMDUSER: sa
22+
SQLPASSWORD: $(PASSWORD)
23+
24+
- template: include-runtests-linux.yml
25+
parameters:
26+
RunName: 'SQL DB'
27+
# AZURESERVER must be defined as a variable in the pipeline
28+
SQLCMDSERVER: $(AZURESERVER)
29+
AZURECLIENTSECRET: $(AZURECLIENTSECRET)
30+
31+
- task: Palmmedia.reportgenerator.reportgenerator-build-release-task.reportgenerator@4
32+
displayName: Merge coverage data
33+
inputs:
34+
reports: '"SQL 2017.coverage.xml";"SQL DB.coverage.xml"' # REQUIRED # The coverage reports that should be parsed (separated by semicolon). Globbing is supported.
35+
targetdir: 'coverage' # REQUIRED # The directory where the generated report should be saved.
36+
reporttypes: 'HtmlInline_AzurePipelines;Cobertura' # The output formats and scope (separated by semicolon) Values: Badges, Clover, Cobertura, CsvSummary, Html, HtmlChart, HtmlInline, HtmlInline_AzurePipelines, HtmlInline_AzurePipelines_Dark, HtmlSummary, JsonSummary, Latex, LatexSummary, lcov, MarkdownSummary, MHtml, PngChart, SonarQube, TeamCitySummary, TextSummary, Xml, XmlSummary
37+
sourcedirs: '$(Build.SourcesDirectory)' # Optional directories which contain the corresponding source code (separated by semicolon). The source directories are used if coverage report contains classes without path information.
38+
verbosity: 'Info' # The verbosity level of the log messages. Values: Verbose, Info, Warning, Error, Off
39+
tag: '$(build.buildnumber)_#$(build.buildid)_$(Build.SourceBranchName)' # Optional tag or build version.
40+
- task: PublishCodeCoverageResults@1
41+
inputs:
42+
codeCoverageTool: Cobertura
43+
pathToSources: '$(Build.SourcesDirectory)'
44+
summaryFileLocation: $(Build.SourcesDirectory)/coverage/*.xml
45+
reportDirectory: $(Build.SourcesDirectory)/coverage
46+
failIfCoverageEmpty: true
47+
condition: always()
48+
continueOnError: true
49+
env:
50+
disable.coverage.autogenerate: 'true'
8051

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
steps:
2+
- task: GoTool@0
3+
inputs:
4+
version: '1.16.5'
5+
- task: Go@0
6+
displayName: 'Go: get dependencies'
7+
inputs:
8+
command: 'get'
9+
arguments: '-d'
10+
workingDirectory: '$(Build.SourcesDirectory)/cmd/sqlcmd'
11+
12+
13+
- task: Go@0
14+
displayName: 'Go: install gotest.tools/gotestsum'
15+
inputs:
16+
command: 'custom'
17+
customCommand: 'install'
18+
arguments: 'gotest.tools/gotestsum@latest'
19+
workingDirectory: '$(System.DefaultWorkingDirectory)'
20+
21+
- task: Go@0
22+
displayName: 'Go: install github.com/axw/gocov/gocov'
23+
inputs:
24+
command: 'custom'
25+
customCommand: 'install'
26+
arguments: 'github.com/axw/gocov/gocov@latest'
27+
workingDirectory: '$(System.DefaultWorkingDirectory)'
28+
29+
- task: Go@0
30+
displayName: 'Go: install github.com/axw/gocov/gocov'
31+
inputs:
32+
command: 'custom'
33+
customCommand: 'install'
34+
arguments: 'github.com/AlekSi/gocov-xml@latest'
35+
workingDirectory: '$(System.DefaultWorkingDirectory)'
36+

.pipelines/include-runtests-linux.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
parameters:
2+
- name: RunName
3+
type: string
4+
- name: SQLCMDUSER
5+
type: string
6+
default: ''
7+
- name: SQLPASSWORD
8+
type: string
9+
default: ''
10+
- name: AZURECLIENTSECRET
11+
type: string
12+
default: ''
13+
- name: SQLCMDSERVER
14+
type: string
15+
default: .
16+
- name: SQLCMDDBNAME
17+
type: string
18+
default: ''
19+
steps:
20+
- script: |
21+
~/go/bin/gotestsum --junitfile "${{ parameters.RunName }}.testresults.xml" -- ./... -coverprofile="${{ parameters.RunName }}.coverage.txt" -covermode count
22+
~/go/bin/gocov convert "${{ parameters.RunName }}.coverage.txt" > "${{ parameters.RunName }}.coverage.json"
23+
~/go/bin/gocov-xml < "${{ parameters.RunName }}.coverage.json" > "${{ parameters.RunName }}.coverage.xml"
24+
mkdir -p coverage
25+
workingDirectory: '$(Build.SourcesDirectory)'
26+
displayName: 'run tests'
27+
env:
28+
SQLPASSWORD: ${{ parameters.SQLPASSWORD }}
29+
SQLCMDUSER: ${{ parameters.SQLCMDUSER }}
30+
SQLCMDPASSWORD: ${{ parameters.SQLPASSWORD }}
31+
AZURE_TENANT_ID: $(AZURE_TENANT_ID)
32+
AZURE_CLIENT_ID: $(AZURE_CLIENT_ID)
33+
AZURE_CLIENT_SECRET: ${{ parameters.AZURECLIENTSECRET }}
34+
SQLCMDSERVER: ${{ parameters.SQLCMDSERVER }}
35+
SQLCMDDBNAME: ${{ parameters.SQLCMDDBNAME }}
36+
continueOnError: true
37+
38+
- task: PublishTestResults@2
39+
displayName: "Publish junit-style results"
40+
inputs:
41+
testResultsFiles: '"${{ parameters.RunName }}.coverage.xml"'
42+
testResultsFormat: JUnit
43+
searchFolder: '$(Build.SourcesDirectory)'
44+
testRunTitle: '${{ parameters.RunName }} - $(Build.SourceBranchName)'
45+
condition: always()
46+
continueOnError: true

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,60 @@ We will be implementing as many command line switches and behaviors as possible
2020
- Some behaviors that were kept to maintain compatibility with `OSQL` may be changed, such as alignment of column headers for some data types.
2121
- All commands must fit on one line, even `EXIT`. Interactive mode will not check for open parentheses or quotes for commands and prompt for successive lines. The native sqlcmd allows the query run by `EXIT(query)` to span multiple lines.
2222

23+
### Azure Active Directory Authentication
24+
25+
This version of sqlcmd supports a broader range of AAD authentication models, based on the [azidentity package](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity).
26+
27+
#### Command line
28+
29+
To use AAD auth, you can use one of two command line switches
30+
31+
`-G` is (mostly) compatible with its usage in the prior version of sqlcmd. If a user name and password are provided, it will authenticate using AAD Password authentication. If a user name is provided it will use AAD Interactive authentication which may display a web browser. If no user name or password is provided, it will use a DefaultAzureCredential which attempts to authenticate through a variety of mechanisms.
32+
33+
`--authentication-method=` can be used to specify one of the following authentication types.
34+
35+
`ActiveDirectoryDefault`
36+
37+
- For an overview of the types of authentication this mode will use, see (<https://github.com/Azure/azure-sdk-for-go/tree/main/sdk/azidentity#defaultazurecredential>).
38+
- Choose this method if your database automation scripts are intended to run in both local development environments and in a production deployment in Azure. You'll be able to use a client secret or an Azure CLI login on your development environment and a managed identity or client secret on your production deployment without changing the script.
39+
- Setting environment variables AZURE_TENANT_ID, and AZURE_CLIENT_ID are necessary for DefaultAzureCredential to begin checking the environment configuration and look for one of the following additional environment variables in order to authenticate:
40+
41+
- Setting environment variable AZURE_CLIENT_SECRET configures the DefaultAzureCredential to choose ClientSecretCredential.
42+
- Setting environment variable AZURE_CLIENT_CERTIFICATE_PATH configures the DefaultAzureCredential to choose ClientCertificateCredential if AZURE_CLIENT_SECRET is not set.
43+
- Setting environment variable AZURE_USERNAME configures the DefaultAzureCredential to choose UsernamePasswordCredential if AZURE_CLIENT_SECRET and AZURE_CLIENT_CERTIFICATE_PATH are not set.
44+
45+
`ActiveDirectoryIntegrated`
46+
47+
This method is currently not implemented and will fall back to `ActiveDirectoryDefault`
48+
49+
`ActiveDirectoryPassword`
50+
51+
This method will authenticate using a user name and password. It will not work if MFA is required.
52+
You provide the user name and password using the usual command line switches or SQLCMD environment variables.
53+
Set `AZURE_TENANT_ID` environment variable to the tenant id of the server if not using the default tenant of the user.
54+
55+
`ActiveDirectoryInteractive`
56+
57+
This method will launch a web browser to authenticate the user.
58+
Set `AZURE_TENANT_ID` environment variable to the tenant id of the server if not using the default.
59+
60+
`ActiveDirectoryManagedIdentity`
61+
62+
Use this method when running sqlcmd on an Azure VM that has either a system-assigned or user-assigned managed identity. If using a user-assigned managed identity, set the user name to the ID of the managed identity. If using a system-assigned identity, leave user name empty.
63+
64+
`ActiveDirectoryServicePrincipal`
65+
66+
This method authenticates the provided user name as a service principal id and the password as the client secret for the service principal. Set `AZURE_TENANT_ID` environment variable to the tenant id of the service principal.
67+
68+
### Environment variables for AAD auth
69+
70+
Some settings for AAD auth do not have command line inputs, and some environment variables are consumed directly by the `azidentity` package used by `sqlcmd`.
71+
These environment variables can be set to configure some aspects of AAD auth and to bypass default behaviors. In addition to the variables listed above, the following are sqlcmd-specific and apply to multiple methods.
72+
73+
`SQLCMDAZURERESOURCE` - defines the URL of the Azure SQL database resource in the Azure cloud where the database resides. By default, `sqlcmd` attempts to match the DNS suffix of the server name with one of the well known Azure cloud DNS suffixes. If no match is found it uses `https://database.windows.net`.
74+
75+
`SQLCMDCLIENTID` - set this to the identifier of an application registered in your AAD which is authorized to authenticate to Azure SQL Database. Applies to `ActiveDirectoryInteractive` and `ActiveDirectoryPassword` methods.
76+
2377
### Packages
2478

2579
#### sqlcmd executable

cmd/sqlcmd/main.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type SQLCmdArguments struct {
2020
// Whether to trust the server certificate on an encrypted connection
2121
TrustServerCertificate bool `short:"C" help:"Implicitly trust the server certificate without validation."`
2222
DatabaseName string `short:"d" help:"This option sets the sqlcmd scripting variable SQLCMDDBNAME. This parameter specifies the initial database. The default is your login's default-database property. If the database does not exist, an error message is generated and sqlcmd exits."`
23-
UseTrustedConnection bool `short:"E" xor:"uid" help:"Uses a trusted connection instead of using a user name and password to sign in to SQL Server, ignoring any any environment variables that define user name and password."`
23+
UseTrustedConnection bool `short:"E" xor:"uid, auth" help:"Uses a trusted connection instead of using a user name and password to sign in to SQL Server, ignoring any any environment variables that define user name and password."`
2424
UserName string `short:"U" xor:"uid" help:"The login name or contained database user name. For contained database users, you must provide the database name option"`
2525
// Files from which to read query text
2626
InputFile []string `short:"i" xor:"input1, input2" type:"existingFile" help:"Identifies one or more files that contain batches of SQL statements. If one or more files do not exist, sqlcmd will exit. Mutually exclusive with -Q/-q."`
@@ -31,7 +31,10 @@ type SQLCmdArguments struct {
3131
Query string `short:"Q" xor:"input2" help:"Executes a query when sqlcmd starts and then immediately exits sqlcmd. Multiple-semicolon-delimited queries can be executed."`
3232
Server string `short:"S" help:"[tcp:]server[\\instance_name][,port]Specifies the instance of SQL Server to which to connect. It sets the sqlcmd scripting variable SQLCMDSERVER."`
3333
// Disable syscommands with a warning
34-
DisableCmdAndWarn bool `short:"X" xor:"syscmd" help:"Disables commands that might compromise system security. Sqlcmd issues a warning and continues."`
34+
DisableCmdAndWarn bool `short:"X" xor:"syscmd" help:"Disables commands that might compromise system security. Sqlcmd issues a warning and continues."`
35+
// AuthenticationMethod is new for go-sqlcmd
36+
AuthenticationMethod string `xor:"auth" help:"Specifies the SQL authentication method to use to connect to Azure SQL Database. One of:ActiveDirectoryDefault,ActiveDirectoryIntegrated,ActiveDirectoryPassword,ActiveDirectoryInteractive,ActiveDirectoryManagedIdentity,ActiveDirectoryServicePrincipal,SqlPassword"`
37+
UseAad bool `short:"G" xor:"auth" help:"Tells sqlcmd to use Active Directory authentication. If no user name is provided, authentication method ActiveDirectoryDefault is used. If a password is provided, ActiveDirectoryPassword is used. Otherwise ActiveDirectoryInteractive is used."`
3538
DisableVariableSubstitution bool `short:"x" help:"Causes sqlcmd to ignore scripting variables. This parameter is useful when a script contains many INSERT statements that may contain strings that have the same format as regular variables, such as $(variable_name)."`
3639
Variables map[string]string `short:"v" help:"Creates a sqlcmd scripting variable that can be used in a sqlcmd script. Enclose the value in quotation marks if the value contains spaces. You can specify multiple var=values values. If there are errors in any of the values specified, sqlcmd generates an error message and then exits"`
3740
}
@@ -51,6 +54,26 @@ func newArguments() SQLCmdArguments {
5154

5255
var args SQLCmdArguments
5356

57+
func (a SQLCmdArguments) authenticationMethod(hasPassword bool) string {
58+
if a.UseTrustedConnection {
59+
return sqlcmd.NotSpecified
60+
}
61+
if a.UseAad {
62+
switch {
63+
case a.UserName == "":
64+
return sqlcmd.ActiveDirectoryIntegrated
65+
case hasPassword:
66+
return sqlcmd.ActiveDirectoryPassword
67+
default:
68+
return sqlcmd.ActiveDirectoryInteractive
69+
}
70+
}
71+
if a.AuthenticationMethod == "" {
72+
return sqlcmd.NotSpecified
73+
}
74+
return a.AuthenticationMethod
75+
}
76+
5477
func main() {
5578
kong.Parse(&args)
5679
vars := sqlcmd.InitializeVariables(!args.DisableCmdAndWarn)
@@ -64,9 +87,20 @@ func main() {
6487
// setVars initializes scripting variables from command line arguments
6588
func setVars(vars *sqlcmd.Variables, args *SQLCmdArguments) {
6689
varmap := map[string]func(*SQLCmdArguments) string{
67-
sqlcmd.SQLCMDDBNAME: func(a *SQLCmdArguments) string { return a.DatabaseName },
68-
sqlcmd.SQLCMDLOGINTIMEOUT: func(a *SQLCmdArguments) string { return "" },
69-
sqlcmd.SQLCMDUSEAAD: func(a *SQLCmdArguments) string { return "" },
90+
sqlcmd.SQLCMDDBNAME: func(a *SQLCmdArguments) string { return a.DatabaseName },
91+
sqlcmd.SQLCMDLOGINTIMEOUT: func(a *SQLCmdArguments) string { return "" },
92+
sqlcmd.SQLCMDUSEAAD: func(a *SQLCmdArguments) string {
93+
if a.UseAad {
94+
return "true"
95+
}
96+
switch a.AuthenticationMethod {
97+
case sqlcmd.ActiveDirectoryIntegrated:
98+
case sqlcmd.ActiveDirectoryInteractive:
99+
case sqlcmd.ActiveDirectoryPassword:
100+
return "true"
101+
}
102+
return ""
103+
},
70104
sqlcmd.SQLCMDWORKSTATION: func(a *SQLCmdArguments) string { return "" },
71105
sqlcmd.SQLCMDSERVER: func(a *SQLCmdArguments) string { return a.Server },
72106
sqlcmd.SQLCMDERRORLEVEL: func(a *SQLCmdArguments) string { return "" },
@@ -124,6 +158,7 @@ func run(vars *sqlcmd.Variables) (int, error) {
124158
}
125159
s.Connect.UseTrustedConnection = args.UseTrustedConnection
126160
s.Connect.TrustServerCertificate = args.TrustServerCertificate
161+
s.Connect.AuthenticationMethod = args.authenticationMethod(s.Connect.Password != "")
127162
s.Connect.DisableEnvironmentVariables = args.DisableCmdAndWarn
128163
s.Connect.DisableVariableSubstitution = args.DisableVariableSubstitution
129164
s.Format = sqlcmd.NewSQLCmdDefaultFormatter(false)

0 commit comments

Comments
 (0)