# Migrate to saif-business-roles

!!! danger "AI Agent Safety Check — Required Before Starting"
    This migration involves Terraform state operations. Before proceeding with any step, confirm the AI agent can safely execute this runbook:

    1. **Model capability**: Use an agent capable of reliably following multi-step state-migration runbooks (reasoning-class models are recommended). If you are unsure, test with a simpler task first.
    2. **Dry-run first**: Always run `terraform plan` before `terraform apply`. If the plan shows unexpected destroys, stop and investigate.
    3. **State backup**: Confirm the TFC workspace has a recent state version before applying.

    If any of these checks cannot be satisfied, do not continue.

The `okta-business-roles` Terraform module has been **deprecated** and replaced by two purpose-built Forge modules:

| Old Module | New Module | Handles |
|---|---|---|
| `okta-business-roles` | `saif-business-roles-corp` | Corp users — Entra ID security groups |
| `okta-business-roles` | `saif-business-roles-external` | External users — Okta groups and rules |

!!! warning "Breaking Change"
    This migration involves YAML schema changes (PascalCase → snake_case) and a Terraform state migration. Follow all steps in order. Skipping the state migration will cause Terraform to destroy and recreate existing groups.

[TOC]

---

## Identify Your Repo Type

Before starting, read `.azdo/vars.yml` in the repo to determine the tenant:

```yaml
variables:
  - name: Tenant
    value: corp      # "corp" → follow the Corp section below
  - name: TeamName
    value: Customer  # used to derive the workspace tag
```

- If `Tenant: corp` → follow [Migrate a Corp Repo](#migrate-a-corp-repo)
- If `Tenant: external` → follow [Migrate an External Repo](#migrate-an-external-repo)

---

## Migrate a Corp Repo

**Applies to:** repos named `*-okta-business-roles-corp`

### Step 0 — Verify model

Ask the AI agent: *"What model are you using?"*

- ✅ Response contains **Claude Opus 4.x** → proceed to Step 1
- ❌ Any other response → **stop**. Do not continue. Instruct the user to switch to Claude Opus 4.x before restarting this migration.

### Step 1 — Create a branch

```bash
git checkout -b feat/migrate-to-saif-business-roles-corp
```

### Step 2 — Rename the infra directory

Corp repos no longer use Okta — move the infra folder to reflect that:

```bash
git mv infra/okta infra/entra
```

### Step 3 — Rename and convert `business-roles.yml`

If the file is named `okta-business-roles.yml`, rename it:

```bash
git mv infra/entra/okta-business-roles.yml infra/entra/business-roles.yml
```

Convert the YAML content using this key mapping — rename every key, keep all values unchanged:

| Old Key | New Key |
|---|---|
| `BusinessRoles` (root list) | `business_roles` |
| `Name` | `name` |
| `Description` | `description` |
| `RoleSets` | `role_sets` |
| `Division` | `division` |
| `Roles` (under `RoleSets`) | `job_titles` |
| `ManualUsers` | `manual_users` |

=== "Before"

    ```yaml
    BusinessRoles:
      - Name: "Claims Adjuster"
        Description: "Claims staff"
        RoleSets:
          - Division: Claims Division
            Roles:
              - Claims Adjuster I
        ManualUsers: []
    ```

=== "After"

    ```yaml
    business_roles:
      - name: "Claims Adjuster"
        description: "Claims staff"
        role_sets:
          - division: Claims Division
            job_titles:
              - Claims Adjuster I
        manual_users: []
    ```

### Step 4 — Update `vars.yml`

Check `infra/entra/vars.yml`. If it contains `TeamName:`, rename it to `team_name:`:

=== "Before"

    ```yaml
    TeamName: "Platform"
    ```

=== "After"

    ```yaml
    team_name: "Platform"
    ```

### Step 5 — Replace `business-roles.generated.tf`

1. Read `.azdo/vars.yml` and note the `TeamName` value (e.g. `Customer`).
2. The workspace tag is `<teamname-lowercase>-busrole-corp` (e.g. `customer-busrole-corp`).
3. If the file is named `okta-business-roles.generated.tf`, rename it:

    ```bash
    git mv infra/entra/okta-business-roles.generated.tf infra/entra/business-roles.generated.tf
    ```

4. Replace the **entire** file contents with the following, substituting `<teamname-lowercase>`.

!!! warning "Keep the Okta provider for now"
    The old `okta-business-roles` module created Okta groups for corp roles. Those resources are still in state and must be explicitly destroyed by this migration apply. Terraform needs the Okta provider present to do that. Once the apply completes and the Okta resources are gone from state, the provider and its variables can be safely removed in the cleanup PR (Step 10).

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      tags = ["<teamname-lowercase>-busrole-corp"]
    }
  }

  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = ">= 3.0, < 4.0"
    }
    okta = {
      source  = "okta/okta"
      version = ">=4.10.0,< 5.0.0"
    }
  }
}

