diff --git a/.teamcity/components/project.kt b/.teamcity/components/project.kt index b699e4a0b0..b2ab843018 100644 --- a/.teamcity/components/project.kt +++ b/.teamcity/components/project.kt @@ -5,6 +5,7 @@ const val providerName = "azuread" var services = mapOf( "applications" to "Applications", + "approleassignments" to "App Role Assignments", "conditionalaccess" to "Conditional Access", "directoryroles" to "Directory Roles", "domains" to "Domains", diff --git a/docs/resources/app_role_assignment.md b/docs/resources/app_role_assignment.md new file mode 100644 index 0000000000..88a94bee57 --- /dev/null +++ b/docs/resources/app_role_assignment.md @@ -0,0 +1,174 @@ +--- +subcategory: "App Role Assignments" +--- + +# Resource: azuread_app_role_assignment + +Manages an app role assignment for a group, user or service principal. Can be used to grant admin consent for application permissions. + +## 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: `AppRoleAssignment.ReadWrite.All` and `Application.Read.All`, or `AppRoleAssignment.ReadWrite.All` and `Directory.Read.All`, or `Application.ReadWrite.All`, or `Directory.ReadWrite.All` + +When authenticated with a user principal, this resource requires one of the following directory roles: `Application Administrator` or `Global Administrator` + +## Example Usage + +*App role assignment for accessing Microsoft Graph* + +```terraform +data "azuread_application_published_app_ids" "well_known" {} + +resource "azuread_service_principal" "msgraph" { + application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph + use_existing = true +} + +resource "azuread_application" "example" { + display_name = "example" + + required_resource_access { + resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph + + resource_access { + id = azuread_service_principal.msgraph.app_role_ids["User.Read.All"] + type = "Role" + } + + resource_access { + id = azuread_service_principal.msgraph.oauth2_permission_scope_ids["User.ReadWrite"] + type = "Scope" + } + } +} + +resource "azuread_service_principal" "example" { + application_id = azuread_application.example.application_id +} + +resource "azuread_app_role_assignment" "example" { + app_role_id = azuread_service_principal.msgraph.app_role_ids["User.Read.All"] + principal_object_id = azuread_service_principal.example.object_id + resource_object_id = azuread_service_principal.msgraph.object_id +} +``` + +*App role assignment for internal application* + +```terraform +resource "azuread_application" "internal" { + display_name = "internal" + + app_role { + allowed_member_types = ["Application"] + description = "Apps can query the database" + display_name = "Query" + enabled = true + id = "00000000-0000-0000-0000-111111111111" + value = "Query.All" + } +} + +resource "azuread_service_principal" "internal" { + application_id = azuread_application.internal.application_id +} + +resource "azuread_application" "example" { + display_name = "example" + + required_resource_access { + resource_app_id = azuread_application.internal.application_id + + resource_access { + id = azuread_service_principal.internal.app_role_ids["Query.All"] + type = "Role" + } + } +} + +resource "azuread_service_principal" "example" { + application_id = azuread_application.example.application_id +} + +resource "azuread_app_role_assignment" "example" { + app_role_id = azuread_service_principal.internal.app_role_ids["Query.All"] + principal_object_id = azuread_service_principal.example.object_id + resource_object_id = azuread_service_principal.internal.object_id +} +``` + +*Assign a user and group to an internal application* + +```terraform +data "azuread_domains" "example" { + only_initial = true +} + +resource "azuread_application" "internal" { + display_name = "internal" + + app_role { + allowed_member_types = ["Application", "User"] + description = "Admins can perform all task actions" + display_name = "Admin" + enabled = true + id = "00000000-0000-0000-0000-222222222222" + value = "Admin.All" + } +} + +resource "azuread_service_principal" "internal" { + application_id = azuread_application.internal.application_id +} + +resource "azuread_group" "example" { + display_name = "example" + security_enabled = true +} + +resource "azuread_app_role_assignment" "example" { + app_role_id = azuread_service_principal.internal.app_role_ids["Admin.All"] + principal_object_id = azuread_group.example.object_id + resource_object_id = azuread_service_principal.internal.object_id +} + +resource "azuread_user" "example" { + display_name = "D. Duck" + password = "SecretP@sswd99!" + user_principal_name = "d.duck@${data.azuread_domains.example.domains.0.domain_name}" +} + +resource "azuread_app_role_assignment" "example" { + app_role_id = azuread_service_principal.internal.app_role_ids["Admin.All"] + principal_object_id = azuread_user.example.object_id + resource_object_id = azuread_service_principal.internal.object_id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `app_role_id` - (Required) The ID of the app role to be assigned. Changing this forces a new resource to be created. +* `principal_object_id` - (Required) The object ID of the user, group or service principal to be assigned this app role. Supported object types are Users, Groups or Service Principals. Changing this forces a new resource to be created. +* `resource_object_id` - (Required) The object ID of the service principal representing the resource. Changing this forces a new resource to be created. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `principal_display_name` - The display name of the principal to which the app role is assigned. +* `principal_type` - The object type of the principal to which the app role is assigned. +* `resource_display_name` - The display name of the application representing the resource. + +## Import + +App role assignments can be imported using the object ID of the service principal representing the resource and the ID of the app role assignment (note: _not_ the ID of the app role), e.g. + +```shell +terraform import azuread_app_role_assignment.example 00000000-0000-0000-0000-000000000000/appRoleAssignment/aaBBcDDeFG6h5JKLMN2PQrrssTTUUvWWxxxxxyyyzzz +``` + +-> This ID format is unique to Terraform and is composed of the Resource Service Principal Object ID and the ID of the App Role Assignment in the format `{ResourcePrincipalID}/appRoleAssignment/{AppRoleAssignmentID}`. diff --git a/go.mod b/go.mod index d14fc92ea1..626c06dff1 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.29.0 + github.com/manicminer/hamilton v0.30.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 4e3103f818..be9927bad3 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.29.0 h1:SmKNUMbuk7Crp9mJUWJTNRKnsFzdIxyIhlvvJ0HjCGk= -github.com/manicminer/hamilton v0.29.0/go.mod h1:QryxpD/4+cdKuXNi0UjLDvgxYdP0LLmYz7dYU7DAX4U= +github.com/manicminer/hamilton v0.30.0 h1:n7og9/Xh4d/JX0XG0uG1rv2vlkIkz8jwynCWoQszZvA= +github.com/manicminer/hamilton v0.30.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/internal/clients/client.go b/internal/clients/client.go index 8e99ea2388..db7a418661 100644 --- a/internal/clients/client.go +++ b/internal/clients/client.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-provider-azuread/internal/common" applications "github.com/hashicorp/terraform-provider-azuread/internal/services/applications/client" + approleassignments "github.com/hashicorp/terraform-provider-azuread/internal/services/approleassignments/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" @@ -29,20 +30,22 @@ type Client struct { StopContext context.Context - Applications *applications.Client - ConditionalAccess *conditionalaccess.Client - DirectoryRoles *directoryroles.Client - Domains *domains.Client - Groups *groups.Client - Invitations *invitations.Client - ServicePrincipals *serviceprincipals.Client - Users *users.Client + Applications *applications.Client + AppRoleAssignments *approleassignments.Client + ConditionalAccess *conditionalaccess.Client + DirectoryRoles *directoryroles.Client + Domains *domains.Client + Groups *groups.Client + Invitations *invitations.Client + ServicePrincipals *serviceprincipals.Client + Users *users.Client } func (client *Client) build(ctx context.Context, o *common.ClientOptions) error { client.StopContext = ctx client.Applications = applications.NewClient(o) + client.AppRoleAssignments = approleassignments.NewClient(o) client.Domains = domains.NewClient(o) client.ConditionalAccess = conditionalaccess.NewClient(o) client.DirectoryRoles = directoryroles.NewClient(o) diff --git a/internal/provider/services.go b/internal/provider/services.go index 693441242e..3afe79cfe3 100644 --- a/internal/provider/services.go +++ b/internal/provider/services.go @@ -2,6 +2,7 @@ package provider import ( "github.com/hashicorp/terraform-provider-azuread/internal/services/applications" + "github.com/hashicorp/terraform-provider-azuread/internal/services/approleassignments" "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" @@ -14,6 +15,7 @@ import ( func SupportedServices() []ServiceRegistration { return []ServiceRegistration{ applications.Registration{}, + approleassignments.Registration{}, conditionalaccess.Registration{}, directoryroles.Registration{}, domains.Registration{}, diff --git a/internal/services/approleassignments/app_role_assignment_resource.go b/internal/services/approleassignments/app_role_assignment_resource.go new file mode 100644 index 0000000000..cf59cd6c84 --- /dev/null +++ b/internal/services/approleassignments/app_role_assignment_resource.go @@ -0,0 +1,182 @@ +package approleassignments + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "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/manicminer/hamilton/odata" + + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/services/approleassignments/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/tf" + "github.com/hashicorp/terraform-provider-azuread/internal/utils" + "github.com/hashicorp/terraform-provider-azuread/internal/validate" +) + +func appRoleAssignmentResource() *schema.Resource { + return &schema.Resource{ + CreateContext: appRoleAssignmentResourceCreate, + ReadContext: appRoleAssignmentResourceRead, + DeleteContext: appRoleAssignmentResourceDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Importer: tf.ValidateResourceIDPriorToImport(func(id string) error { + _, err := parse.AppRoleAssignmentID(id) + return err + }), + + Schema: map[string]*schema.Schema{ + "app_role_id": { + Description: "The ID of the app role to be assigned", + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "principal_object_id": { + Description: "The object ID of the user, group or service principal to be assigned this app role", + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "resource_object_id": { + Description: "The object ID of the service principal representing the resource", + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "principal_display_name": { + Description: "The display name of the principal to which the app role is assigned", + Type: schema.TypeString, + Computed: true, + }, + + "principal_type": { + Description: "The object type of the principal to which the app role is assigned", + Type: schema.TypeString, + Computed: true, + }, + + "resource_display_name": { + Description: "The display name of the application representing the resource", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func appRoleAssignmentResourceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).AppRoleAssignments.AppRoleAssignedToClient + servicePrincipalsClient := meta.(*clients.Client).AppRoleAssignments.ServicePrincipalsClient + + appRoleId := d.Get("app_role_id").(string) + principalId := d.Get("principal_object_id").(string) + resourceId := d.Get("resource_object_id").(string) + if _, status, err := servicePrincipalsClient.Get(ctx, resourceId, odata.Query{}); err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(err, "principal_object_id", "Service principal not found for resource (Object ID: %q)", resourceId) + } + return tf.ErrorDiagF(err, "Could not retrieve service principal for resource (Object ID: %q)", principalId) + } + properties := msgraph.AppRoleAssignment{ + AppRoleId: utils.String(appRoleId), + PrincipalId: utils.String(principalId), + ResourceId: utils.String(resourceId), + } + + appRoleAssignment, _, err := client.Assign(ctx, properties) + if err != nil { + return tf.ErrorDiagF(err, "Could not create app role assignment") + } + + if appRoleAssignment.Id == nil || *appRoleAssignment.Id == "" { + return tf.ErrorDiagF(errors.New("ID returned for app role assignment is nil"), "Bad API response") + } + + if appRoleAssignment.ResourceId == nil || *appRoleAssignment.ResourceId == "" { + return tf.ErrorDiagF(errors.New("Resource ID returned for app role assignment is nil"), "Bad API response") + } + + id := parse.NewAppRoleAssignmentID(*appRoleAssignment.ResourceId, *appRoleAssignment.Id) + d.SetId(id.String()) + + return appRoleAssignmentResourceRead(ctx, d, meta) +} + +func appRoleAssignmentResourceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).AppRoleAssignments.AppRoleAssignedToClient + + id, err := parse.AppRoleAssignmentID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing app role assignment with ID %q", d.Id()) + } + + query := odata.Query{Filter: fmt.Sprintf("id eq '%s'", id.AssignmentId)} + appRoleAssignments, status, err := client.List(ctx, id.ResourceId, query) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Resource Service Principal %q was not found - removing from state!", id.ResourceId) + d.SetId("") + return nil + } + return tf.ErrorDiagF(err, "retrieving app role assignments for resource with object ID: %q", id.ResourceId) + } + if appRoleAssignments == nil { + return tf.ErrorDiagF(errors.New("appRoleAssignments was nil"), "retrieving app role assignments for resource with object ID: %q", id.ResourceId) + } + + var appRoleAssignment *msgraph.AppRoleAssignment + for _, assignment := range *appRoleAssignments { + if assignment.Id != nil && *assignment.Id == id.AssignmentId { + appRoleAssignment = &assignment + break + } + } + if appRoleAssignment == nil { + log.Printf("[DEBUG] App Role Assignment %q for Resource %q was not found - removing from state!", id.AssignmentId, id.ResourceId) + d.SetId("") + return nil + } + + tf.Set(d, "app_role_id", appRoleAssignment.AppRoleId) + tf.Set(d, "principal_display_name", appRoleAssignment.PrincipalDisplayName) + tf.Set(d, "principal_object_id", appRoleAssignment.PrincipalId) + tf.Set(d, "principal_type", appRoleAssignment.PrincipalType) + tf.Set(d, "resource_display_name", appRoleAssignment.ResourceDisplayName) + tf.Set(d, "resource_object_id", appRoleAssignment.ResourceId) + + return nil +} + +func appRoleAssignmentResourceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).AppRoleAssignments.AppRoleAssignedToClient + + id, err := parse.AppRoleAssignmentID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing app role assignment with ID %q", d.Id()) + } + + if status, err := client.Remove(ctx, id.ResourceId, id.AssignmentId); err != nil { + return tf.ErrorDiagPathF(err, "id", "Deleting app role assignment for resource %q with ID %q, got status %d", id.ResourceId, id.AssignmentId, status) + } + + return nil +} diff --git a/internal/services/approleassignments/app_role_assignment_resource_test.go b/internal/services/approleassignments/app_role_assignment_resource_test.go new file mode 100644 index 0000000000..29847533b4 --- /dev/null +++ b/internal/services/approleassignments/app_role_assignment_resource_test.go @@ -0,0 +1,264 @@ +package approleassignments_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/manicminer/hamilton/odata" + + "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/approleassignments/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/utils" +) + +type AppRoleAssignmentResource struct{} + +func TestAccAppRoleAssignment_servicePrincipalForMsGraph(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_app_role_assignment", "test") + r := AppRoleAssignmentResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.servicePrincipalForMsGraph(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAppRoleAssignment_servicePrincipalForTenantApp(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_app_role_assignment", "test_admin") + r := AppRoleAssignmentResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.servicePrincipalForTenantApp(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That("azuread_app_role_assignment.test_query").ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAppRoleAssignment_groupForTenantApp(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_app_role_assignment", "test") + r := AppRoleAssignmentResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.groupForTenantApp(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAppRoleAssignment_userForTenantApp(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_app_role_assignment", "test") + r := AppRoleAssignmentResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.userForTenantApp(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func (r AppRoleAssignmentResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.AppRoleAssignments.AppRoleAssignedToClient + client.BaseClient.DisableRetries = true + + id, err := parse.AppRoleAssignmentID(state.ID) + if err != nil { + return nil, fmt.Errorf("parsing App Role Assignment ID: %v", err) + } + + query := odata.Query{Filter: fmt.Sprintf("id eq '%s'", id.AssignmentId)} + appRoleAssignments, status, err := client.List(ctx, id.ResourceId, query) + if err != nil { + if status == http.StatusNotFound { + return nil, fmt.Errorf("Resource Service Principal with ID %q does not exist", id.ResourceId) + } + return nil, fmt.Errorf("failed to retrieve Resource Service Principal with ID %q: %+v", id.ResourceId, err) + } + + if appRoleAssignments == nil { + return nil, fmt.Errorf("failed to retrieve App Role Assignments for Resource with ID %q: appRoleAssignments was nil", id.ResourceId) + } + + for _, assignment := range *appRoleAssignments { + if assignment.Id != nil && *assignment.Id == id.AssignmentId { + return utils.Bool(true), nil + } + } + + return utils.Bool(false), nil +} + +func (AppRoleAssignmentResource) servicePrincipalForMsGraph(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azuread" {} + +data "azuread_application_published_app_ids" "well_known" {} + +resource "azuread_service_principal" "msgraph" { + application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph + use_existing = true +} + +resource "azuread_application" "test" { + display_name = "acctest-appRoleAssignment-%[1]d" + + required_resource_access { + resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph + + resource_access { + id = azuread_service_principal.msgraph.app_role_ids["User.Read.All"] + type = "Role" + } + + resource_access { + id = azuread_service_principal.msgraph.oauth2_permission_scope_ids["User.ReadWrite"] + type = "Scope" + } + } +} + +resource "azuread_service_principal" "test" { + application_id = azuread_application.test.application_id +} + +resource "azuread_app_role_assignment" "test" { + app_role_id = azuread_service_principal.msgraph.app_role_ids["User.Read.All"] + principal_object_id = azuread_service_principal.test.object_id + resource_object_id = azuread_service_principal.msgraph.object_id +} +`, data.RandomInteger) +} + +func (AppRoleAssignmentResource) tenantAppTemplate(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azuread" {} + +resource "azuread_application" "internal" { + display_name = "acctest-AppRoleAssignment-internal-%[1]d" + + app_role { + allowed_member_types = ["Application", "User"] + description = "Admins can perform all task actions" + display_name = "Admin" + enabled = true + id = "%[2]s" + value = "Admin.All" + } + + app_role { + allowed_member_types = ["Application"] + description = "Apps can query the database" + display_name = "Query" + enabled = true + id = "%[3]s" + value = "Query.All" + } +} + +resource "azuread_service_principal" "internal" { + application_id = azuread_application.internal.application_id +} +`, data.RandomInteger, data.UUID(), data.UUID()) +} + +func (r AppRoleAssignmentResource) servicePrincipalForTenantApp(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_application" "test" { + display_name = "acctest-appRoleAssignment-%[2]d" + + required_resource_access { + resource_app_id = azuread_application.internal.application_id + + resource_access { + id = azuread_service_principal.internal.app_role_ids["Admin.All"] + type = "Role" + } + + resource_access { + id = azuread_service_principal.internal.app_role_ids["Query.All"] + type = "Role" + } + } +} + +resource "azuread_service_principal" "test" { + application_id = azuread_application.test.application_id +} + +resource "azuread_app_role_assignment" "test_admin" { + app_role_id = azuread_service_principal.internal.app_role_ids["Admin.All"] + principal_object_id = azuread_service_principal.test.object_id + resource_object_id = azuread_service_principal.internal.object_id +} + +resource "azuread_app_role_assignment" "test_query" { + app_role_id = azuread_service_principal.internal.app_role_ids["Query.All"] + principal_object_id = azuread_service_principal.test.object_id + resource_object_id = azuread_service_principal.internal.object_id +} +`, r.tenantAppTemplate(data), data.RandomInteger) +} + +func (r AppRoleAssignmentResource) groupForTenantApp(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_group" "test" { + display_name = "acctest-appRoleAssignment-%[2]d" + security_enabled = true +} + +resource "azuread_app_role_assignment" "test" { + app_role_id = azuread_service_principal.internal.app_role_ids["Admin.All"] + principal_object_id = azuread_group.test.object_id + resource_object_id = azuread_service_principal.internal.object_id +} +`, r.tenantAppTemplate(data), data.RandomInteger) +} + +func (r AppRoleAssignmentResource) userForTenantApp(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +data "azuread_domains" "test" { + only_initial = true +} + +resource "azuread_user" "test" { + display_name = "acctest-appRoleAssignment-%[2]d" + password = "%[3]s" + user_principal_name = "acctest-AppRoleAssignment-%[2]d@${data.azuread_domains.test.domains.0.domain_name}" +} + +resource "azuread_app_role_assignment" "test" { + app_role_id = azuread_service_principal.internal.app_role_ids["Admin.All"] + principal_object_id = azuread_user.test.object_id + resource_object_id = azuread_service_principal.internal.object_id +} +`, r.tenantAppTemplate(data), data.RandomInteger, data.RandomPassword) +} diff --git a/internal/services/approleassignments/client/client.go b/internal/services/approleassignments/client/client.go new file mode 100644 index 0000000000..0fb0ff2800 --- /dev/null +++ b/internal/services/approleassignments/client/client.go @@ -0,0 +1,25 @@ +package client + +import ( + "github.com/manicminer/hamilton/msgraph" + + "github.com/hashicorp/terraform-provider-azuread/internal/common" +) + +type Client struct { + AppRoleAssignedToClient *msgraph.AppRoleAssignedToClient + ServicePrincipalsClient *msgraph.ServicePrincipalsClient +} + +func NewClient(o *common.ClientOptions) *Client { + appRoleAssignedToClient := msgraph.NewAppRoleAssignedToClient(o.TenantID) + o.ConfigureClient(&appRoleAssignedToClient.BaseClient) + + servicePrincipalsClient := msgraph.NewServicePrincipalsClient(o.TenantID) + o.ConfigureClient(&servicePrincipalsClient.BaseClient) + + return &Client{ + AppRoleAssignedToClient: appRoleAssignedToClient, + ServicePrincipalsClient: servicePrincipalsClient, + } +} diff --git a/internal/services/approleassignments/parse/approleassignment.go b/internal/services/approleassignments/parse/approleassignment.go new file mode 100644 index 0000000000..87a6f7eb73 --- /dev/null +++ b/internal/services/approleassignments/parse/approleassignment.go @@ -0,0 +1,35 @@ +package parse + +import ( + "fmt" +) + +const appRoleAssignment = "appRoleAssignment" + +type AppRoleAssignmentId struct { + ResourceId string + AssignmentId string +} + +func NewAppRoleAssignmentID(objectId, keyId string) AppRoleAssignmentId { + return AppRoleAssignmentId{ + ResourceId: objectId, + AssignmentId: keyId, + } +} + +func (id AppRoleAssignmentId) String() string { + return id.ResourceId + "/" + appRoleAssignment + "/" + id.AssignmentId +} + +func AppRoleAssignmentID(idString string) (*AppRoleAssignmentId, error) { + id, err := ObjectSubResourceID(idString, appRoleAssignment) + if err != nil { + return nil, fmt.Errorf("unable to parse App Role Assignment ID: %v", err) + } + + return &AppRoleAssignmentId{ + ResourceId: id.objectId, + AssignmentId: id.subId, + }, nil +} diff --git a/internal/services/approleassignments/parse/object.go b/internal/services/approleassignments/parse/object.go new file mode 100644 index 0000000000..9a43dc291a --- /dev/null +++ b/internal/services/approleassignments/parse/object.go @@ -0,0 +1,53 @@ +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]) + } + + return &id, nil +} diff --git a/internal/services/approleassignments/registration.go b/internal/services/approleassignments/registration.go new file mode 100644 index 0000000000..78a65c4061 --- /dev/null +++ b/internal/services/approleassignments/registration.go @@ -0,0 +1,31 @@ +package approleassignments + +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 "App Role Assignments" +} + +// WebsiteCategories returns a list of categories which can be used for the sidebar +func (r Registration) WebsiteCategories() []string { + return []string{ + "App Role Assignments", + } +} + +// 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_app_role_assignment": appRoleAssignmentResource(), + } +} diff --git a/vendor/github.com/manicminer/hamilton/msgraph/app_role_assignments.go b/vendor/github.com/manicminer/hamilton/msgraph/app_role_assignments.go index 8ac0d0a4fa..fadb5cca5f 100644 --- a/vendor/github.com/manicminer/hamilton/msgraph/app_role_assignments.go +++ b/vendor/github.com/manicminer/hamilton/msgraph/app_role_assignments.go @@ -3,9 +3,12 @@ package msgraph import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" + + "github.com/manicminer/hamilton/odata" ) type appRoleAssignmentsResourceType string @@ -137,3 +140,108 @@ func (c *AppRoleAssignmentsClient) Assign(ctx context.Context, clientServicePrin return &appRoleAssignment, status, nil } + +// AppRoleAssignedToClient performs operations on AppRoleAssignments. +type AppRoleAssignedToClient struct { + BaseClient Client +} + +// NewAppRoleAssignedToClient returns a new AppRoleAssignedToClient +func NewAppRoleAssignedToClient(tenantId string) *AppRoleAssignedToClient { + return &AppRoleAssignedToClient{ + BaseClient: NewClient(Version10, tenantId), + } +} + +// List returns a list of app role assignments granted for a service principal +func (c *AppRoleAssignedToClient) List(ctx context.Context, id string, query odata.Query) (*[]AppRoleAssignment, int, error) { + resp, status, _, err := c.BaseClient.Get(ctx, GetHttpRequestInput{ + ValidStatusCodes: []int{http.StatusOK}, + Uri: Uri{ + Entity: fmt.Sprintf("/servicePrincipals/%s/appRoleAssignedTo", id), + HasTenantId: true, + Params: query.Values(), + }, + }) + if err != nil { + return nil, status, fmt.Errorf("AppRoleAssignedToClient.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 data struct { + AppRoleAssignments []AppRoleAssignment `json:"value"` + } + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, status, fmt.Errorf("json.Unmarshal(): %v", err) + } + + return &data.AppRoleAssignments, status, nil +} + +// Remove removes an app role assignment for a service principal +func (c *AppRoleAssignedToClient) Remove(ctx context.Context, resourceId, appRoleAssignmentId string) (int, error) { + _, status, _, err := c.BaseClient.Delete(ctx, DeleteHttpRequestInput{ + ConsistencyFailureFunc: RetryOn404ConsistencyFailureFunc, + ValidStatusCodes: []int{http.StatusNoContent}, + Uri: Uri{ + Entity: fmt.Sprintf("/servicePrincipals/%s/appRoleAssignedTo/%s", resourceId, appRoleAssignmentId), + HasTenantId: true, + }, + }) + if err != nil { + return status, fmt.Errorf("AppRoleAssignedToClient.BaseClient.Delete(): %v", err) + } + + return status, nil +} + +// Assign assigns an app role for a service principal to the specified user/group/service principal object +func (c *AppRoleAssignedToClient) Assign(ctx context.Context, appRoleAssignment AppRoleAssignment) (*AppRoleAssignment, int, error) { + var status int + + if appRoleAssignment.ResourceId == nil { + return nil, status, errors.New("AppRoleAssignedToClient.Assign(): ResourceId was nil for appRoleAssignment") + } + if appRoleAssignment.AppRoleId == nil { + return nil, status, errors.New("AppRoleAssignedToClient.Assign(): AppRoleId was nil for appRoleAssignment") + } + if appRoleAssignment.PrincipalId == nil { + return nil, status, errors.New("AppRoleAssignedToClient.Assign(): PrincipalId was nil for appRoleAssignment") + } + + body, err := json.Marshal(appRoleAssignment) + if err != nil { + return nil, status, fmt.Errorf("json.Marshal(): %v", err) + } + + resp, status, _, err := c.BaseClient.Post(ctx, PostHttpRequestInput{ + Body: body, + ConsistencyFailureFunc: RetryOn404ConsistencyFailureFunc, + ValidStatusCodes: []int{http.StatusCreated}, + Uri: Uri{ + Entity: fmt.Sprintf("/servicePrincipals/%s/appRoleAssignedTo", *appRoleAssignment.ResourceId), + HasTenantId: true, + }, + }) + if err != nil { + return nil, status, fmt.Errorf("AppRoleAssignedToClient.BaseClient.Post(): %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 newAppRoleAssignment AppRoleAssignment + if err := json.Unmarshal(respBody, &newAppRoleAssignment); err != nil { + return nil, status, fmt.Errorf("json.Unmarshal(): %v", err) + } + + return &newAppRoleAssignment, status, nil +} diff --git a/vendor/github.com/manicminer/hamilton/odata/odata.go b/vendor/github.com/manicminer/hamilton/odata/odata.go index 83d7f2fcd7..562077ba47 100644 --- a/vendor/github.com/manicminer/hamilton/odata/odata.go +++ b/vendor/github.com/manicminer/hamilton/odata/odata.go @@ -86,7 +86,7 @@ type OData struct { Context *string `json:"@odata.context"` MetadataEtag *string `json:"@odata.metadataEtag"` Type *Type `json:"@odata.type"` - Count *string `json:"@odata.count"` + Count *int `json:"@odata.count"` NextLink *string `json:"@odata.nextLink"` Delta *string `json:"@odata.delta"` DeltaLink *string `json:"@odata.deltaLink"` diff --git a/vendor/modules.txt b/vendor/modules.txt index 377960408f..c700070a3f 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.29.0 +# github.com/manicminer/hamilton v0.30.0 ## explicit github.com/manicminer/hamilton/auth github.com/manicminer/hamilton/environments