Add migration to update ApiClients with centralized authorization scopes

This migration updates the ScopesJson for the ApiClient with Id 91000000-0000-0000-0000-000000000001 to include additional authorization scopes for improved API access control. The Down method reverts the changes if necessary.
This commit is contained in:
Torsten Brendgen
2026-07-09 23:49:01 +02:00
parent dc93ea0813
commit dfd9c16f61
27 changed files with 5617 additions and 34 deletions

View File

@@ -0,0 +1,422 @@
param(
[string]$ApiBaseUrl = 'http://localhost:5286/api',
[string]$ClientId = 'ssp-demo-worker',
[string]$ClientSecret = 'DemoOnly-DoNotUseInProduction!',
[int]$ApiTimeoutSec = 30
)
$ErrorActionPreference = 'Stop'
function Invoke-Api {
param(
[ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
[string]$Method,
[string]$Path,
[object]$Body,
[string]$BearerToken,
[switch]$AllowNotFound
)
$uri = ('{0}/{1}' -f $ApiBaseUrl.TrimEnd('/'), $Path.TrimStart('/'))
$parameters = @{
Method = $Method
Uri = $uri
TimeoutSec = $ApiTimeoutSec
ErrorAction = 'Stop'
}
if ($Body) {
$parameters.Body = ($Body | ConvertTo-Json -Depth 20)
$parameters.ContentType = 'application/json'
}
if ($BearerToken) {
$parameters.Headers = @{
Authorization = "Bearer $BearerToken"
}
}
try {
Invoke-RestMethod @parameters
}
catch {
$statusCode = $null
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
if ($AllowNotFound -and $statusCode -eq 404) {
return $null
}
$responseBody = '<empty response body>'
if ($_.Exception.Response) {
try {
$stream = $_.Exception.Response.GetResponseStream()
if ($stream) {
$reader = [System.IO.StreamReader]::new($stream)
$text = $reader.ReadToEnd()
if (-not [string]::IsNullOrWhiteSpace($text)) {
$responseBody = $text
}
}
}
catch {
$responseBody = '<could not read response body>'
}
}
throw "API request failed. Method=[$Method], Uri=[$uri], Response=[$responseBody]. $($_.Exception.Message)"
}
}
function Get-AuthToken {
param([string]$Scope)
$token = Invoke-Api -Method POST -Path 'auth/token' -Body @{
clientId = $ClientId
clientSecret = $ClientSecret
scope = $Scope
}
return $token.accessToken
}
function Find-ByName {
param(
[object[]]$Items,
[string]$Name
)
return @($Items | Where-Object { $_.name -eq $Name })[0]
}
function ConvertTo-GuidValue {
param([object]$Value)
return [guid]@($Value)[0]
}
function ConvertTo-ApiItems {
param([object]$Value)
if ($null -eq $Value) {
return @()
}
$items = @($Value)
if ($items.Count -eq 1 -and $items[0] -is [array]) {
return @($items[0])
}
if ($items.Count -eq 1 -and $items[0].PSObject.Properties['value']) {
return @($items[0].value)
}
return $items
}
function Write-TestSection {
param([string]$Name)
Write-Host ""
Write-Host ("=== {0} ===" -f $Name) -ForegroundColor Cyan
}
function Write-TestInfo {
param(
[string]$Label,
[object]$Value
)
Write-Host (" - {0}: {1}" -f $Label, $Value)
}
Describe 'API controller workflow smoke test' {
$script:token = $null
$script:environmentId = $null
$script:domainIds = @()
$script:targetIds = @()
$script:deploymentBatchId = $null
$script:templateSelectionIds = @()
$script:targetAssignmentIds = @()
$script:configurationValueId = $null
$script:credentialSecretId = $null
$script:testRunId = ([guid]::NewGuid().ToString('N')).Substring(0, 8)
$script:environmentName = "Pester Environment $script:testRunId"
$script:domainName1 = "Pester Domain A $script:testRunId"
$script:domainName2 = "Pester Domain B $script:testRunId"
$script:targetName1 = "PST-$script:testRunId-01"
$script:targetName2 = "PST-$script:testRunId-02"
$script:targetName3 = "PST-$script:testRunId-03"
$script:credentialSecretName = "Pester/Workflow/$script:testRunId"
try {
It 'creates an environment, links two domains, creates targets and composes a deployment batch' {
Write-TestSection -Name 'test run'
Write-TestInfo -Label 'API base URL' -Value $ApiBaseUrl
Write-TestInfo -Label 'Run id' -Value $script:testRunId
Write-TestSection -Name 'auth and inventory'
$script:token = Get-AuthToken -Scope 'configuration.read configuration.write credential.read credential.write credential.resolve deployment.read deployment.write queue.read template.read token.manage token.admin'
Write-TestInfo -Label 'Bearer token' -Value 'issued'
$allowedScopesResponse = Invoke-Api -Method GET -Path 'tokens/scopes' -BearerToken $script:token
$allowedScopes = @($allowedScopesResponse)
if ($allowedScopesResponse.PSObject.Properties['value']) {
$allowedScopes = @($allowedScopesResponse.value)
}
$allowedScopes -contains 'deployment.write' | Should Be $true
Write-TestInfo -Label 'Allowed scopes' -Value @($allowedScopes).Count
$myTokens = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'tokens/my' -BearerToken $script:token))
$myTokens.Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Managed tokens visible' -Value $myTokens.Count
$services = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'Service' -BearerToken $script:token))
$services.Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Services' -Value $services.Count
$service = $services | Select-Object -First 1
Invoke-Api -Method GET -Path ("Service/{0}" -f $service.id) -BearerToken $script:token | Should Not BeNullOrEmpty
Invoke-Api -Method GET -Path ("Service/{0}/RoleDefinitions" -f $service.id) -BearerToken $script:token | Out-Null
$templateCategories = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'TemplateCategory' -BearerToken $script:token))
$templateCategories.Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Template categories' -Value $templateCategories.Count
Invoke-Api -Method GET -Path ("TemplateCategory/{0}" -f $templateCategories[0].id) -BearerToken $script:token | Should Not BeNullOrEmpty
$deploymentRules = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'deployment-rules' -BearerToken $script:token))
$deploymentRules.Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Deployment rules' -Value $deploymentRules.Count
Invoke-Api -Method GET -Path ("deployment-rules/{0}" -f $deploymentRules[0].id) -BearerToken $script:token | Should Not BeNullOrEmpty
$configurationDefinitions = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'configuration-definitions' -BearerToken $script:token))
$configurationDefinitions.Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Configuration definitions' -Value $configurationDefinitions.Count
$configurationDefinition = $configurationDefinitions | Select-Object -First 1
Invoke-Api -Method GET -Path ("configuration-definitions/{0}" -f $configurationDefinition.id) -BearerToken $script:token | Should Not BeNullOrEmpty
Invoke-Api -Method GET -Path ("configuration-definitions/{0}/values" -f $configurationDefinition.id) -BearerToken $script:token | Out-Null
Write-TestSection -Name 'credential secret'
$script:credentialSecretId = ConvertTo-GuidValue -Value (Invoke-Api -Method POST -Path 'credential-secrets' -BearerToken $script:token -Body @{
name = $script:credentialSecretName
userName = "CONTOSO\svc-pester-$script:testRunId"
secretValue = 'PesterSecret!'
secretType = 'Credential'
metadataJson = (@{ source = 'Api.Workflow.Tests.ps1'; runId = $script:testRunId } | ConvertTo-Json -Compress)
isEnabled = $true
})
Write-TestInfo -Label 'Created credential secret' -Value $script:credentialSecretId
Invoke-Api -Method GET -Path ("credential-secrets/{0}" -f $script:credentialSecretId) -BearerToken $script:token | Should Not BeNullOrEmpty
$resolvedSecret = Invoke-Api -Method GET -Path ("credential-secrets/resolve?name={0}" -f [uri]::EscapeDataString($script:credentialSecretName)) -BearerToken $script:token
$resolvedSecret.userName | Should Be "CONTOSO\svc-pester-$script:testRunId"
Write-TestInfo -Label 'Resolved credential user' -Value $resolvedSecret.userName
Write-TestSection -Name 'environment and domains'
Invoke-Api -Method POST -Path 'Environment' -BearerToken $script:token -Body @{
name = $script:environmentName
environmentType = 'Test'
hostingType = 'OnPrem'
providerType = 'Pester'
metadataJson = (@{ source = 'Api.Workflow.Tests.ps1'; runId = $script:testRunId } | ConvertTo-Json -Compress)
} | Out-Null
$environment = Find-ByName -Items @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'Environment' -BearerToken $script:token)) -Name $script:environmentName
$environment | Should Not BeNullOrEmpty
$script:environmentId = ConvertTo-GuidValue -Value $environment.id
Write-TestInfo -Label 'Created environment' -Value ("{0} ({1})" -f $script:environmentName, $script:environmentId)
$script:configurationValueId = ConvertTo-GuidValue -Value (Invoke-Api -Method POST -Path 'configuration-values' -BearerToken $script:token -Body @{
configurationDefinitionId = [string](ConvertTo-GuidValue -Value $configurationDefinition.id)
scopeType = 'Environment'
scopeId = [string]$script:environmentId
valueSourceType = 'Static'
valueJson = (@{ value = "Pester-$script:testRunId" } | ConvertTo-Json -Compress)
sortOrder = 999
})
@(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path ("configuration-values?scopeType=Environment&scopeId={0}" -f $script:environmentId) -BearerToken $script:token)).Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Created configuration value' -Value $script:configurationValueId
$domainId1 = Invoke-Api -Method POST -Path 'Domain' -BearerToken $script:token -Body @{
name = $script:domainName1
fqdn = "pester-a-$script:testRunId.contoso.local"
netBIOS = "PSTA$($script:testRunId.Substring(0, 4))".ToUpper()
}
$domainId2 = Invoke-Api -Method POST -Path 'Domain' -BearerToken $script:token -Body @{
name = $script:domainName2
fqdn = "pester-b-$script:testRunId.contoso.local"
netBIOS = "PSTB$($script:testRunId.Substring(0, 4))".ToUpper()
}
$script:domainIds = @(
(ConvertTo-GuidValue -Value $domainId1),
(ConvertTo-GuidValue -Value $domainId2)
)
Write-TestInfo -Label 'Created domains' -Value ($script:domainIds -join ', ')
foreach ($domainId in $script:domainIds) {
Invoke-Api -Method POST -Path ("Domain/{0}/Environment/{1}" -f $domainId, $script:environmentId) -BearerToken $script:token | Out-Null
}
Write-TestInfo -Label 'Linked domains to environment' -Value $script:domainIds.Count
Write-TestSection -Name 'targets'
$targetDefinitions = @(
@{ name = $script:targetName1; domainId = $script:domainIds[0]; role = 'WebFrontEnd'; order = 10 },
@{ name = $script:targetName2; domainId = $script:domainIds[0]; role = 'Application'; order = 20 },
@{ name = $script:targetName3; domainId = $script:domainIds[1]; role = 'Search'; order = 30 }
)
foreach ($targetDefinition in $targetDefinitions) {
$targetId = Invoke-Api -Method POST -Path 'Target' -BearerToken $script:token -Body @{
domainID = $targetDefinition.domainId
name = $targetDefinition.name
targetType = 'VirtualMachine'
providerType = 'OnPrem'
externalId = $targetDefinition.name
metadataJson = (@{ source = 'Api.Workflow.Tests.ps1'; role = $targetDefinition.role } | ConvertTo-Json -Compress)
}
$script:targetIds += ConvertTo-GuidValue -Value $targetId
Write-TestInfo -Label 'Created target' -Value ("{0} / {1} ({2})" -f $targetDefinition.name, $targetDefinition.role, $targetId)
}
@($script:targetIds).Count | Should Be 3
Write-TestSection -Name 'template selection'
$templates = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'Template' -BearerToken $script:token))
$onPremServiceIds = @(
$services |
Where-Object { -not $_.isCloudService } |
ForEach-Object { [string]$_.id }
)
$onPremCategoryIds = @(
$templateCategories |
Where-Object { $onPremServiceIds -contains [string]$_.serviceId } |
ForEach-Object { [string]$_.id }
)
$selectedTemplates = @(
$templates |
Where-Object { $onPremCategoryIds -contains [string]$_.templateCategoryId } |
Select-Object -First 2
)
@($selectedTemplates).Count | Should BeGreaterThan 0
Write-TestInfo -Label 'Available templates' -Value $templates.Count
Write-TestInfo -Label 'Selected templates' -Value (($selectedTemplates | ForEach-Object { $_.name }) -join ', ')
$templateSelections = @()
$sortOrder = 10
foreach ($template in $selectedTemplates) {
$versions = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path ("Template/{0}/Versions" -f $template.id) -BearerToken $script:token))
$version = $versions | Select-Object -First 1
$version | Should Not BeNullOrEmpty
$templateSelections += @{
templateVersionId = [string](ConvertTo-GuidValue -Value $version.id)
templateRole = 'Service'
sortOrder = $sortOrder
alias = "Pester-$sortOrder"
}
Write-TestInfo -Label 'Pinned template version' -Value ("{0} -> {1}" -f $template.name, $version.id)
$sortOrder += 10
}
$targetAssignments = @()
foreach ($targetDefinition in $targetDefinitions) {
$targetId = $script:targetIds[$targetDefinitions.IndexOf($targetDefinition)]
$targetAssignments += @{
targetId = [string]$targetId
roleKey = $targetDefinition.role
sortOrder = $targetDefinition.order
nodeDataJson = (@{ nodeName = $targetDefinition.name; pester = $true } | ConvertTo-Json -Compress)
}
}
Write-TestSection -Name 'deployment batch'
$script:deploymentBatchId = ConvertTo-GuidValue -Value (Invoke-Api -Method POST -Path 'deployment-batches' -BearerToken $script:token -Body @{
status = 'Pending'
targetIds = @()
templateSelections = $templateSelections
targetAssignments = $targetAssignments
})
$script:deploymentBatchId | Should Not BeNullOrEmpty
Write-TestInfo -Label 'Created deployment batch' -Value $script:deploymentBatchId
$composition = Invoke-Api -Method GET -Path ("deployment-batches/{0}/composition" -f $script:deploymentBatchId) -BearerToken $script:token
@($composition.templateSelections).Count | Should Be @($templateSelections).Count
$composition = Invoke-Api -Method GET -Path ("deployment-batches/{0}/composition" -f $script:deploymentBatchId) -BearerToken $script:token
@($composition.targetAssignments).Count | Should Be 3
$script:targetAssignmentIds = @($composition.targetAssignments | ForEach-Object { ConvertTo-GuidValue -Value $_.id })
Write-TestInfo -Label 'Composition template selections' -Value @($composition.templateSelections).Count
Write-TestInfo -Label 'Composition target assignments' -Value @($composition.targetAssignments).Count
$deployments = @(ConvertTo-ApiItems -Value (Invoke-Api -Method GET -Path 'Deployment' -BearerToken $script:token) | Where-Object { [string]$_.deploymentGroupId -eq [string]$script:deploymentBatchId })
$deployments.Count | Should Be 3
Write-TestInfo -Label 'Deployment rows' -Value $deployments.Count
Invoke-Api -Method GET -Path ("Deployment/{0}" -f $deployments[0].id) -BearerToken $script:token | Should Not BeNullOrEmpty
Invoke-Api -Method GET -Path ("deployment-artifacts?deploymentGroupId={0}" -f $script:deploymentBatchId) -BearerToken $script:token | Out-Null
Invoke-Api -Method GET -Path 'Deployment/QueueJobs' -BearerToken $script:token | Out-Null
Write-TestInfo -Label 'Artifact and queue endpoints' -Value 'reachable'
}
}
finally {
if ($script:token) {
Write-TestSection -Name 'cleanup'
if ($script:credentialSecretId) {
Invoke-Api -Method DELETE -Path ("credential-secrets/{0}" -f $script:credentialSecretId) -BearerToken $script:token -AllowNotFound | Out-Null
Write-TestInfo -Label 'Deleted credential secret' -Value $script:credentialSecretId
}
if ($script:configurationValueId) {
Invoke-Api -Method DELETE -Path ("configuration-values/{0}" -f $script:configurationValueId) -BearerToken $script:token -AllowNotFound | Out-Null
Write-TestInfo -Label 'Deleted configuration value' -Value $script:configurationValueId
}
foreach ($assignmentId in @($script:targetAssignmentIds)) {
if ($script:deploymentBatchId) {
Invoke-Api -Method DELETE -Path ("deployment-batches/{0}/target-assignments/{1}" -f $script:deploymentBatchId, $assignmentId) -BearerToken $script:token -AllowNotFound | Out-Null
}
}
if ($script:deploymentBatchId) {
Invoke-Api -Method DELETE -Path ("deployment-batches/{0}" -f $script:deploymentBatchId) -BearerToken $script:token -AllowNotFound | Out-Null
Write-TestInfo -Label 'Deleted deployment batch' -Value $script:deploymentBatchId
}
foreach ($targetId in @($script:targetIds)) {
Invoke-Api -Method DELETE -Path ("Target/{0}" -f $targetId) -BearerToken $script:token -AllowNotFound | Out-Null
}
if (@($script:targetIds).Count -gt 0) {
Write-TestInfo -Label 'Deleted targets' -Value @($script:targetIds).Count
}
foreach ($domainId in @($script:domainIds)) {
if ($script:environmentId) {
Invoke-Api -Method DELETE -Path ("Domain/{0}/Environment/{1}" -f $domainId, $script:environmentId) -BearerToken $script:token -AllowNotFound | Out-Null
}
Invoke-Api -Method DELETE -Path ("Domain/{0}" -f $domainId) -BearerToken $script:token -AllowNotFound | Out-Null
}
if (@($script:domainIds).Count -gt 0) {
Write-TestInfo -Label 'Deleted domains' -Value @($script:domainIds).Count
}
if ($script:environmentId) {
Invoke-Api -Method DELETE -Path ("Environment/{0}" -f $script:environmentId) -BearerToken $script:token -AllowNotFound | Out-Null
Write-TestInfo -Label 'Deleted environment' -Value $script:environmentId
}
}
}
}

