Skip to content

Repository files navigation

Terraform AzAPI Sentinel Content Hub Solution

Microsoft Sentinel Content Hub solutions as code: the Install/Update button in the Content Hub, expressed as a map of solutions, via the Azure/azapi provider.

CI Release Terraform Registry License


Why this is not a one line resource

There is no azurerm resource for Content Hub solutions (the sentinel surface stops at azurerm_sentinel_metadata), so it has to be azapi. That much is unsurprising. The surprise is that the obvious azapi call does not work.

Microsoft.SecurityInsights/contentPackages is documented as Content Package - Install, "Install a package to the workspace". A PUT to it is accepted, returns 200, and creates a package record that shows up in a contentPackages list. It installs nothing: no content templates, no metadata, and installedVersion stays null. That was verified against a live workspace on api-versions 2023-04-01-preview, 2024-09-01 and 2025-09-01, with both the documented minimal body and the full catalog body, waiting five minutes each time. A naive module built on that call produces a resource that looks installed in Terraform and in the API, and delivers no content.

What actually installs a solution is an ARM deployment of the package's own packagedContent, a self-contained template carrying every content template plus the package record. That is what the portal does. So the module:

  1. Lists the catalog and resolves your content id to its versioned contentProductId, which is not derivable: the content id is truncated and then suffixed with an opaque hash (azuresentinel.azure-sentinel-solution-microsoft365defender becomes azuresentinel.azure-sentinel-solution-microsoft365-sl-ly2pizzgu22as).
  2. Fetches that package's packagedContent (expand path properties/packagedContent; the bare property name is rejected).
  3. Claims the contentPackages record so Terraform owns it.
  4. Deploys the template, installing the content on top of the claimed record.

Destroy runs in reverse: the ARM deployment history entry goes first (a no-op for content), then the package delete, which is the call that actually uninstalls and cascades to every content template the solution delivered.

Installing a solution is not enabling its content

Worth being blunt about, because it is the most common misconception. A solution delivers templates. Installing Microsoft Defender XDR gives you 40 analytics rule templates, not 40 running rules. Turn them into live resources with the sibling modules:

What the solution ships What turns it on
Analytics rule templates libre-devops/sentinel-alert-rule/azurerm
Workbook templates libre-devops/sentinel-workbook/azurerm
Playbook templates libre-devops/logic-app-workflow/azurerm
A data connector see below

The data connector case is the sharpest edge. What a solution installs for a connector is the connector's page in Sentinel (kind: StaticUI, a connectorUiConfig and nothing else), not a working connector. Nothing is ingested until the connector itself is created, with libre-devops/sentinel-data-connector/azurerm or, for Defender XDR specifically, libre-devops/sentinel-defender-xdr-connector/azapi. The solutions_with_ui_only_connectors output names the solutions this applies to, so it is visible rather than something you find out when nothing arrives.

Note that the Defender XDR connector module has a hard acknowledge_unonboarded_workspace precondition: on a workspace connected to the unified Microsoft Defender portal the platform owns that connector and Terraform must not manage it. Installing the solution is safe either way.

Version pinning is a change gate, not a version selector

expected_version asserts that the catalog is serving exactly that version and fails the plan otherwise. It cannot install an older version, because the catalog only ever exposes the current one's content. Pin it when you want to be told before a Microsoft update lands rather than have it apply quietly; clear it to track whatever is current.

Usage

module "sentinel" {
  source  = "libre-devops/sentinel/azurerm"
  version = "~> 4.0"

  workspace_id = module.log_analytics.workspace_ids["log-ldo-uks-prd-001"]
}

module "content_hub_solution" {
  source  = "libre-devops/sentinel-content-hub-solution/azapi"
  version = "~> 1.0"

  workspace_id = module.sentinel.onboarding_id

  solutions = {
    "azuresentinel.azure-sentinel-solution-microsoft365defender"     = {}
    "azuresentinel.azure-sentinel-solution-sentinelsoaressentials"   = {}
    "microsoftsentinelcommunity.azure-sentinel-solution-sochandbook" = { expected_version = "3.0.6" }
  }
}

