diff --git a/docs/data-sources/service_principal.md b/docs/data-sources/service_principal.md index 6c98078314..3bd7a131c3 100644 --- a/docs/data-sources/service_principal.md +++ b/docs/data-sources/service_principal.md @@ -63,6 +63,7 @@ The following attributes are exported: * `application_tenant_id` - The tenant ID where the associated application is registered. * `description` - A description of the service principal provided for internal end-users. * `display_name` - The display name of the application associated with this service principal. +* `features` - A `features` block as described below. * `homepage_url` - Home page or landing page of the associated application. * `login_url` - The URL where the service provider redirects the user to Azure AD to authenticate. Azure AD uses the URL to launch the application from Microsoft 365 or the Azure AD My Apps. * `logout_url` - The URL that will be used by Microsoft's authorization service to logout an user using OpenId Connect front-channel, back-channel or SAML logout protocols, taken from the associated application. @@ -93,6 +94,15 @@ The following attributes are exported: --- +`features` block exports the following: + +* `custom_single_sign_on_app` - Whether this service principal represents a custom SAML application. +* `enterprise_application` - Whether this service principal represents an Enterprise Application. +* `gallery_application` - Whether this service principal represents a gallery application. +* `visible_to_users` - Whether this app is visible to users in My Apps and Office 365 Launcher. + +--- + `oauth2_permission_scopes` block exports the following: * `admin_consent_description` - Delegated permission description that appears in all tenant-wide admin consent experiences, intended to be read by an administrator granting the permission on behalf of all users. diff --git a/docs/resources/service_principal.md b/docs/resources/service_principal.md index de3e90ad7d..c1b6dbe7c9 100644 --- a/docs/resources/service_principal.md +++ b/docs/resources/service_principal.md @@ -28,12 +28,32 @@ resource "azuread_application" "example" { owners = [data.azuread_client_config.current.object_id] } +resource "azuread_service_principal" "example" { + application_id = azuread_application.example.application_id + app_role_assignment_required = false + owners = [data.azuread_client_config.current.object_id] +} +``` + +*Create a service principal for an enterprise application* + +```terraform +data "azuread_client_config" "current" {} + +resource "azuread_application" "example" { + display_name = "example" + owners = [data.azuread_client_config.current.object_id] +} + resource "azuread_service_principal" "example" { application_id = azuread_application.example.application_id app_role_assignment_required = false owners = [data.azuread_client_config.current.object_id] - tags = ["example", "tags", "here"] + features { + enterprise_application = true + gallery_application = true + } } ``` @@ -75,6 +95,7 @@ The following arguments are supported: * `app_role_assignment_required` - (Optional) Whether this service principal requires an app role assignment to a user or group before Azure AD will issue a user or access token to the application. Defaults to `false`. * `application_id` - (Required) The application ID (client ID) of the application for which to create a service principal. * `description` - (Optional) A description of the service principal provided for internal end-users. +* `features` - (Optional) A `features` block as described below. Cannot be used together with the `tags` property. * `login_url` - (Optional) The URL where the service provider redirects the user to Azure AD to authenticate. Azure AD uses the URL to launch the application from Microsoft 365 or the Azure AD My Apps. When blank, Azure AD performs IdP-initiated sign-on for applications configured with SAML-based single sign-on. * `notes` - (Optional) A free text field to capture information about the service principal, typically used for operational purposes. * `notification_email_addresses` - (Optional) A set of email addresses where Azure AD sends a notification when the active certificate is near the expiration date. This is only for the certificates used to sign the SAML token issued for Azure AD Gallery applications. @@ -84,13 +105,22 @@ The following arguments are supported: * `preferred_single_sign_on_mode` - (Optional) The single sign-on mode configured for this application. Azure AD uses the preferred single sign-on mode to launch the application from Microsoft 365 or the Azure AD My Apps. Supported values are `oidc`, `password`, `saml` or `notSupported`. Omit this property or specify a blank string to unset. * `saml_single_sign_on` - (Optional) A `saml_single_sign_on` block as documented below. -* `tags` - (Optional) A set of tags to apply to the service principal. +* `tags` - (Optional) A set of tags to apply to the service principal. Cannot be used together with the `features` block. * `use_existing` - (Optional) When true, any existing service principal linked to the same application will be automatically imported. When false, an import error will be raised for any pre-existing service principal. -> **Caveats of `use_existing`** Enabling this behaviour is useful for managing existing service principals that may already be installed in your tenant for Microsoft-published APIs, as it allows you to make changes where permitted, and then also reference them in your Terraform configuration. However, the behaviour of delete operations is also affected - when `use_existing` is `true`, Terraform will still attempt to delete the service principal on destroy, although it will not raise an error if the deletion fails (as it often the case for first-party Microsoft applications). --- +`features` block supports the following: + +* `custom_single_sign_on_app` - (Optional) Whether this service principal represents a custom SAML application. Defaults to `false`. +* `enterprise_application` - (Optional) Whether this service principal represents an Enterprise Application. Defaults to `false`. +* `gallery_application` - (Optional) Whether this service principal represents a gallery application. Defaults to `false`. +* `visible_to_users` - (Optional) Whether this app is visible to users in My Apps and Office 365 Launcher. Defaults to `true`. + +--- + `saml_single_sign_on` supports the following: * `relay_state` - (Optional) The relative URI the service provider would redirect to after completion of the single sign-on flow. diff --git a/internal/services/serviceprincipals/service_principal_data_source.go b/internal/services/serviceprincipals/service_principal_data_source.go index ea6bec4abb..508ee63ae3 100644 --- a/internal/services/serviceprincipals/service_principal_data_source.go +++ b/internal/services/serviceprincipals/service_principal_data_source.go @@ -99,6 +99,39 @@ func servicePrincipalData() *schema.Resource { Computed: true, }, + "features": { + Description: "Block of features configured for this service principal using tags", + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_single_sign_on_app": { + Description: "Whether this service principal represents a custom SAML application", + Type: schema.TypeBool, + Computed: true, + }, + + "enterprise_application": { + Description: "Whether this service principal represents an Enterprise Application", + Type: schema.TypeBool, + Computed: true, + }, + + "gallery_application": { + Description: "Whether this service principal represents a gallery application", + Type: schema.TypeBool, + Computed: true, + }, + + "visible_to_users": { + Description: "Whether this app is visible to users in My Apps and Office 365 Launcher", + Type: schema.TypeBool, + Computed: true, + }, + }, + }, + }, + "homepage_url": { Description: "Home page or landing page of the application", Type: schema.TypeString, @@ -313,6 +346,7 @@ func servicePrincipalDataSourceRead(ctx context.Context, d *schema.ResourceData, tf.Set(d, "application_tenant_id", servicePrincipal.AppOwnerOrganizationId) tf.Set(d, "description", servicePrincipal.Description) tf.Set(d, "display_name", servicePrincipal.DisplayName) + tf.Set(d, "features", flattenFeatures(servicePrincipal.Tags)) tf.Set(d, "homepage_url", servicePrincipal.Homepage) tf.Set(d, "logout_url", servicePrincipal.LogoutUrl) tf.Set(d, "login_url", servicePrincipal.LoginUrl) diff --git a/internal/services/serviceprincipals/service_principal_data_source_test.go b/internal/services/serviceprincipals/service_principal_data_source_test.go index cbd0cc06ae..99970ebf06 100644 --- a/internal/services/serviceprincipals/service_principal_data_source_test.go +++ b/internal/services/serviceprincipals/service_principal_data_source_test.go @@ -61,6 +61,11 @@ func (ServicePrincipalDataSource) testCheckFunc(data acceptance.TestData) resour check.That(data.ResourceName).Key("application_tenant_id").HasValue(tenantId), check.That(data.ResourceName).Key("description").HasValue("An internal app for testing"), check.That(data.ResourceName).Key("display_name").Exists(), + check.That(data.ResourceName).Key("features.#").HasValue("1"), + check.That(data.ResourceName).Key("features.0.custom_single_sign_on_app").HasValue("true"), + check.That(data.ResourceName).Key("features.0.enterprise_application").HasValue("true"), + check.That(data.ResourceName).Key("features.0.gallery_application").HasValue("true"), + check.That(data.ResourceName).Key("features.0.visible_to_users").HasValue("true"), check.That(data.ResourceName).Key("homepage_url").HasValue(fmt.Sprintf("https://test-%d.internal", data.RandomInteger)), check.That(data.ResourceName).Key("login_url").HasValue(fmt.Sprintf("https://test-%d.internal/login", data.RandomInteger)), check.That(data.ResourceName).Key("logout_url").HasValue(fmt.Sprintf("https://test-%d.internal/logout", data.RandomInteger)), diff --git a/internal/services/serviceprincipals/service_principal_resource.go b/internal/services/serviceprincipals/service_principal_resource.go index 4c5419fefa..0d76b3adeb 100644 --- a/internal/services/serviceprincipals/service_principal_resource.go +++ b/internal/services/serviceprincipals/service_principal_resource.go @@ -85,6 +85,42 @@ func servicePrincipalResource() *schema.Resource { ValidateFunc: validation.StringLenBetween(0, 1024), }, + "features": { + Description: "Block of features to configure for this service principal using tags", + Type: schema.TypeList, + Optional: true, + Computed: true, + ConflictsWith: []string{"tags"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "custom_single_sign_on_app": { + Description: "Whether this service principal represents a custom SAML application", + Type: schema.TypeBool, + Optional: true, + }, + + "enterprise_application": { + Description: "Whether this service principal represents an Enterprise Application", + Type: schema.TypeBool, + Optional: true, + }, + + "gallery_application": { + Description: "Whether this service principal represents a gallery application", + Type: schema.TypeBool, + Optional: true, + }, + + "visible_to_users": { + Description: "Whether this app is visible to users in My Apps and Office 365 Launcher", + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + }, + }, + }, + "login_url": { Description: "The URL where the service provider redirects the user to Azure AD to authenticate. Azure AD uses the URL to launch the application from Microsoft 365 or the Azure AD My Apps. When blank, Azure AD performs IdP-initiated sign-on for applications configured with SAML-based single sign-on", Type: schema.TypeString, @@ -134,10 +170,12 @@ func servicePrincipalResource() *schema.Resource { }, "tags": { - Description: "A set of tags to apply to the service principal", - Type: schema.TypeSet, - Optional: true, - Set: schema.HashString, + Description: "A set of tags to apply to the service principal", + Type: schema.TypeSet, + Optional: true, + Computed: true, + Set: schema.HashString, + ConflictsWith: []string{"features"}, Elem: &schema.Schema{ Type: schema.TypeString, }, @@ -308,6 +346,13 @@ func servicePrincipalResourceCreate(ctx context.Context, d *schema.ResourceData, return servicePrincipalResourceUpdate(ctx, d, meta) } + var tags []string + if v, ok := d.GetOk("features"); ok { + tags = expandFeatures(v.([]interface{})) + } else { + tags = tf.ExpandStringSlice(d.Get("tags").(*schema.Set).List()) + } + properties := msgraph.ServicePrincipal{ AccountEnabled: utils.Bool(d.Get("account_enabled").(bool)), AlternativeNames: tf.ExpandStringSlicePtr(d.Get("alternative_names").(*schema.Set).List()), @@ -319,7 +364,7 @@ func servicePrincipalResourceCreate(ctx context.Context, d *schema.ResourceData, NotificationEmailAddresses: tf.ExpandStringSlicePtr(d.Get("notification_email_addresses").(*schema.Set).List()), PreferredSingleSignOnMode: utils.NullableString(d.Get("preferred_single_sign_on_mode").(string)), SamlSingleSignOnSettings: expandSamlSingleSignOn(d.Get("saml_single_sign_on").([]interface{})), - Tags: tf.ExpandStringSlicePtr(d.Get("tags").(*schema.Set).List()), + Tags: &tags, } // Sort the owners into two slices, the first containing up to 20 and the rest overflowing to the second slice @@ -399,6 +444,14 @@ func servicePrincipalResourceUpdate(ctx context.Context, d *schema.ResourceData, client := meta.(*clients.Client).ServicePrincipals.ServicePrincipalsClient directoryObjectsClient := meta.(*clients.Client).ServicePrincipals.DirectoryObjectsClient + var tags []string + featuresChanged := d.HasChange("features") + if v, ok := d.GetOk("features"); ok && len(v.([]interface{})) > 0 && featuresChanged { + tags = expandFeatures(v.([]interface{})) + } else { + tags = tf.ExpandStringSlice(d.Get("tags").(*schema.Set).List()) + } + properties := msgraph.ServicePrincipal{ DirectoryObject: msgraph.DirectoryObject{ ID: utils.String(d.Id()), @@ -412,7 +465,7 @@ func servicePrincipalResourceUpdate(ctx context.Context, d *schema.ResourceData, NotificationEmailAddresses: tf.ExpandStringSlicePtr(d.Get("notification_email_addresses").(*schema.Set).List()), PreferredSingleSignOnMode: utils.NullableString(d.Get("preferred_single_sign_on_mode").(string)), SamlSingleSignOnSettings: expandSamlSingleSignOn(d.Get("saml_single_sign_on").([]interface{})), - Tags: tf.ExpandStringSlicePtr(d.Get("tags").(*schema.Set).List()), + Tags: &tags, } if _, err := client.Update(ctx, properties); err != nil { @@ -493,6 +546,7 @@ func servicePrincipalResourceRead(ctx context.Context, d *schema.ResourceData, m tf.Set(d, "application_tenant_id", servicePrincipal.AppOwnerOrganizationId) tf.Set(d, "description", servicePrincipal.Description) tf.Set(d, "display_name", servicePrincipal.DisplayName) + tf.Set(d, "features", flattenFeatures(servicePrincipal.Tags)) tf.Set(d, "homepage_url", servicePrincipal.Homepage) tf.Set(d, "logout_url", servicePrincipal.LogoutUrl) tf.Set(d, "login_url", servicePrincipal.LoginUrl) diff --git a/internal/services/serviceprincipals/service_principal_resource_test.go b/internal/services/serviceprincipals/service_principal_resource_test.go index 0d9c0b4293..ba843f8423 100644 --- a/internal/services/serviceprincipals/service_principal_resource_test.go +++ b/internal/services/serviceprincipals/service_principal_resource_test.go @@ -63,7 +63,7 @@ func TestAccServicePrincipal_complete(t *testing.T) { }) } -func TestAccServicePrincipal_update(t *testing.T) { +func TestAccServicePrincipal_completeUpdate(t *testing.T) { data := acceptance.BuildTestData(t, "azuread_service_principal", "test") r := ServicePrincipalResource{} @@ -104,6 +104,83 @@ func TestAccServicePrincipal_update(t *testing.T) { }) } +func TestAccServicePrincipal_features(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_service_principal", "test") + r := ServicePrincipalResource{} + tenantId := os.Getenv("ARM_TENANT_ID") + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.features(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("app_role_ids.%").HasValue("2"), + check.That(data.ResourceName).Key("app_roles.#").HasValue("2"), + check.That(data.ResourceName).Key("application_tenant_id").HasValue(tenantId), + check.That(data.ResourceName).Key("homepage_url").HasValue(fmt.Sprintf("https://test-%d.internal", data.RandomInteger)), + check.That(data.ResourceName).Key("logout_url").HasValue(fmt.Sprintf("https://test-%d.internal/logout", data.RandomInteger)), + check.That(data.ResourceName).Key("oauth2_permission_scope_ids.%").HasValue("2"), + check.That(data.ResourceName).Key("oauth2_permission_scopes.#").HasValue("2"), + check.That(data.ResourceName).Key("service_principal_names.#").HasValue("2"), + check.That(data.ResourceName).Key("redirect_uris.#").HasValue("2"), + check.That(data.ResourceName).Key("sign_in_audience").HasValue("AzureADMyOrg"), + check.That(data.ResourceName).Key("type").HasValue("Application"), + ), + }, + data.ImportStep("use_existing"), + }) +} + +func TestAccServicePrincipal_featuresUpdate(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_service_principal", "test") + r := ServicePrincipalResource{} + + data.ResourceTest(t, r, []resource.TestStep{ + { + Config: r.basic(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + { + Config: r.features(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + { + Config: r.basic(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + { + Config: r.features(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + { + Config: r.complete(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + { + Config: r.features(data), + Check: resource.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep("use_existing"), + }) +} + func TestAccServicePrincipal_owners(t *testing.T) { data := acceptance.BuildTestData(t, "azuread_service_principal", "test") r := ServicePrincipalResource{} @@ -254,7 +331,7 @@ resource "azuread_service_principal" "test" { `, data.RandomInteger) } -func (ServicePrincipalResource) complete(data acceptance.TestData) string { +func (ServicePrincipalResource) templateComplete(data acceptance.TestData) string { return fmt.Sprintf(` provider "azuread" {} @@ -317,14 +394,21 @@ resource "azuread_application" "test" { ] } } +`, data.RandomInteger, data.UUID(), data.UUID(), data.UUID(), data.UUID()) +} + +func (r ServicePrincipalResource) complete(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s resource "azuread_service_principal" "test" { application_id = azuread_application.test.application_id account_enabled = false + alternative_names = ["foo", "bar"] app_role_assignment_required = true description = "An internal app for testing" - login_url = "https://test-%[1]d.internal/login" + login_url = "https://test-%[2]d.internal/login" notes = "Just testing something" preferred_single_sign_on_mode = "saml" @@ -337,10 +421,47 @@ resource "azuread_service_principal" "test" { relay_state = "/samlHome" } - alternative_names = ["foo", "bar"] - tags = ["test", "multiple", "CapitalS"] + tags = [ + "WindowsAzureActiveDirectoryCustomSingleSignOnApplication", + "WindowsAzureActiveDirectoryIntegratedApp", + "WindowsAzureActiveDirectoryGalleryApplicationNonPrimaryV1", + ] } -`, data.RandomInteger, data.UUID(), data.UUID(), data.UUID(), data.UUID()) +`, r.templateComplete(data), data.RandomInteger) +} + +func (r ServicePrincipalResource) features(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_service_principal" "test" { + application_id = azuread_application.test.application_id + + account_enabled = false + alternative_names = ["foo", "bar"] + app_role_assignment_required = true + description = "An internal app for testing" + login_url = "https://test-%[2]d.internal/login" + notes = "Just testing something" + preferred_single_sign_on_mode = "saml" + + features { + custom_single_sign_on_app = true + enterprise_application = true + gallery_application = true + visible_to_users = false + } + + notification_email_addresses = [ + "alerts.internal@hashitown.net", + "cto@hashitown.net", + ] + + saml_single_sign_on { + relay_state = "/samlHome" + } +} +`, r.templateComplete(data), data.RandomInteger) } func (ServicePrincipalResource) templateThreeUsers(data acceptance.TestData) string { diff --git a/internal/services/serviceprincipals/serviceprincipals.go b/internal/services/serviceprincipals/serviceprincipals.go index 7d6fbf24ae..71fe5bb198 100644 --- a/internal/services/serviceprincipals/serviceprincipals.go +++ b/internal/services/serviceprincipals/serviceprincipals.go @@ -1,11 +1,38 @@ package serviceprincipals import ( - "github.com/manicminer/hamilton/msgraph" + "strings" "github.com/hashicorp/terraform-provider-azuread/internal/utils" + "github.com/manicminer/hamilton/msgraph" ) +func expandFeatures(in []interface{}) (out []string) { + if len(in) == 0 || in[0] == nil { + return + } + + features := in[0].(map[string]interface{}) + + if v, ok := features["custom_single_sign_on_app"]; ok && v.(bool) { + out = append(out, "WindowsAzureActiveDirectoryCustomSingleSignOnApplication") + } + + if v, ok := features["enterprise_application"]; ok && v.(bool) { + out = append(out, "WindowsAzureActiveDirectoryIntegratedApp") + } + + if v, ok := features["gallery_application"]; ok && v.(bool) { + out = append(out, "WindowsAzureActiveDirectoryGalleryApplicationNonPrimaryV1") + } + + if v, ok := features["visible_to_users"]; ok && !v.(bool) { + out = append(out, "HideApp") + } + + return +} + func expandSamlSingleSignOn(in []interface{}) *msgraph.SamlSingleSignOnSettings { result := msgraph.SamlSingleSignOnSettings{} if len(in) == 0 || in[0] == nil { @@ -19,6 +46,36 @@ func expandSamlSingleSignOn(in []interface{}) *msgraph.SamlSingleSignOnSettings return &result } +func flattenFeatures(tags *[]string) []interface{} { + result := map[string]bool{ + "custom_single_sign_on_app": false, + "enterprise_application": false, + "gallery_application": false, + "visible_to_users": true, + } + + if tags == nil || len(*tags) == 0 { + return []interface{}{result} + } + + for _, tag := range *tags { + if strings.EqualFold(tag, "WindowsAzureActiveDirectoryCustomSingleSignOnApplication") { + result["custom_single_sign_on_app"] = true + } + if strings.EqualFold(tag, "WindowsAzureActiveDirectoryIntegratedApp") { + result["enterprise_application"] = true + } + if strings.EqualFold(tag, "WindowsAzureActiveDirectoryGalleryApplicationNonPrimaryV1") { + result["gallery_application"] = true + } + if strings.EqualFold(tag, "HideApp") { + result["visible_to_users"] = false + } + } + + return []interface{}{result} +} + func flattenSamlSingleSignOn(in *msgraph.SamlSingleSignOnSettings) []map[string]interface{} { if in == nil { return []map[string]interface{}{}