Skip to content

Commit ee60a92

Browse files
Added new resource: dbtcloud_azure_ad_application (#658)
* Added azure-ad-application resource * Address comments in PR
1 parent 02341fe commit ee60a92

11 files changed

Lines changed: 876 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Changes
2+
body: Added resource for azure-ad-application-resource
3+
time: 2026-04-08T09:38:13.641938+03:00
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
page_title: "dbtcloud_azure_ad_application Resource - dbtcloud"
3+
subcategory: ""
4+
description: |-
5+
Manages an Azure Active Directory (Microsoft Entra ID) application registration for a dbt Cloud account. This enables Azure DevOps integration, allowing dbt Cloud to access Azure DevOps repositories for project setup.
6+
The client_id, client_secret and tenant_id are encrypted at rest and never returned by the API. They are stored as sensitive values in Terraform state so they can be resent on every update — the API requires all three on both create and update.
7+
Destroy behaviour: running terraform destroy calls the dbt Cloud DELETE endpoint, which marks the record as inactive. Due to a known dbt Cloud backend limitation, the underlying database row is retained and re-creating the resource against the same account without a backend cleanup will fail with a unique-constraint error. If you need to recreate the resource after a destroy, contact dbt Cloud support to have the orphaned record removed, or use terraform import to re-adopt the existing record ID.
8+
Requires the Azure DevOps integration feature to be enabled on the account (enterprise plans only).
9+
---
10+
11+
# dbtcloud_azure_ad_application (Resource)
12+
13+
14+
Manages an Azure Active Directory (Microsoft Entra ID) application registration for a dbt Cloud account. This enables Azure DevOps integration, allowing dbt Cloud to access Azure DevOps repositories for project setup.
15+
16+
The `client_id`, `client_secret` and `tenant_id` are encrypted at rest and never returned by the API. They are stored as sensitive values in Terraform state so they can be resent on every update — the API requires all three on both create and update.
17+
18+
**Destroy behaviour:** running `terraform destroy` calls the dbt Cloud DELETE endpoint, which marks the record as inactive. Due to a known dbt Cloud backend limitation, the underlying database row is retained and re-creating the resource against the same account without a backend cleanup will fail with a unique-constraint error. If you need to recreate the resource after a destroy, contact dbt Cloud support to have the orphaned record removed, or use `terraform import` to re-adopt the existing record ID.
19+
20+
Requires the Azure DevOps integration feature to be enabled on the account (enterprise plans only).
21+
22+
## Example Usage
23+
24+
```terraform
25+
resource "dbtcloud_azure_ad_application" "this" {
26+
organization_name = "my-azure-devops-org"
27+
client_id = "00000000-0000-0000-0000-000000000000"
28+
client_secret = var.azure_client_secret
29+
tenant_id = "00000000-0000-0000-0000-000000000001"
30+
31+
# Optional: defaults to "service_user". Set to "service_principal" to use
32+
# service principal authentication instead.
33+
azure_service_authentication_method = "service_user"
34+
}
35+
36+
# NOTE: destroying this resource calls the dbt Cloud DELETE endpoint, which
37+
# marks the record as inactive but does not remove the underlying database row.
38+
# Re-creating the resource against the same account after a destroy will fail
39+
# with a unique-constraint error. To recover, ask dbt Cloud support to remove
40+
# the orphaned record, or use `terraform import` to re-adopt it:
41+
#
42+
# terraform import dbtcloud_azure_ad_application.this <id>
43+
```
44+
45+
<!-- schema generated by tfplugindocs -->
46+
## Schema
47+
48+
### Required
49+
50+
- `client_id` (String, Sensitive) The client ID (application ID) of the Azure AD app registration. Stored as a sensitive value — the API never returns it.
51+
- `client_secret` (String, Sensitive) The client secret of the Azure AD app registration. Stored as a sensitive value — the API never returns it.
52+
- `organization_name` (String) The name of the Azure DevOps organization.
53+
- `tenant_id` (String, Sensitive) The tenant ID of the Azure AD directory. Stored as a sensitive value — the API never returns it.
54+
55+
### Optional
56+
57+
- `azure_service_authentication_method` (String) The method used for service authentication. One of: ~~~service_user~~~, ~~~service_principal~~~. Defaults to ~~~service_user~~~.
58+
59+
### Read-Only
60+
61+
- `account_id` (Number) The ID of the dbt Cloud account.
62+
- `created_at` (String) Timestamp when the application was created.
63+
- `id` (Number) The ID of the Azure AD application.
64+
- `oauth_redirect_uri_domain` (String) The domain used for the OAuth redirect URI. Set automatically by dbt Cloud based on the account's subdomain.
65+
- `updated_at` (String) Timestamp when the application was last updated.
66+
67+
## Import
68+
69+
Import is supported using the following syntax:
70+
71+
```shell
72+
# using import blocks (requires Terraform >= 1.5)
73+
import {
74+
to = dbtcloud_azure_ad_application.this
75+
id = "azure_ad_application_id"
76+
}
77+
78+
import {
79+
to = dbtcloud_azure_ad_application.this
80+
id = "12345"
81+
}
82+
83+
# using the older import command
84+
terraform import dbtcloud_azure_ad_application.this azure_ad_application_id
85+
terraform import dbtcloud_azure_ad_application.this 12345
86+
87+
# NOTE: client_id, client_secret, and tenant_id will be empty after import —
88+
# the API never returns these values. You must set them in your config to
89+
# avoid drift on the next apply.
90+
#
91+
# Import is also the recovery path if destroy left an orphaned record in dbt
92+
# Cloud (the DELETE endpoint soft-deletes the row rather than removing it).
93+
# Find the existing record ID and import it instead of creating a new one.
94+
```
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
terraform {
2+
required_providers {
3+
dbtcloud = {
4+
source = "dbt-labs/dbtcloud"
5+
version = ">= 0.3"
6+
}
7+
}
8+
}
9+
10+
provider "dbtcloud" {
11+
account_id = 1234 # replace with your account ID
12+
token = "xxxx" # replace with your API token
13+
host_url = "https://dbt.com/api" # replace with your dbt Cloud host URL if different
14+
}
15+
16+
# ── Case 1: Create with service_user auth (default) ──────────────────────────
17+
# Apply this first. Verify in the dbt Cloud UI under Account Settings > Integrations
18+
# that the Azure AD application appears with the correct org name.
19+
resource "dbtcloud_azure_ad_application" "test" {
20+
organization_name = "xxxx"
21+
client_id = "xxxx"
22+
client_secret = "xxxx"
23+
tenant_id = "xxxx"
24+
25+
azure_service_authentication_method = "service_user"
26+
}
27+
28+
output "azure_ad_application_id" {
29+
value = dbtcloud_azure_ad_application.test.id
30+
}
31+
32+
output "oauth_redirect_uri_domain" {
33+
description = "The OAuth redirect URI domain set by the API (computed)"
34+
value = dbtcloud_azure_ad_application.test.oauth_redirect_uri_domain
35+
}
36+
37+
# ── Case 2: Switch auth method to service_principal ───────────────────────────
38+
# Comment out Case 1 above and uncomment this block, then re-apply.
39+
# Verify the auth method changes in the UI without recreating the resource.
40+
# resource "dbtcloud_azure_ad_application" "test" {
41+
# organization_name = "xxxx"
42+
# client_id = "xxxx"
43+
# client_secret = "xxxx"
44+
# tenant_id = "xxxx"
45+
#
46+
# azure_service_authentication_method = "service_principal"
47+
# }
48+
49+
# ── Case 3: Rotate the client_secret ─────────────────────────────────────────
50+
# Change client_secret to a new value and re-apply.
51+
# The API requires all four fields (org, client_id, secret, tenant_id) on every
52+
# update — they are stored in state as sensitive so they can be resent.
53+
# resource "dbtcloud_azure_ad_application" "test" {
54+
# organization_name = "my-azure-devops-org"
55+
# client_id = "00000000-0000-0000-0000-000000000000"
56+
# client_secret = "my-NEW-client-secret" # rotated
57+
# tenant_id = "00000000-0000-0000-0000-000000000001"
58+
#
59+
# azure_service_authentication_method = "service_user"
60+
# }
61+
62+
# ── Case 4: Destroy ────────────────────────────────────────────────────────────
63+
# Run: terraform destroy
64+
# Verify the Azure AD application is removed from Account Settings > Integrations.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# using import blocks (requires Terraform >= 1.5)
2+
import {
3+
to = dbtcloud_azure_ad_application.this
4+
id = "azure_ad_application_id"
5+
}
6+
7+
import {
8+
to = dbtcloud_azure_ad_application.this
9+
id = "12345"
10+
}
11+
12+
# using the older import command
13+
terraform import dbtcloud_azure_ad_application.this azure_ad_application_id
14+
terraform import dbtcloud_azure_ad_application.this 12345
15+
16+
# NOTE: client_id, client_secret, and tenant_id will be empty after import —
17+
# the API never returns these values. You must set them in your config to
18+
# avoid drift on the next apply.
19+
#
20+
# Import is also the recovery path if destroy left an orphaned record in dbt
21+
# Cloud (the DELETE endpoint soft-deletes the row rather than removing it).
22+
# Find the existing record ID and import it instead of creating a new one.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
resource "dbtcloud_azure_ad_application" "this" {
2+
organization_name = "my-azure-devops-org"
3+
client_id = "00000000-0000-0000-0000-000000000000"
4+
client_secret = var.azure_client_secret
5+
tenant_id = "00000000-0000-0000-0000-000000000001"
6+
7+
# Optional: defaults to "service_user". Set to "service_principal" to use
8+
# service principal authentication instead.
9+
azure_service_authentication_method = "service_user"
10+
}
11+
12+
# NOTE: destroying this resource calls the dbt Cloud DELETE endpoint, which
13+
# marks the record as inactive but does not remove the underlying database row.
14+
# Re-creating the resource against the same account after a destroy will fail
15+
# with a unique-constraint error. To recover, ask dbt Cloud support to remove
16+
# the orphaned record, or use `terraform import` to re-adopt it:
17+
#
18+
# terraform import dbtcloud_azure_ad_application.this <id>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package dbt_cloud
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
type AzureADApplication struct {
12+
ID *int64 `json:"id,omitempty"`
13+
AccountID int64 `json:"account_id,omitempty"`
14+
OrganizationName string `json:"organization_name"`
15+
ClientID *string `json:"client_id,omitempty"`
16+
ClientSecret *string `json:"client_secret,omitempty"`
17+
TenantID *string `json:"tenant_id,omitempty"`
18+
AzureServiceAuthenticationMethod string `json:"azure_service_authentication_method,omitempty"`
19+
OAuthRedirectURIDomain *string `json:"oauth_redirect_uri_domain,omitempty"`
20+
CreatedAt *string `json:"created_at,omitempty"`
21+
UpdatedAt *string `json:"updated_at,omitempty"`
22+
}
23+
24+
type AzureADApplicationResponse struct {
25+
Data AzureADApplication `json:"data"`
26+
Status ResponseStatus `json:"status"`
27+
}
28+
29+
type AzureADApplicationListResponse struct {
30+
Data []AzureADApplication `json:"data"`
31+
Status ResponseStatus `json:"status"`
32+
}
33+
34+
// GetAzureADApplicationForAccount fetches the single Azure AD application for
35+
// this account via the list endpoint. Returns nil if none exists yet.
36+
func (c *Client) GetAzureADApplicationForAccount() (*AzureADApplication, error) {
37+
req, err := http.NewRequest(
38+
"GET",
39+
fmt.Sprintf(
40+
"%s/v3/accounts/%s/azure-ad-applications/",
41+
c.HostURL,
42+
strconv.FormatInt(c.AccountID, 10),
43+
),
44+
nil,
45+
)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
body, err := c.doRequestWithRetry(req)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
listResp := AzureADApplicationListResponse{}
56+
if err = json.Unmarshal(body, &listResp); err != nil {
57+
return nil, err
58+
}
59+
60+
if len(listResp.Data) == 0 {
61+
return nil, nil
62+
}
63+
return &listResp.Data[0], nil
64+
}
65+
66+
func (c *Client) GetAzureADApplication(applicationID int64) (*AzureADApplication, error) {
67+
req, err := http.NewRequest(
68+
"GET",
69+
fmt.Sprintf(
70+
"%s/v3/accounts/%s/azure-ad-applications/%d/",
71+
c.HostURL,
72+
strconv.FormatInt(c.AccountID, 10),
73+
applicationID,
74+
),
75+
nil,
76+
)
77+
if err != nil {
78+
return nil, err
79+
}
80+
81+
body, err := c.doRequestWithRetry(req)
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
resp := AzureADApplicationResponse{}
87+
if err = json.Unmarshal(body, &resp); err != nil {
88+
return nil, err
89+
}
90+
91+
return &resp.Data, nil
92+
}
93+
94+
func (c *Client) CreateAzureADApplication(app AzureADApplication) (*AzureADApplication, error) {
95+
payload, err := json.Marshal(app)
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
req, err := http.NewRequest(
101+
"POST",
102+
fmt.Sprintf(
103+
"%s/v3/accounts/%s/azure-ad-applications/",
104+
c.HostURL,
105+
strconv.FormatInt(c.AccountID, 10),
106+
),
107+
strings.NewReader(string(payload)),
108+
)
109+
if err != nil {
110+
return nil, err
111+
}
112+
113+
body, err := c.doRequestWithRetry(req)
114+
if err != nil {
115+
return nil, err
116+
}
117+
118+
resp := AzureADApplicationResponse{}
119+
if err = json.Unmarshal(body, &resp); err != nil {
120+
return nil, err
121+
}
122+
123+
return &resp.Data, nil
124+
}
125+
126+
func (c *Client) UpdateAzureADApplication(applicationID int64, app AzureADApplication) (*AzureADApplication, error) {
127+
// account_id is passed via the URL path; zero it out to avoid API rejection.
128+
app.AccountID = 0
129+
130+
payload, err := json.Marshal(app)
131+
if err != nil {
132+
return nil, err
133+
}
134+
135+
req, err := http.NewRequest(
136+
"POST",
137+
fmt.Sprintf(
138+
"%s/v3/accounts/%s/azure-ad-applications/%d/",
139+
c.HostURL,
140+
strconv.FormatInt(c.AccountID, 10),
141+
applicationID,
142+
),
143+
strings.NewReader(string(payload)),
144+
)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
body, err := c.doRequestWithRetry(req)
150+
if err != nil {
151+
return nil, err
152+
}
153+
154+
resp := AzureADApplicationResponse{}
155+
if err = json.Unmarshal(body, &resp); err != nil {
156+
return nil, err
157+
}
158+
159+
return &resp.Data, nil
160+
}
161+
162+
func (c *Client) DeleteAzureADApplication(applicationID int64) error {
163+
req, err := http.NewRequest(
164+
"DELETE",
165+
fmt.Sprintf(
166+
"%s/v3/accounts/%s/azure-ad-applications/%d/",
167+
c.HostURL,
168+
strconv.FormatInt(c.AccountID, 10),
169+
applicationID,
170+
),
171+
nil,
172+
)
173+
if err != nil {
174+
return err
175+
}
176+
177+
_, err = c.doRequestWithRetry(req)
178+
return err
179+
}

0 commit comments

Comments
 (0)