workspace_id accepts the sentinel module's onboarding_id or a plain workspace id; the module parses the workspace back out of either.

Finding a solution's content id

The key of the solutions map is the package content id, in publisherId.offerId form. Two ways to get one:

  • Set catalog_discovery_enabled = true and read the content_hub_catalog output, which lists every catalogued package with its content id, display name, version and deprecated flag.
  • Read SolutionMetadata.json for the solution in the Azure/Azure-Sentinel repo and join its publisherId and offerId with a dot.

Do not infer the id from the display name. Microsoft Defender XDR is ...-microsoft365defender, not ...-microsoftthreatprotection, and SOC Handbook publishes under microsoftsentinelcommunity, not azuresentinel.

What the module enforces

At plan: the content id shape, the api-version shapes, the numeric expected_version shape, and the deployment name prefix. Per solution, a precondition that the package exists in this workspace's catalog (with a message that tells you how to find the right id), that expected_version matches if set, and that a package the catalog flags deprecated is not installed unless allow_deprecated = true (104 of the 975 catalogued packages are deprecated, so this fires in practice). An advisory check warns when a solution's template passes 75 percent of the ARM template limit of 4 MB.

Notes worth knowing

  • State size. Solution templates are large, and this module holds one copy per solution in state (Microsoft Defender XDR alone is 1.9 MB). Three solutions is roughly 4.4 MB of state. The template is passed to the install through azapi's sensitive_body, a write-only argument, so it is stored once rather than twice. Reading it through an ephemeral resource would keep it out of state entirely, but Terraform's test framework cannot mock ephemeral resource types, which would cost every plan-time unit test in tests/. That trade is revisitable when the limitation lifts.
  • ARM template limit. A solution installs as one ARM deployment, so it is bound by the 4 MB template limit. Microsoft Defender XDR (374 content templates) sits at 1.9 MB today.
  • Deployment names. Derived from the content id and capped at the ARM limit of 64 characters. The two Microsoft solutions above truncate to an identical 55 character prefix, so a hash suffix is appended to keep them distinct.
  • Permissions. Installing content needs Microsoft Sentinel Contributor on the workspace.
  • api-versions are variables, per the estate convention for azapi modules, so consumers are never pinned to this module's release cadence.

Security scan exceptions

None. tflint and trivy are clean on the module and both examples, so there is no .trivyignore.yaml in this repo.

Requirements

Name Version
terraform >= 1.11.0, < 2.0.0
azapi >= 2.0.0, < 3.0.0

Providers

Name Version
azapi >= 2.0.0, < 3.0.0

Modules

No modules.

Resources

Name Type
azapi_resource.install resource
azapi_resource.package resource
azapi_resource.package data source
azapi_resource.workspace data source
azapi_resource_list.catalog data source
azapi_resource_list.catalog_discovery data source

Inputs

