diff --git a/docs/resources/application_pre_authorized.md b/docs/resources/application_pre_authorized.md new file mode 100644 index 0000000000..fac746dc32 --- /dev/null +++ b/docs/resources/application_pre_authorized.md @@ -0,0 +1,71 @@ +--- +subcategory: "Applications" +--- + +# Resource: azuread_application_pre_authorized + +Manages client applications that are pre-authorized with the specified permissions to access an application's APIs without requiring user consent. + +## Example Usage + +```terraform +resource "azuread_application" "authorized" { + display_name = "example-authorized-app" +} + +resource "azuread_application" "authorizer" { + display_name = "example-authorizing-app" + + api { + oauth2_permission_scope { + admin_consent_description = "Administer the application" + admin_consent_display_name = "Administer" + enabled = true + id = "ced9c4c3-c273-4f0f-ac71-a20377b90f9c" + type = "Admin" + value = "administer" + } + + oauth2_permission_scope { + admin_consent_description = "Access the application" + admin_consent_display_name = "Access" + enabled = true + id = "2d5e07ca-664d-4d9b-ad61-ec07fd215213" + type = "User" + user_consent_description = "Access the application" + user_consent_display_name = "Access" + value = "user_impersonation" + } + } +} + +resource "azuread_application_pre_authorized" "example" { + application_object_id = azuread_application.authorizer.object_id + authorized_app_id = azuread_application.authorized.application_id + permission_ids = ["ced9c4c3-c273-4f0f-ac71-a20377b90f9c", "2d5e07ca-664d-4d9b-ad61-ec07fd215213"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `application_object_id` - (Required) The object ID of the application for which permissions are being authorized. Changing this field forces a new resource to be created. +* `authorized_application_id` - (Optional) The application ID (client ID) of the application being authorized. Changing this field forces a new resource to be created. +* `permission_ids` - (Required) A set of permission scope IDs required by the authorized application. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +*No additional attributes are exported* + +## Import + +Pre-authorized applications can be imported using the object ID of the authorizing application and the application ID of the application being authorized, e.g. + +```shell +terraform import azuread_application_pre_authorized.example 00000000-0000-0000-0000-000000000000/preAuthorizedApplication/11111111-1111-1111-1111-111111111111 +``` + +-> **NOTE:** This ID format is unique to Terraform and is composed of the authorizing application's object ID, the string "preAuthorizedApplication" and the authorized application's application ID (client ID) in the format `{ObjectId}/preAuthorizedApplication/{ApplicationId}`. diff --git a/internal/services/applications/application_pre_authorized_resource.go b/internal/services/applications/application_pre_authorized_resource.go new file mode 100644 index 0000000000..e8e4b235d5 --- /dev/null +++ b/internal/services/applications/application_pre_authorized_resource.go @@ -0,0 +1,258 @@ +package applications + +import ( + "context" + "errors" + "fmt" + "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/services/applications/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 applicationPreAuthorizedResource() *schema.Resource { + return &schema.Resource{ + CreateContext: applicationPreAuthorizedResourceCreate, + ReadContext: applicationPreAuthorizedResourceRead, + UpdateContext: applicationPreAuthorizedResourceUpdate, + DeleteContext: applicationPreAuthorizedResourceDelete, + + 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.ApplicationPreAuthorizedID(id) + return err + }), + + Schema: map[string]*schema.Schema{ + "application_object_id": { + Description: "The object ID of the application to which this pre-authorized application should be added", + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "authorized_app_id": { + Description: "The application ID of the pre-authorized application", + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validate.UUID, + }, + + "permission_ids": { + Description: "The IDs of the permission scopes required by the pre-authorized application", + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateDiagFunc: validate.UUID, + }, + }, + }, + } +} + +func applicationPreAuthorizedResourceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).Applications.ApplicationsClient + id := parse.NewApplicationPreAuthorizedID(d.Get("application_object_id").(string), d.Get("authorized_app_id").(string)) + + tf.LockByName(applicationResourceName, id.ObjectId) + defer tf.UnlockByName(applicationResourceName, id.ObjectId) + + app, status, err := client.Get(ctx, id.ObjectId) + if err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(nil, "application_object_id", "Application with object ID %q was not found", id.ObjectId) + } + return tf.ErrorDiagPathF(err, "application_object_id", "Retrieving application with object ID %q", id.ObjectId) + } + if app == nil || app.ID == nil { + return tf.ErrorDiagF(errors.New("nil application or application with nil ID was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + + newPreAuthorizedApps := make([]msgraph.ApiPreAuthorizedApplication, 0) + if app.Api != nil && app.Api.PreAuthorizedApplications != nil { + for _, a := range *app.Api.PreAuthorizedApplications { + if a.AppId != nil && strings.EqualFold(*a.AppId, id.AppId) { + return tf.ImportAsExistsDiag("azuread_application_pre_authorized", id.String()) + } + newPreAuthorizedApps = append(newPreAuthorizedApps, a) + } + } + + newPreAuthorizedApps = append(newPreAuthorizedApps, msgraph.ApiPreAuthorizedApplication{ + AppId: utils.String(id.AppId), + PermissionIds: tf.ExpandStringSlicePtr(d.Get("permission_ids").(*schema.Set).List()), + }) + + properties := msgraph.Application{ + ID: app.ID, + Api: &msgraph.ApplicationApi{ + PreAuthorizedApplications: &newPreAuthorizedApps, + }, + } + + if _, err := client.Update(ctx, properties); err != nil { + return tf.ErrorDiagF(err, "Adding pre-authorized application %q for application with object ID %q", id.AppId, id.ObjectId) + } + + d.SetId(id.String()) + + return applicationPreAuthorizedResourceRead(ctx, d, meta) +} + +func applicationPreAuthorizedResourceUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).Applications.ApplicationsClient + id, err := parse.ApplicationPreAuthorizedID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing pre-authorized application ID %q", d.Id()) + } + + tf.LockByName(applicationResourceName, id.ObjectId) + defer tf.UnlockByName(applicationResourceName, id.ObjectId) + + app, status, err := client.Get(ctx, id.ObjectId) + if err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(nil, "application_object_id", "Application with object ID %q was not found", id.ObjectId) + } + return tf.ErrorDiagPathF(err, "application_object_id", "Retrieving application with object ID %q", id.ObjectId) + } + if app == nil || app.ID == nil { + return tf.ErrorDiagF(errors.New("nil application or application with nil ID was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + if app.Api == nil || app.Api.PreAuthorizedApplications == nil { + return tf.ErrorDiagF(errors.New("application with nil preAuthorizedApplications was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + + found := false + newPreAuthorizedApps := *app.Api.PreAuthorizedApplications + for i, a := range newPreAuthorizedApps { + if a.AppId != nil && strings.EqualFold(*a.AppId, id.AppId) { + found = true + newPreAuthorizedApps[i].PermissionIds = tf.ExpandStringSlicePtr(d.Get("permission_ids").(*schema.Set).List()) + break + } + } + if !found { + return tf.ErrorDiagF(fmt.Errorf("could not match an existing preAuthorizedApplication for %q", id.AppId), "retrieving application with object ID %q", id.ObjectId) + } + + properties := msgraph.Application{ + ID: app.ID, + Api: &msgraph.ApplicationApi{ + PreAuthorizedApplications: &newPreAuthorizedApps, + }, + } + + if _, err := client.Update(ctx, properties); err != nil { + return tf.ErrorDiagF(err, "Updating pre-authorized application %q for application with object ID %q", id.AppId, id.ObjectId) + } + + return applicationPreAuthorizedResourceRead(ctx, d, meta) +} + +func applicationPreAuthorizedResourceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).Applications.ApplicationsClient + id, err := parse.ApplicationPreAuthorizedID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing pre-authorized application ID %q", d.Id()) + } + + app, status, err := client.Get(ctx, id.ObjectId) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Application with ID %q for pre-authorized application %q was not found - removing from state!", id.ObjectId, id.AppId) + d.SetId("") + return nil + } + return tf.ErrorDiagPathF(err, "application_object_id", "Retrieving Application with object ID %q", id.ObjectId) + } + if app == nil || app.ID == nil { + return tf.ErrorDiagF(errors.New("nil application or application with nil ID was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + if app.Api == nil || app.Api.PreAuthorizedApplications == nil { + return tf.ErrorDiagF(errors.New("application with nil preAuthorizedApplications was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + + var preAuthorizedApp *msgraph.ApiPreAuthorizedApplication + for _, a := range *app.Api.PreAuthorizedApplications { + if a.AppId != nil && strings.EqualFold(*a.AppId, id.AppId) { + preAuthorizedApp = &a + break + } + } + if preAuthorizedApp == nil { + log.Printf("[DEBUG] No matching preAuthorizedApplication for ID %q - removing from state!", id) + d.SetId("") + return nil + } + + d.Set("application_object_id", id.ObjectId) + d.Set("authorized_app_id", id.AppId) + d.Set("permission_ids", tf.FlattenStringSlicePtr(preAuthorizedApp.PermissionIds)) + + return nil +} + +func applicationPreAuthorizedResourceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*clients.Client).Applications.ApplicationsClient + id, err := parse.ApplicationPreAuthorizedID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing pre-authorized application ID %q", d.Id()) + } + + app, status, err := client.Get(ctx, id.ObjectId) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Application with ID %q for pre-authorized application %q was not found - removing from state!", id.ObjectId, id.AppId) + d.SetId("") + return nil + } + return tf.ErrorDiagPathF(err, "application_object_id", "Retrieving Application with object ID %q", id.ObjectId) + } + if app == nil || app.ID == nil { + return tf.ErrorDiagF(errors.New("nil application or application with nil ID was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + if app.Api == nil || app.Api.PreAuthorizedApplications == nil { + return tf.ErrorDiagF(errors.New("application with nil preAuthorizedApplications was returned"), "API error retrieving application with object ID %q", id.ObjectId) + } + + newPreAuthorizedApps := make([]msgraph.ApiPreAuthorizedApplication, 0) + for _, a := range *app.Api.PreAuthorizedApplications { + if a.AppId != nil && !strings.EqualFold(*a.AppId, id.AppId) { + newPreAuthorizedApps = append(newPreAuthorizedApps, a) + break + } + } + + properties := msgraph.Application{ + ID: app.ID, + Api: &msgraph.ApplicationApi{ + PreAuthorizedApplications: &newPreAuthorizedApps, + }, + } + + if _, err := client.Update(ctx, properties); err != nil { + return tf.ErrorDiagF(err, "Removing pre-authorized application %q from application with object ID %q", id.AppId, id.ObjectId) + } + + return nil +} diff --git a/internal/services/applications/application_pre_authorized_resource_test.go b/internal/services/applications/application_pre_authorized_resource_test.go new file mode 100644 index 0000000000..ec243bae5d --- /dev/null +++ b/internal/services/applications/application_pre_authorized_resource_test.go @@ -0,0 +1,132 @@ +package applications_test + +import ( + "context" + "fmt" + "net/http" + "strings" + "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/applications/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/utils" +) + +type ApplicationPreAuthorizedResource struct{} + +func TestAccApplicationPreAuthorized_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_application_pre_authorized", "test") + r := ApplicationPreAuthorizedResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.basic(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("authorized_app_id").Exists(), + check.That(data.ResourceName).Key("permission_ids.#").HasValue("2"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccApplicationPreAuthorized_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_application_pre_authorized", "test") + r := ApplicationPreAuthorizedResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.basic(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport(data)), + }) +} + +func (ApplicationPreAuthorizedResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.Applications.ApplicationsClient + client.BaseClient.DisableRetries = true + + id, err := parse.ApplicationPreAuthorizedID(state.ID) + if err != nil { + return nil, fmt.Errorf("parsing Pre-Authorized Application ID: %v", err) + } + + app, status, err := client.Get(ctx, id.ObjectId) + if err != nil { + if status == http.StatusNotFound { + return nil, fmt.Errorf("Application with object ID %q does not exist", id.ObjectId) + } + return nil, fmt.Errorf("failed to retrieve Application with object ID %q: %+v", id.ObjectId, err) + } + + if app.Api != nil && app.Api.PreAuthorizedApplications != nil { + for _, a := range *app.Api.PreAuthorizedApplications { + if a.AppId != nil && strings.EqualFold(*a.AppId, id.AppId) { + return utils.Bool(true), nil + } + } + } + + return nil, fmt.Errorf("Pre-Authorized Application %q was not found for Application %q", id.AppId, id.ObjectId) +} + +func (ApplicationPreAuthorizedResource) basic(data acceptance.TestData) string { + return fmt.Sprintf(` +resource "azuread_application" "authorized" { + display_name = "acctestApp-authorized-%[1]d" +} + +resource "azuread_application" "authorizer" { + display_name = "acctestApp-authorizer-%[1]d" + + api { + oauth2_permission_scope { + admin_consent_description = "Administer the application" + admin_consent_display_name = "Administer" + enabled = true + id = "%[2]s" + type = "Admin" + value = "administer" + } + + oauth2_permission_scope { + admin_consent_description = "Access the application" + admin_consent_display_name = "Access" + enabled = true + id = "%[3]s" + type = "User" + user_consent_description = "Access the application" + user_consent_display_name = "Access" + value = "user_impersonation" + } + } +} + +resource "azuread_application_pre_authorized" "test" { + application_object_id = azuread_application.authorizer.object_id + authorized_app_id = azuread_application.authorized.application_id + permission_ids = ["%[2]s", "%[3]s"] +} +`, data.RandomInteger, data.UUID(), data.UUID()) +} + +func (r ApplicationPreAuthorizedResource) requiresImport(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_application_pre_authorized" "import" { + application_object_id = azuread_application_pre_authorized.test.application_object_id + authorized_app_id = azuread_application_pre_authorized.test.authorized_app_id + permission_ids = ["%[2]s"] +} +`, r.basic(data), data.UUID()) +} diff --git a/internal/services/applications/parse/pre_authorized_application.go b/internal/services/applications/parse/pre_authorized_application.go new file mode 100644 index 0000000000..cac93e4dd2 --- /dev/null +++ b/internal/services/applications/parse/pre_authorized_application.go @@ -0,0 +1,31 @@ +package parse + +import "fmt" + +type ApplicationPreAuthorizedId struct { + ObjectId string + AppId string +} + +func NewApplicationPreAuthorizedID(objectId, appId string) ApplicationPreAuthorizedId { + return ApplicationPreAuthorizedId{ + ObjectId: objectId, + AppId: appId, + } +} + +func (id ApplicationPreAuthorizedId) String() string { + return id.ObjectId + "/preAuthorizedApplication/" + id.AppId +} + +func ApplicationPreAuthorizedID(idString string) (*ApplicationPreAuthorizedId, error) { + id, err := ObjectSubResourceID(idString, "preAuthorizedApplication") + if err != nil { + return nil, fmt.Errorf("unable to parse Pre-Authorized Application ID: %v", err) + } + + return &ApplicationPreAuthorizedId{ + ObjectId: id.objectId, + AppId: id.subId, + }, nil +} diff --git a/internal/services/applications/registration.go b/internal/services/applications/registration.go index 8242b2a1d5..724977be1f 100644 --- a/internal/services/applications/registration.go +++ b/internal/services/applications/registration.go @@ -28,8 +28,9 @@ func (r Registration) SupportedDataSources() 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_application": applicationResource(), - "azuread_application_certificate": applicationCertificateResource(), - "azuread_application_password": applicationPasswordResource(), + "azuread_application": applicationResource(), + "azuread_application_certificate": applicationCertificateResource(), + "azuread_application_password": applicationPasswordResource(), + "azuread_application_pre_authorized": applicationPreAuthorizedResource(), } }