provider "okta" {
  org_name        = var.okta_org_name
  base_url        = var.okta_base_url
  client_id       = var.okta_client_id
  private_key     = var.okta_pk
  private_key_id  = var.okta_private_key_id
  scopes          = var.okta_scopes
}

provider "azuread" {}

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 = "Boolean to indicate if the environment is production"
  type        = bool
}

locals {
  yaml_roles     = yamldecode(file("./business-roles.yml"))
  yaml_vars      = yamldecode(file("./vars.yml"))
  business_roles = { for role in local.yaml_roles.business_roles : role.name => role }
}

module "busRoles" {
  source         = "app.terraform.io/SAIFCorp/business-roles-corp/saif"
  version        = ">= 4.0.0, < 5.0.0"
  business_roles = local.business_roles
  is_production  = var.is_production
}
```

### Step 6 — Update the pipeline

In `.azdo/azure-pipelines.yml`, update two things:

**1. The `paths.include` trigger:**

=== "Before"

    ```yaml
      paths:
        include:
          - infra/okta/okta-business-roles.yml
    ```

=== "After"

    ```yaml
      paths:
        include:
          - infra/entra/business-roles.yml
    ```

**2. The `extends` block — update the template name:**

=== "Before"

    ```yaml
    extends:
      template: okta-terraform-business-roles.yml@templates
      parameters:
        Tenant: ${{variables.Tenant}}
        TeamName: ${{variables.TeamName}}
    ```

=== "After"

    ```yaml
    extends:
      template: entra-terraform-business-roles.yml@templates
      parameters:
        Tenant: ${{variables.Tenant}}
        TeamName: ${{variables.TeamName}}
    ```

!!! note
    `entra-terraform-business-roles.yml` defaults `TerraformDirectory` to `infra` and appends `/entra` automatically, so no extra parameter is needed.

### Step 7 — Add `moved.tf` for state migration

!!! danger "Required — Do not skip"
    Without this file, Terraform will **destroy and recreate all existing Entra ID groups**.

For each role listed in `business-roles.yml`, add a pair of `moved` blocks. The key is the exact string value of each role's `name` field.

Create `infra/entra/moved.tf`:

```hcl
# Repeat this pair for every role in business-roles.yml.
# Replace "Claims Adjuster" with the exact role name.

moved {
  from = module.busRoles.module.entra_id["Claims Adjuster"].module.group.azuread_group_without_members.group_without_members[0]
  to   = module.busRoles.module.entra_id_group["Claims Adjuster"].azuread_group_without_members.group_without_members[0]
}

