Skip to content

Instantly share code, notes, and snippets.

@soulemike
Last active October 12, 2026 05:56
Show Gist options
  • Select an option

  • Save soulemike/f0e2e5f9c813dd221e556ebcda53e766 to your computer and use it in GitHub Desktop.

Select an option

Save soulemike/f0e2e5f9c813dd221e556ebcda53e766 to your computer and use it in GitHub Desktop.
Automatic Classification Config Generation

Automatic Classification Config Generation for ServiceEM

Summary

ServiceEM creates groups with a predictable naming convention, but classification reports require explicit tier metadata. Since groups do not support Custom Security Attributes (CSA), the only automatic classification path is AlternateObjectTierLevelAttributes.

This design adds an optional -UpdateClassificationConfig switch to landing-zone creation that automatically generates AlternateObjectTierLevelAttributes.Group filters from the ServiceEM naming convention and persists them to EntraOpsConfig.json. The implementation supports multiple runs with varying prefixes, merges without destroying existing filters, and an opt-in force overwrite for intentional replacement.


Problem

  • ServiceEM creates EAM-aligned groups with names like SG-Rg-Management-ControlPlane-Admins.
  • Classification reports show these groups as Unclassified unless manually tagged.
  • Groups cannot use CSAs (Microsoft Entra limitation).
  • The Object Classification File requires per-object maintenance.
  • Users must manually discover and write AlternateObjectTierLevelAttributes.Group filter expressions.
  • Multi-service deployments may use different GroupPrefix values across teams or landing zones.
  • Existing custom filters must not be silently destroyed by a ServiceEM run.

Solution Overview

Generate wildcard-based AlternateObjectTierLevelAttributes.Group filter expressions that match the ServiceEM naming convention. On each run with -UpdateClassificationConfig:

  1. Read the existing Group filter for each tier.
  2. Detect whether the existing filter is empty, already contains ServiceEM-like -like patterns, or is a custom expression.
  3. Merge new patterns with -or when safe; skip when custom filters are detected.
  4. Persist the merged result to both $Global:EntraOpsConfig and EntraOpsConfig.json on disk.

Merge Behaviour

Tier-by-Tier Logic

Each tier (ControlPlane, ManagementPlane, WorkloadPlane) is evaluated independently.

Existing Filter State Action Example Result
Empty Write the new pattern directly. $Object.ObjectDisplayName -like "SG-*-ControlPlane-*"
Contains ServiceEM-like -like patterns Append new pattern with -or if not already present. $Object.ObjectDisplayName -like "SG-*-ControlPlane-*" -or $Object.ObjectDisplayName -like "PRG-*-ControlPlane-*"
Already contains the exact new pattern Skip with verbose log. No change.
Custom expression (regex, complex logic, no -like on ObjectDisplayName) Skip with warning. Use -ForceClassificationConfigUpdate to replace. No change unless forced.

Pattern Detection Heuristic

A filter is considered "ServiceEM-like" when it contains at least one substring matching:

\$Object\.ObjectDisplayName\s+-like\s+"[^"]*"

Anything else (e.g. $Object.ObjectDisplayName -match "Tier0-.*", $Object.AssignedAdministrativeUnits.displayName -contains "...", or compound logic without -like on display name) is treated as custom and protected from overwrite.

Deduplication

Before appending, the helper checks if the new pattern string already exists as a substring in the existing filter. This prevents -or bloat from repeated runs with the same prefix.


Parameters

-UpdateClassificationConfig [switch]

Available on:

  • New-EntraOpsServiceBootstrap
  • New-EntraOpsSubscriptionLandingZone

When set, triggers the classification config helper after the deployment report is assembled.

-ForceClassificationConfigUpdate [switch]

Available alongside -UpdateClassificationConfig.

When set, the helper replaces existing custom filters instead of skipping them. Use this when:

  • You are intentionally standardising on a single naming convention.
  • You want to migrate from a custom filter to the ServiceEM-generated approach.
  • You accept that your previous custom expression will be lost.

Usage Examples

First Run — Default Prefix

New-EntraOpsSubscriptionLandingZone `
  -DeploymentPrefix "Management" `
  -AzureRegion "westeurope" `
  -SubscriptionId "<subscription-id>" `
  -UpdateClassificationConfig

Result:

"Group": {
  "Enabled": true,
  "ControlPlane": "$Object.ObjectDisplayName -like \"SG-*-ControlPlane-*\"",
  "ManagementPlane": "$Object.ObjectDisplayName -like \"SG-*-ManagementPlane-*\"",
  "WorkloadPlane": "$Object.ObjectDisplayName -like \"SG-*-WorkloadPlane-*\"",
  "UserAccess": ""
}

Second Run — Different Prefix

