Skip to content

Commit 1391821

Browse files
authored
Implemented custom Azure CLI token authorizer (#282)
* Implemented custom Azure CLI token authorizer * Refresh behavior is triggering approximately every 4 minutes. * Fixes #275 * Updated documentation and linux az cli mock Co-authored-by: Serge Smertin <[email protected]>
1 parent e4547af commit 1391821

File tree

8 files changed

+218
-52
lines changed

8 files changed

+218
-52
lines changed

.github/ISSUE_TEMPLATE/provider-issue.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ Please list the resources as a list, for example:
1919

2020
If this issue appears to affect multiple resources, it may be an issue with Terraform's core, so please mention this.
2121

22-
### Terraform Configuration Files & Environment Variable Names
22+
### Environment variable names
23+
To get relevant environment variable _names_ please copypaste the output of the following command:
24+
`$ env | sort | grep -E 'DATABRICKS|AWS|AZURE|ARM|TEST' | awk -F= '{print $1}'`
25+
26+
### Terraform Configuration Files
2327
```hcl
2428
# Copy-paste your Terraform configurations here - for large Terraform configs,
2529
# please use a service like Dropbox and share a link to the ZIP file. For

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,15 @@ vendor:
4545

4646
test-azcli:
4747
@echo "✓ Running Terraform Acceptance Tests for Azure..."
48-
@/bin/bash scripts/run.sh azcli '^(TestAcc|TestAzureAcc)' --debug
48+
@/bin/bash scripts/run.sh azcli '^(TestAcc|TestAzureAcc)' --debug --tee
4949

5050
test-azsp:
5151
@echo "✓ Running Terraform Acceptance Tests for Azure..."
52-
@/bin/bash scripts/run.sh azsp '^(TestAcc|TestAzureAcc)' --debug
52+
@/bin/bash scripts/run.sh azsp '^(TestAcc|TestAzureAcc)' --debug --tee
5353

5454
test-mws:
5555
@echo "✓ Running acceptance Tests for Multiple Workspace APIs on AWS..."
56-
@/bin/bash scripts/run.sh mws '^TestMwsAcc' --debug
56+
@/bin/bash scripts/run.sh mws '^TestMwsAcc' --debug --tee
5757

5858
test-awsst:
5959
@echo "✓ Running Terraform Acceptance Tests for AWS ST..."

common/azure_auth.go

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"github.com/Azure/go-autorest/autorest/adal"
1414
"github.com/Azure/go-autorest/autorest/azure"
1515
"github.com/Azure/go-autorest/autorest/azure/auth"
16-
"github.com/Azure/go-autorest/autorest/azure/cli"
1716
)
1817

1918
// List of management information
@@ -128,37 +127,6 @@ func (aa *AzureAuth) addSpManagementTokenVisitor(r *http.Request, management aut
128127
return nil
129128
}
130129

131-
func (aa *AzureAuth) configureWithAzureCLI() (func(r *http.Request) error, error) {
132-
if aa.resourceID() == "" {
133-
return nil, nil
134-
}
135-
if aa.IsClientSecretSet() {
136-
return nil, nil
137-
}
138-
// verify that Azure CLI is authenticated
139-
_, err := cli.GetTokenFromCLI(AzureDatabricksResourceID)
140-
if err != nil {
141-
if err.Error() == "Invoking Azure CLI failed with the following error: " {
142-
return nil, fmt.Errorf("Most likely Azure CLI is not installed. " +
143-
"See https://docs.microsoft.com/en-us/cli/azure/?view=azure-cli-latest for details.")
144-
}
145-
return nil, err
146-
}
147-
if aa.UsePATForCLI {
148-
log.Printf("[INFO] Using Azure CLI authentication with session-generated PAT")
149-
return func(r *http.Request) error {
150-
pat, err := aa.acquirePAT(auth.NewAuthorizerFromCLIWithResource)
151-
if err != nil {
152-
return err
153-
}
154-
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.TokenValue))
155-
return nil
156-
}, nil
157-
}
158-
log.Printf("[INFO] Using Azure CLI authentication with AAD tokens")
159-
return aa.simpleAADRequestVisitor(auth.NewAuthorizerFromCLIWithResource)
160-
}
161-
162130
// go nolint
163131
func (aa *AzureAuth) simpleAADRequestVisitor(
164132
authorizerFactory func(resource string) (autorest.Authorizer, error),

common/azure_cli_auth.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package common
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"os/exec"
10+
"sync"
11+
"time"
12+
13+
"github.com/Azure/go-autorest/autorest"
14+
"github.com/Azure/go-autorest/autorest/adal"
15+
"github.com/Azure/go-autorest/autorest/azure/cli"
16+
)
17+
18+
type refreshableCliToken struct {
19+
resource string
20+
token *adal.Token
21+
lock *sync.RWMutex
22+
refreshMinutes int
23+
}
24+
25+
// OAuthToken implements adal.OAuthTokenProvider
26+
func (rct *refreshableCliToken) OAuthToken() string {
27+
if rct == nil {
28+
return ""
29+
}
30+
if rct.token == nil {
31+
return ""
32+
}
33+
return rct.token.OAuthToken()
34+
}
35+
36+
// EnsureFreshWithContext implements adal.RefresherWithContext
37+
func (rct *refreshableCliToken) EnsureFreshWithContext(ctx context.Context) error {
38+
refreshInterval := time.Duration(rct.refreshMinutes) * time.Minute
39+
if !rct.token.WillExpireIn(refreshInterval) {
40+
return nil
41+
}
42+
rct.lock.Lock()
43+
defer rct.lock.Unlock()
44+
if !rct.token.WillExpireIn(refreshInterval) {
45+
return nil
46+
}
47+
return rct.refreshInternal(rct.resource)
48+
}
49+
50+
// RefreshWithContext implements adal.RefresherWithContext
51+
func (rct *refreshableCliToken) RefreshWithContext(ctx context.Context) error {
52+
rct.lock.Lock()
53+
defer rct.lock.Unlock()
54+
return rct.refreshInternal(rct.resource)
55+
}
56+
57+
// RefreshExchangeWithContext implements adal.RefresherWithContext
58+
func (rct *refreshableCliToken) RefreshExchangeWithContext(ctx context.Context, resource string) error {
59+
rct.lock.Lock()
60+
defer rct.lock.Unlock()
61+
return rct.refreshInternal(rct.resource)
62+
}
63+
64+
func (rct *refreshableCliToken) refreshInternal(resource string) (err error) {
65+
out, err := exec.Command("az", "account", "get-access-token", "--resource", resource).Output()
66+
if ee, ok := err.(*exec.ExitError); ok {
67+
err = fmt.Errorf("Cannot get access token: %s", string(ee.Stderr))
68+
return
69+
}
70+
if err != nil {
71+
err = fmt.Errorf("Cannot get access token: %v", err)
72+
return
73+
}
74+
var cliToken cli.Token
75+
err = json.Unmarshal(out, &cliToken)
76+
if err != nil {
77+
return
78+
}
79+
token, err := cliToken.ToADALToken()
80+
if err != nil {
81+
return err
82+
}
83+
log.Printf("[INFO] Refreshed OAuth token for %s from Azure CLI, which expires on %s", resource, cliToken.ExpiresOn)
84+
rct.token = &token
85+
return
86+
}
87+
88+
func (aa *AzureAuth) cliAuthorizer(resource string) (autorest.Authorizer, error) {
89+
rct := refreshableCliToken{
90+
lock: &sync.RWMutex{},
91+
resource: resource,
92+
refreshMinutes: 6,
93+
}
94+
err := rct.refreshInternal(resource)
95+
if err != nil {
96+
return nil, err
97+
}
98+
return autorest.NewBearerAuthorizer(&rct), nil
99+
}
100+
101+
func (aa *AzureAuth) configureWithAzureCLI() (func(r *http.Request) error, error) {
102+
if aa.resourceID() == "" {
103+
return nil, nil
104+
}
105+
if aa.IsClientSecretSet() {
106+
return nil, nil
107+
}
108+
// verify that Azure CLI is authenticated
109+
_, err := cli.GetTokenFromCLI(AzureDatabricksResourceID)
110+
if err != nil {
111+
if err.Error() == "Invoking Azure CLI failed with the following error: " {
112+
return nil, fmt.Errorf("Most likely Azure CLI is not installed. " +
113+
"See https://docs.microsoft.com/en-us/cli/azure/?view=azure-cli-latest for details.")
114+
}
115+
return nil, err
116+
}
117+
if aa.UsePATForCLI {
118+
log.Printf("[INFO] Using Azure CLI authentication with session-generated PAT")
119+
return func(r *http.Request) error {
120+
pat, err := aa.acquirePAT(aa.cliAuthorizer)
121+
if err != nil {
122+
return err
123+
}
124+
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.TokenValue))
125+
return nil
126+
}, nil
127+
}
128+
log.Printf("[INFO] Using Azure CLI authentication with AAD tokens")
129+
return aa.simpleAADRequestVisitor(aa.cliAuthorizer)
130+
}

common/azure_cli_auth_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package common
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestAzureCliAuth(t *testing.T) {
14+
defer CleanupEnvironment()()
15+
os.Setenv("PATH", "testdata:/bin")
16+
// fake expiration date for az mock cli
17+
os.Setenv("EXPIRE", "15M")
18+
19+
cnt := []int{0}
20+
server := httptest.NewServer(http.HandlerFunc(
21+
func(rw http.ResponseWriter, req *http.Request) {
22+
cnt[0]++
23+
if req.RequestURI == "/api/2.0/clusters/list-zones" {
24+
assert.Equal(t, "Bearer ...", req.Header.Get("Authorization"))
25+
_, err := rw.Write([]byte(`{"zones": ["a", "b", "c"]}`))
26+
assert.NoError(t, err)
27+
return
28+
}
29+
assert.Fail(t, fmt.Sprintf("Received unexpected call: %s %s",
30+
req.Method, req.RequestURI))
31+
}))
32+
defer server.Close()
33+
34+
client := DatabricksClient{
35+
Host: server.URL,
36+
AzureAuth: AzureAuth{
37+
ResourceID: "/a/b/c",
38+
},
39+
InsecureSkipVerify: true,
40+
}
41+
err := client.Configure()
42+
assert.NoError(t, err)
43+
44+
type ZonesInfo struct {
45+
Zones []string `json:"zones,omitempty"`
46+
DefaultZone string `json:"default_zone,omitempty"`
47+
}
48+
var zi ZonesInfo
49+
err = client.Get("/clusters/list-zones", nil, &zi)
50+
assert.NoError(t, err)
51+
assert.NotNil(t, zi)
52+
assert.Len(t, zi.Zones, 3)
53+
54+
err = client.Get("/clusters/list-zones", nil, &zi)
55+
assert.NoError(t, err)
56+
57+
assert.Equal(t, 2, cnt[0], "There should be only one HTTP call")
58+
}

common/testdata/az

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@ if [ "yes" == "$FAIL" ]; then
55
exit 1
66
fi
77

8+
# Macos
9+
EXP="$(date -v+${EXPIRE:=10S} +'%F %T' 2>/dev/null)"
10+
if [ "" = "${EXP}" ]; then
11+
# Linux
12+
EXPIRE=$(echo $EXPIRE | sed 's/S/seconds/')
13+
EXPIRE=$(echo $EXPIRE | sed 's/M/minutes/')
14+
EXP=$(date --date=+${EXPIRE:=10seconds} +'%F %T')
15+
fi
816

9-
echo '{
10-
"accessToken": "...",
11-
"expiresOn": "2020-08-20 11:02:42.634058",
12-
"subscription": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
13-
"tenant": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
14-
"tokenType": "Bearer"
15-
}'
17+
echo "{
18+
\"accessToken\": \"...\",
19+
\"expiresOn\": \"${EXP}\",
20+
\"subscription\": \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",
21+
\"tenant\": \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",
22+
\"tokenType\": \"Bearer\"
23+
}"

docs/index.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,11 @@ environment variable `DATABRICKS_TOKEN`.
113113

114114
## Special configurations for Azure
115115

116-
!> **Warning** Please note that the azure service principal authentication currently uses a generated Databricks PAT token and not a AAD token for the authentication. This is due to the Databricks AAD feature not yet supporting AAD tokens for secret scopes. This will be refactored in a transparent manner when that support is enabled. The only field to be impacted is `pat_token_duration_seconds` which will be deprecated and after AAD support is fully supported.
117-
118116
In order to work with Azure Databricks workspace, provider has to know it's `id` (or construct it from `azure_subscription_id`, `azure_workspace_name` and `azure_workspace_name`). Provider works with [Azure CLI authentication](https://docs.microsoft.com/en-us/cli/azure/authenticate-azure-cli?view=azure-cli-latest) to facilitate local development workflows, though for automated scenarios a service principal auth is necessary (and specification of `azure_client_id`, `azure_client_secret` and `azure_tenant_id` parameters).
119117

120118
### Authenticating with Azure Service Principal
121119

122-
-> **Note** **Azure Service Principal Authentication** will only work on Azure Databricks where as the API Token authentication will work on both **Azure** and **AWS**. Internally `azure_auth` will generate a session-based PAT token.
120+
!> **Warning** Please note that the azure service principal authentication currently uses a generated Databricks PAT token and not a AAD token for the authentication. This is due to the Databricks AAD feature not yet supporting AAD tokens for [secret scopes](https://docs.microsoft.com/en-us/azure/databricks/dev-tools/api/latest/secrets#--create-secret-scope). This will be refactored in a transparent manner when that support is available. The only field to be impacted is `pat_token_duration_seconds` which will be deprecated and after AAD support is fully supported.
123121

124122
```hcl
125123
provider "azurerm" {
@@ -129,15 +127,15 @@ provider "azurerm" {
129127
subscription_id = var.subscription_id
130128
}
131129
132-
resource "azurerm_databricks_workspace" "demo_test_workspace" {
130+
resource "azurerm_databricks_workspace" "this" {
133131
location = "centralus"
134132
name = "my-workspace-name"
135133
resource_group_name = var.resource_group
136134
sku = "premium"
137135
}
138136
139137
provider "databricks" {
140-
azure_workspace_resource_id = azurerm_databricks_workspace.demo_test_workspace.id
138+
azure_workspace_resource_id = azurerm_databricks_workspace.this.id
141139
azure_client_id = var.client_id
142140
azure_client_secret = var.client_secret
143141
azure_tenant_id = var.tenant_id
@@ -151,22 +149,22 @@ resource "databricks_scim_user" "my-user" {
151149

152150
### Authenticating with Azure CLI
153151

154-
-> **Note** **Azure Service Principal Authentication** will only work on Azure Databricks where as the API Token authentication will work on both **Azure** and **AWS**. Internally `azure_auth` will generate a session-based PAT token.
152+
It's possible to use _experimental_ [Azure CLI](https://docs.microsoft.com/cli/azure/) authentication, where provider would rely on access token cached by `az login` command, so that local development scenarios are possible. Technically, provider will call `az account get-access-token` each time before an access token is about to expire. It is [verified to work](https://github.com/databrickslabs/terraform-provider-databricks/pull/282) with all API and is enabled by default. It could be turned off by setting `azure_use_pat_for_cli` to `true` on provider configuration.
155153

156154
```hcl
157155
provider "azurerm" {
158156
features {}
159157
}
160158
161-
resource "azurerm_databricks_workspace" "demo_test_workspace" {
159+
resource "azurerm_databricks_workspace" "this" {
162160
location = "centralus"
163161
name = "my-workspace-name"
164162
resource_group_name = var.resource_group
165163
sku = "premium"
166164
}
167165
168166
provider "databricks" {
169-
azure_workspace_resource_id = azurerm_databricks_workspace.demo_test_workspace.id
167+
azure_workspace_resource_id = azurerm_databricks_workspace.this.id
170168
}
171169
172170
resource "databricks_scim_user" "my-user" {

provider/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ func DatabricksProvider() terraform.ResourceProvider {
217217
"azure_use_pat_for_cli": {
218218
Type: schema.TypeBool,
219219
Optional: true,
220-
Default: true,
220+
Default: false,
221221
Description: "Create ephemeral PAT tokens also for AZ CLI authenticated requests",
222222
},
223223
"azure_auth": {

0 commit comments

Comments
 (0)