Providers/Okta/Public/New-OktaProfileAttribute.ps1
|
function New-OktaProfileAttribute { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Adds the module's custom attributes to the Okta user schemas #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param( [Parameter()] [string[]]$Attribute, [Parameter()] [switch]$Remove, [Parameter()] [switch]$PassThru ) $connection = Get-OktaConnection $definitionPath = Join-Path -Path (Get-OktaDataPath) -ChildPath 'OktaProfileAttributes.csv' $definitions = @(Import-Csv -Path $definitionPath -Encoding UTF8) if ($Attribute) { $definitions = @($definitions | Where-Object { $Attribute -contains $_.Name }) $unknown = @($Attribute | Where-Object { $definitions.Name -notcontains $_ }) if ($unknown) { throw "No definition in $definitionPath for: $($unknown -join ', ')" } } $result = [PSCustomObject]@{ Applied = @() Removed = @() Skipped = @() Errors = @() } # A user type's schema is INDEPENDENT, not an extension of the default one. So an attribute # with no UserType has to be written to every type's schema, not just the default - a # contractor whose type only carried the contractor-specific attributes would be rejected # for every shared attribute the seed tries to set on it, including labSeedTag, which # teardown depends on. # # Attributes naming a type go to that type alone. That is what makes them invisible to a # default-schema export, which is the point of having them. $shared = @($definitions | Where-Object { -not $_.PSObject.Properties['UserType'] -or -not $_.UserType }) $namedTypes = @($definitions | Where-Object { $_.PSObject.Properties['UserType'] -and $_.UserType } | ForEach-Object { $_.UserType } | Sort-Object -Unique) # Every type defined by the module gets the shared set even if it has no attributes of its # own, so the list comes from the user type data rather than from what happens to be tagged. $typeKeys = @('') $typeFile = Join-Path (Get-OktaDataPath) 'OktaUserTypes.csv' if (Test-Path -Path $typeFile) { $typeKeys += @((Import-Csv -Path $typeFile -Encoding UTF8).Name) } $typeKeys = @($typeKeys + $namedTypes | Sort-Object -Unique) $byUserType = [ordered]@{} foreach ($typeKey in $typeKeys) { $forThisType = @($shared) if ($typeKey) { $forThisType += @($definitions | Where-Object { $_.PSObject.Properties['UserType'] -and $_.UserType -eq $typeKey }) } if ($forThisType.Count -gt 0) { $byUserType[$typeKey] = $forThisType } } foreach ($userTypeKey in @($byUserType.Keys)) { $schemaPath = $null try { $schemaPath = Get-OktaSchemaPath -UserTypeKey $userTypeKey -Prefix $connection.Prefix } catch { # A missing type is only fatal when adding. During teardown it means the type has # already gone, which is the desired end state anyway. if ($Remove) { Write-Verbose "Skipping schema for '$userTypeKey': $($_.Exception.Message)" continue } $result.Errors += $_.Exception.Message Write-Error $_.Exception.Message continue } $properties = [ordered]@{} foreach ($definition in $byUserType[$userTypeKey]) { if ($Remove) { $properties[$definition.Name] = $null continue } $property = [ordered]@{ title = $definition.Title description = $definition.Description type = $definition.Type required = [bool]::Parse($definition.Required) scope = if ($definition.Scope) { $definition.Scope } else { 'NONE' } master = @{ type = if ($definition.Master) { $definition.Master } else { 'PROFILE_MASTER' } } permissions = @(@{ principal = 'SELF' action = if ($definition.SelfPermission) { $definition.SelfPermission } else { 'READ_ONLY' } }) } # Only send the constraints that were actually specified. Sending minLength on a # boolean, or a null maxLength on a string, is a 400 rather than a no-op. if ($definition.MinLength) { $property.minLength = [int]$definition.MinLength } if ($definition.MaxLength) { $property.maxLength = [int]$definition.MaxLength } if ($definition.EnumValues) { $values = @($definition.EnumValues -split ';' | Where-Object { $_ }) $property.enum = $values # oneOf is what puts readable labels on the values in the admin console. Okta # requires it to agree with enum exactly, so it is generated from the same list # rather than maintained beside it. $property.oneOf = @($values | ForEach-Object { @{ const = $_; title = $_ } }) } if ($definition.Type -eq 'array') { $itemType = if ($definition.ItemType) { $definition.ItemType } else { 'string' } $property.items = @{ type = $itemType } $property.union = 'DISABLE' } $properties[$definition.Name] = $property } if ($properties.Count -eq 0) { continue } $typeLabel = if ($userTypeKey) { "$userTypeKey user schema" } else { 'default user schema' } $action = if ($Remove) { 'Remove custom profile attributes' } else { 'Add custom profile attributes' } $target = "$typeLabel ($($properties.Keys -join ', '))" if (-not $PSCmdlet.ShouldProcess($target, $action)) { $result.Skipped += @($properties.Keys) continue } $body = @{ definitions = @{ custom = @{ id = '#custom' type = 'object' properties = $properties required = @() } } } try { # Retried on the "deletion process is incomplete" 400. Okta reserves an attribute # name for some seconds after it is deleted, so a teardown immediately followed by # a re-seed - the module's normal workflow - hits this every time. $null = Invoke-OktaPendingCleanupRequest -Method POST -Path $schemaPath -Body $body if ($Remove) { $result.Removed += @($properties.Keys) Write-Verbose "Removed $($properties.Count) attributes from $schemaPath" } else { $result.Applied += @($properties.Keys) Write-Verbose "Applied $($properties.Count) attributes to $schemaPath" } } catch { $message = "Schema update failed for $typeLabel : $($_.Exception.Message)" $result.Errors += $message Write-Error $message } } if ($PassThru) { return $result } } |