New-EntraOpsSubscriptionLandingZone `
  -DeploymentPrefix "Connectivity" `
  -GroupPrefix "PRG" `
  -AzureRegion "northeurope" `
  -SubscriptionId "<subscription-id>" `
  -UpdateClassificationConfig

Result:

"Group": {
  "Enabled": true,
  "ControlPlane": "$Object.ObjectDisplayName -like \"SG-*-ControlPlane-*\" -or $Object.ObjectDisplayName -like \"PRG-*-ControlPlane-*\"",
  "ManagementPlane": "$Object.ObjectDisplayName -like \"SG-*-ManagementPlane-*\" -or $Object.ObjectDisplayName -like \"PRG-*-ManagementPlane-*\"",
  "WorkloadPlane": "$Object.ObjectDisplayName -like \"SG-*-WorkloadPlane-*\" -or $Object.ObjectDisplayName -like \"PRG-*-WorkloadPlane-*\"",
  "UserAccess": ""
}

Third Run — Same Prefix Again (Idempotent)

New-EntraOpsSubscriptionLandingZone `
  -DeploymentPrefix "Dev" `
  -AzureRegion "westeurope" `
  -SubscriptionId "<subscription-id>" `
  -UpdateClassificationConfig

Result: No change. The SG-* pattern is already present; deduplication skips the append.

Custom Filter Exists — Safe Skip

A user has manually configured:

"ControlPlane": "$Object.ObjectDisplayName -match \"^Tier0-.*\""

Running with -UpdateClassificationConfig:

New-EntraOpsServiceBootstrap -ServiceName "MyService" -UpdateClassificationConfig

Result:

WARNING: Custom Group classification filter detected for ControlPlane. Skipping to avoid overwrite. Use -ForceClassificationConfigUpdate to replace.

The ManagementPlane and WorkloadPlane tiers are still updated if they are empty or ServiceEM-like.

Force Overwrite

New-EntraOpsServiceBootstrap `
  -ServiceName "MyService" `
  -UpdateClassificationConfig `
  -ForceClassificationConfigUpdate

Result: All three tiers are replaced with the ServiceEM-generated patterns. The previous -match filter is lost.


Alternative: Broader Wildcard

For environments where naming conventions vary but you want minimal config complexity, a broader wildcard can be used instead of prefix-specific patterns:

"ControlPlane": "$Object.ObjectDisplayName -like \"*-ControlPlane-*\""

This matches any prefix, any delimiter, any service name — as long as the plane name appears between delimiters.

Aspect Prefix-Specific Merge Broader Wildcard
Precision High — only matches intended prefixes Medium — could match non-ServiceEM groups containing "ControlPlane"
Maintenance Low — automatic merge handles new prefixes None — one filter forever
Multi-service support Native via -or chaining Native — single expression
Custom filter coexistence Supported via merge heuristic Would overwrite custom filters unless -Force is used

Recommendation: Use the prefix-specific merge approach as the default. It is precise, safe with custom filters, and scales automatically. Offer the broader wildcard as a documented manual alternative for simple environments.


Config File Processing

The implementation leverages the existing EntraOps config infrastructure:

Reading

$configJson = Get-Content -Path $ConfigFilePath -Raw | ConvertFrom-Json -Depth 10 -AsHashtable
  • ConvertFrom-Json -AsHashtable creates case-sensitive OrderedHashtable objects.
  • The helper accesses properties via $configJson.AlternateObjectTierLevelAttributes['Group'] (dictionary-style) to remain compatible with both hashtable and PSCustomObject representations.

Writing

$configJson | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigFilePath -Encoding UTF8
  • Round-trips the entire config with preserved structure and depth.
  • [ordered]@{} is used when creating new sections to maintain key order for readability.

In-Memory Global Variable

$Global:EntraOpsConfig.AlternateObjectTierLevelAttributes['Group'] = $mergedFilters
  • Updated simultaneously with the disk file so subsequent cmdlets in the same session see the new filters immediately.
  • No reload required before running Save-EntraOpsPrivilegedEAMJson or New-EntraOpsReportingData.

Case Sensitivity Considerations

  • ConvertFrom-Json -AsHashtable keys are case-sensitive, but EntraOps consistently uses PascalCase (AlternateObjectTierLevelAttributes, ControlPlane, etc.).
  • The helper normalises key lookups to match the casing used by New-EntraOpsConfigFile.
  • If $Global:EntraOpsConfig was loaded via Connect-EntraOps (which may deliver case-insensitive hashtables), both .Property and ['Property'] access patterns work.

Deployment Report Enrichment

When -UpdateClassificationConfig is used, the deployment report includes a ClassificationConfig property:

$report = New-EntraOpsSubscriptionLandingZone -DeploymentPrefix "Mgmt" -UpdateClassificationConfig
$report.ClassificationConfig
Name              Value
----              -----
ControlPlane      Merged (2 patterns)
ManagementPlane   Merged (2 patterns)
WorkloadPlane     Merged (2 patterns)
UserAccess        (empty)