moved {
  from = module.busRoles.module.entra_id["Claims Adjuster"].module.group.azuread_group.group[0]
  to   = module.busRoles.module.entra_id_group["Claims Adjuster"].azuread_group.group[0]
}
```

Both resource types (`azuread_group_without_members` for non-prod, `azuread_group` for prod) can coexist — Terraform only moves whichever exists in the workspace's state.

If you are unsure of the exact role names, run `terraform state list` and look for entries matching `module.busRoles.module.entra_id[*]`.

### Step 8 — Validate

Run from `infra/entra/`:

```bash
terraform init -upgrade
terraform plan
```

Expected output:

- **0 to destroy** for resources matching `azuread_group` or `azuread_group_without_members`
- **N to destroy** for Okta-side corp groups (`okta_group`) — this is intentional; corp roles are no longer managed via Okta

If you see destroys for `azuread_group` resources, the `moved.tf` keys don't match state. Run `terraform state list | grep entra_id` to see the exact keys and correct them.

### Step 9 — Open the migration PR

```bash
git add .
git commit -m "feat: migrate to saif-business-roles-corp"
git push origin feat/migrate-to-saif-business-roles-corp
```

Open a pull request with:

- **Title:** `feat: migrate to saif-business-roles-corp`
- **Description:** reference this migration guide and note which roles were migrated

After the PR is merged, allow the pipeline to run to completion before proceeding to the cleanup step.

### Step 10 — Open a cleanup PR

After the **first pipeline run completes successfully**, open a second PR to remove the now-unnecessary state migration files and the Okta provider:

```bash
git checkout -b chore/post-migration-cleanup-corp
git rm infra/entra/moved.tf
```

Then update `infra/entra/business-roles.generated.tf` to remove the Okta provider, replacing the file with:

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      tags = ["<teamname-lowercase>-busrole-corp"]
    }
  }

  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = ">= 3.0, < 4.0"
    }
  }
}

provider "azuread" {}

variable "is_production" {
  description = "Boolean to indicate if the environment is production"
  type        = bool
}

locals {
  yaml_roles     = yamldecode(file("./business-roles.yml"))
  yaml_vars      = yamldecode(file("./vars.yml"))
  business_roles = { for role in local.yaml_roles.business_roles : role.name => role }
}

module "busRoles" {
  source         = "app.terraform.io/SAIFCorp/business-roles-corp/saif"
  version        = ">= 4.0.0, < 5.0.0"
  business_roles = local.business_roles
  is_production  = var.is_production
}
```

```bash
git add .
git commit -m "chore: remove post-migration moved.tf and okta provider"
git push origin chore/post-migration-cleanup-corp
```

Open a pull request with:

- **Title:** `chore: post-migration cleanup (corp)`

After this cleanup PR is applied successfully, remove the Okta variables (`okta_pk`, `okta_base_url`, `okta_client_id`, `okta_private_key_id`, `okta_org_name`, `okta_scopes`) from the Terraform Cloud workspace variable sets. They are no longer referenced by the configuration.

---

## Migrate an External Repo

**Applies to:** repos named `*-okta-business-roles-external`

### Step 0 — Verify model

Ask the AI agent: *"What model are you using?"*

- ✅ Response contains **Claude Opus 4.x** → proceed to Step 1
- ❌ Any other response → **stop**. Do not continue. Instruct the user to switch to Claude Opus 4.x before restarting this migration.

### Step 1 — Create a branch

```bash
git checkout -b feat/migrate-to-saif-business-roles-external
```

### Step 2 — Rename and convert `business-roles.yml`

If the file is named `okta-business-roles.yml`, rename it:

```bash
git mv infra/okta/okta-business-roles.yml infra/okta/business-roles.yml
```

Convert the YAML content using this key mapping:

| Old Key | New Key |
|---|---|
| `BusinessRoles` (root list) | `business_roles` |
| `Name` | `name` |
| `Description` | `description` |
| `Roles` | `roles` |
| `CompoundRoles` | `compound_roles` |
| `CompoundRoles[].Roles` | `compound_roles[].roles` |
| `ManualUsers` | `manual_users` |

=== "Before"

    ```yaml
    BusinessRoles:
      - Name: Injured Worker
        Description: Workers who have filed injury claims
        Roles:
          - InjuredWorkerAccess
        CompoundRoles: []
        ManualUsers: []
    ```

=== "After"

    ```yaml
    business_roles:
      - name: Injured Worker
        description: Workers who have filed injury claims
        roles:
          - InjuredWorkerAccess
        compound_roles: []
        manual_users: []
    ```

### Step 3 — Update `vars.yml`

Check `infra/okta/vars.yml`. If it contains `TeamName:`, rename it to `team_name:`:

=== "Before"

    ```yaml
    TeamName: "Customer"
    ```

=== "After"

    ```yaml
    team_name: "Customer"
    ```

