diff --git a/docs/data-sources/users.md b/docs/data-sources/users.md index ca9f963dd..05fe88f50 100644 --- a/docs/data-sources/users.md +++ b/docs/data-sources/users.md @@ -25,48 +25,56 @@ data "octopusdeploy_users" "example" { ### Optional -- `filter` (String) A filter with which to search. +- `filter` (String) A filter search by username, display name or email - `ids` (List of String) A filter to search by a list of IDs. - `skip` (Number) A filter to specify the number of items to skip in the response. -- `space_id` (String) A Space ID to filter by. Will revert what is specified on the provider if not set. +- `space_id` (String, Deprecated) The space ID associated with this user. - `take` (Number) A filter to specify the number of items to take (or return) in the response. ### Read-Only -- `id` (String) An auto-generated identifier that includes the timestamp when this data source was last modified. -- `users` (List of Object) A list of users that match the filter(s). (see [below for nested schema](#nestedatt--users)) +- `id` (String) The unique ID for this resource. +- `users` (Attributes List) (see [below for nested schema](#nestedatt--users)) ### Nested Schema for `users` +Required: + +- `display_name` (String) The display name of this resource. +- `username` (String) The username associated with this resource. + +Optional: + +- `can_password_be_edited` (Boolean) Specifies whether or not the password can be edited. +- `email_address` (String) The email address of this resource. +- `identity` (Attributes Set) The identities associated with the user. (see [below for nested schema](#nestedatt--users--identity)) +- `is_active` (Boolean) Specifies whether or not the user is active. +- `is_requestor` (Boolean) Specifies whether or not the user is the requestor. +- `is_service` (Boolean) Specifies whether or not the user is a service account. + Read-Only: -- `can_password_be_edited` (Boolean) -- `display_name` (String) -- `email_address` (String) -- `id` (String) -- `identity` (Set of Object) (see [below for nested schema](#nestedobjatt--users--identity)) -- `is_active` (Boolean) -- `is_requestor` (Boolean) -- `is_service` (Boolean) -- `password` (String) -- `username` (String) - - +- `id` (String) The unique ID for this resource. + + ### Nested Schema for `users.identity` Read-Only: -- `claim` (Set of Object) (see [below for nested schema](#nestedobjatt--users--identity--claim)) -- `provider` (String) +- `claim` (Attributes Set) The claim associated with the identity. (see [below for nested schema](#nestedatt--users--identity--claim)) +- `provider` (String) The identity provider. - + ### Nested Schema for `users.identity.claim` -Read-Only: +Required: + +- `name` (String) The name of this resource. +- `value` (String) The value of this resource. + +Optional: -- `is_identifying_claim` (Boolean) -- `name` (String) -- `value` (String) +- `is_identifying_claim` (Boolean) Specifies whether or not the claim is an identifying claim. diff --git a/octopusdeploy/data_source_users.go b/octopusdeploy/data_source_users.go deleted file mode 100644 index c541d86d7..000000000 --- a/octopusdeploy/data_source_users.go +++ /dev/null @@ -1,46 +0,0 @@ -package octopusdeploy - -import ( - "context" - "time" - - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/users" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" -) - -func dataSourceUsers() *schema.Resource { - return &schema.Resource{ - Description: "Provides information about existing users.", - ReadContext: dataSourceUsersRead, - Schema: getUserDataSchema(), - } -} - -func dataSourceUsersRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - query := users.UsersQuery{ - Filter: d.Get("filter").(string), - IDs: expandArray(d.Get("ids").([]interface{})), - Skip: d.Get("skip").(int), - Take: d.Get("take").(int), - } - - spaceID := d.Get("space_id").(string) - - client := meta.(*client.Client) - existingUsers, err := users.Get(client, spaceID, query) - if err != nil { - return diag.FromErr(err) - } - - flattenedUsers := []interface{}{} - for _, user := range existingUsers.Items { - flattenedUsers = append(flattenedUsers, flattenUser(user)) - } - - d.Set("users", flattenedUsers) - d.SetId("Users " + time.Now().UTC().String()) - - return nil -} diff --git a/octopusdeploy/provider.go b/octopusdeploy/provider.go index 5f1a0822f..786ce3e1c 100644 --- a/octopusdeploy/provider.go +++ b/octopusdeploy/provider.go @@ -28,7 +28,6 @@ func Provider() *schema.Provider { "octopusdeploy_polling_tentacle_deployment_targets": dataSourcePollingTentacleDeploymentTargets(), "octopusdeploy_ssh_connection_deployment_targets": dataSourceSSHConnectionDeploymentTargets(), "octopusdeploy_teams": dataSourceTeams(), - "octopusdeploy_users": dataSourceUsers(), "octopusdeploy_user_roles": dataSourceUserRoles(), "octopusdeploy_worker_pools": dataSourceWorkerPools(), }, diff --git a/octopusdeploy_framework/datasource_users.go b/octopusdeploy_framework/datasource_users.go new file mode 100644 index 000000000..c030f4ca2 --- /dev/null +++ b/octopusdeploy_framework/datasource_users.go @@ -0,0 +1,80 @@ +package octopusdeploy_framework + +import ( + "context" + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/users" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "time" +) + +type userDataSource struct { + *Config +} + +type usersDataSourceModel struct { + ID types.String `tfsdk:"id"` + SpaceID types.String `tfsdk:"space_id"` + IDs types.List `tfsdk:"ids"` + Filter types.String `tfsdk:"filter"` + Skip types.Int64 `tfsdk:"skip"` + Take types.Int64 `tfsdk:"take"` + Users types.List `tfsdk:"users"` +} + +func NewUsersDataSource() datasource.DataSource { + return &userDataSource{} +} + +func (u *userDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = util.GetTypeName("users") +} + +func (u *userDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schemas.UserSchema{}.GetDatasourceSchema() +} + +func (u *userDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + u.Config = DataSourceConfiguration(req, resp) +} + +func (u *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var err error + var data usersDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + query := users.UsersQuery{ + IDs: util.GetIds(data.IDs), + Filter: data.Filter.ValueString(), + Skip: util.GetNumber(data.Skip), + Take: util.GetNumber(data.Take), + } + + util.DatasourceReading(ctx, "users", query) + + existingUsers, err := users.Get(u.Client, data.SpaceID.ValueString(), query) + if err != nil { + resp.Diagnostics.AddError("unable to load users", err.Error()) + return + } + + mappedUsers := []schemas.UserTypeResourceModel{} + tflog.Debug(ctx, fmt.Sprintf("users returned from API: %#v", existingUsers)) + for _, user := range existingUsers.Items { + mappedUsers = append(mappedUsers, schemas.MapFromUser(user)) + } + + util.DatasourceResultCount(ctx, "users", len(mappedUsers)) + + data.Users, _ = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: schemas.UserObjectType()}, mappedUsers) + data.ID = types.StringValue("Users " + time.Now().UTC().String()) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/octopusdeploy/data_source_users_test.go b/octopusdeploy_framework/datasource_users_test.go similarity index 79% rename from octopusdeploy/data_source_users_test.go rename to octopusdeploy_framework/datasource_users_test.go index e022c30ce..d0984cc70 100644 --- a/octopusdeploy/data_source_users_test.go +++ b/octopusdeploy_framework/datasource_users_test.go @@ -1,29 +1,28 @@ -package octopusdeploy +package octopusdeploy_framework import ( "fmt" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccDataSourceUsers(t *testing.T) { localName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) name := fmt.Sprintf("data.octopusdeploy_users.%s", localName) username := "d" - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + PreCheck: func() { TestAccPreCheck(t) }, Steps: []resource.TestStep{ { - Config: testAccDataSourceUsersConfig(localName, username), Check: resource.ComposeTestCheckFunc( testAccCheckUsersDataSourceID(name), resource.TestCheckResourceAttrSet(name, "users.#"), - )}, + ), + Config: testAccDataSourceUsersConfig(localName, username), + }, }, }) } diff --git a/octopusdeploy_framework/framework_provider.go b/octopusdeploy_framework/framework_provider.go index 265eeac96..2da08dea3 100644 --- a/octopusdeploy_framework/framework_provider.go +++ b/octopusdeploy_framework/framework_provider.go @@ -74,6 +74,7 @@ func (p *octopusDeployFrameworkProvider) DataSources(ctx context.Context) []func NewTagSetsDataSource, NewScriptModuleDataSource, NewTenantProjectDataSource, + NewUsersDataSource, } } diff --git a/octopusdeploy_framework/schemas/schema.go b/octopusdeploy_framework/schemas/schema.go index 3160e01ab..0a38087bf 100644 --- a/octopusdeploy_framework/schemas/schema.go +++ b/octopusdeploy_framework/schemas/schema.go @@ -126,6 +126,40 @@ func GetReadonlyDescriptionDatasourceSchema(resourceDescription string) datasour } } +func GetUsernameDatasourceSchema(isRequired bool) datasourceSchema.Attribute { + s := datasourceSchema.StringAttribute{ + Description: "The username associated with this resource.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + } + + if isRequired { + s.Required = true + } else { + s.Optional = true + } + + return s +} + +func GetValueDatasourceSchema(isRequired bool) datasourceSchema.Attribute { + s := datasourceSchema.StringAttribute{ + Description: "The value of this resource.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + } + + if isRequired { + s.Required = true + } else { + s.Optional = true + } + + return s +} + func GetIdResourceSchema() resourceSchema.Attribute { return resourceSchema.StringAttribute{ Description: "The unique ID for this resource.", diff --git a/octopusdeploy_framework/schemas/user.go b/octopusdeploy_framework/schemas/user.go new file mode 100644 index 000000000..5333f018e --- /dev/null +++ b/octopusdeploy_framework/schemas/user.go @@ -0,0 +1,206 @@ +package schemas + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/users" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + UserResourceDescription = "user" +) + +type UserSchema struct{} + +var _ EntitySchema = UserSchema{} + +func UserObjectType() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.StringType, + "username": types.StringType, + "can_password_be_edited": types.BoolType, + "display_name": types.StringType, + "email_address": types.StringType, + "is_active": types.BoolType, + "is_requestor": types.BoolType, + "is_service": types.BoolType, + "identity": types.SetType{ + ElemType: types.ObjectType{AttrTypes: IdentityObjectType()}, + }, + } +} + +func (u UserSchema) GetDatasourceSchema() datasourceSchema.Schema { + return datasourceSchema.Schema{ + Description: "Provides information about existing users.", + Attributes: map[string]datasourceSchema.Attribute{ + //request + "ids": GetQueryIDsDatasourceSchema(), + "space_id": GetUserSpaceIdDatasourceSchema(), + "filter": GetFilterDatasourceSchema(), + "skip": GetQuerySkipDatasourceSchema(), + "take": GetQueryTakeDatasourceSchema(), + + //response + "id": GetIdDatasourceSchema(true), + "users": datasourceSchema.ListNestedAttribute{ + Computed: true, + Optional: false, + NestedObject: datasourceSchema.NestedAttributeObject{ + Attributes: u.GetDatasourceSchemaAttributes(), + }, + }, + }, + } +} + +func (u UserSchema) GetDatasourceSchemaAttributes() map[string]datasourceSchema.Attribute { + return map[string]datasourceSchema.Attribute{ + "id": GetIdDatasourceSchema(true), + "username": GetUsernameDatasourceSchema(true), + "can_password_be_edited": GetBooleanDatasourceAttribute("Specifies whether or not the password can be edited.", true), + "display_name": GetDisplayNameDatasourceSchema(), + "email_address": GetEmailAddressDatasourceSchema(), + "is_active": GetBooleanDatasourceAttribute("Specifies whether or not the user is active.", true), + "is_requestor": GetBooleanDatasourceAttribute("Specifies whether or not the user is the requestor.", true), + "is_service": GetBooleanDatasourceAttribute("Specifies whether or not the user is a service account.", true), + "identity": datasourceSchema.SetNestedAttribute{ + Description: "The identities associated with the user.", + Optional: true, + NestedObject: datasourceSchema.NestedAttributeObject{ + Attributes: map[string]datasourceSchema.Attribute{ + "provider": datasourceSchema.StringAttribute{ + Description: "The identity provider.", + Computed: true, + }, + "claim": datasourceSchema.SetNestedAttribute{ + Description: "The claim associated with the identity.", + Computed: true, + NestedObject: datasourceSchema.NestedAttributeObject{ + Attributes: map[string]datasourceSchema.Attribute{ + "name": GetNameDatasourceSchema(true), + "is_identifying_claim": GetBooleanDatasourceAttribute("Specifies whether or not the claim is an identifying claim.", true), + "value": GetValueDatasourceSchema(true), + }, + }, + }, + }, + }, + }, + } +} + +func GetUserSpaceIdDatasourceSchema() datasourceSchema.Attribute { + return datasourceSchema.StringAttribute{ + Description: "The space ID associated with this user.", + Optional: true, + DeprecationMessage: "This attribute is deprecated and will be removed in a future release. Users are not scoped to spaces, meaning providing a space ID will not affect the result.", + } +} + +func GetFilterDatasourceSchema() datasourceSchema.Attribute { + return datasourceSchema.StringAttribute{ + Description: "A filter search by username, display name or email", + Optional: true, + } +} + +func GetDisplayNameDatasourceSchema() datasourceSchema.Attribute { + s := datasourceSchema.StringAttribute{ + Description: "The display name of this resource.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + Required: true, + } + + return s +} + +func GetEmailAddressDatasourceSchema() datasourceSchema.Attribute { + s := datasourceSchema.StringAttribute{ + Description: "The email address of this resource.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + Optional: true, + } + + return s +} + +func IdentityObjectType() map[string]attr.Type { + return map[string]attr.Type{ + "provider": types.StringType, + "claim": types.SetType{ElemType: types.ObjectType{AttrTypes: IdentityClaimObjectType()}}, + } +} + +func IdentityClaimObjectType() map[string]attr.Type { + return map[string]attr.Type{ + "name": types.StringType, + "is_identifying_claim": types.BoolType, + "value": types.StringType, + } +} + +func (u UserSchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{} +} + +func MapIdentityClaims(claims map[string]users.IdentityClaim) []attr.Value { + claimsList := make([]attr.Value, 0, len(claims)) + for key, claim := range claims { + claimMap := map[string]attr.Value{ + "is_identifying_claim": types.BoolValue(claim.IsIdentifyingClaim), + "name": types.StringValue(key), + "value": types.StringValue(claim.Value), + } + claimsList = append(claimsList, types.ObjectValueMust(IdentityClaimObjectType(), claimMap)) + } + return claimsList +} + +func MapIdentities(identities []users.Identity) []attr.Value { + identitiesList := make([]attr.Value, 0, len(identities)) + for _, identity := range identities { + identityMap := map[string]attr.Value{ + "provider": types.StringValue(identity.IdentityProviderName), + "claim": types.SetValueMust(types.ObjectType{AttrTypes: IdentityClaimObjectType()}, MapIdentityClaims(identity.Claims)), + } + identitiesList = append(identitiesList, types.ObjectValueMust(IdentityObjectType(), identityMap)) + } + return identitiesList +} + +func MapFromUser(u *users.User) UserTypeResourceModel { + var user UserTypeResourceModel + user.ID = types.StringValue(u.ID) + user.Username = types.StringValue(u.Username) + user.CanPasswordBeEdited = types.BoolValue(u.CanPasswordBeEdited) + user.DisplayName = types.StringValue(u.DisplayName) + user.EmailAddress = types.StringValue(u.EmailAddress) + user.IsActive = types.BoolValue(u.IsActive) + user.IsRequestor = types.BoolValue(u.IsRequestor) + user.IsService = types.BoolValue(u.IsService) + user.Identity = types.SetValueMust(types.ObjectType{AttrTypes: IdentityObjectType()}, MapIdentities(u.Identities)) + + return user +} + +type UserTypeResourceModel struct { + Username types.String `tfsdk:"username"` + CanPasswordBeEdited types.Bool `tfsdk:"can_password_be_edited"` + DisplayName types.String `tfsdk:"display_name"` + EmailAddress types.String `tfsdk:"email_address"` + IsActive types.Bool `tfsdk:"is_active"` + IsRequestor types.Bool `tfsdk:"is_requestor"` + IsService types.Bool `tfsdk:"is_service"` + Identity types.Set `tfsdk:"identity"` + + ResourceModel +}