This allows callers to inspect what changed without parsing the config file.


What Gets Classified

Object Type Classification Source Notes
Groups Generated AlternateObjectTierLevelAttributes.Group filters Automatic for all ServiceEM-created groups matching the naming convention.
Users / Service Principals Existing CSA or alternate filters Unchanged by this feature.
Access Packages / Catalogs / Resource Groups Role assignment classification These are not object-classified via AlternateObjectTierLevelAttributes; their classification comes from RBAC role/scope analysis as before.

Files Involved

File Role
EntraOps/Private/Update-EntraOpsServiceEMClassificationConfig.ps1 Helper — reads existing filters, detects pattern type, merges or skips, persists to config.
EntraOps/Public/ServiceEM/New-EntraOpsServiceBootstrap.ps1 Entry point — accepts -UpdateClassificationConfig and -ForceClassificationConfigUpdate, calls helper after deployment.
EntraOps/Public/ServiceEM/New-EntraOpsSubscriptionLandingZone.ps1 Entry point — accepts same switches, passes through to bootstrap.

Design Decisions

Why merge instead of overwrite?

  • Multi-team environments often use different prefixes per team or workload.
  • Existing custom filters represent deliberate user configuration that should not be silently lost.
  • Idempotency — repeated runs with the same prefix produce no duplicate -or clauses.

Why tier-by-tier instead of all-or-nothing?

A user might have a sophisticated ControlPlane filter (e.g. based on Administrative Unit membership) while leaving ManagementPlane empty. Tier-by-tier merging allows ServiceEM to fill the gaps without touching the custom logic.

Why -Force instead of automatic overwrite?

Config file mutation should always be a deliberate action. -Force makes the destructive path explicit and auditable.

Why not enable by default?

  • Explicit opt-in avoids surprising users who already have custom classification strategies.
  • Config file mutation should remain a deliberate action in production pipelines.

Related Documentation

@Cloud-Architekt

Cloud-Architekt commented Oct 12, 2026 •

Copy link
Copy Markdown

Thanks for the detailed design. I've chosen a different approach for classifying ServiceEM groups. Here's why, and what is implemented now.

Why I didn't go with naming-convention filters

  • Display names aren't trustworthy for classification. Anyone who can manage a group can rename it. WorkloadPlane groups aren't role-assignable by default, so a less privileged admin could rename one to raise or lower its tier, or take it out of classification entirely. The patterns would also match groups ServiceEM didn't create.
  • The filters run as code. AlternateObjectTierLevelAttributes expressions are executed as PowerShell. Having ServiceEM rewrite them automatically, with merge rules and a force-overwrite switch, makes EntraOpsConfig.json harder to review as trusted code.
  • Each prefix adds another -or clause, and the filters would break if the group name delimiter ever becomes configurable.
  • Pattern filters aren't the only automatic option for groups. The Object Classification File also works for groups, matched by object ID.

What's implemented

ServiceEM now classifies only the groups it creates for the workload plane. Both are found by mail nickname (<ServiceName>.WorkloadPlane.Admins / .Users), not by display name:

Group Tier
WorkloadPlane-Admins WorkloadPlane
WorkloadPlane-Users UserAccess

There are two options, and they can be used together:

  1. Administrative units: ServiceEM.AdministrativeUnits.{WorkloadPlane, UserAccess}
    • Set to an AU object ID, and ServiceEM adds each group to the AU for its tier.
    • Classify them with a static Alternate Tier Level Attributes filter on the AU ID, e.g. $Object.AssignedAdministrativeUnits.id -contains "<id>". Unlike a name, AU membership can't be changed by renaming the group.
    • Role-assignable or PIM-managed groups can't be members of a restricted management AU, so they are skipped with a warning.
  2. Object Classification File: ServiceEM.UpdateObjectClassificationFile
    • ServiceEM adds the groups to the file in the repository. It's data, not code, matched by object ID.
    • An existing entry with a different tier is kept, with a warning.
    • Remove-EntraOpsServiceCatalog removes the entries of the groups it deletes.

Both options are in the Configuration Wizard and New-EntraOpsConfigFile.

ControlPlane-Admins, ManagementPlane-Admins and the administrator group (CatalogPlane-Members) are deliberately left out. In the Centralized model they're tenant-wide groups and should be classified separately, e.g. through the Object Classification File.

Related changes:

  • ControlPlane-Admins, ManagementPlane-Admins and CatalogPlane-Members created by ServiceEM are always role-assignable.
  • WorkloadPlane-Admins are role-assignable only with ServiceEM.RoleAssignableWorkloadPlaneAdmins. New-EntraOpsSubscriptionLandingZone can override this with -RoleAssignableWorkloadPlaneAdmins.
  • WorkloadPlane-Users are never role-assignable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment