Skip to content

Commit e754a42

Browse files
authored
Add SCIM Config resource (#2179)
* add SCIM config * add README * add newline * clarify that resource ID should always be default * use legacy implementation to hardcode scim app platform endpoints * merge create/update funcs, correct path & namespace * go generate docs * generate schema * update URL construction & clean up test cases
1 parent 16230c5 commit e754a42

File tree

7 files changed

+406
-0
lines changed

7 files changed

+406
-0
lines changed

docs/resources/scim_config.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "grafana_scim_config Resource - terraform-provider-grafana"
4+
subcategory: "Grafana Enterprise"
5+
description: |-
6+
Note: This resource is available only with Grafana Enterprise.
7+
Official documentation https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-scim-provisioning/
8+
---
9+
10+
# grafana_scim_config (Resource)
11+
12+
**Note:** This resource is available only with Grafana Enterprise.
13+
14+
* [Official documentation](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-scim-provisioning/)
15+
16+
17+
18+
<!-- schema generated by tfplugindocs -->
19+
## Schema
20+
21+
### Required
22+
23+
- `enable_group_sync` (Boolean) Whether group synchronization is enabled.
24+
- `enable_user_sync` (Boolean) Whether user synchronization is enabled.
25+
26+
### Optional
27+
28+
- `org_id` (String) The Organization ID. If not set, the Org ID defined in the provider block will be used.
29+
30+
### Read-Only
31+
32+
- `id` (String) The ID of this resource.
33+
34+
## Import
35+
36+
Import is supported using the following syntax:
37+
38+
```shell
39+
terraform import grafana_scim_config.name ""
40+
terraform import grafana_scim_config.name "{{ orgID }}"
41+
```
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
terraform import grafana_scim_config.name ""
2+
terraform import grafana_scim_config.name "{{ orgID }}"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
resource "grafana_scim_config" "default" {
2+
enable_user_sync = true
3+
enable_group_sync = false
4+
}

internal/resources/grafana/catalog-resource.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,19 @@ spec:
430430
---
431431
apiVersion: backstage.io/v1alpha1
432432
kind: Component
433+
metadata:
434+
name: resource-grafana_scim_config
435+
title: grafana_scim_config (resource)
436+
description: |
437+
resource `grafana_scim_config` in Grafana Labs' Terraform Provider
438+
spec:
439+
subcomponentOf: component:default/terraform-provider-grafana
440+
type: terraform-resource
441+
owner: group:default/identity-squad
442+
lifecycle: production
443+
---
444+
apiVersion: backstage.io/v1alpha1
445+
kind: Component
433446
metadata:
434447
name: resource-grafana_user
435448
title: grafana_user (resource)
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package grafana
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"net/url"
10+
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
12+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
13+
14+
"github.com/grafana/terraform-provider-grafana/v3/internal/common"
15+
)
16+
17+
// SCIMConfig represents the SCIM configuration structure
18+
type SCIMConfig struct {
19+
APIVersion string `json:"apiVersion"`
20+
Kind string `json:"kind"`
21+
Metadata SCIMConfigMetadata `json:"metadata"`
22+
Spec SCIMConfigSpec `json:"spec"`
23+
}
24+
25+
// SCIMConfigMetadata represents the metadata for SCIM config
26+
type SCIMConfigMetadata struct {
27+
Name string `json:"name"`
28+
Namespace string `json:"namespace"`
29+
}
30+
31+
// SCIMConfigSpec represents the SCIM configuration specification
32+
type SCIMConfigSpec struct {
33+
EnableUserSync bool `json:"enableUserSync"`
34+
EnableGroupSync bool `json:"enableGroupSync"`
35+
}
36+
37+
func resourceSCIMConfig() *common.Resource {
38+
schema := &schema.Resource{
39+
Description: `
40+
**Note:** This resource is available only with Grafana Enterprise.
41+
42+
* [Official documentation](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-scim-provisioning/)
43+
`,
44+
CreateContext: CreateOrUpdateSCIMConfig,
45+
UpdateContext: CreateOrUpdateSCIMConfig,
46+
ReadContext: ReadSCIMConfig,
47+
DeleteContext: DeleteSCIMConfig,
48+
Importer: &schema.ResourceImporter{
49+
StateContext: schema.ImportStatePassthroughContext,
50+
},
51+
Schema: map[string]*schema.Schema{
52+
"org_id": orgIDAttribute(),
53+
"enable_user_sync": {
54+
Type: schema.TypeBool,
55+
Required: true,
56+
Description: "Whether user synchronization is enabled.",
57+
},
58+
"enable_group_sync": {
59+
Type: schema.TypeBool,
60+
Required: true,
61+
Description: "Whether group synchronization is enabled.",
62+
},
63+
},
64+
}
65+
66+
return common.NewLegacySDKResource(
67+
common.CategoryGrafanaEnterprise,
68+
"grafana_scim_config",
69+
common.NewResourceID(common.OptionalIntIDField("orgID")),
70+
schema,
71+
)
72+
}
73+
74+
func CreateOrUpdateSCIMConfig(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
75+
_, orgID := OAPIClientFromNewOrgResource(meta, d)
76+
77+
// Get the transport configuration to access HTTP client and API key
78+
metaClient := meta.(*common.Client)
79+
transportConfig := metaClient.GrafanaAPIConfig
80+
if transportConfig == nil {
81+
return diag.Errorf("transport configuration not available")
82+
}
83+
84+
// Determine namespace based on whether this is on-prem or cloud
85+
var namespace string
86+
switch {
87+
case metaClient.GrafanaOrgID > 0:
88+
// On-prem Grafana instance - use "default" namespace
89+
namespace = "default"
90+
case metaClient.GrafanaStackID > 0:
91+
// Grafana Cloud instance - use "stacks-{stackId}" namespace
92+
namespace = fmt.Sprintf("stacks-%d", metaClient.GrafanaStackID)
93+
default:
94+
return diag.Errorf("expected either Grafana org ID (for local Grafana) or Grafana stack ID (for Grafana Cloud) to be set")
95+
}
96+
97+
// Create or update SCIM config
98+
scimConfig := SCIMConfig{
99+
APIVersion: "scim.grafana.app/v0alpha1",
100+
Kind: "SCIMConfig",
101+
Metadata: SCIMConfigMetadata{
102+
Name: "default",
103+
Namespace: namespace,
104+
},
105+
Spec: SCIMConfigSpec{
106+
EnableUserSync: d.Get("enable_user_sync").(bool),
107+
EnableGroupSync: d.Get("enable_group_sync").(bool),
108+
},
109+
}
110+
111+
jsonData, err := json.Marshal(scimConfig)
112+
if err != nil {
113+
return diag.FromErr(fmt.Errorf("failed to marshal SCIM config: %w", err))
114+
}
115+
116+
apiPath, err := url.JoinPath(transportConfig.BasePath, "apis/scim.grafana.app/v0alpha1/namespaces", namespace, "config/default")
117+
if err != nil {
118+
return diag.FromErr(fmt.Errorf("failed to construct API path: %w", err))
119+
}
120+
requestURL := fmt.Sprintf("%s://%s/%s",
121+
transportConfig.Schemes[0], transportConfig.Host, apiPath)
122+
123+
req, err := http.NewRequestWithContext(ctx, "PUT", requestURL, bytes.NewBuffer(jsonData))
124+
if err != nil {
125+
return diag.FromErr(fmt.Errorf("failed to create request: %w", err))
126+
}
127+
128+
req.Header.Set("Content-Type", "application/json")
129+
req.Header.Set("Authorization", "Bearer "+transportConfig.APIKey)
130+
131+
// Use the HTTP client from the transport configuration
132+
httpClient := &http.Client{}
133+
resp, err := httpClient.Do(req)
134+
if err != nil {
135+
return diag.FromErr(fmt.Errorf("failed to create or update SCIM config: %w", err))
136+
}
137+
defer resp.Body.Close()
138+
139+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
140+
return diag.FromErr(fmt.Errorf("failed to create or update SCIM config, status: %d", resp.StatusCode))
141+
}
142+
143+
// Set ID if this is a create operation (ID is empty)
144+
if d.Id() == "" {
145+
d.SetId(MakeOrgResourceID(orgID, "scim-config"))
146+
}
147+
148+
return ReadSCIMConfig(ctx, d, meta)
149+
}
150+
151+
func ReadSCIMConfig(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
152+
// Get the transport configuration to access HTTP client and API key
153+
metaClient := meta.(*common.Client)
154+
transportConfig := metaClient.GrafanaAPIConfig
155+
if transportConfig == nil {
156+
return diag.Errorf("transport configuration not available")
157+
}
158+
159+
// Determine namespace based on whether this is on-prem or cloud
160+
var namespace string
161+
switch {
162+
case metaClient.GrafanaOrgID > 0:
163+
// On-prem Grafana instance - use "default" namespace
164+
namespace = "default"
165+
case metaClient.GrafanaStackID > 0:
166+
// Grafana Cloud instance - use "stacks-{stackId}" namespace
167+
namespace = fmt.Sprintf("stacks-%d", metaClient.GrafanaStackID)
168+
default:
169+
return diag.Errorf("expected either Grafana org ID (for local Grafana) or Grafana stack ID (for Grafana Cloud) to be set")
170+
}
171+
172+
// Read SCIM config
173+
apiPath, err := url.JoinPath(transportConfig.BasePath, "apis/scim.grafana.app/v0alpha1/namespaces", namespace, "config/default")
174+
if err != nil {
175+
return diag.FromErr(fmt.Errorf("failed to construct API path: %w", err))
176+
}
177+
requestURL := fmt.Sprintf("%s://%s/%s",
178+
transportConfig.Schemes[0], transportConfig.Host, apiPath)
179+
180+
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
181+
if err != nil {
182+
return diag.FromErr(fmt.Errorf("failed to create request: %w", err))
183+
}
184+
185+
req.Header.Set("Authorization", "Bearer "+transportConfig.APIKey)
186+
187+
// Use the HTTP client from the transport configuration
188+
httpClient := &http.Client{}
189+
resp, err := httpClient.Do(req)
190+
if err != nil {
191+
return diag.FromErr(fmt.Errorf("failed to read SCIM config: %w", err))
192+
}
193+
defer resp.Body.Close()
194+
195+
if resp.StatusCode == http.StatusNotFound {
196+
d.SetId("")
197+
return nil
198+
}
199+
200+
if resp.StatusCode != http.StatusOK {
201+
return diag.FromErr(fmt.Errorf("failed to read SCIM config, status: %d", resp.StatusCode))
202+
}
203+
204+
var scimConfig SCIMConfig
205+
if err := json.NewDecoder(resp.Body).Decode(&scimConfig); err != nil {
206+
return diag.FromErr(fmt.Errorf("failed to decode SCIM config: %w", err))
207+
}
208+
209+
err = d.Set("enable_user_sync", scimConfig.Spec.EnableUserSync)
210+
if err != nil {
211+
return diag.FromErr(err)
212+
}
213+
err = d.Set("enable_group_sync", scimConfig.Spec.EnableGroupSync)
214+
if err != nil {
215+
return diag.FromErr(err)
216+
}
217+
218+
return nil
219+
}
220+
221+
func DeleteSCIMConfig(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
222+
// Get the transport configuration to access HTTP client and API key
223+
metaClient := meta.(*common.Client)
224+
transportConfig := metaClient.GrafanaAPIConfig
225+
if transportConfig == nil {
226+
return diag.Errorf("transport configuration not available")
227+
}
228+
229+
// Determine namespace based on whether this is on-prem or cloud
230+
var namespace string
231+
switch {
232+
case metaClient.GrafanaOrgID > 0:
233+
// On-prem Grafana instance - use "default" namespace
234+
namespace = "default"
235+
case metaClient.GrafanaStackID > 0:
236+
// Grafana Cloud instance - use "stacks-{stackId}" namespace
237+
namespace = fmt.Sprintf("stacks-%d", metaClient.GrafanaStackID)
238+
default:
239+
return diag.Errorf("expected either Grafana org ID (for local Grafana) or Grafana stack ID (for Grafana Cloud) to be set")
240+
}
241+
242+
// Delete SCIM config
243+
apiPath, err := url.JoinPath(transportConfig.BasePath, "apis/scim.grafana.app/v0alpha1/namespaces", namespace, "config/default")
244+
if err != nil {
245+
return diag.FromErr(fmt.Errorf("failed to construct API path: %w", err))
246+
}
247+
requestURL := fmt.Sprintf("%s://%s/%s",
248+
transportConfig.Schemes[0], transportConfig.Host, apiPath)
249+
250+
req, err := http.NewRequestWithContext(ctx, "DELETE", requestURL, nil)
251+
if err != nil {
252+
return diag.FromErr(fmt.Errorf("failed to create request: %w", err))
253+
}
254+
255+
req.Header.Set("Authorization", "Bearer "+transportConfig.APIKey)
256+
257+
// Use the HTTP client from the transport configuration
258+
httpClient := &http.Client{}
259+
resp, err := httpClient.Do(req)
260+
if err != nil {
261+
return diag.FromErr(fmt.Errorf("failed to delete SCIM config: %w", err))
262+
}
263+
defer resp.Body.Close()
264+
265+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
266+
return diag.FromErr(fmt.Errorf("failed to delete SCIM config, status: %d", resp.StatusCode))
267+
}
268+
269+
return nil
270+
}

0 commit comments

Comments
 (0)