diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index ee26d9e8..62ee523d 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -255,10 +255,133 @@ This design ensures workflows can be re-run safely without causing duplicate ope --- +## Attribute contracts + +The AD provider enforces strict validation of attributes to ensure fail-fast behavior and prevent silent failures. + +### CreateIdentity - Supported attributes + +The following attributes are supported when creating identities via `CreateIdentity`: + +#### Identity attributes +- `SamAccountName` (string) - User logon name (pre-Windows 2000) +- `UserPrincipalName` (string) - User principal name (email-style logon) +- `Path` (string) - DistinguishedName of the OU where the user should be created + +#### Name attributes +- `Name` (string) - Full name (CN/RDN) +- `GivenName` (string) - First name +- `Surname` (string) - Last name +- `DisplayName` (string) - Display name + +#### Organizational attributes +- `Description` (string) - User description +- `Department` (string) - Department +- `Title` (string) - Job title + +#### Contact attributes +- `EmailAddress` (string) - Email address + +#### Relationship attributes +- `Manager` (string) - Manager DN, GUID, UPN, or sAMAccountName (auto-resolved to DN) + +#### Password attributes +- `AccountPassword` (SecureString or ProtectedString) - Password as SecureString or DPAPI-protected string +- `AccountPasswordAsPlainText` (string) - Plaintext password (explicit opt-in, automatically redacted in events) + +:::warning +Only one password attribute can be used at a time. Using both `AccountPassword` and `AccountPasswordAsPlainText` will throw an error. +::: + +#### State attributes +- `Enabled` (boolean) - Account enabled state (default: `$true`) + +#### Extension container +- `OtherAttributes` (hashtable) - Custom LDAP attributes not covered by named parameters + - Must be a hashtable + - Keys are interpreted as LDAP attribute names and are validated by the underlying AD cmdlets at runtime + - Values must use types supported by the AD cmdlets for `-OtherAttributes` (for example: string, string[], byte[]) + +**Example:** +```powershell +$attrs = @{ + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + Department = 'IT' + Title = 'Engineer' + EmailAddress = 'john.doe@example.com' + OtherAttributes = @{ + extensionAttribute1 = 'CustomValue' + employeeType = 'Contractor' + } +} +``` + +### EnsureAttribute - Supported attributes + +The following attributes are supported when updating identities via `EnsureAttribute`: + +#### Name attributes +- `GivenName` (string) - First name +- `Surname` (string) - Last name +- `DisplayName` (string) - Display name + +#### Organizational attributes +- `Description` (string) - User description +- `Department` (string) - Department +- `Title` (string) - Job title + +#### Contact attributes +- `EmailAddress` (string) - Email address + +#### Identity attributes +- `UserPrincipalName` (string) - User principal name + +#### Relationship attributes +- `Manager` (string) - Manager DN, GUID, UPN, or sAMAccountName (auto-resolved to DN) + +:::info +**Note:** Custom LDAP attributes (via `OtherAttributes`) are not supported in `EnsureAttribute`. They can only be set during identity creation via `CreateIdentity`. + +Password, Path, Name, and Enabled attributes are also CreateIdentity-only and cannot be modified via `EnsureAttribute`. +::: + +### Validation behavior + +**Strict mode (default):** +- Unsupported attribute keys cause an immediate error +- Error messages list the unsupported attributes and provide guidance +- No silent attribute dropping + +**Example error:** +``` +AD Provider: Unsupported attributes in CreateIdentity operation. +Unsupported attributes: InvalidAttr1, InvalidAttr2 + +Supported attributes for CreateIdentity: + - Identity: SamAccountName, UserPrincipalName, Path + - Name: Name, GivenName, Surname, DisplayName + - Organization: Description, Department, Title + - Contact: EmailAddress + - Relationship: Manager + - Password: AccountPassword, AccountPasswordAsPlainText + - State: Enabled + - Extension: OtherAttributes (hashtable of LDAP attributes) + +To set custom LDAP attributes, use the 'OtherAttributes' container. +``` + +--- + ## Observability -- **Events emitted by provider (if any):** - - Steps emit events via the execution context; provider operations are traced through step events +- **Events emitted by provider:** + - `Provider.AD.CreateIdentity.AttributesRequested` - Emitted after identity creation with requested attributes + - `Provider.AD.EnsureAttribute.AttributeChanged` - Emitted when an attribute is modified +- **Event data includes:** + - Requested attributes (for CreateIdentity) + - Old and new values (for attribute changes in EnsureAttribute) - **Sensitive data redaction:** Credential objects and secure strings are not included in operation results or events --- diff --git a/examples/workflows/templates/ad-joiner-complete.psd1 b/examples/workflows/templates/ad-joiner-complete.psd1 index ceff16ec..e7862686 100644 --- a/examples/workflows/templates/ad-joiner-complete.psd1 +++ b/examples/workflows/templates/ad-joiner-complete.psd1 @@ -15,6 +15,12 @@ DisplayName = 'New User' Description = 'New employee account' Path = 'OU=Joiners,OU=Users,DC=contoso,DC=local' + OtherAttributes = @{ + # Custom LDAP attributes for organization-specific needs + employeeType = 'Employee' + extensionAttribute1 = 'EMPL-2024-001' + company = 'Contoso Ltd' + } } # Provider alias - references the key in the provider hashtable. # The host chooses this name when creating the provider hashtable. diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 new file mode 100644 index 00000000..bbcc5078 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -0,0 +1,86 @@ +function Get-IdleADAttributeContract { + <# + .SYNOPSIS + Returns the supported attribute contract for AD Provider operations. + + .DESCRIPTION + Defines which attributes are supported for CreateIdentity and EnsureAttribute operations. + This contract serves as the single source of truth for attribute validation. + + .PARAMETER Operation + The operation to get the contract for: 'CreateIdentity' or 'EnsureAttribute'. + + .OUTPUTS + System.Collections.Hashtable + Returns a hashtable where keys are supported attribute names and values contain metadata. + + .EXAMPLE + $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' + $supportedKeys = $contract.Keys + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [string] $Operation + ) + + if ($Operation -eq 'CreateIdentity') { + return @{ + # Identity Attributes + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Name Attributes + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Organizational Attributes + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Contact Attributes + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Relationship Attributes + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Password Attributes + AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # State Attributes + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + + # Extension Container + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + } + } + elseif ($Operation -eq 'EnsureAttribute') { + return @{ + # Name Attributes + GivenName = @{ Target = 'Parameter'; Type = 'String' } + Surname = @{ Target = 'Parameter'; Type = 'String' } + DisplayName = @{ Target = 'Parameter'; Type = 'String' } + + # Organizational Attributes + Description = @{ Target = 'Parameter'; Type = 'String' } + Department = @{ Target = 'Parameter'; Type = 'String' } + Title = @{ Target = 'Parameter'; Type = 'String' } + + # Contact Attributes + EmailAddress = @{ Target = 'Parameter'; Type = 'String' } + + # Identity Attributes + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String' } + + # Relationship Attributes + Manager = @{ Target = 'Parameter'; Type = 'String' } + } + } +} diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 34004851..bd3000f9 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -405,6 +405,14 @@ function New-IdleADAdapter { $params['AccountPassword'] = ConvertTo-SecureString -String $plainTextPassword -AsPlainText -Force } + # Handle OtherAttributes for custom LDAP attributes + if ($effectiveAttributes.ContainsKey('OtherAttributes')) { + $otherAttrs = $effectiveAttributes['OtherAttributes'] + if ($null -ne $otherAttrs -and $otherAttrs.Count -gt 0) { + $params['OtherAttributes'] = $otherAttrs + } + } + if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } diff --git a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 new file mode 100644 index 00000000..642b84e9 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 @@ -0,0 +1,122 @@ +function Test-IdleADAttributeContract { + <# + .SYNOPSIS + Validates attributes against the AD Provider attribute contract. + + .DESCRIPTION + Performs strict validation of provided attributes against the supported attribute contract. + Throws an exception if unsupported attributes are detected. + + .PARAMETER Attributes + Hashtable of attributes to validate. + + .PARAMETER Operation + The operation context: 'CreateIdentity' or 'EnsureAttribute'. + + .PARAMETER AttributeName + For EnsureAttribute, the specific attribute name being set. + + .OUTPUTS + System.Collections.Hashtable + Returns a hashtable with validation results: + - Requested: array of requested attribute keys + - Supported: array of supported attribute keys + - Unsupported: array of unsupported attribute keys + + .EXAMPLE + $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' + # Throws if unsupported attributes found + + .EXAMPLE + $result = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' + # Throws if attribute not supported for EnsureAttribute + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter()] + [AllowNull()] + [hashtable] $Attributes, + + [Parameter(Mandatory)] + [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [string] $Operation, + + [Parameter()] + [string] $AttributeName + ) + + $contract = Get-IdleADAttributeContract -Operation $Operation + + if ($Operation -eq 'CreateIdentity') { + if ($null -eq $Attributes) { + return @{ + Requested = @() + Supported = @() + Unsupported = @() + } + } + + $requestedKeys = @($Attributes.Keys) + $supportedKeys = @($contract.Keys) + $unsupportedKeys = @($requestedKeys | Where-Object { $_ -notin $supportedKeys }) + + if ($unsupportedKeys.Count -gt 0) { + $errorMessage = "AD Provider: Unsupported attributes in CreateIdentity operation.`n" + $errorMessage += "Unsupported attributes: $($unsupportedKeys -join ', ')`n`n" + $errorMessage += "Supported attributes for CreateIdentity:`n" + + # Generate supported attributes list from contract + $supportedAttributesList = ($supportedKeys | Sort-Object | ForEach-Object { " - $_" }) -join "`n" + $errorMessage += "$supportedAttributesList`n`n" + + if ('OtherAttributes' -in $supportedKeys) { + $errorMessage += "To set custom LDAP attributes, use the 'OtherAttributes' container." + } + + throw $errorMessage + } + + # Validate OtherAttributes if present + if ($Attributes.ContainsKey('OtherAttributes')) { + $otherAttrs = $Attributes['OtherAttributes'] + if ($null -ne $otherAttrs -and $otherAttrs -isnot [hashtable]) { + throw "AD Provider: 'OtherAttributes' must be a hashtable. Received type: $($otherAttrs.GetType().FullName)" + } + } + + return @{ + Requested = $requestedKeys + Supported = @($requestedKeys | Where-Object { $_ -in $supportedKeys }) + Unsupported = $unsupportedKeys + } + } + elseif ($Operation -eq 'EnsureAttribute') { + if ([string]::IsNullOrWhiteSpace($AttributeName)) { + throw "AD Provider: AttributeName is required for EnsureAttribute validation." + } + + $supportedKeys = @($contract.Keys) + + if ($AttributeName -notin $supportedKeys) { + $errorMessage = "AD Provider: Unsupported attribute in EnsureAttribute operation.`n" + $errorMessage += "Attribute: $AttributeName`n`n" + $errorMessage += "Supported attributes for EnsureAttribute:`n" + + # Generate supported attributes list from contract + $supportedAttributesList = ($supportedKeys | Sort-Object | ForEach-Object { " - $_" }) -join "`n" + $errorMessage += "$supportedAttributesList`n`n" + + $errorMessage += "Note: Custom LDAP attributes and password attributes are not supported in EnsureAttribute.`n" + $errorMessage += "For custom attributes, use CreateIdentity with OtherAttributes." + + throw $errorMessage + } + + return @{ + Requested = @($AttributeName) + Supported = @($AttributeName) + Unsupported = @() + } + } +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 69a5659b..5cabcc34 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -361,6 +361,9 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # Validate attributes against contract (strict mode - will throw on unsupported attributes) + $validationResult = Test-IdleADAttributeContract -Attributes $Attributes -Operation 'CreateIdentity' + $adapter = $this.GetEffectiveAdapter($AuthSession) try { @@ -386,6 +389,15 @@ function New-IdleADIdentityProvider { $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) + # Emit observability event + if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { + $eventData = @{ + IdentityKey = $IdentityKey + Requested = $validationResult.Requested + } + $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesRequested', 'Attributes requested during identity creation', 'CreateIdentity', $eventData) + } + return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' @@ -463,6 +475,9 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # Validate attribute against contract (strict mode - will throw on unsupported attributes) + $validationResult = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $Name + $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) @@ -474,8 +489,25 @@ function New-IdleADIdentityProvider { $changed = $false if ($currentValue -ne $Value) { - $adapter.SetUser($user.DistinguishedName, $Name, $Value) + # Special handling for Manager attribute - resolve to DN + $valueToSet = $Value + if ($Name -eq 'Manager' -and $null -ne $Value) { + $valueToSet = $adapter.ResolveManagerDN($Value) + } + + $adapter.SetUser($user.DistinguishedName, $Name, $valueToSet) $changed = $true + + # Emit observability event + if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { + $eventData = @{ + IdentityKey = $IdentityKey + AttributeName = $Name + OldValue = $currentValue + NewValue = $Value + } + $this.EventSink.WriteEvent('Provider.AD.EnsureAttribute.AttributeChanged', "Attribute '$Name' changed", 'EnsureAttribute', $eventData) + } } return [pscustomobject]@{ diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index de760c6c..0ea30b9c 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -66,7 +66,8 @@ function Invoke-IdleIdentityProviderContractTests { It 'EnsureAttribute returns a stable result shape' { $id = "contract-$([guid]::NewGuid().ToString('N'))" - $result = $script:Provider.EnsureAttribute($id, 'contractKey', 'contractValue') + # Use 'Description' - a common attribute supported by most identity providers + $result = $script:Provider.EnsureAttribute($id, 'Description', 'contractValue') $result | Should -Not -BeNullOrEmpty $result.PSObject.Properties.Name | Should -Contain 'Changed' diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 6127c700..b197fb1b 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1480,6 +1480,63 @@ Describe 'AD identity provider' { $updatedUser.Manager | Should -Be $managerUser.DistinguishedName } + It 'EnsureAttribute resolves Manager from UPN' { + # Create a manager user + $managerAttrs = @{ + SamAccountName = 'lwilson' + UserPrincipalName = 'lwilson@contoso.com' + } + $managerUser = $script:ManagerTestAdapter.NewUser('lwilson@contoso.com', $managerAttrs, $true) + + # Create employee without manager + $attrs = @{ + SamAccountName = 'managertest15' + } + $user = $script:ManagerTestAdapter.NewUser('managertest15', $attrs, $true) + + # Set manager via UPN + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest15', + 'Manager', + 'lwilson@contoso.com', # UPN instead of DN + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was resolved to DN + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -Be $managerUser.DistinguishedName + } + + It 'EnsureAttribute resolves Manager from GUID' { + # Create a manager user + $managerAttrs = @{ + SamAccountName = 'kthompson' + } + $managerUser = $script:ManagerTestAdapter.NewUser('kthompson', $managerAttrs, $true) + + # Create employee without manager + $attrs = @{ + SamAccountName = 'managertest16' + } + $user = $script:ManagerTestAdapter.NewUser('managertest16', $attrs, $true) + + # Set manager via GUID + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest16', + 'Manager', + $managerUser.ObjectGuid.ToString(), # GUID instead of DN + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was resolved to DN + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -Be $managerUser.DistinguishedName + } + It 'CreateIdentity throws when Manager GUID does not exist' { # The fake adapter auto-creates users on sAMAccountName/UPN lookup for contract test compatibility # To test resolution failure, we use a non-existent GUID @@ -1493,4 +1550,108 @@ Describe 'AD identity provider' { { $script:ManagerTestAdapter.NewUser('managertest14', $attrs, $true) } | Should -Throw -ExpectedMessage '*Could not find user*' } } + + Context 'Attribute validation (strict mode)' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:ValidationTestProvider = $provider + $script:ValidationTestAdapter = $adapter + } + + It 'CreateIdentity throws when unsupported attribute is provided' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + InvalidAttribute = 'ShouldFail' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest1', $attrs) } | + Should -Throw -ExpectedMessage '*Unsupported attributes*' + } + + It 'CreateIdentity error message lists unsupported attributes' { + $attrs = @{ + GivenName = 'Test' + InvalidAttr1 = 'Value1' + InvalidAttr2 = 'Value2' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest2', $attrs) } | + Should -Throw -ExpectedMessage '*InvalidAttr1*' + } + + It 'CreateIdentity succeeds with all supported attributes' { + $attrs = @{ + SamAccountName = 'validationtest3' + UserPrincipalName = 'test3@example.com' + GivenName = 'Test' + Surname = 'User' + DisplayName = 'Test User' + Description = 'Test Description' + Department = 'IT' + Title = 'Engineer' + EmailAddress = 'test@example.com' + Enabled = $true + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest3', $attrs) } | Should -Not -Throw + } + + It 'CreateIdentity accepts OtherAttributes hashtable' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + OtherAttributes = @{ + extensionAttribute1 = 'CustomValue1' + employeeType = 'Contractor' + } + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest4', $attrs) } | Should -Not -Throw + } + + It 'CreateIdentity throws when OtherAttributes is not a hashtable' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + OtherAttributes = 'NotAHashtable' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest5', $attrs) } | + Should -Throw -ExpectedMessage '*OtherAttributes*must be a hashtable*' + } + + It 'EnsureAttribute throws when unsupported attribute is requested' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'InvalidAttribute', 'Value') } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + + It 'EnsureAttribute error message lists the unsupported attribute' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'CustomLdapAttr', 'Value') } | + Should -Throw -ExpectedMessage '*CustomLdapAttr*' + } + + It 'EnsureAttribute succeeds with supported attributes' { + $supportedAttrs = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName', 'Manager') + + foreach ($attr in $supportedAttrs) { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | Should -Not -Throw + } + } + + It 'EnsureAttribute rejects CreateIdentity-only attributes' { + $createOnlyAttrs = @('Path', 'AccountPassword', 'AccountPasswordAsPlainText', 'Enabled') + + foreach ($attr in $createOnlyAttrs) { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + } + + It 'EnsureAttribute rejects OtherAttributes' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{}) } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + } }