From ecb55220169efe1430b544e840c4883151164fa6 Mon Sep 17 00:00:00 2001 From: Robert Harris Date: Thu, 1 Feb 2024 09:17:58 -0800 Subject: [PATCH 1/3] Add resources for managing users in groups --- docs/data-sources/user.md | 30 ++++++ docs/resources/group_member.md | 48 ++++++++++ doppler/api.go | 54 +++++++++++ doppler/data_source_user.go | 43 +++++++++ doppler/models.go | 30 +++++- doppler/provider.go | 6 +- doppler/resource_group_member.go | 111 +++++++++++++++++++++++ doppler/resource_group_member_types.go | 27 ++++++ examples/data-sources/user.tf | 3 + examples/resources/group_member.tf | 18 ++++ templates/data-sources/user.md.tmpl | 16 ++++ templates/resources/group_member.md.tmpl | 20 ++++ 12 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 docs/data-sources/user.md create mode 100644 docs/resources/group_member.md create mode 100644 doppler/data_source_user.go create mode 100644 doppler/resource_group_member.go create mode 100644 doppler/resource_group_member_types.go create mode 100644 examples/data-sources/user.tf create mode 100644 examples/resources/group_member.tf create mode 100644 templates/data-sources/user.md.tmpl create mode 100644 templates/resources/group_member.md.tmpl diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md new file mode 100644 index 0000000..5cb4791 --- /dev/null +++ b/docs/data-sources/user.md @@ -0,0 +1,30 @@ +--- +page_title: "doppler_user Data Source - terraform-provider-doppler" +subcategory: "User" +description: |- + Retrieve a Doppler user +--- + +# doppler_user (Data Source) + +Retrieve all secrets in the config. + +## Example Usage + +```terraform +data "doppler_user" "nic" { + email = "nic@doppler.com" +} +``` + + +## Schema + +### Required + +- `email` (String) The email address of the Doppler user + +### Read-Only + +- `id` (String) The ID of this resource. +- `slug` (String) The slug of the Doppler user diff --git a/docs/resources/group_member.md b/docs/resources/group_member.md new file mode 100644 index 0000000..51dfea5 --- /dev/null +++ b/docs/resources/group_member.md @@ -0,0 +1,48 @@ +--- +page_title: "doppler_group_member Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler user/group membership. +--- + +# doppler_group_member (Resource) + +Manage a Doppler user/group membership. + +## Example Usage + +```terraform +resource "doppler_group" "engineering" { + name = "engineering" +} + +data "doppler_user" "nic" { + email = "nic@doppler.com" +} + +data "doppler_user" "andre" { + email = "andre@doppler.com" +} + +resource "doppler_group_member" "engineering" { + for_each = toset([data.doppler_user.nic.slug, data.doppler_user.andre.slug]) + group_slug = doppler_group.engineering.slug + user_slug = each.value +} +``` + + +## Schema + +### Required + +- `group_slug` (String) The slug of the Doppler group +- `user_slug` (String) The slug of the Doppler workplace user + +### Read-Only + +- `id` (String) The ID of this resource. + +## Resource ID Format + +Resource IDs are in the format `.workplace_user.`. diff --git a/doppler/api.go b/doppler/api.go index ad1de75..5535422 100644 --- a/doppler/api.go +++ b/doppler/api.go @@ -907,3 +907,57 @@ func (client APIClient) DeleteGroup(ctx context.Context, slug string) error { } return nil } + +// Group Members + +func (client APIClient) CreateGroupMember(ctx context.Context, group string, memberType string, memberSlug string) error { + payload := map[string]interface{}{ + "type": memberType, + "slug": memberSlug, + } + body, err := json.Marshal(payload) + if err != nil { + return &APIError{Err: err, Message: "Unable to serialize group member"} + } + _, err = client.PerformRequestWithRetry(ctx, "POST", fmt.Sprintf("/v3/workplace/groups/group/%s/members", url.QueryEscape(group)), []QueryParam{}, body) + if err != nil { + return err + } + return nil +} + +func (client APIClient) GetGroupMember(ctx context.Context, group string, memberType string, memberSlug string) error { + _, err := client.PerformRequestWithRetry(ctx, "GET", fmt.Sprintf("/v3/workplace/groups/group/%s/members/%s/%s", url.QueryEscape(group), url.QueryEscape(memberType), url.QueryEscape(memberSlug)), []QueryParam{}, nil) + return err +} + +func (client APIClient) DeleteGroupMember(ctx context.Context, group string, memberType string, memberSlug string) error { + _, err := client.PerformRequestWithRetry(ctx, "DELETE", fmt.Sprintf("/v3/workplace/groups/group/%s/members/%s/%s", url.QueryEscape(group), url.QueryEscape(memberType), url.QueryEscape(memberSlug)), []QueryParam{}, nil) + if err != nil { + return err + } + return nil +} + +// Workplace Users + +func (client APIClient) GetWorkplaceUser(ctx context.Context, email string) (*WorkplaceUser, error) { + params := []QueryParam{ + {Key: "email", Value: email}, + } + response, err := client.PerformRequestWithRetry(ctx, "GET", "/v3/workplace/users", params, nil) + if err != nil { + return nil, err + } + var result WorkplaceUsersListResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse workplace user"} + } + if len(result.WorkplaceUsers) > 1 { + return nil, &APIError{Err: err, Message: "Multiple workplace users returned"} + } + if len(result.WorkplaceUsers) == 0 { + return nil, &CustomNotFoundError{Message: "Could not find requested workplace user"} + } + return &result.WorkplaceUsers[0], nil +} diff --git a/doppler/data_source_user.go b/doppler/data_source_user.go new file mode 100644 index 0000000..46802e3 --- /dev/null +++ b/doppler/data_source_user.go @@ -0,0 +1,43 @@ +package doppler + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client := m.(APIClient) + + result, err := client.GetWorkplaceUser(ctx, d.Get("email").(string)) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(result.Slug) + if err := d.Set("slug", result.Slug); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func dataSourceUser() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceUserRead, + Schema: map[string]*schema.Schema{ + "slug": { + Description: "The slug of the Doppler user", + Type: schema.TypeString, + Computed: true, + }, + "email": { + Description: "The email address of the Doppler user", + Type: schema.TypeString, + Required: true, + }, + }, + } +} diff --git a/doppler/models.go b/doppler/models.go index d62a686..8190c2a 100644 --- a/doppler/models.go +++ b/doppler/models.go @@ -218,9 +218,9 @@ type WorkplaceRole struct { } type ServiceAccount struct { - Slug string `json:"slug"` - Name string `json:"name"` - CreatedAt string `json:"created_at"` + Slug string `json:"slug"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` WorkplaceRole WorkplaceRole `json:"workplace_role"` } @@ -242,3 +242,27 @@ type Group struct { type GroupResponse struct { Group Group `json:"group"` } + +type GroupIsMemberResponse struct { + IsMember bool `json:"isMember"` +} + +type WorkplaceUser struct { + Slug string `json:"id"` +} + +type WorkplaceUsersListResponse struct { + WorkplaceUsers []WorkplaceUser `json:"workplace_users"` +} + +func getGroupMemberId(group string, memberType string, memberSlug string) string { + return strings.Join([]string{group, memberType, memberSlug}, ".") +} + +func parseGroupMemberId(id string) (group string, memberType string, memberSlug string, err error) { + tokens := strings.Split(id, ".") + if len(tokens) != 3 { + return "", "", "", errors.New("invalid group member ID") + } + return tokens[0], tokens[1], tokens[2], nil +} diff --git a/doppler/provider.go b/doppler/provider.go index 7eb8d40..9cdfd65 100644 --- a/doppler/provider.go +++ b/doppler/provider.go @@ -40,7 +40,8 @@ func Provider() *schema.Provider { "doppler_service_account": resourceServiceAccount(), - "doppler_group": resourceGroup(), + "doppler_group": resourceGroup(), + "doppler_group_member": resourceGroupMemberWorkplaceUser(), "doppler_project_member_group": resourceProjectMemberGroup(), "doppler_project_member_service_account": resourceProjectMemberServiceAccount(), @@ -51,11 +52,12 @@ func Provider() *schema.Provider { "doppler_integration_aws_parameter_store": resourceIntegrationAWSAssumeRoleIntegration("aws_parameter_store"), "doppler_secrets_sync_aws_parameter_store": resourceSyncAWSParameterStore(), - "doppler_integration_terraform_cloud": resourceIntegrationTerraformCloud(), + "doppler_integration_terraform_cloud": resourceIntegrationTerraformCloud(), "doppler_secrets_sync_terraform_cloud": resourceSyncTerraformCloud(), }, DataSourcesMap: map[string]*schema.Resource{ "doppler_secrets": dataSourceSecrets(), + "doppler_user": dataSourceUser(), }, ConfigureContextFunc: providerConfigure, } diff --git a/doppler/resource_group_member.go b/doppler/resource_group_member.go new file mode 100644 index 0000000..980981c --- /dev/null +++ b/doppler/resource_group_member.go @@ -0,0 +1,111 @@ +package doppler + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +type ResourceGroupMemberGetMemberSlugFunc = func(ctx context.Context, d *schema.ResourceData, m interface{}) (*string, error) + +// Builds a member-type-specific resource schema using the configuration params. +type ResourceGroupMemberBuilder struct { + // The Doppler member type + MemberType string + + // Any additional schema fields for the resource + DataSchema map[string]*schema.Schema + + // A function which uses the resource data to return a member slug + GetMemberSlugFunc ResourceGroupMemberGetMemberSlugFunc +} + +func (builder ResourceGroupMemberBuilder) Build() *schema.Resource { + resourceSchema := map[string]*schema.Schema{ + "group_slug": { + Description: "The slug of the Doppler group", + Type: schema.TypeString, + Required: true, + // Members cannot be moved directly from one group to another, they must be re-created + ForceNew: true, + }, + } + + for name, subschema := range builder.DataSchema { + s := *subschema + resourceSchema[name] = &s + } + + return &schema.Resource{ + CreateContext: builder.CreateContextFunc(), + ReadContext: builder.ReadContextFunc(), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + DeleteContext: builder.DeleteContextFunc(), + Schema: resourceSchema, + } +} + +func (builder ResourceGroupMemberBuilder) CreateContextFunc() schema.CreateContextFunc { + return func(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + group := d.Get("group_slug").(string) + + memberSlug, err := builder.GetMemberSlugFunc(ctx, d, m) + if err != nil { + return diag.FromErr(err) + } + + err = client.CreateGroupMember(ctx, group, builder.MemberType, *memberSlug) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(getGroupMemberId(group, builder.MemberType, *memberSlug)) + + return diags + } +} + +func (builder ResourceGroupMemberBuilder) ReadContextFunc() schema.ReadContextFunc { + return func(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + + group, memberType, memberSlug, err := parseGroupMemberId(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + err = client.GetGroupMember(ctx, group, memberType, memberSlug) + if err != nil { + return handleNotFoundError(err, d) + } + + return diags + } +} + +func (builder ResourceGroupMemberBuilder) DeleteContextFunc() schema.DeleteContextFunc { + return func(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + + group, memberType, memberSlug, err := parseGroupMemberId(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + if err := client.DeleteGroupMember(ctx, group, memberType, memberSlug); err != nil { + return diag.FromErr(err) + } + + return diags + } +} diff --git a/doppler/resource_group_member_types.go b/doppler/resource_group_member_types.go new file mode 100644 index 0000000..54693fb --- /dev/null +++ b/doppler/resource_group_member_types.go @@ -0,0 +1,27 @@ +package doppler + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGroupMemberWorkplaceUser() *schema.Resource { + builder := ResourceGroupMemberBuilder{ + MemberType: "workplace_user", + DataSchema: map[string]*schema.Schema{ + "user_slug": { + Description: "The slug of the Doppler workplace user", + Type: schema.TypeString, + Required: true, + // Members cannot be moved directly from one group to another, they must be re-created + ForceNew: true, + }, + }, + GetMemberSlugFunc: func(ctx context.Context, d *schema.ResourceData, m interface{}) (*string, error) { + workplaceUserSlug := d.Get("user_slug").(string) + return &workplaceUserSlug, nil + }, + } + return builder.Build() +} diff --git a/examples/data-sources/user.tf b/examples/data-sources/user.tf new file mode 100644 index 0000000..f5c0888 --- /dev/null +++ b/examples/data-sources/user.tf @@ -0,0 +1,3 @@ +data "doppler_user" "nic" { + email = "nic@doppler.com" +} diff --git a/examples/resources/group_member.tf b/examples/resources/group_member.tf new file mode 100644 index 0000000..b6cf9ac --- /dev/null +++ b/examples/resources/group_member.tf @@ -0,0 +1,18 @@ +resource "doppler_group" "engineering" { + name = "engineering" +} + +data "doppler_user" "nic" { + email = "nic@doppler.com" +} + +data "doppler_user" "andre" { + email = "andre@doppler.com" +} + +resource "doppler_group_member" "engineering" { + for_each = toset([data.doppler_user.nic.slug, data.doppler_user.andre.slug]) + group_slug = doppler_group.engineering.slug + user_slug = each.value +} + diff --git a/templates/data-sources/user.md.tmpl b/templates/data-sources/user.md.tmpl new file mode 100644 index 0000000..bf55a8c --- /dev/null +++ b/templates/data-sources/user.md.tmpl @@ -0,0 +1,16 @@ +--- +page_title: "doppler_user Data Source - terraform-provider-doppler" +subcategory: "User" +description: |- + Retrieve a Doppler user +--- + +# doppler_user (Data Source) + +Retrieve all secrets in the config. + +## Example Usage + +{{tffile "examples/data-sources/user.tf"}} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/group_member.md.tmpl b/templates/resources/group_member.md.tmpl new file mode 100644 index 0000000..5b060d7 --- /dev/null +++ b/templates/resources/group_member.md.tmpl @@ -0,0 +1,20 @@ +--- +page_title: "doppler_group_member Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler user/group membership. +--- + +# doppler_group_member (Resource) + +Manage a Doppler user/group membership. + +## Example Usage + +{{tffile "examples/resources/group_member.tf"}} + +{{ .SchemaMarkdown | trimspace }} + +## Resource ID Format + +Resource IDs are in the format `.workplace_user.`. From c59588b73c9daf0f91af8d99ca64affbc401f1c0 Mon Sep 17 00:00:00 2001 From: Nic Manoogian Date: Wed, 7 Feb 2024 10:52:07 -0500 Subject: [PATCH 2/3] Fix secrets data source docs and regenerate --- docs/data-sources/secrets.md | 8 +++---- docs/index.md | 1 - examples/data-sources/secrets.tf | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/data-sources/secrets.md b/docs/data-sources/secrets.md index da1266f..b995213 100644 --- a/docs/data-sources/secrets.md +++ b/docs/data-sources/secrets.md @@ -11,9 +11,9 @@ Retrieve all secrets in the config. ## Example Usage -Basic usage: - ```terraform +### Basic Usage + data "doppler_secrets" "this" {} # Access individual secrets @@ -32,11 +32,9 @@ output "max_workers" { output "json_parsing_values" { value = nonsensitive(jsondecode(data.doppler_secrets.this.map.FEATURE_FLAGS)["TOP_SPEED"]) } -``` -Referencing secrets from multiple projects: +### Referencing secrets from multiple projects -```terraform variable "doppler_token_dev" { type = string description = "A token to authenticate with Doppler for the dev config" diff --git a/docs/index.md b/docs/index.md index 9026b05..0199856 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,6 @@ provider "doppler" { ``` - ## Schema ### Required diff --git a/examples/data-sources/secrets.tf b/examples/data-sources/secrets.tf index 04442dc..446837e 100644 --- a/examples/data-sources/secrets.tf +++ b/examples/data-sources/secrets.tf @@ -1,3 +1,5 @@ +### Basic Usage + data "doppler_secrets" "this" {} # Access individual secrets @@ -16,3 +18,41 @@ output "max_workers" { output "json_parsing_values" { value = nonsensitive(jsondecode(data.doppler_secrets.this.map.FEATURE_FLAGS)["TOP_SPEED"]) } + +### Referencing secrets from multiple projects + +variable "doppler_token_dev" { + type = string + description = "A token to authenticate with Doppler for the dev config" +} + +variable "doppler_token_prd" { + type = string + description = "A token to authenticate with Doppler for the prd config" +} + +provider "doppler" { + doppler_token = var.doppler_token_dev + alias = "dev" +} + +provider "doppler" { + doppler_token = var.doppler_token_prd + alias = "prd" +} + +data "doppler_secrets" "dev" { + provider = doppler.dev +} + +data "doppler_secrets" "prd" { + provider = doppler.prd +} + +output "port-dev" { + value = nonsensitive(data.doppler_secrets.dev.map.PORT) +} + +output "port-prd" { + value = nonsensitive(data.doppler_secrets.prd.map.PORT) +} From 5b2c682f222fe0e1746982b546196eac799ea650 Mon Sep 17 00:00:00 2001 From: Nic Manoogian Date: Wed, 7 Feb 2024 10:57:44 -0500 Subject: [PATCH 3/3] Add doc generation section to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a65135e..8f576d2 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,9 @@ New work should branch from `master` and target `master` in PRs. To release, create a GitHub Release (and associated tag) on `master` in the format `vX.X.X`, following [semantic versioning](https://semver.org/). The `release` GitHub Actions workflow will automatically build and ship the new version. + +# Doc Generation + +Everything in the `docs` directory of this repo is automatically generated by [terraform-docs](https://github.com/terraform-docs/terraform-docs) and therefore should not be modified by hand. + +To add or update docs for resources or data sources, modify the files in `examples/` and `templates/` and run `make tfdocs` to regenerate the `docs/` markdown.