From 900241b648eb455147b6251f4f9ae55a693a9832 Mon Sep 17 00:00:00 2001 From: Tom Bamford Date: Mon, 13 Sep 2021 18:12:55 +0100 Subject: [PATCH 1/3] Update Hamilton --- go.mod | 2 +- go.sum | 4 +- .../hamilton/msgraph/applications.go | 40 +++++++++++++++++-- .../manicminer/hamilton/msgraph/client.go | 32 ++++++++++++++- .../hamilton/msgraph/directory_roles.go | 37 +++++++++++++---- .../manicminer/hamilton/msgraph/groups.go | 25 +++++------- .../manicminer/hamilton/msgraph/models.go | 6 +++ .../hamilton/msgraph/serviceprincipals.go | 15 ++++--- .../manicminer/hamilton/odata/odata.go | 11 ++--- vendor/modules.txt | 2 +- 10 files changed, 131 insertions(+), 43 deletions(-) diff --git a/go.mod b/go.mod index 15033f8b2a..d14fc92ea1 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.7.0 github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 // indirect github.com/klauspost/compress v1.12.2 // indirect - github.com/manicminer/hamilton v0.28.2 + github.com/manicminer/hamilton v0.29.0 github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect diff --git a/go.sum b/go.sum index b760b34f0a..4e3103f818 100644 --- a/go.sum +++ b/go.sum @@ -285,8 +285,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/manicminer/hamilton v0.28.2 h1:W+ZRftPTOgWAa9zr3bUW1CzU1yOMFz1ZumHcA5kXqes= -github.com/manicminer/hamilton v0.28.2/go.mod h1:QryxpD/4+cdKuXNi0UjLDvgxYdP0LLmYz7dYU7DAX4U= +github.com/manicminer/hamilton v0.29.0 h1:SmKNUMbuk7Crp9mJUWJTNRKnsFzdIxyIhlvvJ0HjCGk= +github.com/manicminer/hamilton v0.29.0/go.mod h1:QryxpD/4+cdKuXNi0UjLDvgxYdP0LLmYz7dYU7DAX4U= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/vendor/github.com/manicminer/hamilton/msgraph/applications.go b/vendor/github.com/manicminer/hamilton/msgraph/applications.go index a7effa11ef..158aeaaee1 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/applications.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/applications.go @@ -161,9 +161,22 @@ func (c *ApplicationsClient) Update(ctx context.Context, application Application return status, fmt.Errorf("json.Marshal(): %v", err) } + checkApplicationConsistency := func(resp *http.Response, o *odata.OData) bool { + if resp == nil { + return false + } + if resp.StatusCode == http.StatusNotFound { + return true + } + if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + return o.Error.Match(odata.ErrorCannotDeleteOrUpdateEnabledEntitlement) + } + return false + } + _, status, _, err = c.BaseClient.Patch(ctx, PatchHttpRequestInput{ Body: body, - ConsistencyFailureFunc: RetryOn404ConsistencyFailureFunc, + ConsistencyFailureFunc: checkApplicationConsistency, ValidStatusCodes: []int{http.StatusNoContent}, Uri: Uri{ Entity: fmt.Sprintf("/applications/%s", *application.ID), @@ -428,7 +441,7 @@ func (c *ApplicationsClient) AddOwners(ctx context.Context, application *Applica for _, owner := range *application.Owners { // don't fail if an owner already exists checkOwnerAlreadyExists := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorAddedObjectReferencesAlreadyExist) } return false @@ -478,7 +491,7 @@ func (c *ApplicationsClient) RemoveOwners(ctx context.Context, applicationId str // despite the above check, sometimes owners are just gone checkOwnerGone := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorRemovedObjectReferencesDoNotExist) } return false @@ -583,3 +596,24 @@ func (c *ApplicationsClient) DeleteExtension(ctx context.Context, applicationId, return status, nil } + +// UploadLogo uploads the application logo which should be a gif, jpeg or png image +func (c *ApplicationsClient) UploadLogo(ctx context.Context, applicationId, contentType string, logoData []byte) (int, error) { + var status int + + _, status, _, err := c.BaseClient.Put(ctx, PutHttpRequestInput{ + Body: logoData, + ConsistencyFailureFunc: RetryOn404ConsistencyFailureFunc, + ContentType: contentType, + ValidStatusCodes: []int{http.StatusNoContent}, + Uri: Uri{ + Entity: fmt.Sprintf("/applications/%s/logo", applicationId), + HasTenantId: true, + }, + }) + if err != nil { + return status, fmt.Errorf("ApplicationsClient.BaseClient.Put(): %v", err) + } + + return status, nil +} diff --git a/vendor/github.com/manicminer/hamilton/msgraph/client.go b/vendor/github.com/manicminer/hamilton/msgraph/client.go index 6c032205c0..1eee9f9b5f 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/client.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/client.go @@ -44,6 +44,7 @@ type ValidStatusFunc func(*http.Response, *odata.OData) bool // HttpRequestInput is any type that can validate the response to an HTTP request. type HttpRequestInput interface { GetConsistencyFailureFunc() ConsistencyFailureFunc + GetContentType() string GetValidStatusCodes() []int GetValidStatusFunc() ValidStatusFunc } @@ -140,7 +141,7 @@ func (c Client) performRequest(req *http.Request, input HttpRequestInput) (*http } req.Header.Add("Accept", "application/json") - req.Header.Add("Content-Type", "application/json; charset=utf-8") + req.Header.Add("Content-Type", input.GetContentType()) //req.Header.Add("ConsistencyLevel", "eventual") if c.UserAgent != "" { @@ -265,6 +266,11 @@ func (i DeleteHttpRequestInput) GetConsistencyFailureFunc() ConsistencyFailureFu return i.ConsistencyFailureFunc } +// GetContentType returns the content type for the request, currently only application/json is supported +func (i DeleteHttpRequestInput) GetContentType() string { + return "application/json; charset=utf-8" +} + // GetValidStatusCodes returns a []int of status codes considered valid for a DELETE request. func (i DeleteHttpRequestInput) GetValidStatusCodes() []int { return i.ValidStatusCodes @@ -308,6 +314,11 @@ func (i GetHttpRequestInput) GetConsistencyFailureFunc() ConsistencyFailureFunc return i.ConsistencyFailureFunc } +// GetContentType returns the content type for the request, currently only application/json is supported +func (i GetHttpRequestInput) GetContentType() string { + return "application/json; charset=utf-8" +} + // GetValidStatusCodes returns a []int of status codes considered valid for a GET request. func (i GetHttpRequestInput) GetValidStatusCodes() []int { return i.ValidStatusCodes @@ -421,6 +432,11 @@ func (i PatchHttpRequestInput) GetConsistencyFailureFunc() ConsistencyFailureFun return i.ConsistencyFailureFunc } +// GetContentType returns the content type for the request, currently only application/json is supported +func (i PatchHttpRequestInput) GetContentType() string { + return "application/json; charset=utf-8" +} + // GetValidStatusCodes returns a []int of status codes considered valid for a PATCH request. func (i PatchHttpRequestInput) GetValidStatusCodes() []int { return i.ValidStatusCodes @@ -463,6 +479,11 @@ func (i PostHttpRequestInput) GetConsistencyFailureFunc() ConsistencyFailureFunc return i.ConsistencyFailureFunc } +// GetContentType returns the content type for the request, currently only application/json is supported +func (i PostHttpRequestInput) GetContentType() string { + return "application/json; charset=utf-8" +} + // GetValidStatusCodes returns a []int of status codes considered valid for a POST request. func (i PostHttpRequestInput) GetValidStatusCodes() []int { return i.ValidStatusCodes @@ -494,6 +515,7 @@ func (c Client) Post(ctx context.Context, input PostHttpRequestInput) (*http.Res // PutHttpRequestInput configures a PUT request. type PutHttpRequestInput struct { ConsistencyFailureFunc ConsistencyFailureFunc + ContentType string Body []byte ValidStatusCodes []int ValidStatusFunc ValidStatusFunc @@ -505,6 +527,14 @@ func (i PutHttpRequestInput) GetConsistencyFailureFunc() ConsistencyFailureFunc return i.ConsistencyFailureFunc } +// GetContentType returns the content type for the request, defaults to application/json +func (i PutHttpRequestInput) GetContentType() string { + if i.ContentType != "" { + return i.ContentType + } + return "application/json; charset=utf-8" +} + // GetValidStatusCodes returns a []int of status codes considered valid for a PUT request. func (i PutHttpRequestInput) GetValidStatusCodes() []int { return i.ValidStatusCodes diff --git a/vendor/github.com/manicminer/hamilton/msgraph/directory_roles.go b/vendor/github.com/manicminer/hamilton/msgraph/directory_roles.go index 5612da1d50..98cffad40c 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/directory_roles.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/directory_roles.go @@ -79,6 +79,33 @@ func (c *DirectoryRolesClient) Get(ctx context.Context, id string) (*DirectoryRo return &dirRole, status, nil } +// GetByTemplateId retrieves a DirectoryRole manifest for a DirectoryRoleTemplate id. +func (c *DirectoryRolesClient) GetByTemplateId(ctx context.Context, templateId string) (*DirectoryRole, int, error) { + resp, status, _, err := c.BaseClient.Get(ctx, GetHttpRequestInput{ + ValidStatusCodes: []int{http.StatusOK}, + Uri: Uri{ + Entity: fmt.Sprintf("/directoryRoles/roleTemplateId=%s", templateId), + HasTenantId: true, + }, + }) + if err != nil { + return nil, status, fmt.Errorf("DirectoryRolesClient.BaseClient.Get(): %v", err) + } + + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, status, fmt.Errorf("io.ReadAll(): %v", err) + } + + var dirRole DirectoryRole + if err := json.Unmarshal(respBody, &dirRole); err != nil { + return nil, status, fmt.Errorf("json.Unmarshal(): %v", err) + } + + return &dirRole, status, nil +} + // ListMembers retrieves the members of the specified directory role. // id is the object ID of the directory role. func (c *DirectoryRolesClient) ListMembers(ctx context.Context, id string) (*[]string, int, error) { @@ -133,17 +160,13 @@ func (c *DirectoryRolesClient) AddMembers(ctx context.Context, directoryRole *Di for _, member := range *directoryRole.Members { // don't fail if a member already exists checkMemberAlreadyExists := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorAddedObjectReferencesAlreadyExist) } return false } - body, err := json.Marshal(struct { - Member odata.Id `json:"@odata.id"` - }{ - Member: *member.ODataId, - }) + body, err := json.Marshal(DirectoryObject{ODataId: member.ODataId}) if err != nil { return status, fmt.Errorf("json.Marshal(): %v", err) } @@ -242,7 +265,7 @@ func (c *DirectoryRolesClient) Activate(ctx context.Context, roleTemplateID stri // don't fail if a role is already activated checkRoleAlreadyActivated := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorConflictingObjectPresentInDirectory) } return false diff --git a/vendor/github.com/manicminer/hamilton/msgraph/groups.go b/vendor/github.com/manicminer/hamilton/msgraph/groups.go index 816ee9f194..43e3de3a99 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/groups.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/groups.go @@ -63,7 +63,10 @@ func (c *GroupsClient) Create(ctx context.Context, group Group) (*Group, int, er } ownersNotReplicated := func(resp *http.Response, o *odata.OData) bool { - return o != nil && o.Error != nil && o.Error.Match(odata.ErrorResourceDoesNotExist) + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + return o.Error.Match(odata.ErrorResourceDoesNotExist) + } + return false } resp, status, _, err := c.BaseClient.Post(ctx, PostHttpRequestInput{ @@ -398,17 +401,13 @@ func (c *GroupsClient) AddMembers(ctx context.Context, group *Group) (int, error for _, member := range *group.Members { // don't fail if an member already exists checkMemberAlreadyExists := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorAddedObjectReferencesAlreadyExist) } return false } - body, err := json.Marshal(struct { - Member odata.Id `json:"@odata.id"` - }{ - Member: *member.ODataId, - }) + body, err := json.Marshal(DirectoryObject{ODataId: member.ODataId}) if err != nil { return status, fmt.Errorf("json.Marshal(): %v", err) } @@ -452,7 +451,7 @@ func (c *GroupsClient) RemoveMembers(ctx context.Context, id string, memberIds * // despite the above check, sometimes members are just gone checkMemberGone := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorRemovedObjectReferencesDoNotExist) } return false @@ -564,17 +563,13 @@ func (c *GroupsClient) AddOwners(ctx context.Context, group *Group) (int, error) for _, owner := range *group.Owners { // don't fail if an owner already exists checkOwnerAlreadyExists := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorAddedObjectReferencesAlreadyExist) } return false } - body, err := json.Marshal(struct { - Owner odata.Id `json:"@odata.id"` - }{ - Owner: *owner.ODataId, - }) + body, err := json.Marshal(DirectoryObject{ODataId: owner.ODataId}) if err != nil { return status, fmt.Errorf("json.Marshal(): %v", err) } @@ -618,7 +613,7 @@ func (c *GroupsClient) RemoveOwners(ctx context.Context, id string, ownerIds *[] // despite the above check, sometimes owners are just gone checkOwnerGone := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorRemovedObjectReferencesDoNotExist) } return false diff --git a/vendor/github.com/manicminer/hamilton/msgraph/models.go b/vendor/github.com/manicminer/hamilton/msgraph/models.go index fed22147fe..40c5be9e90 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/models.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/models.go @@ -1099,6 +1099,7 @@ type User struct { DisplayName *string `json:"displayName,omitempty"` EmployeeHireDate *time.Time `json:"employeeHireDate,omitempty"` EmployeeId *StringNullWhenEmpty `json:"employeeId,omitempty"` + EmployeeOrgData *EmployeeOrgData `json:"employeeOrgData,omitempty"` EmployeeType *string `json:"employeeType,omitempty"` ExternalUserState *string `json:"externalUserState,omitempty"` FaxNumber *StringNullWhenEmpty `json:"faxNumber,omitempty"` @@ -1255,3 +1256,8 @@ type WindowsHelloForBusinessAuthenticationMethod struct { ID *string `json:"id,omitempty"` KeyStrength *AuthenticationMethodKeyStrength `json:"authenticationMethodKeyStrength,omitempty"` } + +type EmployeeOrgData struct { + CostCenter *string `json:"costCenter,omitempty"` + Division *string `json:"division,omitempty"` +} diff --git a/vendor/github.com/manicminer/hamilton/msgraph/serviceprincipals.go b/vendor/github.com/manicminer/hamilton/msgraph/serviceprincipals.go index 8aefea2568..8f10de7257 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/serviceprincipals.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/serviceprincipals.go @@ -64,7 +64,10 @@ func (c *ServicePrincipalsClient) Create(ctx context.Context, servicePrincipal S } appNotReplicated := func(resp *http.Response, o *odata.OData) bool { - return o != nil && o.Error != nil && o.Error.Match(odata.ErrorServicePrincipalInvalidAppId) + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + return o.Error.Match(odata.ErrorServicePrincipalInvalidAppId) + } + return false } resp, status, _, err := c.BaseClient.Post(ctx, PostHttpRequestInput{ @@ -260,17 +263,13 @@ func (c *ServicePrincipalsClient) AddOwners(ctx context.Context, servicePrincipa for _, owner := range *servicePrincipal.Owners { // don't fail if an owner already exists checkOwnerAlreadyExists := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorAddedObjectReferencesAlreadyExist) } return false } - body, err := json.Marshal(struct { - Owner odata.Id `json:"@odata.id"` - }{ - Owner: *owner.ODataId, - }) + body, err := json.Marshal(DirectoryObject{ODataId: owner.ODataId}) if err != nil { return status, fmt.Errorf("json.Marshal(): %v", err) } @@ -314,7 +313,7 @@ func (c *ServicePrincipalsClient) RemoveOwners(ctx context.Context, servicePrinc // despite the above check, sometimes owners are just gone checkOwnerGone := func(resp *http.Response, o *odata.OData) bool { - if resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest && o != nil && o.Error != nil { return o.Error.Match(odata.ErrorRemovedObjectReferencesDoNotExist) } return false diff --git a/vendor/github.com/manicminer/hamilton/odata/odata.go b/vendor/github.com/manicminer/hamilton/odata/odata.go index 6c7f52936c..83d7f2fcd7 100644 --- a/vendor/github.com/manicminer/hamilton/odata/odata.go +++ b/vendor/github.com/manicminer/hamilton/odata/odata.go @@ -8,11 +8,12 @@ import ( ) const ( - ErrorAddedObjectReferencesAlreadyExist = "One or more added object references already exist" - ErrorConflictingObjectPresentInDirectory = "A conflicting object with one or more of the specified property values is present in the directory" - ErrorResourceDoesNotExist = "Resource '.+' does not exist or one of its queried reference-property objects are not present" - ErrorRemovedObjectReferencesDoNotExist = "One or more removed object references do not exist" - ErrorServicePrincipalInvalidAppId = "The appId '.+' of the service principal does not reference a valid application object" + ErrorAddedObjectReferencesAlreadyExist = "One or more added object references already exist" + ErrorCannotDeleteOrUpdateEnabledEntitlement = "Permission (scope or role) cannot be deleted or updated unless disabled first" + ErrorConflictingObjectPresentInDirectory = "A conflicting object with one or more of the specified property values is present in the directory" + ErrorResourceDoesNotExist = "Resource '.+' does not exist or one of its queried reference-property objects are not present" + ErrorRemovedObjectReferencesDoNotExist = "One or more removed object references do not exist" + ErrorServicePrincipalInvalidAppId = "The appId '.+' of the service principal does not reference a valid application object" ) type Id string diff --git a/vendor/modules.txt b/vendor/modules.txt index 65562dba14..377960408f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -192,7 +192,7 @@ github.com/klauspost/compress/fse github.com/klauspost/compress/huff0 github.com/klauspost/compress/zstd github.com/klauspost/compress/zstd/internal/xxhash -# github.com/manicminer/hamilton v0.28.2 +# github.com/manicminer/hamilton v0.29.0 ## explicit github.com/manicminer/hamilton/auth github.com/manicminer/hamilton/environments From ed139c108022ec5a3bf19c369c74feeaacc2afc5 Mon Sep 17 00:00:00 2001 From: Tom Bamford Date: Mon, 13 Sep 2021 18:13:15 +0100 Subject: [PATCH 2/3] New resources: directory_role and directory_role_member --- .teamcity/components/project.kt | 1 + docs/resources/directory_role.md | 55 +++++ docs/resources/directory_role_member.md | 56 +++++ internal/clients/client.go | 3 + internal/provider/services.go | 2 + .../services/directoryroles/client/client.go | 30 +++ .../directory_role_member_resource.go | 175 ++++++++++++++ .../directory_role_member_resource_test.go | 214 ++++++++++++++++++ .../directoryroles/directory_role_resource.go | 164 ++++++++++++++ .../directory_role_resource_test.go | 86 +++++++ .../parse/directory_role_member.go | 30 +++ .../services/directoryroles/parse/object.go | 57 +++++ .../services/directoryroles/registration.go | 32 +++ internal/tf/suppress/string.go | 11 + 14 files changed, 916 insertions(+) create mode 100644 docs/resources/directory_role.md create mode 100644 docs/resources/directory_role_member.md create mode 100644 internal/services/directoryroles/client/client.go create mode 100644 internal/services/directoryroles/directory_role_member_resource.go create mode 100644 internal/services/directoryroles/directory_role_member_resource_test.go create mode 100644 internal/services/directoryroles/directory_role_resource.go create mode 100644 internal/services/directoryroles/directory_role_resource_test.go create mode 100644 internal/services/directoryroles/parse/directory_role_member.go create mode 100644 internal/services/directoryroles/parse/object.go create mode 100644 internal/services/directoryroles/registration.go create mode 100644 internal/tf/suppress/string.go diff --git a/.teamcity/components/project.kt b/.teamcity/components/project.kt index 09798fe3ca..b699e4a0b0 100644 --- a/.teamcity/components/project.kt +++ b/.teamcity/components/project.kt @@ -6,6 +6,7 @@ const val providerName = "azuread" var services = mapOf( "applications" to "Applications", "conditionalaccess" to "Conditional Access", + "directoryroles" to "Directory Roles", "domains" to "Domains", "groups" to "Groups", "invitations" to "Invitations", diff --git a/docs/resources/directory_role.md b/docs/resources/directory_role.md new file mode 100644 index 0000000000..94a3f3c2e8 --- /dev/null +++ b/docs/resources/directory_role.md @@ -0,0 +1,55 @@ +--- +subcategory: "Directory Roles" +--- + +# Resource: azuread_directory_role + +Manages a Directory Role within Azure Active Directory. Directory Roles are also known as Administrator Roles. + +Directory Roles are built-in to Azure Active Directory and are immutable. However, by default they are not activated in a tenant (except for the Global Administrator role). This resource ensures a directory role is activated from its associated role template, and exports the object ID of the role, so that role assignments can be made for it. + +## API Permissions + +The following API permissions are required in order to use this resource. + +When authenticated with a service principal, this resource requires one of the following application roles: `RoleManagement.ReadWrite.Directory` or `Directory.ReadWrite.All` + +When authenticated with a user principal, this resource requires one of the following directory roles: `Privileged Role Administrator` or `Global Administrator` + +## Example Usage + +*Activate a directory role by its template ID* + +```terraform +resource "azuread_directory_role" "example" { + template_id = "00000000-0000-0000-0000-000000000000" +} +``` + +*Activate a directory role by display name* + +```terraform +resource "azuread_directory_role" "example" { + display_name = "Printer administrator" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `display_name` - (Optional) The display name of the directory role to activate. Changing this forces a new resource to be created. +* `template_id` - (Optional) The object ID of the role template from which to activate the directory role. Changing this forces a new resource to be created. + +~> Either `display_name` or `template_id` must be specified. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `description` - The description of the directory role. +* `object_id` - The object ID of the directory role. + +## Import + +This resource does not support importing. diff --git a/docs/resources/directory_role_member.md b/docs/resources/directory_role_member.md new file mode 100644 index 0000000000..0d3106ec41 --- /dev/null +++ b/docs/resources/directory_role_member.md @@ -0,0 +1,56 @@ +--- +subcategory: "Directory Roles" +--- + +# Resource: azuread_directory_role_member + +Manages a single directory role membership (assignment) within Azure Active Directory. + +## API Permissions + +The following API permissions are required in order to use this resource. + +When authenticated with a service principal, this resource requires one of the following application roles: `RoleManagement.ReadWrite.Directory` or `Directory.ReadWrite.All` + +When authenticated with a user principal, this resource requires one of the following directory roles: `Privileged Role Administrator` or `Global Administrator` + +## Example Usage + +```terraform + +data "azuread_user" "example" { + user_principal_name = "jdoe@hashicorp.com" +} + +resource "azuread_directory_role" "example" { + display_name = "Security administrator" +} + +resource "azuread_directory_role_member" "example" { + directory_role_object_id = azuread_directory_role.example.object_id + member_object_id = data.azuread_user.example.object_id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `directory_role_object_id` - (Required) The object ID of the directory role you want to add the member to. Changing this forces a new resource to be created. +* `member_object_id` - (Required) The object ID of the principal you want to add as a member to the directory role. Supported object types are Users or Service Principals. Changing this forces a new resource to be created. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +*No additional attributes are exported* + +## Import + +Directory role members can be imported using the object ID of the role and the object ID of the member, e.g. + +```shell +terraform import azuread_directory_role_member.test 00000000-0000-0000-0000-000000000000/member/11111111-1111-1111-1111-111111111111 +``` + +-> This ID format is unique to Terraform and is composed of the Directory Role Object ID and the target Member Object ID in the format `{GroupObjectID}/member/{MemberObjectID}`. diff --git a/internal/clients/client.go b/internal/clients/client.go index dc7c1b4144..8e99ea2388 100644 --- a/internal/clients/client.go +++ b/internal/clients/client.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-provider-azuread/internal/common" applications "github.com/hashicorp/terraform-provider-azuread/internal/services/applications/client" conditionalaccess "github.com/hashicorp/terraform-provider-azuread/internal/services/conditionalaccess/client" + directoryroles "github.com/hashicorp/terraform-provider-azuread/internal/services/directoryroles/client" domains "github.com/hashicorp/terraform-provider-azuread/internal/services/domains/client" groups "github.com/hashicorp/terraform-provider-azuread/internal/services/groups/client" invitations "github.com/hashicorp/terraform-provider-azuread/internal/services/invitations/client" @@ -30,6 +31,7 @@ type Client struct { Applications *applications.Client ConditionalAccess *conditionalaccess.Client + DirectoryRoles *directoryroles.Client Domains *domains.Client Groups *groups.Client Invitations *invitations.Client @@ -43,6 +45,7 @@ func (client *Client) build(ctx context.Context, o *common.ClientOptions) error client.Applications = applications.NewClient(o) client.Domains = domains.NewClient(o) client.ConditionalAccess = conditionalaccess.NewClient(o) + client.DirectoryRoles = directoryroles.NewClient(o) client.Groups = groups.NewClient(o) client.Invitations = invitations.NewClient(o) client.ServicePrincipals = serviceprincipals.NewClient(o) diff --git a/internal/provider/services.go b/internal/provider/services.go index 3c2a6140e6..693441242e 100644 --- a/internal/provider/services.go +++ b/internal/provider/services.go @@ -3,6 +3,7 @@ package provider import ( "github.com/hashicorp/terraform-provider-azuread/internal/services/applications" "github.com/hashicorp/terraform-provider-azuread/internal/services/conditionalaccess" + "github.com/hashicorp/terraform-provider-azuread/internal/services/directoryroles" "github.com/hashicorp/terraform-provider-azuread/internal/services/domains" "github.com/hashicorp/terraform-provider-azuread/internal/services/groups" "github.com/hashicorp/terraform-provider-azuread/internal/services/invitations" @@ -14,6 +15,7 @@ func SupportedServices() []ServiceRegistration { return []ServiceRegistration{ applications.Registration{}, conditionalaccess.Registration{}, + directoryroles.Registration{}, domains.Registration{}, groups.Registration{}, invitations.Registration{}, diff --git a/internal/services/directoryroles/client/client.go b/internal/services/directoryroles/client/client.go new file mode 100644 index 0000000000..40bc378988 --- /dev/null +++ b/internal/services/directoryroles/client/client.go @@ -0,0 +1,30 @@ +package client + +import ( + "github.com/manicminer/hamilton/msgraph" + + "github.com/hashicorp/terraform-provider-azuread/internal/common" +) + +type Client struct { + DirectoryObjectsClient *msgraph.DirectoryObjectsClient + DirectoryRolesClient *msgraph.DirectoryRolesClient + DirectoryRoleTemplatesClient *msgraph.DirectoryRoleTemplatesClient +} + +func NewClient(o *common.ClientOptions) *Client { + directoryObjectsClient := msgraph.NewDirectoryObjectsClient(o.TenantID) + o.ConfigureClient(&directoryObjectsClient.BaseClient) + + directoryRolesClient := msgraph.NewDirectoryRolesClient(o.TenantID) + o.ConfigureClient(&directoryRolesClient.BaseClient) + + directoryRoleTemplatesClient := msgraph.NewDirectoryRoleTemplatesClient(o.TenantID) + o.ConfigureClient(&directoryRoleTemplatesClient.BaseClient) + + return &Client{ + DirectoryObjectsClient: directoryObjectsClient, + DirectoryRolesClient: directoryRolesClient, + DirectoryRoleTemplatesClient: directoryRoleTemplatesClient, + } +} diff --git a/internal/services/directoryroles/directory_role_member_resource.go b/internal/services/directoryroles/directory_role_member_resource.go new file mode 100644 index 0000000000..d7894e6e90 --- /dev/null +++ b/internal/services/directoryroles/directory_role_member_resource.go @@ -0,0 +1,175 @@ +package directoryroles + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/manicminer/hamilton/msgraph" + "github.com/manicminer/hamilton/odata" + + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/services/directoryroles/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/tf" + "github.com/hashicorp/terraform-provider-azuread/internal/validate" +) + +const directoryRoleMemberResourceName = "azuread_directory_role_member" + +func directoryRoleMemberResource() *schema.Resource { + return &schema.Resource{ + CreateContext: directoryRoleMemberResourceCreate, + ReadContext: directoryRoleMemberResourceRead, + DeleteContext: directoryRoleMemberResourceDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Importer: tf.ValidateResourceIDPriorToImport(func(id string) error { + _, err := parse.DirectoryRoleMemberID(id) + return err + }), + + Schema: map[string]*schema.Schema{ + "role_object_id": { + Description: "The object ID of the directory role", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "member_object_id": { + Description: "The object ID of the member", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + }, + } +} + +func directoryRoleMemberResourceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).DirectoryRoles.DirectoryRolesClient + directoryObjectsClient := meta.(*clients.Client).DirectoryRoles.DirectoryObjectsClient + + id := parse.NewDirectoryRoleMemberID(d.Get("role_object_id").(string), d.Get("member_object_id").(string)) + + tf.LockByName(directoryRoleMemberResourceName, id.DirectoryRoleId) + defer tf.UnlockByName(directoryRoleMemberResourceName, id.DirectoryRoleId) + + role, status, err := client.Get(ctx, id.DirectoryRoleId) + if err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(nil, "object_id", "Directory role with object ID %q was not found", id.DirectoryRoleId) + } + return tf.ErrorDiagPathF(err, "object_id", "Retrieving directory role with object ID: %q", id.DirectoryRoleId) + } + + _, status, err = client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) + if err == nil { + return tf.ImportAsExistsDiag("azuread_directory_role_member", id.String()) + } else if status != http.StatusNotFound { + return tf.ErrorDiagF(err, "Checking for existing membership of member %q for directory role with object ID: %q", id.MemberId, id.DirectoryRoleId) + } + + memberObject, _, err := directoryObjectsClient.Get(ctx, id.MemberId, odata.Query{}) + if err != nil { + return tf.ErrorDiagF(err, "Could not retrieve member principal object %q", id.MemberId) + } + if memberObject == nil { + return tf.ErrorDiagF(errors.New("returned memberObject was nil"), "Could not retrieve member principal object %q", id.MemberId) + } + if memberObject.ODataId == nil { + return tf.ErrorDiagF(errors.New("ODataId was nil"), "Could not retrieve member principal object %q", id.MemberId) + } + role.Members = &msgraph.Members{*memberObject} + + if _, err := client.AddMembers(ctx, role); err != nil { + return tf.ErrorDiagF(err, "Adding role member %q to directory role %q", id.MemberId, id.DirectoryRoleId) + } + + // Wait for role membership to reflect + deadline, ok := ctx.Deadline() + if !ok { + return tf.ErrorDiagF(errors.New("context has no deadline"), "Waiting for role member %q to reflect for directory role %q", id.MemberId, id.DirectoryRoleId) + } + timeout := time.Until(deadline) + _, err = (&resource.StateChangeConf{ + Pending: []string{"Waiting"}, + Target: []string{"Done"}, + Timeout: timeout, + MinTimeout: 1 * time.Second, + ContinuousTargetOccurence: 3, + Refresh: func() (interface{}, string, error) { + _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) + if err != nil { + if status == http.StatusNotFound { + return "stub", "Waiting", nil + } + return nil, "Error", fmt.Errorf("retrieving role member") + } + return "stub", "Done", nil + }, + }).WaitForStateContext(ctx) + if err != nil { + return tf.ErrorDiagF(err, "Waiting for role member %q to reflect for directory role %q", id.MemberId, id.DirectoryRoleId) + } + + d.SetId(id.String()) + + return directoryRoleMemberResourceRead(ctx, d, meta) +} + +func directoryRoleMemberResourceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).DirectoryRoles.DirectoryRolesClient + + id, err := parse.DirectoryRoleMemberID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing Directory Role Member ID %q", d.Id()) + } + + _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Member with ID %q was not found in directory role %q - removing from state", id.MemberId, id.DirectoryRoleId) + d.SetId("") + return nil + } + return tf.ErrorDiagF(err, "Retrieving role member %q for directory role with object ID: %q", id.MemberId, id.DirectoryRoleId) + } + + tf.Set(d, "role_object_id", id.DirectoryRoleId) + tf.Set(d, "member_object_id", id.MemberId) + + return nil +} + +func directoryRoleMemberResourceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).DirectoryRoles.DirectoryRolesClient + + id, err := parse.DirectoryRoleMemberID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing Directory Role Member ID %q", d.Id()) + } + + tf.LockByName(directoryRoleMemberResourceName, id.DirectoryRoleId) + defer tf.UnlockByName(directoryRoleMemberResourceName, id.DirectoryRoleId) + + if _, err := client.RemoveMembers(ctx, id.DirectoryRoleId, &[]string{id.MemberId}); err != nil { + return tf.ErrorDiagF(err, "Removing member %q from directory role with object ID: %q", id.MemberId, id.DirectoryRoleId) + } + + return nil +} diff --git a/internal/services/directoryroles/directory_role_member_resource_test.go b/internal/services/directoryroles/directory_role_member_resource_test.go new file mode 100644 index 0000000000..dedc96ce41 --- /dev/null +++ b/internal/services/directoryroles/directory_role_member_resource_test.go @@ -0,0 +1,214 @@ +package directoryroles_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance" + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/services/directoryroles/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/utils" +) + +type DirectoryRoleMemberResource struct{} + +func TestAccDirectoryRoleMember_servicePrincipal(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_directory_role_member", "test") + r := DirectoryRoleMemberResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.servicePrincipal(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("role_object_id").IsUuid(), + check.That(data.ResourceName).Key("member_object_id").IsUuid(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccDirectoryRoleMember_user(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_directory_role_member", "testA") + r := DirectoryRoleMemberResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.oneUser(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("role_object_id").IsUuid(), + check.That(data.ResourceName).Key("member_object_id").IsUuid(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccDirectoryRoleMember_multipleUser(t *testing.T) { + dataA := acceptance.BuildTestData(t, "azuread_directory_role_member", "testA") + dataB := acceptance.BuildTestData(t, "azuread_directory_role_member", "testB") + r := DirectoryRoleMemberResource{} + + dataA.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.oneUser(dataA), + Check: resource.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("role_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("member_object_id").IsUuid(), + ), + }, + dataA.ImportStep(), + { + Config: r.twoUsers(dataA), + Check: resource.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("role_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("member_object_id").IsUuid(), + check.That(dataB.ResourceName).ExistsInAzure(r), + check.That(dataB.ResourceName).Key("role_object_id").IsUuid(), + check.That(dataB.ResourceName).Key("member_object_id").IsUuid(), + ), + }, + dataA.ImportStep(), + dataB.ImportStep(), + { + Config: r.oneUser(dataA), + Check: resource.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("role_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("member_object_id").IsUuid(), + ), + }, + dataA.ImportStep(), + }) +} + +func TestAccDirectoryRoleMember_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_directory_role_member", "test") + r := DirectoryRoleMemberResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.servicePrincipal(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport(data)), + }) +} + +func (r DirectoryRoleMemberResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.DirectoryRoles.DirectoryRolesClient + client.BaseClient.DisableRetries = true + + id, err := parse.DirectoryRoleMemberID(state.ID) + if err != nil { + return nil, fmt.Errorf("parsing Directory Role Member ID: %v", err) + } + + _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) + if err != nil { + if status == http.StatusNotFound { + return utils.Bool(false), nil + } + return nil, fmt.Errorf("failed to retrieve directory role member %q (role ID: %q): %+v", id.MemberId, id.DirectoryRoleId, err) + } + + return utils.Bool(true), nil +} + +func (DirectoryRoleMemberResource) templateThreeUsers(data acceptance.TestData) string { + return fmt.Sprintf(` +data "azuread_domains" "test" { + only_initial = true +} + +resource "azuread_user" "testA" { + user_principal_name = "acctestUser.%[1]d.A@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-A" + password = "%[2]s" +} + +resource "azuread_user" "testB" { + user_principal_name = "acctestUser.%[1]d.B@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-B" + mail_nickname = "acctestUser-%[1]d-B" + password = "%[2]s" +} + +resource "azuread_user" "testC" { + user_principal_name = "acctestUser.%[1]d.C@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-C" + password = "%[2]s" +} +`, data.RandomInteger, data.RandomPassword) +} + +func (r DirectoryRoleMemberResource) servicePrincipal(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_application" "test" { + display_name = "acctestServicePrincipal-%[2]d" +} + +resource "azuread_service_principal" "test" { + application_id = azuread_application.test.application_id +} + +resource "azuread_directory_role_member" "test" { + role_object_id = azuread_directory_role.test.object_id + member_object_id = azuread_service_principal.test.object_id +} +`, DirectoryRoleResource{}.byTemplateId(data), data.RandomInteger) +} + +func (r DirectoryRoleMemberResource) oneUser(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s +%[2]s + +resource "azuread_directory_role_member" "testA" { + role_object_id = azuread_directory_role.test.object_id + member_object_id = azuread_user.testA.object_id +} +`, DirectoryRoleResource{}.byTemplateId(data), r.templateThreeUsers(data)) +} + +func (r DirectoryRoleMemberResource) twoUsers(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s +%[2]s + +resource "azuread_directory_role_member" "testA" { + role_object_id = azuread_directory_role.test.object_id + member_object_id = azuread_user.testA.object_id +} + +resource "azuread_directory_role_member" "testB" { + role_object_id = azuread_directory_role.test.object_id + member_object_id = azuread_user.testB.object_id +} +`, DirectoryRoleResource{}.byTemplateId(data), r.templateThreeUsers(data)) +} + +func (r DirectoryRoleMemberResource) requiresImport(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_directory_role_member" "import" { + role_object_id = azuread_directory_role_member.test.role_object_id + member_object_id = azuread_directory_role_member.test.member_object_id +} +`, r.servicePrincipal(data)) +} diff --git a/internal/services/directoryroles/directory_role_resource.go b/internal/services/directoryroles/directory_role_resource.go new file mode 100644 index 0000000000..9282e78adc --- /dev/null +++ b/internal/services/directoryroles/directory_role_resource.go @@ -0,0 +1,164 @@ +package directoryroles + +import ( + "context" + "errors" + "log" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/manicminer/hamilton/msgraph" + + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/tf" + "github.com/hashicorp/terraform-provider-azuread/internal/tf/suppress" + "github.com/hashicorp/terraform-provider-azuread/internal/validate" +) + +func directoryRoleResource() *schema.Resource { + return &schema.Resource{ + CreateContext: directoryRoleResourceCreate, + ReadContext: directoryRoleResourceRead, + DeleteContext: directoryRoleResourceDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "display_name": { + Description: "The display name of the directory role", + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ExactlyOneOf: []string{"display_name", "template_id"}, + DiffSuppressFunc: suppress.CaseDifference, + ValidateDiagFunc: validate.NoEmptyStrings, + }, + + "template_id": { + Description: "The object ID of the template associated with the directory role", + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ExactlyOneOf: []string{"display_name", "template_id"}, + ValidateDiagFunc: validate.UUID, + }, + + "description": { + Description: "The description of the directory role", + Type: schema.TypeString, + Computed: true, + }, + + "object_id": { + Description: "The object ID of the directory role", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func directoryRoleResourceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).DirectoryRoles.DirectoryRolesClient + directoryRoleTemplatesClient := meta.(*clients.Client).DirectoryRoles.DirectoryRoleTemplatesClient + displayName := d.Get("display_name").(string) + templateId := d.Get("template_id").(string) + + // First we find the directory role template + var template *msgraph.DirectoryRoleTemplate + if displayName != "" { + templates, _, err := directoryRoleTemplatesClient.List(ctx) + if err != nil { + return tf.ErrorDiagF(err, "Retrieving directory role templates: %q", err) + } + if templates == nil { + return tf.ErrorDiagF(errors.New("API error: nil result returned"), "Retrieving directory role templates") + } + for _, t := range *templates { + if t.DisplayName != nil && strings.EqualFold(displayName, *t.DisplayName) { + template = &t + break + } + } + } else { + var status int + var err error + template, status, err = directoryRoleTemplatesClient.Get(ctx, templateId) + if err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(nil, "template_id", "Directory role template with object ID %q was not found", templateId) + } + return tf.ErrorDiagPathF(err, "template_id", "Retrieving directory role template with object ID %q: %+v", templateId, err) + } + } + + if template.ID == nil { + return tf.ErrorDiagF(errors.New("API error: template returned with nil ID"), "Retrieving directory role template") + } + + templateId = *template.ID + + // Now look for the directory role created from that template + directoryRole, status, err := client.GetByTemplateId(ctx, templateId) + if err != nil { + if status == http.StatusNotFound { + // Directory role was not found, so activate it + directoryRole, _, err = client.Activate(ctx, templateId) + if err != nil { + return tf.ErrorDiagPathF(err, "template_id", "Activating directory role for template ID %q: %+v", templateId, err) + } + } else { + return tf.ErrorDiagPathF(err, "template_id", "Retrieving directory role with template ID %q: %+v", templateId, err) + } + } + + if directoryRole == nil { + return tf.ErrorDiagF(errors.New("unexpected: directoryRole was nil"), "Retrieving directory role for template ID %q", templateId) + } + if directoryRole.ID == nil { + return tf.ErrorDiagF(errors.New("API error: directoryRole returned with nil ID"), "Retrieving directory role for template ID %q", templateId) + } + + d.SetId(*directoryRole.ID) + + return directoryRoleResourceRead(ctx, d, meta) +} + +func directoryRoleResourceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).DirectoryRoles.DirectoryRolesClient + + directoryRole, status, err := client.Get(ctx, d.Id()) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Directory Role with ID %q was not found - removing from state", d.Id()) + d.SetId("") + return nil + } + return tf.ErrorDiagPathF(err, "template_id", "Retrieving directory role with object ID %q: %+v", d.Id(), err) + } + if directoryRole == nil { + return tf.ErrorDiagF(errors.New("API error: nil directoryRole was returned"), "Retrieving directory role with object ID %q", d.Id()) + } + + tf.Set(d, "description", directoryRole.Description) + tf.Set(d, "display_name", directoryRole.DisplayName) + tf.Set(d, "object_id", directoryRole.ID) + tf.Set(d, "template_id", directoryRole.RoleTemplateId) + + return nil +} + +func directoryRoleResourceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // Directory roles cannot be deactivated or deleted, so this is a no-op + return nil +} diff --git a/internal/services/directoryroles/directory_role_resource_test.go b/internal/services/directoryroles/directory_role_resource_test.go new file mode 100644 index 0000000000..53f1558546 --- /dev/null +++ b/internal/services/directoryroles/directory_role_resource_test.go @@ -0,0 +1,86 @@ +package directoryroles_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance" + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/utils" +) + +type DirectoryRoleResource struct{} + +func TestAccDirectoryRole_byDisplayName(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_directory_role", "test") + r := DirectoryRoleResource{} + + data.ResourceTestIgnoreDangling(t, r, []resource.TestStep{ + { + Config: r.byDisplayName(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("description").Exists(), + check.That(data.ResourceName).Key("object_id").IsUuid(), + check.That(data.ResourceName).Key("template_id").IsUuid(), + ), + }, + }) +} + +func TestAccDirectoryRole_byTemplateId(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_directory_role", "test") + r := DirectoryRoleResource{} + + data.ResourceTestIgnoreDangling(t, r, []resource.TestStep{ + { + Config: r.byTemplateId(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("display_name").HasValue("Printer Administrator"), + check.That(data.ResourceName).Key("description").Exists(), + check.That(data.ResourceName).Key("object_id").IsUuid(), + ), + }, + }) +} + +func (r DirectoryRoleResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.DirectoryRoles.DirectoryRolesClient + client.BaseClient.DisableRetries = true + + role, status, err := client.Get(ctx, state.ID) + if err != nil { + if status == http.StatusNotFound { + return nil, fmt.Errorf("Directory Role with object ID %q does not exist", state.ID) + } + return nil, fmt.Errorf("failed to retrieve Directory Role with object ID %q: %+v", state.ID, err) + } + return utils.Bool(role.ID != nil && *role.ID == state.ID), nil +} + +func (DirectoryRoleResource) byDisplayName(_ acceptance.TestData) string { + return ` +provider "azuread" {} + +resource "azuread_directory_role" "test" { + display_name = "Teams administrator" +} +` +} + +func (DirectoryRoleResource) byTemplateId(_ acceptance.TestData) string { + return ` +provider "azuread" {} + +resource "azuread_directory_role" "test" { + template_id = "644ef478-e28f-4e28-b9dc-3fdde9aa0b1f" // Printer administrator +} +` +} diff --git a/internal/services/directoryroles/parse/directory_role_member.go b/internal/services/directoryroles/parse/directory_role_member.go new file mode 100644 index 0000000000..ad7ba57d7d --- /dev/null +++ b/internal/services/directoryroles/parse/directory_role_member.go @@ -0,0 +1,30 @@ +package parse + +import "fmt" + +type DirectoryRoleMemberId struct { + ObjectSubResourceId + DirectoryRoleId string + MemberId string +} + +func NewDirectoryRoleMemberID(groupId, memberId string) DirectoryRoleMemberId { + return DirectoryRoleMemberId{ + ObjectSubResourceId: NewObjectSubResourceID(groupId, "member", memberId), + DirectoryRoleId: groupId, + MemberId: memberId, + } +} + +func DirectoryRoleMemberID(idString string) (*DirectoryRoleMemberId, error) { + id, err := ObjectSubResourceID(idString, "member") + if err != nil { + return nil, fmt.Errorf("unable to parse Member ID: %v", err) + } + + return &DirectoryRoleMemberId{ + ObjectSubResourceId: *id, + DirectoryRoleId: id.objectId, + MemberId: id.subId, + }, nil +} diff --git a/internal/services/directoryroles/parse/object.go b/internal/services/directoryroles/parse/object.go new file mode 100644 index 0000000000..07129a6471 --- /dev/null +++ b/internal/services/directoryroles/parse/object.go @@ -0,0 +1,57 @@ +package parse + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-uuid" +) + +type ObjectSubResourceId struct { + objectId string + subId string + Type string +} + +func NewObjectSubResourceID(objectId, typeId, subId string) ObjectSubResourceId { + return ObjectSubResourceId{ + objectId: objectId, + Type: typeId, + subId: subId, + } +} + +func (id ObjectSubResourceId) String() string { + return fmt.Sprintf("%s/%s/%s", id.objectId, id.Type, id.subId) +} + +func ObjectSubResourceID(idString, expectedType string) (*ObjectSubResourceId, error) { + parts := strings.Split(idString, "/") + if len(parts) != 3 { + return nil, fmt.Errorf("Object Resource ID should be in the format {objectId}/{type}/{subId} - but got %q", idString) + } + + id := ObjectSubResourceId{ + objectId: parts[0], + Type: parts[1], + subId: parts[2], + } + + if _, err := uuid.ParseUUID(id.objectId); err != nil { + return nil, fmt.Errorf("Object ID isn't a valid UUID (%q): %+v", id.objectId, err) + } + + if id.Type == "" { + return nil, fmt.Errorf("Type in {objectID}/{type}/{subID} should not be empty") + } + + if id.Type != expectedType { + return nil, fmt.Errorf("Type in {objectID}/{type}/{subID} was expected to be %s, got %s", expectedType, parts[2]) + } + + if _, err := uuid.ParseUUID(id.subId); err != nil { + return nil, fmt.Errorf("Object Sub Resource ID isn't a valid UUID (%q): %+v", id.subId, err) + } + + return &id, nil +} diff --git a/internal/services/directoryroles/registration.go b/internal/services/directoryroles/registration.go new file mode 100644 index 0000000000..bc9f4c1125 --- /dev/null +++ b/internal/services/directoryroles/registration.go @@ -0,0 +1,32 @@ +package directoryroles + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +type Registration struct{} + +// Name is the name of this Service +func (r Registration) Name() string { + return "Directory Roles" +} + +// WebsiteCategories returns a list of categories which can be used for the sidebar +func (r Registration) WebsiteCategories() []string { + return []string{ + "Directory Roles", + } +} + +// SupportedDataSources returns the supported Data Sources supported by this Service +func (r Registration) SupportedDataSources() map[string]*schema.Resource { + return map[string]*schema.Resource{} +} + +// SupportedResources returns the supported Resources supported by this Service +func (r Registration) SupportedResources() map[string]*schema.Resource { + return map[string]*schema.Resource{ + "azuread_directory_role": directoryRoleResource(), + "azuread_directory_role_member": directoryRoleMemberResource(), + } +} diff --git a/internal/tf/suppress/string.go b/internal/tf/suppress/string.go new file mode 100644 index 0000000000..0699a13cef --- /dev/null +++ b/internal/tf/suppress/string.go @@ -0,0 +1,11 @@ +package suppress + +import ( + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func CaseDifference(_, old, new string, _ *schema.ResourceData) bool { + return strings.EqualFold(old, new) +} From 8c117162b0c8fe8def30d5e30222528b8544b1e6 Mon Sep 17 00:00:00 2001 From: Tom Bamford Date: Thu, 16 Sep 2021 11:29:48 +0100 Subject: [PATCH 3/3] Address review: assignment scopes --- .../directoryroles/directory_role_member_resource.go | 6 ++---- .../directoryroles/directory_role_member_resource_test.go | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/internal/services/directoryroles/directory_role_member_resource.go b/internal/services/directoryroles/directory_role_member_resource.go index d7894e6e90..9a25feca6c 100644 --- a/internal/services/directoryroles/directory_role_member_resource.go +++ b/internal/services/directoryroles/directory_role_member_resource.go @@ -77,8 +77,7 @@ func directoryRoleMemberResourceCreate(ctx context.Context, d *schema.ResourceDa return tf.ErrorDiagPathF(err, "object_id", "Retrieving directory role with object ID: %q", id.DirectoryRoleId) } - _, status, err = client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) - if err == nil { + if _, status, err = client.GetMember(ctx, id.DirectoryRoleId, id.MemberId); err == nil { return tf.ImportAsExistsDiag("azuread_directory_role_member", id.String()) } else if status != http.StatusNotFound { return tf.ErrorDiagF(err, "Checking for existing membership of member %q for directory role with object ID: %q", id.MemberId, id.DirectoryRoleId) @@ -140,8 +139,7 @@ func directoryRoleMemberResourceRead(ctx context.Context, d *schema.ResourceData return tf.ErrorDiagPathF(err, "id", "Parsing Directory Role Member ID %q", d.Id()) } - _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) - if err != nil { + if _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId); err != nil { if status == http.StatusNotFound { log.Printf("[DEBUG] Member with ID %q was not found in directory role %q - removing from state", id.MemberId, id.DirectoryRoleId) d.SetId("") diff --git a/internal/services/directoryroles/directory_role_member_resource_test.go b/internal/services/directoryroles/directory_role_member_resource_test.go index dedc96ce41..4334ad6502 100644 --- a/internal/services/directoryroles/directory_role_member_resource_test.go +++ b/internal/services/directoryroles/directory_role_member_resource_test.go @@ -116,8 +116,7 @@ func (r DirectoryRoleMemberResource) Exists(ctx context.Context, clients *client return nil, fmt.Errorf("parsing Directory Role Member ID: %v", err) } - _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId) - if err != nil { + if _, status, err := client.GetMember(ctx, id.DirectoryRoleId, id.MemberId); err != nil { if status == http.StatusNotFound { return utils.Bool(false), nil }