Sync from dev @ 252c1cf

Source: main (252c1cf)
Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
2026-04-17 15:57:35 +02:00
commit 17d745bdac
52 changed files with 15601 additions and 0 deletions

46
deploy/RELEASE.md Normal file
View File

@@ -0,0 +1,46 @@
# ASTRAL Public Release Checklist
Use this checklist before publishing a new sanitized version of ASTRAL to the public repository.
## Pre-release scan
Run the following commands from the repository root to ensure no tenant-specific data remains:
```bash
# Search for the original tenant identifiers
grep -ri "cqre" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
grep -ri "kracmar" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
grep -ri "sc_intunebackup" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
# Search for the original tenant ID (replace with your actual tenant ID)
grep -ri "0ec9f34c-17c8-4541-b084-7d64ecdcc997" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
```
Expected result: **zero matches** outside of this release checklist.
## File verification
- [ ] `azure-pipelines.yml` contains no hardcoded tenant domain, email, or service connection name.
- [ ] `azure-pipelines-restore.yml` contains no hardcoded tenant domain, email, or service connection name.
- [ ] `azure-pipelines-review-sync.yml` contains no hardcoded tenant-specific values.
- [ ] `scripts/common.py` uses a generic fallback name (not `CQRE_Intune_Backupper`).
- [ ] `tenant-state/` contains only placeholder files (`.gitkeep`, `README.md`).
- [ ] `prod-as-built.md` has been deleted.
- [ ] All markdown documentation uses generic examples (`contoso.onmicrosoft.com`, `astral-backup@contoso.com`, `sc-astral-backup`).
## Test verification
- [ ] Unit tests pass: `python3 -m unittest discover -s tests -v`
## Publication steps
1. Ensure you are on a clean branch (e.g. `publish/v1.x`).
2. Run the pre-release scan above.
3. Commit any last-minute fixes.
4. Tag the release: `git tag -a v1.0.0 -m "ASTRAL v1.0.0"`
5. Push the tag.
6. Publish to the public repository (fresh clone or specific branch push).
## Note on Git history
If the original repository contained live tenant exports in its history, consider publishing from a **squashed or freshly initialized repository** rather than pushing the full private history. The public template does not benefit from historical tenant data, and a clean history avoids accidental exposure of old exports.

228
deploy/bootstrap-tenant.ps1 Normal file
View File