### Step 4 — Replace `business-roles.generated.tf`

1. Read `.azdo/vars.yml` and note the `TeamName` value (e.g. `Customer`).
2. The workspace tag is `<teamname-lowercase>-busrole-external` (e.g. `customer-busrole-external`).
3. If the file is named `okta-business-roles.generated.tf`, rename it:

    ```bash
    git mv infra/okta/okta-business-roles.generated.tf infra/okta/business-roles.generated.tf
    ```

4. Replace the **entire** file contents with the following, substituting `<teamname-lowercase>`.

!!! note "No `azuread` provider needed"
    Unlike corp repos, the old `okta-business-roles` module created only Okta resources when called with `BusinessRolesExternal` — no Entra ID groups were ever provisioned in the external workspace. The `azuread` provider in the original file was unused and can be removed immediately.

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      tags = ["<teamname-lowercase>-busrole-external"]
    }
  }

  required_providers {
    okta = {
      source  = "okta/okta"
      version = ">=4.10.0,< 5.0.0"
    }
  }
}

provider "okta" {
  org_name        = var.okta_org_name
  base_url        = var.okta_base_url
  client_id       = var.okta_client_id
  private_key     = var.okta_pk
  private_key_id  = var.okta_private_key_id
  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"
}

locals {
  yaml_roles     = yamldecode(file("./business-roles.yml"))
  yaml_vars      = yamldecode(file("./vars.yml"))
  business_roles = { for role in local.yaml_roles.business_roles : role.name => role }
}

module "busRoles" {
  source         = "app.terraform.io/SAIFCorp/business-roles-external/saif"
  version        = ">= 1.0.0, < 2.0.0"
  business_roles = local.business_roles
  team_name      = local.yaml_vars.team_name
}
```

### Step 5 — Update the pipeline trigger path

In `.azdo/azure-pipelines.yml`, update the `paths.include` trigger:

=== "Before"

    ```yaml
      paths:
        include:
          - infra/okta/okta-business-roles.yml
    ```

=== "After"

    ```yaml
      paths:
        include:
          - infra/okta/business-roles.yml
    ```

### Step 6 — Validate

No `moved.tf` is needed — the `saif-business-roles-external` Forge module includes all necessary `moved` blocks automatically.

```bash
terraform init -upgrade
terraform plan
```

Expected output: existing Okta groups and group rules move in-place with **0 to destroy**.

If you see unexpected destroys, confirm the module version constraint `>= 1.0.0, < 2.0.0` resolved correctly (`terraform providers` will show the selected version).

### Step 7 — Open a PR

```bash
git add .
git commit -m "feat: migrate to saif-business-roles-external"
git push origin feat/migrate-to-saif-business-roles-external
```

Open a pull request with:

- **Title:** `feat: migrate to saif-business-roles-external`
- **Description:** reference this migration guide and note which roles were migrated

---

## (Optional) Rename the Repository

The "okta" in repo names like `Platform-okta-business-roles-corp` is now misleading — corp repos don't use Okta at all, and even external repos are managed by Forge rather than directly by `okta-business-roles`. Rename these repos once the migration PR has merged.

**Suggested names:**

| Old Name | New Name |
|---|---|
| `<team>-okta-business-roles-corp` | `<team>-business-roles-corp` |
| `<team>-okta-business-roles-external` | `<team>-business-roles-external` |

### Steps

**1. Rename in Azure DevOps**

In the Azure DevOps project, go to **Project Settings → Repos**, find the repository, click **⋯ → Rename**, and enter the new name.

**2. Update the local remote URL**

After renaming in Azure DevOps, the old URL redirects temporarily but should be updated locally:

```bash
git remote set-url origin <new-clone-url>
```

The new URL follows the same pattern as the old one with "okta-" removed from the repo segment.

**3. Update any references**

Search for the old repo name in:

- Pipeline YAML files that reference this repo by name
- Any ADO service connections or pipeline resource declarations
- README files or internal documentation

---

## Getting Help

If you encounter issues during migration, [open a Platform team support request](https://github.com/saif-corp/forge/issues/new) and include your `terraform plan` output.
