From bf6e27111d672b09f28e42b7f3c7f00d8edc2677 Mon Sep 17 00:00:00 2001 From: Siddhu Warrier Date: Wed, 30 Oct 2024 22:33:24 +0000 Subject: [PATCH] feat(lh-86966): add new Terraform resource to manage users in an MSP-managed tenant (#146) * feat(lh-86966): add new Terraform resource to manage users in an MSP-managed tenant This adds a new resource that allows an MSP portal super-admin to create users in a tenant managed by the MSP portal. * refactor(lh-86966): address Ido's comments Also add validator for user role --- client/client.go | 9 + .../transactiontype/transactiontype.go | 22 +-- client/internal/url/url.go | 8 + client/msp/users/constants_test.go | 5 + client/msp/users/create.go | 40 +++++ client/msp/users/create_test.go | 165 ++++++++++++++++++ client/msp/users/delete.go | 35 ++++ client/msp/users/delete_test.go | 119 +++++++++++++ client/msp/users/models.go | 26 +++ docs/data-sources/msp_managed_tenant.md | 4 +- docs/resources/msp_managed_tenant_users.md | 30 ++++ .../examples/data-sources/msp/tenants/main.tf | 4 + .../data-sources/msp/tenants/providers.tf | 2 +- .../resources/msp/tenants/providers.tf | 2 +- .../examples/resources/msp/users/README.md | 13 ++ .../resources/msp/users/api_token.txt | 1 + provider/examples/resources/msp/users/main.tf | 19 ++ .../examples/resources/msp/users/providers.tf | 12 ++ .../internal/msp/msp_tenant/data_source.go | 2 +- provider/internal/msp/msp_tenant/resource.go | 2 - .../internal/msp/msp_tenant_users/models.go | 14 ++ .../internal/msp/msp_tenant_users/resource.go | 154 ++++++++++++++++ .../msp/msp_tenant_users/resource_test.go | 66 +++++++ provider/internal/provider/provider.go | 2 + 24 files changed, 739 insertions(+), 17 deletions(-) create mode 100644 client/msp/users/constants_test.go create mode 100644 client/msp/users/create.go create mode 100644 client/msp/users/create_test.go create mode 100644 client/msp/users/delete.go create mode 100644 client/msp/users/delete_test.go create mode 100644 client/msp/users/models.go create mode 100644 docs/resources/msp_managed_tenant_users.md create mode 100644 provider/examples/resources/msp/users/README.md create mode 100644 provider/examples/resources/msp/users/api_token.txt create mode 100644 provider/examples/resources/msp/users/main.tf create mode 100644 provider/examples/resources/msp/users/providers.tf create mode 100644 provider/internal/msp/msp_tenant_users/models.go create mode 100644 provider/internal/msp/msp_tenant_users/resource.go create mode 100644 provider/internal/msp/msp_tenant_users/resource_test.go diff --git a/client/client.go b/client/client.go index 67ecbdb..76d74ad 100644 --- a/client/client.go +++ b/client/client.go @@ -5,6 +5,7 @@ package client import ( "context" "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/tenants" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/users" "net/http" "github.com/CiscoDevnet/terraform-provider-cdo/go-client/connector/connectoronboarding" @@ -286,3 +287,11 @@ func (c *Client) ReadMspManagedTenantByUid(ctx context.Context, readByUidInput t func (c *Client) FindMspManagedTenantByName(ctx context.Context, readByNameInput tenants.ReadByNameInput) (*tenants.MspTenantsOutput, error) { return tenants.ReadByName(ctx, c.client, readByNameInput) } + +func (c *Client) CreateUsersInMspManagedTenant(ctx context.Context, createInput users.MspCreateUsersInput) (*[]users.UserDetails, *users.CreateError) { + return users.Create(ctx, c.client, createInput) +} + +func (c *Client) DeleteUsersInMspManagedTenant(ctx context.Context, deleteInput users.MspDeleteUsersInput) (interface{}, error) { + return users.Delete(ctx, c.client, deleteInput) +} diff --git a/client/internal/publicapi/transaction/transactiontype/transactiontype.go b/client/internal/publicapi/transaction/transactiontype/transactiontype.go index f03dc51..f779478 100644 --- a/client/internal/publicapi/transaction/transactiontype/transactiontype.go +++ b/client/internal/publicapi/transaction/transactiontype/transactiontype.go @@ -3,14 +3,16 @@ package transactiontype type Type string const ( - ONBOARD_ASA Type = "ONBOARD_ASA" - ONBOARD_IOS Type = "ONBOARD_IOS" - ONBOARD_DUO_ADMIN_PANEL Type = "ONBOARD_DUO_ADMIN_PANEL" - CREATE_FTD Type = "CREATE_FTD" - REGISTER_FTD Type = "REGISTER_FTD" - DELETE_CDFMC_MANAGED_FTD Type = "DELETE_CDFMC_MANAGED_FTD" - RECONNECT_ASA Type = "RECONNECT_ASA" - READ_ASA Type = "READ_ASA" - DEPLOY_ASA_DEVICE_CHANGES Type = "DEPLOY_ASA_DEVICE_CHANGES" - MSP_CREATE_TENANT Type = "MSP_CREATE_TENANT" + ONBOARD_ASA Type = "ONBOARD_ASA" + ONBOARD_IOS Type = "ONBOARD_IOS" + ONBOARD_DUO_ADMIN_PANEL Type = "ONBOARD_DUO_ADMIN_PANEL" + CREATE_FTD Type = "CREATE_FTD" + REGISTER_FTD Type = "REGISTER_FTD" + DELETE_CDFMC_MANAGED_FTD Type = "DELETE_CDFMC_MANAGED_FTD" + RECONNECT_ASA Type = "RECONNECT_ASA" + READ_ASA Type = "READ_ASA" + DEPLOY_ASA_DEVICE_CHANGES Type = "DEPLOY_ASA_DEVICE_CHANGES" + MSP_CREATE_TENANT Type = "MSP_CREATE_TENANT" + MSP_ADD_USERS_TO_TENANT Type = "MSP_ADD_USERS_TO_TENANT" + MSP_DELETE_USERS_FROM_TENANT Type = "MSP_DELETE_USERS_FROM_TENANT" ) diff --git a/client/internal/url/url.go b/client/internal/url/url.go index b3ebacb..9bafcab 100644 --- a/client/internal/url/url.go +++ b/client/internal/url/url.go @@ -217,3 +217,11 @@ func ReadMspManagedTenantByUid(baseUrl string, tenantUid string) string { func FindMspManagedTenantsByName(baseUrl string, tenantName string) string { return fmt.Sprintf("%s/api/rest/v1/msp/tenants?q=name:%s", baseUrl, tenantName) } + +func CreateUsersInMspManagedTenant(baseUrl string, tenantUid string) string { + return fmt.Sprintf("%s/api/rest/v1/msp/tenants/%s/users", baseUrl, tenantUid) +} + +func DeleteUsersInMspManagedTenant(baseUrl string, tenantUid string) string { + return fmt.Sprintf("%s/api/rest/v1/msp/tenants/%s/users/delete", baseUrl, tenantUid) +} diff --git a/client/msp/users/constants_test.go b/client/msp/users/constants_test.go new file mode 100644 index 0000000..177aa7a --- /dev/null +++ b/client/msp/users/constants_test.go @@ -0,0 +1,5 @@ +package users_test + +const ( + baseUrl = "https://unittest.cdo.cisco.com" +) diff --git a/client/msp/users/create.go b/client/msp/users/create.go new file mode 100644 index 0000000..d779386 --- /dev/null +++ b/client/msp/users/create.go @@ -0,0 +1,40 @@ +package users + +import ( + "context" + "fmt" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/url" +) + +func Create(ctx context.Context, client http.Client, createInp MspCreateUsersInput) (*[]UserDetails, *CreateError) { + client.Logger.Printf("Creating %d users in %s\n", len(createInp.Users), createInp.TenantUid) + createUrl := url.CreateUsersInMspManagedTenant(client.BaseUrl(), createInp.TenantUid) + transaction, err := publicapi.TriggerTransaction( + ctx, + client, + createUrl, + createInp, + ) + if err != nil { + return nil, &CreateError{ + Err: err, + CreatedResourceId: &transaction.EntityUid, + } + } + transaction, err = publicapi.WaitForTransactionToFinishWithDefaults( + ctx, + client, + transaction, + fmt.Sprintf("Waiting for users to be created and added to MSP-managed tenant %s...", createInp.TenantUid), + ) + if err != nil { + return nil, &CreateError{ + Err: err, + CreatedResourceId: &transaction.EntityUid, + } + } + + return &createInp.Users, nil +} diff --git a/client/msp/users/create_test.go b/client/msp/users/create_test.go new file mode 100644 index 0000000..a05d76a --- /dev/null +++ b/client/msp/users/create_test.go @@ -0,0 +1,165 @@ +package users_test + +import ( + "context" + "fmt" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction/transactionstatus" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction/transactiontype" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/model/user/auth/role" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/users" + "github.com/google/uuid" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + netHttp "net/http" + "testing" + "time" +) + +func TestCreate(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + t.Run("successfully create users in MSP-managed tenant", func(t *testing.T) { + httpmock.Reset() + var managedTenantUid = uuid.New().String() + var createInp = users.MspCreateUsersInput{ + TenantUid: managedTenantUid, + Users: []users.UserDetails{ + {Username: "apples@bananas.com", Role: string(role.SuperAdmin), ApiOnlyUser: false}, + {Username: "api-only-user", Role: string(role.ReadOnly), ApiOnlyUser: true}, + }, + } + var transactionUid = uuid.New().String() + var inProgressTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.IN_PROGRESS, + } + var doneTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.DONE, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users", managedTenantUid), + httpmock.NewJsonResponderOrPanic(200, inProgressTransaction), + ) + httpmock.RegisterResponder( + netHttp.MethodGet, + inProgressTransaction.PollingUrl, + httpmock.NewJsonResponderOrPanic(200, doneTransaction), + ) + + actual, err := users.Create(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), createInp) + + assert.NotNil(t, actual, "Created users should have not been nil") + assert.Nil(t, err, "Created users operation should have not been an error") + assert.Equal(t, createInp.Users, *actual, "Created users operation should have been the same as the created tenant") + }) + + t.Run("user creation transaction fails", func(t *testing.T) { + httpmock.Reset() + var managedTenantUid = uuid.New().String() + var createInp = users.MspCreateUsersInput{ + TenantUid: managedTenantUid, + Users: []users.UserDetails{ + {Username: "apples@bananas.com", Role: string(role.SuperAdmin), ApiOnlyUser: false}, + {Username: "api-only-user", Role: string(role.ReadOnly), ApiOnlyUser: true}, + }, + } + var transactionUid = uuid.New().String() + var inProgressTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.IN_PROGRESS, + } + var errorTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.ERROR, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users", managedTenantUid), + httpmock.NewJsonResponderOrPanic(200, inProgressTransaction), + ) + httpmock.RegisterResponder( + netHttp.MethodGet, + inProgressTransaction.PollingUrl, + httpmock.NewJsonResponderOrPanic(200, errorTransaction), + ) + + actual, err := users.Create(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), createInp) + + assert.Nil(t, actual, "Created users should be nil") + assert.NotNil(t, err, "Created users in tenant operation should have an error") + assert.Equal(t, users.CreateError{ + Err: publicapi.NewTransactionErrorFromTransaction(errorTransaction), + CreatedResourceId: &managedTenantUid, + }, *err, "created transaction error does not match") + }) + + t.Run("user creation API call fails", func(t *testing.T) { + httpmock.Reset() + var managedTenantUid = uuid.New().String() + var createInp = users.MspCreateUsersInput{ + TenantUid: managedTenantUid, + Users: []users.UserDetails{ + {Username: "apples@bananas.com", Role: string(role.SuperAdmin), ApiOnlyUser: false}, + {Username: "api-only-user", Role: string(role.ReadOnly), ApiOnlyUser: true}, + }, + } + var transactionUid = uuid.New().String() + var errorTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.ERROR, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users", managedTenantUid), + httpmock.NewJsonResponderOrPanic(200, errorTransaction), + ) + actual, err := users.Create(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), createInp) + + assert.Nil(t, actual, "Created users in tenant should have not been nil") + assert.NotNil(t, err, "Created users in tenant operation should have not been an error") + }) +} diff --git a/client/msp/users/delete.go b/client/msp/users/delete.go new file mode 100644 index 0000000..1620d55 --- /dev/null +++ b/client/msp/users/delete.go @@ -0,0 +1,35 @@ +package users + +import ( + "context" + "fmt" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/url" +) + +func Delete(ctx context.Context, client http.Client, deleteInp MspDeleteUsersInput) (interface{}, error) { + client.Logger.Printf("Deleting %d users in %s\n", len(deleteInp.Usernames), deleteInp.TenantUid) + deleteUrl := url.DeleteUsersInMspManagedTenant(client.BaseUrl(), deleteInp.TenantUid) + transaction, err := publicapi.TriggerTransaction( + ctx, + client, + deleteUrl, + deleteInp, + ) + if err != nil { + return nil, err + } + + transaction, err = publicapi.WaitForTransactionToFinishWithDefaults( + ctx, + client, + transaction, + fmt.Sprintf("Waiting for users to be deleted from MSP-managed tenant %s...", deleteInp.TenantUid), + ) + if err != nil { + return nil, err + } + + return nil, nil +} diff --git a/client/msp/users/delete_test.go b/client/msp/users/delete_test.go new file mode 100644 index 0000000..0d66c39 --- /dev/null +++ b/client/msp/users/delete_test.go @@ -0,0 +1,119 @@ +package users_test + +import ( + "context" + "fmt" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/http" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction/transactionstatus" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/internal/publicapi/transaction/transactiontype" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/users" + "github.com/google/uuid" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + netHttp "net/http" + "testing" + "time" +) + +func TestDelete(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + t.Run("successfully delete users in MSP-managed tenant", func(t *testing.T) { + httpmock.Reset() + managedTenantUid := uuid.New().String() + deleteInp := users.MspDeleteUsersInput{ + TenantUid: managedTenantUid, + Usernames: []string{"user1@example.com", "api-only-user", "user3@example.com"}, + } + var transactionUid = uuid.New().String() + var inProgressTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_ADD_USERS_TO_TENANT, + Status: transactionstatus.IN_PROGRESS, + } + var doneTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_DELETE_USERS_FROM_TENANT, + Status: transactionstatus.DONE, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users/delete", managedTenantUid), + httpmock.NewJsonResponderOrPanic(200, inProgressTransaction), + ) + httpmock.RegisterResponder( + netHttp.MethodGet, + inProgressTransaction.PollingUrl, + httpmock.NewJsonResponderOrPanic(200, doneTransaction), + ) + + actual, err := users.Delete(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), deleteInp) + + assert.Nil(t, actual, "Deletion output should be nil") + assert.Nil(t, err, "Deletion error should be nil") + }) + + t.Run("transaction to delete users in MSP-managed tenant fails", func(t *testing.T) { + httpmock.Reset() + managedTenantUid := uuid.New().String() + deleteInp := users.MspDeleteUsersInput{ + TenantUid: managedTenantUid, + Usernames: []string{"user1@example.com", "api-only-user", "user3@example.com"}, + } + var transactionUid = uuid.New().String() + var inProgressTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_DELETE_USERS_FROM_TENANT, + Status: transactionstatus.IN_PROGRESS, + } + var errorTransaction = transaction.Type{ + TransactionUid: transactionUid, + TenantUid: uuid.New().String(), + EntityUid: managedTenantUid, + EntityUrl: "https://unittest.cdo.cisco.com/api/rest/v1/msp/tenants/" + managedTenantUid, + PollingUrl: "https://unittest.cdo.cisco.com/api/rest/v1/transactions/" + transactionUid, + SubmissionTime: "2024-09-10T20:10:00Z", + LastUpdatedTime: "2024-10-10T20:10:00Z", + Type: transactiontype.MSP_DELETE_USERS_FROM_TENANT, + Status: transactionstatus.ERROR, + } + + httpmock.RegisterResponder( + netHttp.MethodPost, + fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users/delete", managedTenantUid), + httpmock.NewJsonResponderOrPanic(200, inProgressTransaction), + ) + httpmock.RegisterResponder( + netHttp.MethodGet, + inProgressTransaction.PollingUrl, + httpmock.NewJsonResponderOrPanic(200, errorTransaction), + ) + + actual, err := users.Delete(context.Background(), *http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute), deleteInp) + + assert.Nil(t, actual, "Deletion output should be nil") + assert.NotNil(t, err, "Deletion error should be nil") + assert.Equal(t, err.Error(), fmt.Sprintf("error: transaction failed, uid=%s, status=ERROR, message=, details=map[]", transactionUid)) + }) +} diff --git a/client/msp/users/models.go b/client/msp/users/models.go new file mode 100644 index 0000000..89f3152 --- /dev/null +++ b/client/msp/users/models.go @@ -0,0 +1,26 @@ +package users + +type MspCreateUsersInput struct { + TenantUid string `json:"tenantUid"` + Users []UserDetails `json:"users"` +} + +type MspDeleteUsersInput struct { + TenantUid string `json:"tenantUid"` + Usernames []string `json:"usernames"` +} + +type UserDetails struct { + Username string `json:"username"` + Role string `json:"role"` + ApiOnlyUser bool `json:"apiOnlyUser"` +} + +type CreateError struct { + Err error + CreatedResourceId *string +} + +func (r *CreateError) Error() string { + return r.Err.Error() +} diff --git a/docs/data-sources/msp_managed_tenant.md b/docs/data-sources/msp_managed_tenant.md index 1079d2e..b2d7547 100644 --- a/docs/data-sources/msp_managed_tenant.md +++ b/docs/data-sources/msp_managed_tenant.md @@ -3,12 +3,12 @@ page_title: "cdo_msp_managed_tenant Data Source - cdo" subcategory: "" description: |- - Use this data source to get information on the cloud-delivered FMC in your tenant. + Use this data source to get information on an MSP-managed tenant in your portal. --- # cdo_msp_managed_tenant (Data Source) -Use this data source to get information on the cloud-delivered FMC in your tenant. +Use this data source to get information on an MSP-managed tenant in your portal. diff --git a/docs/resources/msp_managed_tenant_users.md b/docs/resources/msp_managed_tenant_users.md new file mode 100644 index 0000000..7a6a337 --- /dev/null +++ b/docs/resources/msp_managed_tenant_users.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "cdo_msp_managed_tenant_users Resource - cdo" +subcategory: "" +description: |- + Provides a resource to add users to an MSP managed tenant. +--- + +# cdo_msp_managed_tenant_users (Resource) + +Provides a resource to add users to an MSP managed tenant. + + + + +## Schema + +### Required + +- `tenant_uid` (String) Universally unique identifier of the tenant to which the users should be added. +- `users` (Attributes List) The list of users to be added to the tenant. You can add a maximum of 50 users at a time. (see [below for nested schema](#nestedatt--users)) + + +### Nested Schema for `users` + +Required: + +- `api_only_user` (Boolean) Whether the user is an API-only user +- `role` (String) The role to assign to the user in the CDO tenant. +- `username` (String) The name of the user in CDO. This must be a valid e-mail address if the user is not an API-only user. diff --git a/provider/examples/data-sources/msp/tenants/main.tf b/provider/examples/data-sources/msp/tenants/main.tf index 9c8fca3..862ba00 100644 --- a/provider/examples/data-sources/msp/tenants/main.tf +++ b/provider/examples/data-sources/msp/tenants/main.tf @@ -4,4 +4,8 @@ data "cdo_msp_managed_tenant" "tenant" { output "tenant_display_name" { value = data.cdo_msp_managed_tenant.tenant.display_name +} + +output "tenant_region" { + value = data.cdo_msp_managed_tenant.tenant.region } \ No newline at end of file diff --git a/provider/examples/data-sources/msp/tenants/providers.tf b/provider/examples/data-sources/msp/tenants/providers.tf index 271024b..e6ed7fb 100644 --- a/provider/examples/data-sources/msp/tenants/providers.tf +++ b/provider/examples/data-sources/msp/tenants/providers.tf @@ -7,6 +7,6 @@ terraform { } provider "cdo" { - base_url = "https://staging.dev.lockhart.io" + base_url = "" api_token = file("${path.module}/api_token.txt") } diff --git a/provider/examples/resources/msp/tenants/providers.tf b/provider/examples/resources/msp/tenants/providers.tf index 271024b..92f7cba 100644 --- a/provider/examples/resources/msp/tenants/providers.tf +++ b/provider/examples/resources/msp/tenants/providers.tf @@ -7,6 +7,6 @@ terraform { } provider "cdo" { - base_url = "https://staging.dev.lockhart.io" + base_url = """ api_token = file("${path.module}/api_token.txt") } diff --git a/provider/examples/resources/msp/users/README.md b/provider/examples/resources/msp/users/README.md new file mode 100644 index 0000000..f5bd4ea --- /dev/null +++ b/provider/examples/resources/msp/users/README.md @@ -0,0 +1,13 @@ +# MSP users Example + +This example shows you how to add users to an MSP managed tenant. + +## Pre-requisites + +You need access to an MSP Portal, and API token for the MSP portal. + +## Usage +- Modify `providers.tf` accordingly. +- Paste CDO API token for an MSP portal into `api_token.txt` + - see https://docs.defenseorchestrator.com/#!c-api-tokens.html for how to generate this. +- Specify the name of a tenant managed by the MSP Portal. You can get the tenant name by going to Settings in the MSP portal. \ No newline at end of file diff --git a/provider/examples/resources/msp/users/api_token.txt b/provider/examples/resources/msp/users/api_token.txt new file mode 100644 index 0000000..6da4508 --- /dev/null +++ b/provider/examples/resources/msp/users/api_token.txt @@ -0,0 +1 @@ +Paste your API token here \ No newline at end of file diff --git a/provider/examples/resources/msp/users/main.tf b/provider/examples/resources/msp/users/main.tf new file mode 100644 index 0000000..b482af2 --- /dev/null +++ b/provider/examples/resources/msp/users/main.tf @@ -0,0 +1,19 @@ +data "cdo_msp_managed_tenant" "tenant" { + name = "CDO_tenant-name" +} + +resource "cdo_msp_managed_tenant_users" "example" { + tenant_uid = data.cdo_msp_managed_tenant.tenant.id + users = [ + { + username = "username@example.com", + role = "ROLE_SUPER_ADMIN" + api_only_user = false + }, + { + username = "username2@example.com", + role = "ROLE_ADMIN" + api_only_user = false + } + ] +} \ No newline at end of file diff --git a/provider/examples/resources/msp/users/providers.tf b/provider/examples/resources/msp/users/providers.tf new file mode 100644 index 0000000..e6ed7fb --- /dev/null +++ b/provider/examples/resources/msp/users/providers.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + cdo = { + source = "CiscoDevnet/cdo" + } + } +} + +provider "cdo" { + base_url = "" + api_token = file("${path.module}/api_token.txt") +} diff --git a/provider/internal/msp/msp_tenant/data_source.go b/provider/internal/msp/msp_tenant/data_source.go index 18bee84..a30b085 100644 --- a/provider/internal/msp/msp_tenant/data_source.go +++ b/provider/internal/msp/msp_tenant/data_source.go @@ -25,7 +25,7 @@ func (d *DataSource) Metadata(ctx context.Context, request datasource.MetadataRe func (d *DataSource) Schema(ctx context.Context, request datasource.SchemaRequest, response *datasource.SchemaResponse) { response.Schema = schema.Schema{ - MarkdownDescription: "Use this data source to get information on the cloud-delivered FMC in your tenant.", + MarkdownDescription: "Use this data source to get information on an MSP-managed tenant in your portal.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Universally unique identifier of the tenant", diff --git a/provider/internal/msp/msp_tenant/resource.go b/provider/internal/msp/msp_tenant/resource.go index 2b7dca7..69e3bbf 100644 --- a/provider/internal/msp/msp_tenant/resource.go +++ b/provider/internal/msp/msp_tenant/resource.go @@ -88,8 +88,6 @@ func (t *TenantResource) Create(ctx context.Context, request resource.CreateRequ var planData TenantResourceModel response.Diagnostics.Append(request.Plan.Get(ctx, &planData)...) - tflog.Debug(ctx, fmt.Sprintf("Diagnostics: %v", response.Diagnostics)) - tflog.Debug(ctx, fmt.Sprintf("lavda: %v", planData.Name)) if response.Diagnostics.HasError() { return diff --git a/provider/internal/msp/msp_tenant_users/models.go b/provider/internal/msp/msp_tenant_users/models.go new file mode 100644 index 0000000..a85ca0b --- /dev/null +++ b/provider/internal/msp/msp_tenant_users/models.go @@ -0,0 +1,14 @@ +package msp_tenant_users + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type MspManagedTenantUsersResourceModel struct { + TenantUid types.String `tfsdk:"tenant_uid"` + Users []User `tfsdk:"users"` +} + +type User struct { + Username types.String `tfsdk:"username"` + Role types.String `tfsdk:"role"` + ApiOnlyUser types.Bool `tfsdk:"api_only_user"` +} diff --git a/provider/internal/msp/msp_tenant_users/resource.go b/provider/internal/msp/msp_tenant_users/resource.go new file mode 100644 index 0000000..8d3dd97 --- /dev/null +++ b/provider/internal/msp/msp_tenant_users/resource.go @@ -0,0 +1,154 @@ +package msp_tenant_users + +import ( + "context" + "fmt" + cdoClient "github.com/CiscoDevnet/terraform-provider-cdo/go-client" + "github.com/CiscoDevnet/terraform-provider-cdo/go-client/msp/users" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func NewMspManagedTenantUsersResource() resource.Resource { return &MspManagedTenantUsersResource{} } + +type MspManagedTenantUsersResource struct { + client *cdoClient.Client +} + +func (r *MspManagedTenantUsersResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = schema.Schema{ + MarkdownDescription: "Provides a resource to add users to an MSP managed tenant.", + Attributes: map[string]schema.Attribute{ + "tenant_uid": schema.StringAttribute{ + MarkdownDescription: "Universally unique identifier of the tenant to which the users should be added.", + Required: true, + }, + "users": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the user in CDO. This must be a valid e-mail address if the user is not an API-only user.", + }, + "role": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The role to assign to the user in the CDO tenant.", + Validators: []validator.String{ + stringvalidator.OneOf("ROLE_READ_ONLY", "ROLE_ADMIN", "ROLE_SUPER_ADMIN", "ROLE_DEPLOY_ONLY", "ROLE_EDIT_ONLY", "ROLE_VPN_SESSIONS_MANAGER"), + }, + }, + "api_only_user": schema.BoolAttribute{ + Required: true, + MarkdownDescription: "Whether the user is an API-only user", + }, + }, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), // TODO stop destroying and re-adding once read endpoint added + }, + }, + MarkdownDescription: "The list of users to be added to the tenant. You can add a maximum of 50 users at a time.", + Required: true, + }, + }, + } +} + +func (r *MspManagedTenantUsersResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { + tflog.Debug(ctx, "Adding users to the MSSP-managed CDO tenant") + var planData MspManagedTenantUsersResourceModel + response.Diagnostics.Append(request.Plan.Get(ctx, &planData)...) + + if response.Diagnostics.HasError() { + return + } + + _, err := r.createAllUsersInPlan(ctx, &planData) + if err != nil { + response.Diagnostics.AddError("failed to create users in MSP-managed tenant", err.Error()) + return + } + response.Diagnostics.Append(response.State.Set(ctx, &planData)...) +} + +func (r *MspManagedTenantUsersResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + tflog.Debug(ctx, "Reading users from MSP-managed CDO tenant is a NOOP") +} + +func (r *MspManagedTenantUsersResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { +} + +func (r *MspManagedTenantUsersResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { + tflog.Debug(ctx, "Deleting users from MSP-managed CDO tenant") + var stateData MspManagedTenantUsersResourceModel + response.Diagnostics.Append(request.State.Get(ctx, &stateData)...) + if response.Diagnostics.HasError() { + return + } + + _, err := r.deleteAllUsersInState(ctx, &stateData) + if err != nil { + response.Diagnostics.AddError("failed to delete users", err.Error()) + } +} + +func (*MspManagedTenantUsersResource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_msp_managed_tenant_users" +} + +func (resource *MspManagedTenantUsersResource) Configure(ctx context.Context, req resource.ConfigureRequest, res *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*cdoClient.Client) + + if !ok { + res.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *cdoClient.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + resource.client = client +} + +func (r *MspManagedTenantUsersResource) deleteAllUsersInState(ctx context.Context, stateData *MspManagedTenantUsersResourceModel) (interface{}, error) { + var usernames []string + for _, user := range stateData.Users { + usernames = append(usernames, user.Username.ValueString()) + } + deleteInput := users.MspDeleteUsersInput{ + TenantUid: stateData.TenantUid.ValueString(), + Usernames: usernames, + } + return r.client.DeleteUsersInMspManagedTenant(ctx, deleteInput) +} + +func (r *MspManagedTenantUsersResource) createAllUsersInPlan(ctx context.Context, planData *MspManagedTenantUsersResourceModel) (*[]users.UserDetails, *users.CreateError) { + var nativeUsers []users.UserDetails + + // 2. use plan data to create tenant and fill up rest of the model + for _, user := range planData.Users { + username := user.Username.ValueString() + role := user.Role.ValueString() + apiOnlyUser := user.ApiOnlyUser.ValueBool() + nativeUsers = append(nativeUsers, users.UserDetails{ + Username: username, + Role: role, + ApiOnlyUser: apiOnlyUser, + }) + } + + // TODO we need endpoint to read users in an MSP-managed tenant + return r.client.CreateUsersInMspManagedTenant(ctx, users.MspCreateUsersInput{ + TenantUid: planData.TenantUid.ValueString(), + Users: nativeUsers, + }) +} diff --git a/provider/internal/msp/msp_tenant_users/resource_test.go b/provider/internal/msp/msp_tenant_users/resource_test.go new file mode 100644 index 0000000..87f0858 --- /dev/null +++ b/provider/internal/msp/msp_tenant_users/resource_test.go @@ -0,0 +1,66 @@ +package msp_tenant_users_test + +import ( + "fmt" + "github.com/CiscoDevnet/terraform-provider-cdo/internal/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "testing" +) + +type Users struct { + Username string + Role string + ApiOnlyUser bool +} + +var testMspManagedTenantUsersResource = struct { + TenantUid string + Users []Users +}{ + Users: []Users{ + {Username: "user1@example.com", Role: "ROLE_SUPER_ADMIN", ApiOnlyUser: false}, + {Username: "example-api-user", Role: "ROLE_ADMIN", ApiOnlyUser: true}, + }, + TenantUid: acctest.Env.MspTenantId(), +} + +const testMspManagedTenantUsersTemplate = ` +resource "cdo_msp_managed_tenant_users" "test" { + tenant_uid = "{{.TenantUid}}" + users = [ + { + "username": "{{(index .Users 0).Username}}" + "role": "{{(index .Users 0).Role}}" + "api_only_user": "{{(index .Users 0).ApiOnlyUser}}" + }, + { + "username": "{{(index .Users 1).Username}}" + "role": "{{(index .Users 1).Role}}" + "api_only_user": {{(index .Users 1).ApiOnlyUser}} + } + ] +}` + +var testMspManagedTenantUsersResourceConfig = acctest.MustParseTemplate(testMspManagedTenantUsersTemplate, testMspManagedTenantUsersResource) + +func TestAccMspManagedTenantUsersResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: acctest.PreCheckFunc(t), + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Read testing + { + Config: acctest.MspProviderConfig() + testMspManagedTenantUsersResourceConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "tenant_uid", testMspManagedTenantUsersResource.TenantUid), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.0.username", testMspManagedTenantUsersResource.Users[0].Username), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.0.role", testMspManagedTenantUsersResource.Users[0].Role), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.0.api_only_user", fmt.Sprintf("%t", testMspManagedTenantUsersResource.Users[0].ApiOnlyUser)), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.1.username", testMspManagedTenantUsersResource.Users[1].Username), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.1.role", testMspManagedTenantUsersResource.Users[1].Role), + resource.TestCheckResourceAttr("cdo_msp_managed_tenant_users.test", "users.1.api_only_user", fmt.Sprintf("%t", testMspManagedTenantUsersResource.Users[1].ApiOnlyUser)), + ), + }, + }, + }) +} diff --git a/provider/internal/provider/provider.go b/provider/internal/provider/provider.go index fba9d64..bf57b39 100644 --- a/provider/internal/provider/provider.go +++ b/provider/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "github.com/CiscoDevnet/terraform-provider-cdo/internal/msp/msp_tenant" + "github.com/CiscoDevnet/terraform-provider-cdo/internal/msp/msp_tenant_users" "os" "github.com/CiscoDevnet/terraform-provider-cdo/internal/connector" @@ -174,6 +175,7 @@ func (p *CdoProvider) Resources(ctx context.Context) []func() resource.Resource duoadminpanel.NewResource, tenantsettings.NewTenantSettingsResource, msp_tenant.NewTenantResource, + msp_tenant_users.NewMspManagedTenantUsersResource, } }