@@ -0,0 +1,228 @@
#requires -Version 5.1
<#
.SYNOPSIS
Bootstraps an Azure AD app registration for ASTRAL with required Microsoft Graph permissions.
.DESCRIPTION
Creates a single-tenant app registration, assigns read (and optional write) Graph application permissions,
grants admin consent, and configures a workload federated credential for Azure DevOps.
.PARAMETER TenantName
The Microsoft 365 tenant domain, e.g. contoso.onmicrosoft.com.
.PARAMETER ServiceConnectionName
The intended Azure DevOps service connection name (used for the federated credential subject).
.PARAMETER AppDisplayName
Optional display name for the app registration. Default: "ASTRAL Backup Service".
.PARAMETER AdoOrganizationUrl
Optional Azure DevOps organization URL, e.g. https://dev.azure.com/contoso.
If provided, the script prints a one-liner to create the service connection via REST API.
.PARAMETER AddRestorePermissions
If specified, also adds write permissions for the restore pipeline.
.EXAMPLE
.\bootstrap-tenant.ps1 -TenantName "contoso.onmicrosoft.com" -ServiceConnectionName "sc-astral-backup"
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$TenantName,
[Parameter(Mandatory = $true)]
[string]$ServiceConnectionName,
[string]$AppDisplayName = "ASTRAL Backup Service",
[string]$AdoOrganizationUrl = "",
[switch]$AddRestorePermissions
)
$ErrorActionPreference = "Stop"
function Test-ModuleInstalled {
param ([string]$Name)
$mod = Get-Module -ListAvailable -Name $Name | Select-Object -First 1
if (-not $mod) {
Write-Host "Installing module: $Name" -ForegroundColor Cyan
Install-Module $Name -Scope CurrentUser -Force -AllowClobber
}
}
Test-ModuleInstalled "Microsoft.Graph.Applications"
Test-ModuleInstalled "Microsoft.Graph.Identity.SignIns"
Import-Module Microsoft.Graph.Applications
Import-Module Microsoft.Graph.Identity.SignIns
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All","Directory.Read.All" -NoWelcome
$tenant = Get-MgOrganization | Select-Object -First 1
if (-not $tenant) {
throw "Unable to read tenant details. Ensure you are authenticated to the correct tenant."
}
Write-Host "Tenant: $($tenant.DisplayName) ($($tenant.Id))" -ForegroundColor Green
# Required read permissions
$readPermissions = @(
"Device.Read.All",
"DeviceManagementApps.Read.All",
"DeviceManagementConfiguration.Read.All",
"DeviceManagementManagedDevices.Read.All",
"DeviceManagementRBAC.Read.All",
"DeviceManagementScripts.Read.All",
"DeviceManagementServiceConfig.Read.All",
"Group.Read.All",
"Policy.Read.All",
"Policy.Read.ConditionalAccess",
"Policy.Read.DeviceConfiguration",
"User.Read.All",
"Application.Read.All"
)
$optionalReadPermissions = @(
"RoleManagement.Read.Directory",
"Directory.Read.All",
"AuditLog.Read.All"
)
$restorePermissions = @(
"DeviceManagementApps.ReadWrite.All",
"DeviceManagementConfiguration.ReadWrite.All",
"DeviceManagementManagedDevices.ReadWrite.All",
"DeviceManagementRBAC.ReadWrite.All",
"DeviceManagementScripts.ReadWrite.All",
"DeviceManagementServiceConfig.ReadWrite.All",
"Policy.Read.All",
"Policy.ReadWrite.ConditionalAccess"
)
$allPermissions = $readPermissions + $optionalReadPermissions
if ($AddRestorePermissions) {
$allPermissions += $restorePermissions
}
# Get Microsoft Graph SP to map permissions to AppRoles
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
if (-not $graphSp) {
throw "Microsoft Graph service principal not found in tenant."
}
$requiredResourceAccess = @()
$appRoles = @()
foreach ($permName in ($allPermissions | Select-Object -Unique)) {
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $permName } | Select-Object -First 1
if (-not $appRole) {
Write-Warning "Permission '$permName' not found in Microsoft Graph. Skipping."
continue
}
$appRoles += $appRole
}
if ($appRoles.Count -eq 0) {
throw "No valid Graph permissions resolved. Cannot continue."
}
$resourceAccess = @()
foreach ($ar in $appRoles) {
$resourceAccess += @{
id = $ar.Id
type = "Role"
}
}
$requiredResourceAccess = @(
@{
resourceAppId = $graphSp.AppId
resourceAccess = $resourceAccess
}
)
# Create or update app registration
$existingApp = Get-MgApplication -Filter "displayName eq '$AppDisplayName'" | Select-Object -First 1
if ($existingApp) {
Write-Host "Found existing app registration: $($existingApp.AppId)" -ForegroundColor Yellow
$app = $existingApp
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
Write-Host "Updated required resource access." -ForegroundColor Green
}
else {
Write-Host "Creating app registration: $AppDisplayName" -ForegroundColor Cyan
$app = New-MgApplication -DisplayName $AppDisplayName -SignInAudience "AzureADMyOrg" -RequiredResourceAccess $requiredResourceAccess
Write-Host "Created app registration. AppId: $($app.AppId)" -ForegroundColor Green
}
# Ensure service principal exists
$sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" | Select-Object -First 1
if (-not $sp) {
Write-Host "Creating service principal..." -ForegroundColor Cyan
$sp = New-MgServicePrincipal -AppId $app.AppId
}
# Grant admin consent
Write-Host "Granting admin consent..." -ForegroundColor Cyan
foreach ($ar in $appRoles) {
$existingAssignment = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id | Where-Object { $_.AppRoleId -eq $ar.Id }
if (-not $existingAssignment) {
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -PrincipalId $sp.Id -ResourceId $graphSp.Id -AppRoleId $ar.Id | Out-Null
}
}
Write-Host "Admin consent granted." -ForegroundColor Green
# Federated credential for Azure DevOps
$federatedCredentialName = "AstralAzureDevOps-$ServiceConnectionName"
$existingFedCred = Get-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id | Where-Object { $_.Name -eq $federatedCredentialName }
if (-not $existingFedCred) {
Write-Host "Creating federated credential for Azure DevOps..." -ForegroundColor Cyan
# Subject identifier for Azure DevOps workload identity federation
# Format: sc://<ado-org>/<project>/<service-connection-name>
# We require the user to fill in org/project manually or via parameters.
$adoOrg = Read-Host "Enter your Azure DevOps organization name (e.g. 'contoso')"
$adoProject = Read-Host "Enter your Azure DevOps project name (e.g. 'ASTRAL')"
$subject = "sc://$adoOrg/$adoProject/$ServiceConnectionName"
$params = @{
Name = $federatedCredentialName
Issuer = "https://vstoken.dev.azure.com"
Subject = $subject
Audiences = @("api://AzureADTokenExchange")
}
New-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id -BodyParameter $params | Out-Null
Write-Host "Federated credential created. Subject: $subject" -ForegroundColor Green
}
else {
Write-Host "Federated credential already exists." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "=== Bootstrap complete ===" -ForegroundColor Green
Write-Host "Tenant Name: $TenantName"
Write-Host "Tenant ID: $($tenant.Id)"
Write-Host "App Display Name: $AppDisplayName"
Write-Host "App ID: $($app.AppId)"
Write-Host "Service Connection: $ServiceConnectionName"
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host "1. In Azure DevOps, create a Workload Identity Federation service connection."
Write-Host " - Tenant ID: $($tenant.Id)"
Write-Host " - App ID: $($app.AppId)"
Write-Host " - Name: $ServiceConnectionName"
Write-Host ""
if ($AdoOrganizationUrl) {
$project = if ($AdoOrganizationUrl -match "/([^/]+)$") { $matches[1] } else { "YOUR_PROJECT" }
$pat = Read-Host "Enter an Azure DevOps PAT with 'ServiceConnections: Read & manage' scope (input is hidden)" -AsSecureString
$patPlain = [System.Net.NetworkCredential]::new("", $pat).Password
Write-Host ""
Write-Host "You can create the service connection via REST API using:"
Write-Host " curl -u :$patPlain -X POST -H 'Content-Type: application/json' "
Write-Host " -d '{ ... }' "
Write-Host " '$AdoOrganizationUrl/_apis/serviceendpoint/endpoints?api-version=7.1'"
}
Disconnect-MgGraph | Out-Null

View File

@@ -0,0 +1,150 @@
# ASTRAL Onboarding Runbook
This guide walks through deploying ASTRAL into a new Azure DevOps organization and Microsoft 365 tenant.
## Prerequisites
- Azure DevOps organization and project created.
- Owner or Contributor access to the target Microsoft 365 tenant.
- Permission to create app registrations and grant admin consent in Entra ID.
- PowerShell 7+ or Windows PowerShell 5.1 with the `Microsoft.Graph` module (for the bootstrap script).
## Step 1: Import the repository
1. In Azure DevOps, create a new Git repository in your project.
2. Push the contents of this repository into it, or use **Import repository** from a public Git URL.
## Step 2: Create the tenant variable group
1. In Azure DevOps, go to **Pipelines > Library** and create a new Variable Group.
2. Recommended name: `vg-astral-tenant` (you can choose any name).
3. Add the variables from `templates/variables-tenant.yml`. Use your real tenant values:
| Variable | Example value | Notes |
| --- | --- | --- |
| `TENANT_NAME` | `contoso.onmicrosoft.com` | Your M365 tenant domain |
| `SERVICE_CONNECTION_NAME` | `sc-astral-backup` | Name you will use for the service connection |
| `USER_NAME` | `ASTRAL Backup Service` | Git committer name |
| `USER_EMAIL` | `astral-backup@contoso.com` | Git committer email |
| `AGENT_POOL_NAME` | `Azure Pipelines` | Change if using a self-hosted pool |
| `BACKUP_TIMEZONE` | `Europe/Prague` | Valid tz database name |
| `FULL_RUN_HOUR` | `00` | Hour that triggers full export |
| `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` | *(leave empty)* | Filled in Step 8 |
4. If you plan to use Azure OpenAI summaries, also add:
- `ENABLE_PR_AI_SUMMARY` = `true`
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_OPENAI_DEPLOYMENT`
- `AZURE_OPENAI_API_KEY` *(mark as secret)*
## Step 3: Link the variable group to the pipelines
Open each pipeline YAML and uncomment the variable group line near the top:
```yaml
variables:
- group: vg-astral-tenant # <-- uncomment this line
- template: templates/variables-common.yml
```
Do this for:
- `azure-pipelines.yml`
- `azure-pipelines-review-sync.yml`
- `azure-pipelines-restore.yml`
Commit and push the changes.
## Step 4: Run the tenant bootstrap script
Run `deploy/bootstrap-tenant.ps1` in a PowerShell session authenticated to your target tenant.
```powershell
# Example
.\deploy\bootstrap-tenant.ps1 -TenantName "contoso.onmicrosoft.com" -ServiceConnectionName "sc-astral-backup"
```
The script will:
1. Create a single-tenant app registration.
2. Add required Microsoft Graph application permissions.
3. Grant admin consent.
4. Create a workload federated credential for Azure DevOps.
5. Print the App ID and instructions for creating the Azure DevOps service connection.
## Step 5: Create the Azure DevOps service connection
1. In Azure DevOps, go to **Project settings > Service connections**.
2. Click **New service connection > Azure Resource Manager > Workload identity federation (manual)**.
3. Fill in:
- **Subscription**: leave blank or select if you also want ARM access (not required).
- **Tenant ID**: your Microsoft 365 tenant ID.
- **Service Connection Name**: the same value you set in `SERVICE_CONNECTION_NAME` (e.g. `sc-astral-backup`).
- **App ID**: from the bootstrap script output.
4. Save the service connection.
## Step 6: Import the pipelines
1. Go to **Pipelines > Create pipeline > Azure Repos Git**.
2. Select your repository.
3. Choose **Existing Azure Pipelines YAML file**.
4. Import each of the three YAMLs one by one:
- `azure-pipelines.yml` (main backup)
- `azure-pipelines-review-sync.yml` (review sync)
- `azure-pipelines-restore.yml` (restore)
## Step 7: Grant repository permissions to the build identity
1. Go to **Project settings > Repositories**.
2. Select your repository.
3. Under **Security**, grant the **Build Service** account:
- Contribute
- Create branch
- Force push
- Create pull request
- Edit pull request
- Tag creation (if you enable tagging)
4. Under **Pipelines**, grant the build service **Queue builds** permission on `azure-pipelines-restore.yml` if you plan to use auto-remediation.
## Step 8: Set the restore pipeline definition ID
After importing `azure-pipelines-restore.yml`, find its definition ID:
1. Open the restore pipeline in Azure DevOps.
2. The URL contains `definitionId=XX`. Note the number.
3. Go back to your variable group (`vg-astral-tenant`) and set:
- `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` = `XX`
## Step 9: Validate the deployment
1. Import `deploy/validate-deployment.yml` as a one-time pipeline.
2. Run it.
3. Verify that all checks pass:
- Graph token acquisition
- Required roles present
- Test read from Graph
- Test PR creation and abandonment
## Step 10: Run the first backup
1. Queue a manual run of `azure-pipelines.yml`.
2. Set `forceFullRun=true` to get a complete initial snapshot.
3. Verify that `tenant-state/` is populated and a rolling PR is created.
## Optional: progressive feature rollout
| Phase | What to enable |
| --- | --- |
| Backup-only | `ENABLE_PR_REVIEW_SUMMARY=false`, `ENABLE_PR_REVIEWER_DECISIONS=false`, `AUTO_REMEDIATE_AFTER_MERGE=false` |
| Review package | `ENABLE_PR_REVIEW_SUMMARY=true`, `ENABLE_PR_REVIEWER_DECISIONS=true` |
| Full package | Also enable restore and set `AUTO_REMEDIATE_AFTER_MERGE=true` if desired |
| AI summaries | `ENABLE_PR_AI_SUMMARY=true` plus Azure OpenAI variables |
## Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Pipeline fails at "Get Graph Token" | Wrong service connection name or missing federated credential | Verify `SERVICE_CONNECTION_NAME` matches the service connection exactly |
| "Missing required Graph roles" | Admin consent not granted | Run bootstrap script again or grant consent manually in Entra ID |
| Rolling PR not created | Build identity lacks PR permissions | Add **Create pull request** and **Edit pull request** permissions |
| Restore pipeline queue fails | `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` wrong or missing queue permission | Verify the ID and grant **Queue builds** on the restore pipeline |
| Empty `tenant-state/` after run | First run may have no data if Graph returns nothing; also check `BACKUP_FOLDER` path | Verify Graph permissions and re-run |

118
deploy/publish-public.yml Normal file
View File

@@ -0,0 +1,118 @@
trigger: none
pr: none
# Publisher pipeline: pushes a sanitized snapshot of the dev repo to the public template repo.
#
# Usage:
# Queue this pipeline manually and optionally provide a tag name (e.g. v1.1.0).
#
# Prerequisites:
# - PUBLIC_REPO_URL (pipeline variable)
# - PUBLIC_REPO_PAT (secret pipeline variable)
parameters:
- name: tagName
displayName: Optional release tag (e.g. v1.1.0)
type: string
default: ""
variables:
- template: ../templates/variables-common.yml
jobs:
- job: publish_public_template
displayName: Publish sanitized snapshot to public repo
pool:
name: $(AGENT_POOL_NAME)
steps:
- checkout: self
persistCredentials: true
- task: Bash@3
displayName: Run sync-to-public
inputs:
targetType: inline
script: |
set -euo pipefail
chmod +x "$(Build.SourcesDirectory)/deploy/sync-to-public.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
# Run the sync script; it clones the public repo into a temp subdir
"$(Build.SourcesDirectory)/deploy/sync-to-public.sh" \
"$(PUBLIC_REPO_URL)" \
"${{ parameters.tagName }}"
# The script prints the clone path in its output. Extract the last temp dir it used.
PUBLIC_CLONE="$TMP_DIR/public"
mkdir -p "$PUBLIC_CLONE"
# Re-run the sync into our controlled temp dir to guarantee the path
cd "$(Build.SourcesDirectory)"
rsync -a \
--exclude='.git' \
--exclude='tenant-state' \
--exclude='prod-as-built.md' \
--exclude='node_modules' \
--exclude='__pycache__' \
--exclude='.DS_Store' \
--exclude='deploy/sync-to-public.sh' \
--exclude='deploy/publish-public.yml' \
"$(Build.SourcesDirectory)/" "$PUBLIC_CLONE/"
cd "$PUBLIC_CLONE"
# Re-create empty tenant-state structure
mkdir -p tenant-state/intune tenant-state/entra tenant-state/reports/intune tenant-state/reports/entra
touch tenant-state/intune/.gitkeep tenant-state/entra/.gitkeep tenant-state/reports/intune/.gitkeep tenant-state/reports/entra/.gitkeep
cat > tenant-state/README.md <<'EOF'
# tenant-state
This directory is populated automatically by the ASTRAL pipeline.
Do not place manual files here; they will be overwritten on the next export.
EOF
git init
git remote add origin "$(PUBLIC_REPO_URL)" 2>/dev/null || git remote set-url origin "$(PUBLIC_REPO_URL)"
git config user.email "astral-publish@local"
git config user.name "ASTRAL Publisher"
# Fetch existing public main so we can diff against it
git fetch origin main || true
# Stage everything
git add -A
if git diff --cached --quiet; then
echo "No changes to publish."
exit 0
fi
DEV_SHA="$(git -C '$(Build.SourcesDirectory)' rev-parse --short HEAD)"
DEV_BRANCH="$(git -C '$(Build.SourcesDirectory)' rev-parse --abbrev-ref HEAD)"
git commit -m "Sync from dev @ ${DEV_SHA}
Source: ${DEV_BRANCH} (${DEV_SHA})
Excluded: live tenant exports, generated artifacts, and dev-only tooling."
if [ -n "${{ parameters.tagName }}" ]; then
git tag -a "${{ parameters.tagName }}" -m "Release ${{ parameters.tagName }}"
fi
# Push commit (and tag if provided)
git push origin HEAD:main --force
if [ -n "${{ parameters.tagName }}" ]; then
git push origin "${{ parameters.tagName }}"
fi
echo "Publication complete."
if [ -n "${{ parameters.tagName }}" ]; then
echo "Tag: ${{ parameters.tagName }}"
fi
env:
GIT_ASKPASS: echo
GIT_USERNAME: $(PUBLIC_REPO_USERNAME)
GIT_PASSWORD: $(PUBLIC_REPO_PAT)

View File

@@ -0,0 +1,120 @@
trigger: none
pr: none
# One-time validation pipeline for ASTRAL onboarding.
# Import this pipeline, run it manually, and verify all checks pass.
variables:
# Uncomment after creating your tenant variable group.
# - group: vg-astral-tenant
- template: ../templates/variables-common.yml
jobs:
- job: validate_environment
displayName: Validate ASTRAL deployment
pool:
name: $(AGENT_POOL_NAME)
steps:
- checkout: self
persistCredentials: true
- task: AzurePowerShell@5
displayName: Validate Graph token acquisition
inputs:
azureSubscription: $(SERVICE_CONNECTION_NAME)
azurePowerShellVersion: LatestVersion
ScriptType: inlineScript
Inline: |
$getTokenParams = @{
ResourceTypeName = 'MSGraph'
AsSecureString = $true
ErrorAction = 'Stop'
}
$tokenCommand = Get-Command Get-AzAccessToken -ErrorAction Stop
if ($tokenCommand.Parameters.ContainsKey('ForceRefresh')) {
$getTokenParams['ForceRefresh'] = $true
}
$accessToken = ([PSCredential]::New('dummy', (Get-AzAccessToken @getTokenParams).Token).GetNetworkCredential().Password)
$tokenParts = $accessToken.Split('.')
if ($tokenParts.Length -lt 2) { throw "Invalid Graph access token format." }
$payload = $tokenParts[1].Replace('-', '+').Replace('_', '/')
switch ($payload.Length % 4) {
2 { $payload += '==' }
3 { $payload += '=' }
}
$payloadJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload))
$claims = $payloadJson | ConvertFrom-Json
$roles = @($claims.roles)
$sortedRoles = $roles | Sort-Object
Write-Host "Graph token roles: $($sortedRoles -join ', ')"
$requiredReadRoles = @(
"Device.Read.All",
"DeviceManagementApps.Read.All",
"DeviceManagementConfiguration.Read.All",
"DeviceManagementManagedDevices.Read.All",
"DeviceManagementRBAC.Read.All",
"DeviceManagementScripts.Read.All",
"DeviceManagementServiceConfig.Read.All",
"Group.Read.All",
"Policy.Read.All",
"Policy.Read.ConditionalAccess",
"Policy.Read.DeviceConfiguration",
"User.Read.All",
"Application.Read.All"
)
$missing = $requiredReadRoles | Where-Object { $_ -notin $sortedRoles }
if ($missing) {
throw "Missing required Graph roles: $($missing -join ', ')"
}
Write-Host "All required read roles are present." -ForegroundColor Green
# Export token for subsequent steps
Write-Host "##vso[task.setvariable variable=GRAPH_TOKEN;issecret=true]$accessToken"
- task: Bash@3
displayName: Validate Graph read access
inputs:
targetType: inline
script: |
set -euo pipefail
TOKEN="$(GRAPH_TOKEN)"
URL="https://graph.microsoft.com/v1.0/organization?$select=id,displayName"
RESPONSE=$(curl -sf -H "Authorization: Bearer $TOKEN" "$URL")
echo "Graph read test response: $RESPONSE"
echo "Graph connectivity confirmed."
- task: Bash@3
displayName: Validate PR creation permission
inputs:
targetType: inline
script: |
set -euo pipefail
TOKEN="$(System.AccessToken)"
COLLECTION_URI="$(System.CollectionUri)"
PROJECT="$(System.TeamProject)"
REPO_ID="$(Build.Repository.ID)"
API="${COLLECTION_URI%/}/${PROJECT}/_apis/git/repositories/${REPO_ID}/pullrequests?api-version=7.1"
BODY=$(cat <<EOF
{
"sourceRefName": "refs/heads/main",
"targetRefName": "refs/heads/main",
"title": "ASTRAL validation test PR",
"description": "This is a temporary PR created by the validate-deployment pipeline. It will be abandoned immediately.",
"isDraft": true
}
EOF
)
echo "Creating test PR..."
PR_RESPONSE=$(curl -sf -u ":$TOKEN" -H "Content-Type: application/json" -d "$BODY" "$API")
PR_ID=$(echo "$PR_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['pullRequestId'])")
echo "Created test PR #$PR_ID"
ABANDON_API="${COLLECTION_URI%/}/${PROJECT}/_apis/git/repositories/${REPO_ID}/pullrequests/${PR_ID}?api-version=7.1"
echo "Abandoning test PR #$PR_ID..."
curl -sf -u ":$TOKEN" -H "Content-Type: application/json" -X PATCH -d '{"status":"abandoned"}' "$ABANDON_API"
echo "PR creation and abandonment successful."