Name Description Type Default Required
catalog_api_version API version for Microsoft.SecurityInsights/contentProductPackages, the read-only Content Hub catalog
this module resolves solutions against. Variablised so consumers are never pinned to this module's
release cadence.
string "2025-09-01" no
catalog_discovery_enabled Populate the content_hub_catalog output with every catalogued package (content id, display name, version, kind, deprecated flag), for finding the content id of a solution you only know by name. Off by default because it adds roughly 975 entries to state. bool false no
deployment_api_version API version for Microsoft.Resources/deployments, the ARM deployment that actually installs a
solution's content. See the README: the Content Hub install is an ARM deployment of the package's
own template, not a plain PUT of the package resource.
string "2021-04-01" no
deployment_name_prefix Prefix for the generated ARM deployment name of each install. The rest of the name is derived from the solution's content id (and hash-suffixed when the combination would exceed the ARM limit of 64 characters). string "sentinel-solution-" no
location Azure region passed to the solution template as its location and workspace-location parameters.
Defaults to null, which reads the region from the target workspace, so the common case needs no
input. Set it only to override that.
string null no
package_api_version API version for Microsoft.SecurityInsights/contentPackages, the installed-package record this module owns so that a destroy uninstalls the solution. string "2025-09-01" no
resource_timeouts azapi operation timeouts for the install deployment and the package record. Create is bounded well
below the azapi default of 30m: a solution that fails to install otherwise leaves azapi polling a
stuck ARM deployment for the full default, hiding the real error behind a long hang. Large solutions
(Microsoft Defender XDR carries 374 content templates) still install in well under a minute, so 20m
is generous.
object({
create = optional(string, "20m")
read = optional(string, "10m")
update = optional(string, "20m")
delete = optional(string, "30m")
})
{} no
retry_error_message_regex Regular expressions azapi retries on when an install or package call fails. The default covers only
the genuinely transient races: the Sentinel onboarding propagation race (a workspace onboarded in the
same apply can still report "not onboarded to Microsoft Sentinel" for a short while), the freshly
created workspace race, and throttling. It deliberately does NOT retry generic "not found" or 5xx
errors, which for a content install are usually persistent faults that should fail fast. Set null to
disable retries.
list(string)
[
"(?i)not onboarded to Microsoft Sentinel",
"(?i)workspace could not be found",
"(?i)too many requests"
]
no
solutions Content Hub solutions to install, keyed by the package content id (its Content Hub identifier, for
example "azuresentinel.azure-sentinel-solution-microsoft365defender"). Find an id in the catalog by
display name with the content_hub_catalog output, or read it from the publisherId and offerId in the
solution's SolutionMetadata.json in the Azure/Azure-Sentinel repo.

Per solution:

- expected_version: assert the catalog is serving this exact version, and fail the plan if it is not.
This is a CHANGE GATE, not a version selector: the catalog only ever exposes the current version's
content, so an older version cannot be installed through this API. Leave null to track whatever
Microsoft currently publishes.
- allow_deprecated: install even though the catalog flags the package deprecated. Off by default, and
104 of the 975 catalogued packages are deprecated, so this guard fires in practice.
map(object({
expected_version = optional(string)
allow_deprecated = optional(bool, false)
}))
{} no
workspace_id Resource id of the Sentinel-onboarded Log Analytics workspace to install into. Accepts the sentinel module's onboarding_id or a plain workspace id, per the estate's pass-ids principle. string n/a yes

Outputs

Name Description
content_hub_catalog The whole Content Hub catalog as a list of {contentId, displayName, version, kind, deprecated}, for discovering the content id of a solution you only know by name. Null unless catalog_discovery_enabled is true, because it adds roughly 975 entries to state.
deployment_ids Map of solution content id to the resource id of the ARM deployment that installed it.
deployment_names Map of solution content id to the generated ARM deployment name, which is derived from the content id and hash-suffixed when it would exceed the ARM limit of 64 characters.
installed_versions Map of solution content id to the version installed, which is the version the catalog was serving at apply time.
solution_ids Map of solution content id to the resource id of its installed contentPackages record, the resource whose deletion uninstalls the solution.
solution_ids_zipmap Map of solution content id to {name, id} for easy composition.
solutions Map of solution content id to everything resolved from the catalog for it: displayName, version, contentProductId, kind, schemaVersion and the deprecated flag.
solutions_with_ui_only_connectors Content ids of the installed solutions whose template ships a data connector. Read this: what a
solution installs for a connector is the connector's PAGE in Sentinel (kind StaticUI), not a working
connector. Nothing is ingested until the connector itself is created, for example with
libre-devops/sentinel-data-connector/azurerm or libre-devops/sentinel-defender-xdr-connector/azapi.
workspace_id Resource id of the Sentinel-onboarded workspace the solutions were installed into, parsed back out of workspace_id so an onboarding id resolves to its workspace.

About

🛡️ Terraform module to install Microsoft Sentinel Content Hub solutions via AzAPI

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages