diff --git a/docs/resources/application.md b/docs/resources/application.md index 0809aa2d4..9a0fb4936 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -144,6 +144,33 @@ resource "azuread_application" "example" { } ``` +*Create application and generate a password* + +```terraform +data "azuread_client_config" "current" {} + +resource "time_rotating" "example" { + rotation_days = 180 +} + +resource "azuread_application" "example" { + display_name = "example" + owners = [data.azuread_client_config.current.object_id] + + password { + display_name = "MySecret-1" + start_date = time_rotating.example.id + end_date = timeadd(time_rotating.example.id, "4320h") + } +} + +output "example_password" { + sensitive = true + value = tolist(azuread_application.example.password).0.value +} + +``` + *Create application from a gallery template* ```terraform @@ -157,8 +184,8 @@ resource "azuread_application" "example" { } resource "azuread_service_principal" "example" { - application_id = azuread_application.example.application_id - use_existing = true + client_id = azuread_application.example.client_id + use_existing = true } ``` @@ -187,6 +214,10 @@ The following arguments are supported: -> **Ownership of Applications** It's recommended to always specify one or more application owners, including the principal being used to execute Terraform, such as in the example above. +* `password` - (Optional) A single `password` block as documented below. The password is generated during creation. By default, no password is generated. + +-> **Creating a Password** The `password` block supports a single password for the application, and is provided so that a password can be generated when a new application is created. This helps to make new applications available for authentication more quickly. To add additional passwords to an application, see the [azuread_application_password](application_password.html) resource. + * `prevent_duplicate_names` - (Optional) If `true`, will return an error if an existing application is found with the same name. Defaults to `false`. * `privacy_statement_url` - (Optional) URL of the application's privacy statement. * `public_client` - (Optional) A `public_client` block as documented below, which configures non-web app or non-web API application settings, for example mobile or other public clients such as an installed application running on a desktop device. @@ -284,6 +315,14 @@ The following arguments are supported: --- +`password` block supports the following: + +* `display_name` - (Required) A display name for the password. Changing this field forces a new resource to be created. +* `end_date` - (Optional) The end date until which the password is valid, formatted as an RFC3339 date string (e.g. `2018-01-01T01:02:03Z`). Changing this field forces a new resource to be created. +* `start_date` - (Optional) The start date from which the password is valid, formatted as an RFC3339 date string (e.g. `2018-01-01T01:02:03Z`). If this isn't specified, the current date is used. Changing this field forces a new resource to be created. + +--- + `public_client` block supports the following: * `redirect_uris` - (Optional) A set of URLs where user tokens are sent for sign-in, or the redirect URIs where OAuth 2.0 authorization codes and access tokens are sent. Must be a valid `https` or `ms-appx-web` URL. @@ -326,6 +365,8 @@ The following arguments are supported: * `access_token_issuance_enabled` - (Optional) Whether this web application can request an access token using OAuth 2.0 implicit flow. * `id_token_issuance_enabled` - (Optional) Whether this web application can request an ID token using OAuth 2.0 implicit flow. +--- + ## Attributes Reference In addition to all arguments above, the following attributes are exported: @@ -337,8 +378,18 @@ In addition to all arguments above, the following attributes are exported: * `logo_url` - CDN URL to the application's logo, as uploaded with the `logo_image` property. * `oauth2_permission_scope_ids` - A mapping of OAuth2.0 permission scope values to scope IDs, intended to be useful when referencing permission scopes in other resources in your configuration. * `object_id` - The application's object ID. +* `password` - A `password` block as documented below. Note that this block is a set rather than a list, and you will need to convert or iterate it to address its attributes (see the usage example above). +* `publisher_domain` - The verified publisher domain for the application. * `publisher_domain` - The verified publisher domain for the application. +--- + +`password` block exports the following: + +* `key_id` - (Required) The unique key ID for the generated password. +* `value` - (Required) The generated password for the application. + + ## Import Applications can be imported using the object ID of the application, in the following format. diff --git a/internal/helpers/credentials.go b/internal/helpers/credentials.go index d040a77e3..2fd23865c 100644 --- a/internal/helpers/credentials.go +++ b/internal/helpers/credentials.go @@ -185,15 +185,14 @@ func KeyCredentialForResource(d *pluginsdk.ResourceData) (*msgraph.KeyCredential return &credential, nil } -func PasswordCredentialForResource(d *pluginsdk.ResourceData) (*msgraph.PasswordCredential, error) { +func PasswordCredential(in map[string]interface{}) (*msgraph.PasswordCredential, error) { credential := msgraph.PasswordCredential{} - // display_name, start_date and end_date support intentionally remains for if/when the API supports user-specified values for these - if v, ok := d.GetOk("display_name"); ok { + if v, ok := in["display_name"]; ok { credential.DisplayName = pointer.To(v.(string)) } - if v, ok := d.GetOk("start_date"); ok { + if v, ok := in["start_date"]; ok && v.(string) != "" { startDate, err := time.Parse(time.RFC3339, v.(string)) if err != nil { return nil, CredentialError{str: fmt.Sprintf("Unable to parse the provided start date %q: %+v", v, err), attr: "start_date"} @@ -201,31 +200,44 @@ func PasswordCredentialForResource(d *pluginsdk.ResourceData) (*msgraph.Password credential.StartDateTime = &startDate } - var endDate *time.Time - if v, ok := d.GetOk("end_date"); ok && v.(string) != "" { + if v, ok := in["end_date"]; ok && v.(string) != "" { var err error expiry, err := time.Parse(time.RFC3339, v.(string)) if err != nil { return nil, CredentialError{str: fmt.Sprintf("Unable to parse the provided end date %q: %+v", v, err), attr: "end_date"} } - endDate = &expiry - } else if v, ok := d.GetOk("end_date_relative"); ok && v.(string) != "" { - d, err := time.ParseDuration(v.(string)) - if err != nil { - return nil, CredentialError{str: fmt.Sprintf("Unable to parse `end_date_relative` (%q) as a duration", v), attr: "end_date_relative"} - } - if credential.StartDateTime == nil { - expiry := time.Now().Add(d) - endDate = &expiry - } else { - expiry := credential.StartDateTime.Add(d) - endDate = &expiry - } + credential.EndDateTime = &expiry } - if endDate != nil { - credential.EndDateTime = endDate + + if v, ok := in["key_id"]; ok && v.(string) != "" { + credential.KeyId = pointer.To(v.(string)) + } + + if v, ok := in["value"]; ok && v.(string) != "" { + credential.SecretText = pointer.To(v.(string)) } return &credential, nil } + +func PasswordCredentialForResource(d *pluginsdk.ResourceData) (*msgraph.PasswordCredential, error) { + data := make(map[string]interface{}) + + // display_name, start_date and end_date support intentionally remains for if/when the API supports user-specified values for these + if v, ok := d.GetOk("display_name"); ok { + data["display_name"] = v + } + + if v, ok := d.GetOk("start_date"); ok { + data["start_date"] = v + } + + if v, ok := d.GetOk("end_date"); ok && v.(string) != "" { + data["end_date"] = v + } else if v, ok := d.GetOk("end_date_relative"); ok && v.(string) != "" { + data["end_date_relative"] = v + } + + return PasswordCredential(data) +} diff --git a/internal/services/applications/application_resource.go b/internal/services/applications/application_resource.go index 166093d88..4dc17a769 100644 --- a/internal/services/applications/application_resource.go +++ b/internal/services/applications/application_resource.go @@ -410,6 +410,53 @@ func applicationResource() *pluginsdk.Resource { }, }, + //lintignore:S018 // We are intentionally using TypeSet here to effect a replace-style representation in the diff for this block + "password": { + Description: "App password definition", + Type: pluginsdk.TypeSet, + Optional: true, + Computed: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "display_name": { + Description: "A display name for the password", + Type: pluginsdk.TypeString, + Required: true, + }, + + "start_date": { + Description: "The start date from which the password is valid, formatted as an RFC3339 date string (e.g. `2018-01-01T01:02:03Z`). If this isn't specified, the current date is used", + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.IsRFC3339Time, + }, + + "end_date": { + Description: "The end date until which the password is valid, formatted as an RFC3339 date string (e.g. `2018-01-01T01:02:03Z`)", + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.IsRFC3339Time, + }, + + "key_id": { + Description: "A UUID used to uniquely identify this password credential", + Type: pluginsdk.TypeString, + Computed: true, + }, + + "value": { + Description: "The password for this application, which is generated by Azure Active Directory", + Type: pluginsdk.TypeString, + Computed: true, + Sensitive: true, + }, + }, + }, + }, + "privacy_statement_url": { Description: "URL of the application's privacy statement", Type: pluginsdk.TypeString, @@ -817,7 +864,6 @@ func applicationResourceCustomizeDiff(ctx context.Context, diff *pluginsdk.Resou func applicationDiffSuppress(k, old, new string, d *pluginsdk.ResourceData) bool { suppress := false - switch { case k == "api.#" && old == "1" && new == "0": apiRaw := d.Get("api").([]interface{}) @@ -1018,6 +1064,21 @@ func applicationResourceCreate(ctx context.Context, d *pluginsdk.ResourceData, m Web: expandApplicationWeb(d.Get("web").([]interface{})), } + // Generate an application password, if specified + if v, ok := d.GetOk("password"); ok { + password := v.(*pluginsdk.Set).List() + if len(password) > 1 { + return tf.ErrorDiagPathF(errors.New("`password` must have one element"), "password", "Multiple passwords are not supported with this resource") + } + + credentials, err := expandApplicationPasswordCredentials(password) + if err != nil { + return tf.ErrorDiagPathF(err, "password", "Could not flatten application password credentials") + } + + properties.PasswordCredentials = credentials + } + // Sort the owners into two slices, the first containing up to 20 and the rest overflowing to the second slice // The calling principal should always be in the first slice of owners callerObject, _, err := directoryObjectsClient.Get(ctx, callerId, odata.Query{}) @@ -1080,6 +1141,18 @@ func applicationResourceCreate(ctx context.Context, d *pluginsdk.ResourceData, m id := parse.NewApplicationID(*app.ID()) d.SetId(id.ID()) + // Save the password key ID and generated value to state + if app.PasswordCredentials != nil { + if password := d.Get("password").(*pluginsdk.Set).List(); len(password) == 1 { + pw := password[0].(map[string]interface{}) + if credentials := flattenApplicationPasswordCredentials(app.PasswordCredentials); len(credentials) == 1 { + pw["key_id"] = credentials[0]["key_id"] + pw["value"] = credentials[0]["value"] + tf.Set(d, "password", []interface{}{pw}) + } + } + } + // Attempt to patch the newly created application and set the display name, which will tell us whether it exists yet, then set it back to the desired value. // The SDK handles retries for us here in the event of 404, 429 or 5xx, then returns after giving up. uuid, err := uuid.GenerateUUID() @@ -1151,6 +1224,9 @@ func applicationResourceUpdate(ctx context.Context, d *pluginsdk.ResourceData, m return tf.ErrorDiagPathF(err, "id", "Parsing ID") } + tf.LockByName(applicationResourceName, id.ApplicationId) + defer tf.UnlockByName(applicationResourceName, id.ApplicationId) + displayName := d.Get("display_name").(string) // Perform this check at apply time to catch any duplicate names created during the same apply @@ -1182,6 +1258,113 @@ func applicationResourceUpdate(ctx context.Context, d *pluginsdk.ResourceData, m } } + // Remove and/or set a new application password, if changed + if d.HasChange("password") { + oldPasswordRaw, newPasswordRaw := d.GetChange("password") + oldPasswordBlock := oldPasswordRaw.(*pluginsdk.Set).List() + oldPassword := make(map[string]interface{}) + if len(oldPasswordBlock) > 0 { + oldPassword = oldPasswordBlock[0].(map[string]interface{}) + } + + if oldPassword["key_id"] != nil { + keyIdToRemove := oldPassword["key_id"].(string) + if _, err = client.RemovePassword(ctx, id.ApplicationId, keyIdToRemove); err != nil { + return tf.ErrorDiagF(err, "Removing password credential %q from application with object ID %q", id.ApplicationId, keyIdToRemove) + } + + // Wait for application password to be deleted + if err = helpers.WaitForDeletion(ctx, func(ctx context.Context) (*bool, error) { + defer func() { client.BaseClient.DisableRetries = false }() + client.BaseClient.DisableRetries = true + + app, _, err := client.Get(ctx, id.ApplicationId, odata.Query{}) + if err != nil { + return nil, err + } + + credential := helpers.GetPasswordCredential(app.PasswordCredentials, keyIdToRemove) + if credential == nil { + return pointer.To(false), nil + } + + return pointer.To(true), nil + }); err != nil { + return tf.ErrorDiagF(err, "Waiting for deletion of password credential %q from application with object ID %q", keyIdToRemove, id.ApplicationId) + } + } + + newPasswordBlock := newPasswordRaw.(*pluginsdk.Set).List() + if len(newPasswordBlock) > 1 { + return tf.ErrorDiagPathF(errors.New("`password` must have one element"), "password", "Multiple passwords are not supported with this resource") + } + + // Proceed to add a new password to replace the now-removed one, if the password block is present in the configuration + if len(newPasswordBlock) > 0 { + newPassword := newPasswordBlock[0].(map[string]interface{}) + + credential, err := helpers.PasswordCredential(newPassword) + if err != nil { + attr := "" + if kerr, ok := err.(helpers.CredentialError); ok { + attr = kerr.Attr() + } + return tf.ErrorDiagPathF(err, attr, "Generating password credential for %s", id.ApplicationId) + } + + newCredential, _, err := client.AddPassword(ctx, id.ApplicationId, *credential) + if err != nil { + return tf.ErrorDiagF(err, "Adding password for application with object ID %q", id.ApplicationId) + } + if newCredential == nil { + return tf.ErrorDiagF(errors.New("nil credential received when adding password"), "API error adding password for application with object ID %q", id.ApplicationId) + } + if newCredential.KeyId == nil { + return tf.ErrorDiagF(errors.New("nil or empty keyId received"), "API error adding password for application with object ID %q", id.ApplicationId) + } + if newCredential.SecretText == nil || len(*newCredential.SecretText) == 0 { + return tf.ErrorDiagF(errors.New("nil or empty password received"), "API error adding password for application with object ID %q", id.ApplicationId) + } + + // Wait for the credential to appear in the application manifest, this can take several minutes + timeout, _ := ctx.Deadline() + polledForCredential, err := (&pluginsdk.StateChangeConf{ //nolint:staticcheck + Pending: []string{"Waiting"}, + Target: []string{"Done"}, + Timeout: time.Until(timeout), + MinTimeout: 1 * time.Second, + ContinuousTargetOccurence: 5, + Refresh: func() (interface{}, string, error) { + app, _, err := client.Get(ctx, id.ApplicationId, odata.Query{}) + if err != nil { + return nil, "Error", err + } + + if app.PasswordCredentials != nil { + for _, cred := range *app.PasswordCredentials { + if cred.KeyId != nil && strings.EqualFold(*cred.KeyId, *newCredential.KeyId) { + return &cred, "Done", nil + } + } + } + + return nil, "Waiting", nil + }, + }).WaitForStateContext(ctx) + + if err != nil { + return tf.ErrorDiagF(err, "Waiting for password credential for application with object ID %q", id.ApplicationId) + } else if polledForCredential == nil { + return tf.ErrorDiagF(errors.New("password credential not found in application manifest"), "Waiting for password credential for application with object ID %q", id.ApplicationId) + } + + // Ensure the new value is persisted to state + newPassword["key_id"] = pointer.From(newCredential.KeyId) + newPassword["value"] = pointer.From(newCredential.SecretText) + tf.Set(d, "password", []interface{}{newPassword}) + } + } + var tags []string if v, ok := d.GetOk("feature_tags"); ok && len(v.([]interface{})) > 0 && d.HasChange("feature_tags") { tags = helpers.ApplicationExpandFeatures(v.([]interface{})) @@ -1334,6 +1517,30 @@ func applicationResourceRead(ctx context.Context, d *pluginsdk.ResourceData, met tf.Set(d, "terms_of_service_url", app.Info.TermsOfServiceUrl) } + if app.PasswordCredentials != nil { + currentPassword := d.Get("password").(*pluginsdk.Set).List() + passwordToSave := make([]interface{}, 0) + + var keyIdToMatch, existingValue string + + if len(currentPassword) == 1 { + keyIdToMatch = currentPassword[0].(map[string]interface{})["key_id"].(string) + existingValue = currentPassword[0].(map[string]interface{})["value"].(string) + } + + for _, credential := range flattenApplicationPasswordCredentials(app.PasswordCredentials) { + // Match against the known key ID, or select the first returned password if not present in state + if keyIdToMatch == "" || credential["key_id"] == keyIdToMatch { + // Retain the value from state, if known + credential["value"] = existingValue + passwordToSave = append(passwordToSave, credential) + break + } + } + + tf.Set(d, "password", passwordToSave) + } + logoImage := "" if v := d.Get("logo_image").(string); v != "" { logoImage = v diff --git a/internal/services/applications/application_resource_test.go b/internal/services/applications/application_resource_test.go index 3e8b9b21a..36b3afb61 100644 --- a/internal/services/applications/application_resource_test.go +++ b/internal/services/applications/application_resource_test.go @@ -9,6 +9,7 @@ import ( "net/http" "regexp" "testing" + "time" "github.com/hashicorp/go-azure-helpers/lang/pointer" "github.com/hashicorp/go-azure-sdk/sdk/odata" @@ -33,6 +34,7 @@ func TestAccApplication_basic(t *testing.T) { check.That(data.ResourceName).Key("application_id").Exists(), check.That(data.ResourceName).Key("client_id").Exists(), check.That(data.ResourceName).Key("object_id").Exists(), + check.That(data.ResourceName).Key("password.#").HasValue("0"), check.That(data.ResourceName).Key("display_name").HasValue(fmt.Sprintf("acctest-APP-%d", data.RandomInteger)), ), }, @@ -594,6 +596,61 @@ func TestAccApplication_logo(t *testing.T) { }) } +func TestAccApplication_password(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_application", "test") + r := ApplicationResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.password(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password.#").HasValue("1"), + check.That(data.ResourceName).Key("password.0.key_id").Exists(), + check.That(data.ResourceName).Key("password.0.value").Exists(), + check.That(data.ResourceName).Key("password.0.start_date").Exists(), + check.That(data.ResourceName).Key("password.0.end_date").Exists(), + check.That(data.ResourceName).Key("password.0.display_name").HasValue(fmt.Sprintf("acctest-appPassword-%d", data.RandomInteger)), + ), + }, + }) +} + +func TestAccApplication_passwordUpdate(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_application", "test") + startDate := time.Now().AddDate(0, 0, 7).UTC().Format(time.RFC3339) + endDate := time.Now().AddDate(0, 5, 27).UTC().Format(time.RFC3339) + updateEndDate := time.Now().AddDate(0, 11, 28).UTC().Format(time.RFC3339) + r := ApplicationResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.passwordComplete(data, startDate, endDate), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password.#").HasValue("1"), + check.That(data.ResourceName).Key("password.0.key_id").Exists(), + check.That(data.ResourceName).Key("password.0.value").Exists(), + check.That(data.ResourceName).Key("password.0.start_date").Exists(), + check.That(data.ResourceName).Key("password.0.end_date").Exists(), + check.That(data.ResourceName).Key("password.0.display_name").HasValue(fmt.Sprintf("acctest-appPasswordComplete-%d", data.RandomInteger)), + ), + }, + { + Config: r.passwordComplete(data, startDate, updateEndDate), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("password.#").HasValue("1"), + check.That(data.ResourceName).Key("password.0.key_id").Exists(), + check.That(data.ResourceName).Key("password.0.value").Exists(), + check.That(data.ResourceName).Key("password.0.start_date").Exists(), + check.That(data.ResourceName).Key("password.0.end_date").Exists(), + check.That(data.ResourceName).Key("password.0.display_name").HasValue(fmt.Sprintf("acctest-appPasswordComplete-%d", data.RandomInteger)), + ), + }, + }) +} + func (r ApplicationResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { client := clients.Applications.ApplicationsClientBeta client.BaseClient.DisableRetries = true @@ -1572,3 +1629,39 @@ resource "azuread_application" "test" { } `, data.RandomInteger) } + +func (r ApplicationResource) password(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azuread" {} + +data "azuread_client_config" "current" {} + +resource "azuread_application" "test" { + display_name = "acctest-appPassword-%[1]d" + owners = [data.azuread_client_config.current.object_id] + + password { + display_name = "acctest-appPassword-%[1]d" + } +} +`, data.RandomInteger) +} + +func (r ApplicationResource) passwordComplete(data acceptance.TestData, startDate, endDate string) string { + return fmt.Sprintf(` +provider "azuread" {} + +data "azuread_client_config" "current" {} + +resource "azuread_application" "test" { + display_name = "acctest-APP-%[1]d" + owners = [data.azuread_client_config.current.object_id] + + password { + display_name = "acctest-appPasswordComplete-%[1]d" + start_date = "%[2]s" + end_date = "%[3]s" + } +} +`, data.RandomInteger, startDate, endDate) +} diff --git a/internal/services/applications/applications.go b/internal/services/applications/applications.go index 9059e080a..be521285b 100644 --- a/internal/services/applications/applications.go +++ b/internal/services/applications/applications.go @@ -405,6 +405,28 @@ func expandApplicationApi(input []interface{}) (result *msgraph.ApplicationApi) return } +func expandApplicationPasswordCredentials(input []interface{}) (*[]msgraph.PasswordCredential, error) { + if len(input) == 0 { + return nil, nil + } + + result := make([]msgraph.PasswordCredential, 0) + + for _, password := range input { + if password == nil { + continue + } + + credential, err := helpers.PasswordCredential(password.(map[string]interface{})) + if err != nil { + return nil, err + } + result = append(result, *credential) + } + + return &result, nil +} + func expandApplicationAppRoles(input []interface{}) *[]msgraph.AppRole { result := make([]msgraph.AppRole, 0) @@ -793,6 +815,36 @@ func flattenApplicationSpa(in *msgraph.ApplicationSpa) []map[string]interface{} }} } +func flattenApplicationPasswordCredentials(input *[]msgraph.PasswordCredential) []map[string]interface{} { + output := make([]map[string]interface{}, 0) + + if input == nil { + return output + } + + for _, in := range *input { + var startDate, endDate string + + if in.StartDateTime != nil { + startDate = in.StartDateTime.Format(time.RFC3339) + } + + if in.EndDateTime != nil { + endDate = in.EndDateTime.Format(time.RFC3339) + } + + output = append(output, map[string]interface{}{ + "key_id": pointer.From(in.KeyId), + "display_name": pointer.From(in.DisplayName), + "start_date": startDate, + "end_date": endDate, + "value": pointer.From(in.SecretText), + }) + } + + return output +} + func flattenApplicationWeb(in *msgraph.ApplicationWeb) []map[string]interface{} { if in == nil { return []map[string]interface{}{}