Forge v2 to v3¶
This guide walks through migrating a Forge 2.x application to Forge 3.0. The migration covers .NET 10, Aspire 13, Entra ID authentication, Terraform restructuring, and pipeline updates.
â ī¸ Important: This guide assumes you have already completed the Forge 1.x â 2.x migration. If your application is still on Forge 1.x, complete that migration first before proceeding.
đ§Ē Test-Tools Repository? If you're migrating a test-tools repository (for managing test users and non-prod role assignments), see the Test Tools Migration Guide instead.
- đ Prerequisites
- 1ī¸âŖ .NET 10 Upgrade
- 2ī¸âŖ Package Updates
- 3ī¸âŖ AppHost Changes
- 4ī¸âŖ Infrastructure Folder Restructuring
- 5ī¸âŖ Terraform Variable Casing
- 6ī¸âŖ Entra ID Migration
- 7ī¸âŖ Pipeline Updates
- 8ī¸âŖ Build and Test
- đ Troubleshooting
- đ Resources
- đ¤ Using an AI Agent for Migration
đ Prerequisites¶
-
Complete Forge 1.x â 2.x migration (if not already done) â See Migration Guide: Forge v1 to v2
-
Install .NET 10 SDK â Download from dotnet.microsoft.com/download/dotnet/10.0
- Update SAIF Tools (if not already on latest version)
# Check current versions first
saif --version # Should be 3.0.0+
aspire --version # Should be 13.1.0+
# If versions are outdated, run update (twice to ensure all updates are applied)
saif update
Start-Sleep -Seconds 20 # Wait for background updates to complete
saif update
Start-Sleep -Seconds 20 # Wait for background updates to complete
# Verify updates
saif --version
aspire --version
- Install dotnet-outdated-tool â ī¸ REQUIRED for migration
# Check if already installed
dotnet outdated --version
# If not installed or outdated, install/update it
dotnet tool install -g dotnet-outdated-tool
# Or if already installed: dotnet tool update -g dotnet-outdated-tool
đ§ Why this is critical: The
dotnet-outdatedtool automatically updates all package references to their latest compatible versions, saving significant time and reducing errors during the migration. Without it, you'll need to manually update dozens of package versions across multiple project files.
1ī¸âŖ .NET 10 Upgrade¶
- Update the
<TargetFramework>in all.csprojfiles fromnet9.0tonet10.0
Before¶
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ContainerFamily>noble-chiseled</ContainerFamily>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.Messaging.ServiceBus" Version="9.4.2" />
<PackageReference Include="SAIF.Platform" Version="1.0.*" />
<PackageReference Include="SAIF.Platform.Authentication.AspNetCore" Version="1.0.*" />
<PackageReference Include="SAIF.Platform.Azure" Version="2.*" />
</ItemGroup>
</Project>
After¶
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ContainerFamily>noble-chiseled-extra</ContainerFamily>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.Messaging.ServiceBus" Version="13.1.0" />
<PackageReference Include="SAIF.Platform" Version="3.0.0" />
<PackageReference Include="SAIF.Platform.Authentication.AspNetCore" Version="3.0.0" />
<PackageReference Include="SAIF.Platform.Azure" Version="3.0.0" />
</ItemGroup>
</Project>
Files to Update¶
src/**/*.csproj
2ī¸âŖ Package Updates¶
â ī¸ IMPORTANT: This is the most critical step in the migration. The
dotnet-outdatedtool will automatically update all your package references to Forge 3.0 compatible versions.
- Run
dotnet-outdatedto update all packages (from your repository root):
# First, view all outdated packages to see what will change
dotnet outdated
# Update all packages to latest versions
# This updates SAIF.Platform, Aspire, and all dependencies
dotnet outdated -u
# Verify the updates were applied
dotnet restore
dotnet build
đĄ Tip: If
dotnet-outdated -ufails or skips some packages, try running it multiple times. Some packages may have dependencies that need to be updated in a specific order.
- Run
saif updateto ensure Aspire tooling is current:
Verify Package Versions¶
After running dotnet-outdated -u, verify that your packages have been updated to the expected versions. You can use the following command to check specific package versions:
Open your .csproj files and confirm the following minimum versions:
| Package | Minimum Version |
|---|---|
SAIF.Platform |
3.0.0+ |
SAIF.Platform.Authentication.AspNetCore |
3.0.0+ |
SAIF.Platform.Azure |
3.0.0+ |
SAIF.Platform.Kiota.HttpClientLibrary |
3.0.0+ |
SAIF.Platform.Aspire.Hosting |
3.0.0+ |
Aspire.Hosting.AppHost |
13.1.0+ |
Aspire.Hosting.Azure.CosmosDB |
13.1.0+ |
Aspire.Hosting.Azure.ServiceBus |
13.1.0+ |
Aspire.Azure.Messaging.ServiceBus |
13.1.0+ |
Aspire.Hosting.JavaScript |
13.1.0+ |
Microsoft.Extensions.Http.Resilience |
10.1.0+ |
Microsoft.Extensions.ServiceDiscovery |
10.1.0+ |
Microsoft.EntityFrameworkCore.Design |
10.0.0+ |
Microsoft.Extensions.Hosting |
10.0.0+ |
Microsoft.NET.Test.Sdk |
18.0.1+ |
âšī¸ Note: If any package wasn't updated to the expected version, run
dotnet outdated -uagain or manually update the version in your.csprojfile.
Packages to Remove¶
The following packages are no longer needed and should be removed if present in your project:
| Package | Reason |
|---|---|
Aspire.Hosting.NodeJs |
Replaced by Aspire.Hosting.JavaScript |
SAIF.Platform.ApiDescription.Client |
TypeSpec now handles OpenAPI generation |
Microsoft.AspNetCore.OpenApi |
TypeSpec now handles OpenAPI generation |
Microsoft.AspNetCore.Http.Abstractions |
No longer needed for OpenAPI |
Microsoft.OpenApi |
TypeSpec now handles OpenAPI generation |
Swashbuckle.AspNetCore.SwaggerGen |
TypeSpec now handles OpenAPI generation |
- Remove deprecated packages from your
.csprojfiles:
<!-- Remove these packages if present -->
<PackageReference Include="Aspire.Hosting.NodeJs" />
<PackageReference Include="SAIF.Platform.ApiDescription.Client" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" />
- Remove API Description code - Delete
ApiDescriptionand related OpenAPI code fromProgram.cs:
// Remove these lines:
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Remove app.MapOpenApi() and related middleware
âšī¸ Note: OpenAPI specs are now generated from TypeSpec definitions during build. The generated spec is automatically deployed to Azure API Management.
npm Package Updates (If Applicable)¶
If your project has a React frontend or TypeSpec definitions, update the SAIF platform npm packages:
- Update SAIF platform npm packages to version 3.0.0+:
# For React frontends
cd src/<project>.Frontend
npm install @saif/platform@^3.0.0 @saif/platform-react@^3.0.0
# For TypeSpec projects
cd src/<project>.TypeSpec
npm install @saif/platform-typespec@^3.0.0
Verify in package.json:
| Package | Minimum Version | Used In |
|---|---|---|
@saif/platform |
3.0.0+ |
React frontends |
@saif/platform-react |
3.0.0+ |
React frontends |
@saif/platform-typespec |
3.0.0+ |
TypeSpec projects |
âšī¸ Note: These are primarily dependency updates for compatibility with Forge 3.0. No significant API changes are required in your frontend or TypeSpec code.
3ī¸âŖ AppHost Changes¶
The AppHost project requires structural changes for Aspire 13.
Before¶
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<KeycloakAutoGenerate>true</KeycloakAutoGenerate>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.0" />
<PackageReference Include="Aspire.Hosting.Azure.CosmosDB" Version="9.4.0" />
<PackageReference Include="Aspire.Hosting.Azure.ServiceBus" Version="9.4.2" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.4.0" />
<PackageReference Include="SAIF.Platform.Aspire.Hosting" Version="1.0.9" />
</ItemGroup>
</Project>
After¶
<Project Sdk="Aspire.AppHost.Sdk/13.1.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Azure.CosmosDB" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Azure.ServiceBus" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.1.0" />
<PackageReference Include="SAIF.Platform.Aspire.Hosting" Version="3.0.0" />
</ItemGroup>
</Project>
Checklist¶
- SDK declaration: Move from
<Sdk Name="..." Version="..." />to<Project Sdk="Aspire.AppHost.Sdk/13.1.0"> - Remove
IsAspireHost: No longer needed (implicit) - Replace
Aspire.Hosting.NodeJswithAspire.Hosting.JavaScript
JavaScript App Registration Changes¶
Aspire 13 replaces AddNpmApp with AddJavaScriptApp. Update your AppHost resource builder files:
Before¶
var frontend = builder
.AddNpmApp("FrontEndProjectId2", "../ApplicationName1.FrontEnd", scriptName: "dev")
.WithEnvironment("BROWSER", "none")
.WithHttpEndpoint(env: "PORT", port: 23000)
.WithExternalHttpEndpoints();
After¶
var frontend = builder
.AddJavaScriptApp("FrontEndProjectId2", "../ApplicationName1.FrontEnd", "dev")
.WithEnvironment("BROWSER", "none")
.WithHttpEndpoint(env: "PORT", port: 23000)
.WithExternalHttpEndpoints();
Key Changes:
- Replace AddNpmApp with AddJavaScriptApp
- Script name moves from named parameter scriptName: "dev" to third positional parameter "dev"
- If using args parameter, replace with .WithArgs() method call
4ī¸âŖ Infrastructure Folder Restructuring¶
Forge 3.0 reorganizes the infra/ folder structure for better separation of concerns.
Before¶
infra/
âââ app/ # API infrastructure
âââ auth/
â âââ app/ # App-to-app auth (Okta)
â âââ user/ # User auth (Okta)
âââ bootstrap/
âââ okta-client/ # Okta client registration
âââ web/
After¶
infra/
âââ api/ # Renamed from app/
âââ auth/
â âââ corp/ # Internal auth (Entra ID) - NEW
â âââ ext/ # External auth (Okta)
â âââ app/
â âââ okta-client/
â âââ user/
âââ bootstrap/
âââ web/
Checklist¶
Run these commands from your repository root:
- Rename
app/toapi/
- Create external auth directory
- Move Okta auth folders to
ext/
git mv ./infra/auth/app/ ./infra/auth/ext/app/
git mv ./infra/auth/user/ ./infra/auth/ext/user/
git mv ./infra/okta-client/ ./infra/auth/ext/okta-client/
- Create corporate auth directory for Entra ID
- Update TypeSpec config to reflect new OpenAPI output path
Since the app/ folder was renamed to api/, update tspconfig.yaml to emit OpenAPI specs to the new location:
Before (src/<project>.TypeSpec/tspconfig.yaml)
emit:
- "@typespec/openapi3"
- "@typespec/http-server-csharp"
options:
"@typespec/openapi3":
emitter-output-dir: "{project-root}/../../infra/app/openapi"
"@typespec/http-server-csharp":
emitter-output-dir: "{project-root}/../../src/<project>"
â ī¸ Frontend Reference: If you have a React frontend, the npm script
generate:backend-clientinpackage.jsonwill also reference this path. Update it from../../infra/app/openapi/openapi.v1.yamlto../../infra/api/openapi/openapi.v1.yamlwhen you move the folder.
After
emit:
- "@typespec/openapi3"
- "@typespec/http-server-csharp"
options:
"@typespec/openapi3":
emitter-output-dir: "{project-root}/../../infra/api/openapi"
"@typespec/http-server-csharp":
emitter-output-dir: "{project-root}/../../src/<project>"
- Find and replace all remaining
infra/appreferences
Use find-and-replace across your entire repository to catch any remaining references:
# Search for remaining references (case-sensitive)
Get-ChildItem -Path . -Recurse -Include *.tf,*.yml,*.yaml,*.cs,*.csproj,*.json |
Select-String "infra/app" -CaseSensitive
# PowerShell find and replace
Get-ChildItem -Path . -Recurse -Include *.tf,*.yml,*.yaml,*.cs,*.csproj,*.json | ForEach-Object {
$content = Get-Content $_.FullName -Raw
$newContent = $content -creplace 'infra/app', 'infra/api'
if ($content -ne $newContent) {
Set-Content $_.FullName -Value $newContent -NoNewline
Write-Host "Updated: $($_.FullName)"
}
}
â ī¸ Manual Review Required: After running the find-and-replace, manually review the changes to ensure no unintended replacements were made (e.g., in comments or documentation that should remain as-is).
5ī¸âŖ Terraform Variable Casing¶
- Update Terraform variables to
snake_case
Before (infra/app/vars.yml)¶
ApplicationName: MyApp
ApplicationType: Api
ApiType: Experience
Owner: MyTeam
ProjectId: it-api-exp-myapp
After (infra/api/vars.yml)¶
application_name: MyApp
application_type: Api
api_type: Experience
owner: MyTeam
project_id: it-api-exp-myapp
Before (infra/app/app.generated.tf)¶
variable "Environment" {
type = string
description = "The environment in which the resources are deployed"
}
variable "EnvironmentShortName" {
type = string
description = "The short name of the environment"
default = ""
}
variable "BuildNumber" {
type = string
description = "The build number of the pipeline that deployed the resources."
}
variable "IsProduction" {
type = bool
description = "Is this a production environment?"
}
variable "Application_Secrets" {
type = map(string)
description = "Secrets for your application from Azure Devops Library"
default = {}
}
locals {
Variables = yamldecode(file("${path.module}/vars.yml"))
API = {
Name = local.Variables.ApplicationName
Type = local.Variables.ApiType
OpenApiFile = file("${path.module}/openapi/${var.OpenAPIFile}")
}
}
module "saif-appservices" {
source = "app.terraform.io/SAIFCorp/saif-apiservice/azure"
Owner = local.Variables.Owner
ProjectId = local.Variables.ProjectId
Environment = var.Environment
EnvironmentShortName = var.EnvironmentShortName
Tags = local.common_tags
API = local.API
IsProduction = var.IsProduction
Application_Settings = local.settings_map
Application_Secrets = var.Application_Secrets
}
After (infra/api/app.generated.tf)¶
variable "environment" {
type = string
description = "The environment in which the resources are deployed"
}
variable "environment_short_name" {
type = string
description = "The short name of the environment"
default = ""
}
variable "build_number" {
type = string
description = "The build number of the pipeline that deployed the resources."
}
variable "is_production" {
type = bool
description = "Is this a production environment?"
}
variable "application_secrets" {
type = map(string)
description = "Secrets for your application from Azure Devops Library"
default = {}
}
locals {
variables = yamldecode(file("${path.module}/vars.yml"))
api = {
name = local.variables.application_name
type = local.variables.api_type
open_api_file = file("${path.module}/openapi/${var.open_api_file}")
}
}
module "saif-appservices" {
source = "app.terraform.io/SAIFCorp/saif-apiservice/azure"
version = ">= 3.0.0, < 4.0.0"
owner = local.variables.owner
project_id = local.variables.project_id
environment = var.environment
environment_short_name = var.environment_short_name
tags = local.common_tags
api = local.api
is_production = var.is_production
application_settings = local.settings_map
application_secrets = var.application_secrets
}
Variable Name Mapping¶
| Before (PascalCase) | After (snake_case) |
|---|---|
Environment |
environment |
EnvironmentShortName |
environment_short_name |
Tenant |
tenant |
BuildNumber |
build_number |
IsProduction |
is_production |
Application_Secrets |
application_secrets |
OpenAPIFile |
open_api_file |
ProjectId |
project_id |
ApplicationName |
application_name |
Bootstrap Terraform Updates¶
- Update
infra/bootstrap/bootstrap.generated.tfto use snake_case variables and module version 3.0:
Before¶
variable "ProjectId" {
type = string
description = "The id of the project to create the resources in."
}
variable "Environment" {
type = string
description = "The environment to deploy the resources in."
default = ""
}
module "bootstrapper" {
source = "app.terraform.io/SAIFCorp/bootstrapper/saif"
version = ">=1.0.0, <2.0.0"
ProjectId = var.ProjectId
Environment = var.Environment
HasWebApp = try(local.features.has_web_app, false)
}
After¶
variable "project_id" {
type = string
description = "The id of the project to create the resources in."
}
variable "environment" {
type = string
description = "The environment to deploy the resources in."
default = ""
}
module "bootstrapper" {
source = "app.terraform.io/SAIFCorp/bootstrapper/saif"
version = ">= 3.0.0, < 4.0.0"
project_id = var.project_id
environment = var.environment
has_web_app = try(local.features.has_web_app, false)
has_subscriptions = try(local.features.has_subscriptions, false)
}
Web App Terraform Updates¶
If your project has a web frontend (infra/web/), update the module and variables:
- Update
infra/web/web.generated.tf:
| Variable Before | Variable After |
|---|---|
Environment |
environment |
EnvironmentShortName |
environment_short_name |
Tenant |
tenant |
DeployedBy |
deployed_by |
BuildNumber |
build_number |
IsProduction |
is_production |
createStagingSlot |
create_staging_slot |
Before¶
variable "Environment" {
type = string
description = "The environment in which the resources are deployed"
}
variable "EnvironmentShortName" {
type = string
description = "The short name of the environment"
default = ""
}
variable "Tenant" {
type = string
}
variable "DeployedBy" {
type = string
}
variable "BuildNumber" {
type = string
description = "The build number of the pipeline that deployed the resources."
}
variable "IsProduction" {
type = bool
description = "Is this a production environment?"
}
variable "createStagingSlot" {
type = bool
description = "Whether to create a secondary slot for the application"
default = false
}
locals {
Variables = yamldecode(file("${path.module}/vars.yml"))
common_tags = {
DeployedBy = var.DeployedBy
ApplicationName = local.Variables.ApplicationName
BuildNumber = var.BuildNumber
TeamName = local.Variables.Owner
}
}
module "saif-webapp" {
source = "app.terraform.io/SAIFCorp/saif-webapp/azure"
version = ">=2.0.0, <3.0.0"
Environment = var.Environment
Owner = local.Variables.Owner
ProjectId = local.Variables.ProjectId
Tags = local.common_tags
IsProduction = var.IsProduction
ApplicationName = local.Variables.ApplicationName
Application_Settings = local.settings_map
IsExternalApp = true
}
After¶
variable "environment" {
type = string
description = "The environment in which the resources are deployed"
}
variable "environment_short_name" {
type = string
description = "The short name of the environment"
default = ""
}
variable "tenant" {
type = string
}
variable "deployed_by" {
type = string
}
variable "build_number" {
type = string
description = "The build number of the pipeline that deployed the resources."
}
variable "is_production" {
type = bool
description = "Is this a production environment?"
}
variable "create_staging_slot" {
type = bool
description = "Whether to create a secondary slot for the application"
default = false
}
locals {
variables = yamldecode(file("${path.module}/vars.yml"))
common_tags = {
deployed_by = var.deployed_by
application_name = local.variables.application_name
build_number = var.build_number
team_name = local.variables.owner
}
}
module "saif-webapp" {
source = "app.terraform.io/SAIFCorp/saif-webapp/azure"
version = ">= 3.0.0, < 4.0.0"
environment = var.environment
owner = local.variables.owner
project_id = local.variables.project_id
tags = local.common_tags
is_production = var.is_production
application_name = local.variables.application_name
application_settings = local.settings_map
create_staging_slot = var.create_staging_slot
}
6ī¸âŖ Entra ID Migration¶
Forge 3.0 adds Entra ID authentication alongside the existing Okta configuration. The app registration is automatically created by the saif-apiservice moduleâno manual Azure Portal steps required.
đ For detailed security configuration guidance, see the Security Configuration Guide. This section covers migration-specific steps; the security guide provides comprehensive documentation on app roles, scopes, business roles, and TypeSpec auth requirements.
Step 1: Create API Auth Configuration¶
-
Create
infra/api/config.ymlto define app roles and scopes for Entra ID. -
Migrate app roles and scopes from Okta: If you have existing permissions in your Okta configuration, migrate them to Entra ID format.
â ī¸ Critical: In Entra ID, app roles and scopes cannot share the same
value. Azure uses thevaluefield to generate unique identifiers, and duplicates will cause deployment errors. Use a naming convention to differentiate them (e.g.,App.Readfor app roles vsUser.Readfor scopes).
Old Okta format (infra/auth/ext/okta-client/user_groups.yml):
Old Okta format (infra/auth/ext/okta-client/scopes.yml):
New Entra ID format (infra/api/config.yml):
# App Roles (Application Permissions - service-to-service)
app_roles:
- value: App.Read
display_name: Read Access
description: Read access to the API
- value: App.Write
display_name: Write Access
description: Write access to the API
# Scopes (Delegated Permissions - on-behalf-of user)
scopes:
- value: User.Read
display_name: Read Access (Delegated)
description: Read access to the API on behalf of the user
- value: User.Write
display_name: Write Access (Delegated)
description: Write access to the API on behalf of the user
- Update Okta configuration to match: Keep Okta and Entra ID permissions in sync by updating the Okta files to use the same values:
Updated Okta format (infra/auth/ext/okta-client/user_groups.yml):
Updated Okta format (infra/auth/ext/okta-client/scopes.yml):
scopes:
User.Read: Read access to the API on behalf of the user
User.Write: Write access to the API on behalf of the user
âšī¸ Why keep them in sync? When clients authenticate via either Okta or Entra ID, your API's authorization logic should check for the same permission values. Consistent naming across both identity providers simplifies your code and avoids authorization bugs.
Step 2: Create API Auth Terraform¶
- Create
infra/api/auth.generated.tf:
locals {
auth_config = yamldecode(file("${path.module}/config.yml"))
app_roles = [
for role in local.auth_config.app_roles : {
value = role.value
allowed_member_types = ["User", "Application"]
description = role.description
display_name = role.display_name
}
]
scopes = [
for scope in local.auth_config.scopes : {
value = scope.value
admin_consent_description = scope.description
admin_consent_display_name = scope.display_name
user_consent_description = scope.description
user_consent_display_name = scope.display_name
}
]
}
module "application_permissions" {
source = "app.terraform.io/SAIFCorp/application-permissions/saif"
version = ">= 3.0.0, < 4.0.0"
project_id = local.variables.project_id
environment = var.environment
environment_short_name = var.environment_short_name
app_roles = local.app_roles
scopes = local.scopes
depends_on = [module.saif-appservices]
}
Step 3: Create Corporate Auth Configuration¶
- Create
infra/auth/corp/config.ymlto define business role mappings and authorized apps:
# Business Roles to App Role Assignments
business_roles: []
# Authorized Client Applications
authorized_apps: []
- Migrate corporate business roles from Okta: If you have existing
AuthorizedBusinessRolesCorpentries in your oldinfra/auth/user/business-role-app-role.ymlfile, migrate them to the new Entra ID format:
Old Okta format (infra/auth/user/business-role-app-role.yml):
AuthorizedBusinessRolesCorp:
- name: API.Readers
appRoles:
- read
- name: API.Writers
appRoles:
- read
- write
New Entra ID format (infra/auth/corp/config.yml):
business_roles:
- name: API.Readers
app_roles:
- read
- name: API.Writers
app_roles:
- read
- write
authorized_apps: []
âšī¸ Note: The
namefield should reference an Entra ID security group. Users in that group will be assigned the specifiedapp_roleswhen authenticating.
- Create
infra/auth/corp/auth-internal.generated.tf:
terraform {
cloud {
organization = "SAIFCorp"
workspaces {
project = "your-project-id"
}
}
}
variable "project_id" {
type = string
description = "The project ID of the current application"
}
variable "environment" {
type = string
description = "The environment in which the resources are deployed"
}
variable "environment_short_name" {
type = string
description = "The short name of the environment"
}
locals {
auth_config = yamldecode(file("${path.module}/config.yml"))
app_role_assignments = flatten([
for role in local.auth_config.business_roles : [
for app_role in role.app_roles : {
role_value = app_role
business_role = role.name
}
]
])
pre_authorized_applications = [
for app in local.auth_config.authorized_apps : {
project_id = app.project_id
scope_values = app.scopes
}
]
}
module "application_permissions" {
source = "app.terraform.io/SAIFCorp/application-permissions/saif"
version = ">= 3.0.0, < 4.0.0"
project_id = var.project_id
environment = var.environment
environment_short_name = var.environment_short_name
app_role_assignments = local.app_role_assignments
pre_authorized_applications = local.pre_authorized_applications
}
Step 4: Update External Auth (Okta) Modules¶
After moving the Okta auth folders to infra/auth/ext/, update the module versions and variable casing in each file.
âšī¸ What changes and what stays the same:
Element Casing Example Terraform variables snake_caseis_production,tenantModule input parameters snake_caseis_production = var.is_productionLocal variable names snake_caselocal.features,local.app_permissionsYAML keys in features/*.yml snake_caseproject_id,front_end_project_idYAML keys in user_groups.yml snake_caseapp_permissions(wasAppPermissions)YAML keys in scopes.yml snake_casescopes(wasScopes)Module instance names PascalCase (unchanged) module "oktaApp-Api"Output names PascalCase (unchanged) output "ClientId",output "OpenIdConnectClientId"Module output references PascalCase (unchanged) module.oktaApp-Api.ClientId,module.oktaApp-Web[0].ClientId
- Update
infra/auth/ext/okta-client/okta-client.generated.tf:
| Element Before | Element After |
|---|---|
var.IsProduction |
var.is_production |
var.Tenant |
var.tenant |
local.features.ProjectId |
local.features.project_id |
local.features.FrontEndProjectId |
local.features.front_end_project_id |
local.features.ApplicationName |
local.features.application_name |
local.features.IsExperienceApi |
local.features.is_experience_api |
local.features.RedirectUrisNonProd |
local.features.redirect_uris_np |
local.features.RedirectUrisProd |
local.features.redirect_uris_prod |
.AppPermissions |
.app_permissions |
.Scopes |
.scopes (if scopes.yml exists) |
module.oktaApp-Api.ClientId |
module.oktaApp-Api.ClientId â
(unchanged) |
module.oktaApp-Web[0].ClientId |
module.oktaApp-Web[0].ClientId â
(unchanged) |
# Variables use snake_case
variable "is_production" {
description = "Is this a production environment?"
type = bool
}
variable "tenant" {
description = "The name of the Okta organization"
type = string
}
locals {
# Local variable names use snake_case
features = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
# YAML keys are now snake_case: .app_permissions (was .AppPermissions)
app_permissions = yamldecode(file("./user_groups.yml")).app_permissions
# YAML keys are now snake_case: .scopes (was .Scopes)
scopes = try(yamldecode(file("./scopes.yml")).scopes, {})
# Feature file keys are now snake_case
has_front_end = try(local.features.front_end_project_id, "") != ""
}
# Module instance names remain PascalCase
module "oktaApp-Api" {
source = "app.terraform.io/SAIFCorp/oidc-client/okta"
version = ">= 3.0.0, < 4.0.0"
# Feature file keys are snake_case: .project_id (was .ProjectId)
project_id = local.features.project_id
is_production = var.is_production
tenant = var.tenant
# ...
}
# Output names remain PascalCase
output "ClientId" {
# Module output references remain PascalCase
value = module.oktaApp-Api.ClientId
}
output "ClientSecret" {
value = module.oktaApp-Api.ClientSecret
sensitive = true
}
module "oktaApp-Web" {
count = local.has_front_end ? 1 : 0
source = "app.terraform.io/SAIFCorp/oidc-client/okta"
version = ">= 3.0.0, < 4.0.0"
# Feature file keys are snake_case
project_id = local.features.front_end_project_id
experience_api_project_id = local.features.project_id
application_name = local.features.application_name
is_production = var.is_production
tenant = var.tenant
# Module output references remain PascalCase
target_auth_server_id = module.oktaApp-Api.AuthServerId
target_auth_server_url = module.oktaApp-Api.AuthServerUrl
target_auth_server_audience = module.oktaApp-Api.AuthServerAudience
# ...
}
# Output names remain PascalCase
output "OpenIdConnectClientId" {
# Module output references remain PascalCase: .ClientId (NOT .client_id)
value = local.has_front_end ? module.oktaApp-Web[0].ClientId : null
}
output "OpenIdConnectClientSecret" {
value = local.has_front_end ? module.oktaApp-Web[0].ClientSecret : null
sensitive = true
}
output "AuthServerId" {
value = module.oktaApp-Api.AuthServerId
}
output "AuthServerUrl" {
value = module.oktaApp-Api.AuthServerUrl
}
output "AuthServerAudience" {
value = module.oktaApp-Api.AuthServerAudience
}
output "TenantOrigin" {
value = module.oktaApp-Api.TenantOrigin
}
- Update YAML files to use snake_case keys:
user_groups.yml - Change AppPermissions to app_permissions:
âšī¸ Note: The
user_groups.ymlfile defines app permissions as key-value pairs where the key is the permission name and the value is its description.
scopes.yml (optional) - If present, change Scopes to scopes:
âšī¸ Note: The
scopes.ymlfile is optional. If your project doesn't have one, you can skip this step.
features/*.yml - Change all keys to snake_case:
# Before (api.yml)
ProjectId: it-api-exp-myapp
ApplicationName: MyApp
IsExperienceApi: true
ApiType: Experience
# After (api.yml)
project_id: it-api-exp-myapp
application_name: MyApp
is_experience_api: true
api_type: Experience
# Before (web.yml) - if you have a front-end
FrontEndProjectId: it-web-myapp
RedirectUrisNonProd: []
RedirectUrisProd: []
# After (web.yml)
front_end_project_id: it-web-myapp
redirect_uris_np: []
redirect_uris_prod: []
- Update
infra/auth/ext/app/auth-app.generated.tf:
| Element Before | Element After |
|---|---|
local.yaml.AuthorizedApps |
local.yaml.authorized_apps |
app.ProjectId |
app.project_id |
app.Scopes |
app.scopes |
local.features.ProjectId |
local.features.project_id |
terraform {
cloud {
organization = "SAIFCorp"
workspaces {
project = "your-project-id"
}
}
required_providers {
okta = {
source = "okta/okta"
}
}
}
provider "okta" {
org_name = var.okta_org_name
base_url = var.okta_base_url
client_id = var.okta_client_id
private_key_id = var.okta_private_key_id
private_key = var.okta_pk
scopes = var.okta_scopes
}
variable "okta_pk" {
description = "The value of the system environment variable okta_pk"
}
variable "okta_base_url" {
description = "The base URL of the Okta organization"
}
variable "okta_client_id" {
description = "The client ID of the Okta organization"
}
variable "okta_private_key_id" {
description = "The private key ID of the Okta organization"
}
variable "okta_org_name" {
description = "The name of the Okta organization"
}
variable "okta_scopes" {
description = "The scopes of the Okta organization"
}
variable "is_production" {
description = "Is this a production environment?"
type = bool
default = false
}
variable "tenant" {
type = string
description = "The tenant name"
default = "ext"
validation {
condition = var.tenant == "ext"
error_message = "Tenant must be 'ext'. Corporate authentication now uses Entra ID."
}
}
locals {
features = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
yaml = yamldecode(file("./authorized-apps.yml"))
# YAML keys are now snake_case
authorized_apps = { for app in local.yaml.authorized_apps : app.project_id => app.scopes }
project_ids = toset(keys(local.authorized_apps))
project_id = local.features.project_id
}
module "app-auth" {
source = "app.terraform.io/SAIFCorp/app-auth/okta"
version = ">= 3.0.0, < 4.0.0"
authorized_apps = local.authorized_apps
is_production = var.is_production
target_project_id = local.project_id
tenant = var.tenant
}
module "auth-server-trust" {
source = "app.terraform.io/SAIFCorp/auth-server-trust/okta"
version = ">= 3.0.0, < 4.0.0"
auth_server_project_id = local.project_id
trusted_auth_server_project_ids = local.project_ids
}
- Update
authorized-apps.ymlto use snake_case keys:
# Before
AuthorizedApps:
- ProjectId: it-api-proc-other
Scopes:
- read
# After
authorized_apps:
- project_id: it-api-proc-other
scopes:
- read
- Update
infra/auth/ext/user/auth-user.generated.tf:
âšī¸ Okta Provider Configuration: The
role-authmodule still requires an explicit Okta provider. Keep theprovider "okta"block and related variables in this file.
| Element Before | Element After |
|---|---|
local.features.ProjectId |
local.features.project_id |
local.features.FrontEndProjectId |
local.features.front_end_project_id |
local.yaml.AuthorizedBusinessRolesCorp |
(removed - corp auth uses Entra ID) |
local.yaml.AuthorizedBusinessRolesExternal |
local.yaml.authorized_business_roles |
role.appRoles |
role.app_roles |
terraform {
cloud {
organization = "SAIFCorp"
workspaces {
project = "your-project-id"
}
}
required_providers {
okta = {
source = "okta/okta"
}
}
}
# Okta provider is required for role-auth module
provider "okta" {
org_name = var.okta_org_name
base_url = var.okta_base_url
client_id = var.okta_client_id
private_key_id = var.okta_private_key_id
private_key = var.okta_pk
scopes = var.okta_scopes
}
variable "okta_pk" {
description = "The value of the system environment variable okta_pk"
}
variable "okta_base_url" {
description = "The base URL of the Okta organization"
}
variable "okta_client_id" {
description = "The client ID of the Okta organization"
}
variable "okta_private_key_id" {
description = "The private key ID of the Okta organization"
}
variable "okta_org_name" {
description = "The name of the Okta organization"
}
variable "okta_scopes" {
description = "The scopes of the Okta organization"
}
variable "is_production" {
description = "Is this a production environment?"
type = bool
default = false
}
variable "tenant" {
type = string
description = "Okta tenant, either corp or ext"
}
locals {
yaml = yamldecode(file("./business-role-app-role.yml"))
features = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
# Feature keys are now snake_case
_project_ids_with_optional_front_end = [local.features.project_id, try(local.features.front_end_project_id, "")]
project_ids = [for id in local._project_ids_with_optional_front_end : id if id != ""]
# Okta only handles External tenant authentication (corp uses Entra ID)
authorized_business_roles = { for role in local.yaml.authorized_business_roles : role.name => role.app_roles }
}
module "role-auth" {
for_each = toset(local.project_ids)
source = "app.terraform.io/SAIFCorp/role-auth/okta"
version = ">= 3.0.0, < 4.0.0"
project_id = each.value
authorized_roles = local.authorized_business_roles
is_production = var.is_production
}
- Update
business-role-app-role.ymlto use snake_case keys:
# Before
AuthorizedBusinessRolesCorp:
- name: My Business Role
appRoles:
- read
AuthorizedBusinessRolesExternal:
- name: External Role
appRoles:
- read
# After (Note: Okta now only handles external users; corp auth uses Entra ID)
authorized_business_roles:
- name: External Role
app_roles:
- read
âšī¸ Note: In Forge 3.0, Okta only handles external user authentication. The old
AuthorizedBusinessRolesCorpmappings should be migrated to Entra ID ininfra/auth/corp/config.ymlusing thebusiness_roleskey. The oldAuthorizedBusinessRolesExternalmappings becomeauthorized_business_roleshere for Okta external users only.If you don't have any external business role mappings yet, use an empty array:
External Auth Casing Summary¶
| Element Type | Casing | Examples |
|---|---|---|
| Variables | snake_case |
is_production, tenant |
| Module inputs | snake_case |
is_production, target_project_id, authorized_apps |
| Module names | PascalCase | oktaApp-Api, oktaApp-Web |
| Outputs | PascalCase | ClientId, ClientSecret, AuthServerId |
External Auth Module Version Summary¶
| Before | After |
|---|---|
saif-oidcclient/okta >=1.0.0, <2.0.0 |
oidc-client/okta >= 3.0.0, < 4.0.0 |
saif-appauth/okta >=1.0.0, <2.0.0 |
app-auth/okta >= 3.0.0, < 4.0.0 |
saif-authserver-trust/okta >=1.0.0, <2.0.0 |
auth-server-trust/okta >= 3.0.0, < 4.0.0 |
saif-roleauth/okta >=1.0.0, <2.0.0 |
role-auth/okta >= 3.0.0, < 4.0.0 |
7ī¸âŖ Pipeline Updates¶
- Update the CI/CD pipeline configuration to use Forge 3.0 templates
Before (.azdo/azure-pipelines-api.yml)¶
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
extends:
template: azure-dotnet-api-v2.yml@templates
parameters:
# ...
dotNetVersion: '9.x'
# ...
After (.azdo/azure-pipelines-api.yml)¶
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: refs/heads/releases/v3
extends:
template: azure-dotnet-api-v3.yml@templates
parameters:
# ...
dotNetVersion: '10.x'
# ...
Key Changes¶
| Setting | Before | After |
|---|---|---|
| Template ref | (default/main) | refs/heads/releases/v3 |
| Template | azure-dotnet-api-v2.yml |
azure-dotnet-api-v3.yml |
| PR Template | azure-dotnet-api-pr-v2.yml |
azure-dotnet-api-pr-v3.yml |
dotNetVersion |
'9.x' |
'10.x' |
nodeVersion |
'20.x' |
'22.x' |
Web Pipeline Updates¶
If your project has a React frontend, update the web pipelines as well.
Before (.azdo/azure-pipelines-web.yml)¶
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
extends:
template: azure-react-web-v2.yml@templates
parameters:
# ...
nodeVersion: '20.x'
# ...
After (.azdo/azure-pipelines-web.yml)¶
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: refs/heads/releases/v3
extends:
template: azure-react-web-v3.yml@templates
parameters:
# ...
nodeVersion: '22.x'
# ...
Web Pipeline Key Changes¶
| Setting | Before | After |
|---|---|---|
| Template ref | (default/main) | refs/heads/releases/v3 |
| Template | azure-react-web-v2.yml |
azure-react-web-v3.yml |
| PR Template | azure-react-web-pr-v2.yml |
azure-react-web-pr-v3.yml |
nodeVersion |
'20.x' |
'22.x' |
Auth Pipeline Consolidation¶
Forge 3.0 consolidates the separate Okta auth pipelines into a unified auth pipeline that handles both Okta and Entra ID.
Pipelines to Remove¶
- Delete the following Okta-specific pipeline files:
# Remove old Okta auth pipelines
Remove-Item -Path .azdo/okta-appauth.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-appauth-pr.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-userauth.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-userauth-pr.yml -ErrorAction SilentlyContinue
New Auth Pipelines¶
- Create
.azdo/azure-pipelines-auth.yml:
variables:
- template: vars/base.yml
- template: vars/auth.yml
- template: Variables/versioning.yml@templates
- group: TerraformCloud
- group: Dynatrace
name: $(MajorVersion).$(MinorVersion).$(PatchVersion)$(PreReleaseVersionSeparator)$(PreReleaseVersion)
trigger: none
pool:
vmimage: 'ubuntu-latest'
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: refs/heads/releases/v3
extends:
template: azure-auth.yml@templates
parameters:
projectId: ${{ variables.ProjectId }}
- Create
.azdo/azure-pipelines-auth-pr.yml:
variables:
- template: vars/base.yml
- template: vars/auth.yml
- template: Variables/versioning.yml@templates
- group: TerraformCloud
name: $(MajorVersion).$(MinorVersion).$(PatchVersion)$(PreReleaseVersionSeparator)$(PreReleaseVersion)
trigger: none
pool:
vmimage: 'ubuntu-latest'
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: refs/heads/releases/v3
extends:
template: azure-auth-pr.yml@templates
parameters:
projectId: ${{ variables.ProjectId }}
- Create
.azdo/vars/auth.yml:
variables:
- name: MajorVersion
value: $[format('{0:yyyy}', Pipeline.StartTime)]
- name: MinorVersion
value: $[format('{0:M}', Pipeline.StartTime)]
- name: PatchVersion
value: $[counter(format('{0}.{1}.{2}', variables['MajorVersion'], variables['MinorVersion'], variables['PreReleaseVersion']), 1)]
8ī¸âŖ Build and Test¶
After completing all changes:
- Clean and restore
- Build
- Run tests
- Start AppHost locally and verify dashboard
đ Troubleshooting¶
SDK Issues¶
SDK not found:
Package conflicts:
Aspire Issues¶
Aspire CLI not found:
- See Install Aspire
AppHost fails to start:
- Verify all Aspire packages are version 13.x
- Check .NET 10 SDK is active:
dotnet --version - Rebuild:
dotnet clean && dotnet build
đ Resources¶
- Security Configuration Guide - App roles, scopes, business roles, and TypeSpec auth
- .NET 10 Release Notes
- Aspire Documentation
- Entra ID Documentation
- Terraform Cloud Registry
đ¤ Using an AI Agent for Migration¶
Use GitHub Copilot to automate this migration. Copy and paste the following prompt:
Complete the Forge v2 to v3 migration for this repository following the migration guide.
First, use `get_doc` to retrieve the full migration guide: `guides/migration/forge-v2-to-v3`
Then copy it to `docs/migration/forge-v2-to-v3.md` in the repo so you can track progress.
Use the `#todo` tool to create a todo list with these tasks:
**Prerequisites (complete before starting migration):**
0. Baseline Build - Run `dotnet build` and `dotnet test` to verify current state builds and note any pre-existing test failures
1. Install .NET 10 SDK - Verify with `dotnet --version` (should show 10.0.x)
2. Update SAIF Tools - Run `saif update`, wait 20 seconds, run `saif update` again, verify `aspire --version` shows 13.1.0+
3. Install dotnet-outdated-tool - Run `dotnet tool install -g dotnet-outdated-tool` (REQUIRED for package updates)
**Migration Sections (from the guide):**
4. .NET 10 Upgrade - Update TargetFramework to net10.0 in all .csproj files
5. Package Updates - Run `dotnet outdated -u` to update all packages, remove deprecated packages (Aspire.Hosting.NodeJs, OpenAPI packages), verify minimum versions per guide
6. AppHost Changes - Update SDK to `Aspire.AppHost.Sdk/13.1.0`, replace AddNpmApp with AddJavaScriptApp, remove IsAspireHost
7. Infrastructure Folder Restructuring - Rename infra/app/ to infra/api/, reorganize auth/ into ext/ and corp/ subfolders
8. Terraform Variable Casing - Convert all PascalCase variables to snake_case in .tf files and vars.yml
9. Entra ID Migration - Create infra/api/config.yml, add auth.generated.tf, create infra/auth/corp/ configuration
10. Pipeline Updates - Update to v3 pipeline templates
After completing each section:
1. Run `dotnet build` to verify the changes compile
2. Update the local migration progress file at `docs/migration/forge-v2-to-v3.md` with completed checkboxes
3. Mark the todo as complete using `#todo` before moving to the next section
**Delegation Strategy:**
Use the `#runSubagent` tool to efficiently discover files before making changes. Launch subagents in parallel for discovery tasks:
- **For .NET Upgrade (Task 4):** Find all .csproj files that reference net8.0 or net9.0
- **For Package Updates (Task 5):** Search for deprecated package references (Aspire.Hosting.NodeJs, Microsoft.AspNetCore.OpenApi)
- **For AppHost Changes (Task 6):** Locate AddNpmApp usages in AppHost resource builder files
- **For Terraform Casing (Tasks 7-8):** Find .tf and .yml files with PascalCase variables (Environment, ProjectId, etc.)
Each subagent should return the complete list of file paths that need updating for its assigned task.
đĄ Tip: The agent will create a todo list to track progress, run terminal commands, delegate complex searches to sub-agents, and can even submit improvements to this guide based on issues encountered during your migration.