View File

@@ -66,25 +66,69 @@ function Invoke-TestApi {
}
}
function Invoke-TestApiExpectFailure {
param(
[ValidateSet('GET', 'POST', 'DELETE')]
[string]$Method,
[string]$Path,
[object]$Body,
[string]$BearerToken
)
$uri = ('{0}/{1}' -f $ApiBaseUrl.TrimEnd('/'), $Path.TrimStart('/'))
$parameters = @{
Method = $Method
Uri = $uri
TimeoutSec = $ApiTimeoutSec
ErrorAction = 'Stop'
}
if ($Body) {
$parameters.Body = ($Body | ConvertTo-Json -Depth 10)
$parameters.ContentType = 'application/json'
}
if ($BearerToken) {
$parameters.Headers = @{
Authorization = "Bearer $BearerToken"
}
}
try {
Invoke-RestMethod @parameters | Out-Null
throw "API request was expected to fail but succeeded. Method=[$Method], Uri=[$uri]."
}
catch {
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
return [int]$_.Exception.Response.StatusCode
}
throw
}
}
Describe 'On-prem client credentials API authentication' {
It 'issues a bearer token for the seeded demo worker client' {
$token = Invoke-TestApi -Method POST -Path 'auth/token' -Body @{
clientId = $ClientId
clientSecret = $ClientSecret
scope = 'deployment.read template.read'
scope = 'configuration.read'
}
$token.accessToken | Should Not BeNullOrEmpty
$token.tokenType | Should Be 'Bearer'
$token.expiresIn | Should BeGreaterThan 0
$token.scope | Should Be 'deployment.read template.read'
$token.scope | Should Be 'configuration.read'
}
It 'allows bearer-token API calls without Windows authentication' {
$token = Invoke-TestApi -Method POST -Path 'auth/token' -Body @{
clientId = $ClientId
clientSecret = $ClientSecret
scope = 'deployment.read template.read'
scope = 'configuration.read'
}
$definitions = @(Invoke-TestApi -Method GET -Path 'configuration-definitions?kind=Parameter' -BearerToken $token.accessToken)
@@ -93,6 +137,18 @@ Describe 'On-prem client credentials API authentication' {
@($definitions | Where-Object { $_.kind -ne 'Parameter' }).Count | Should Be 0
}
It 'rejects bearer-token API calls without the required scope' {
$token = Invoke-TestApi -Method POST -Path 'auth/token' -Body @{
clientId = $ClientId
clientSecret = $ClientSecret
scope = 'deployment.read'
}
$statusCode = Invoke-TestApiExpectFailure -Method GET -Path 'configuration-definitions?kind=Parameter' -BearerToken $token.accessToken
@(401, 403) -contains $statusCode | Should Be $true
}
It 'creates, lists and revokes managed tokens through the token API' {
$adminToken = Invoke-TestApi -Method POST -Path 'auth/token' -Body @{
clientId = $ClientId