Featured image of post Automate Core Business Groups in Okta with Terraform

Automate Core Business Groups in Okta with Terraform

Part 4, Creating core, office, and country groups automatically through Terraform.

Introduction

Last week we went over the following:

  • Overview: Automating group memberships and business line structures using Terraform.
  • Group Memberships: Consolidate user data from Okta queries for efficient processing.
  • Business Line Groups: Manage 200+ dynamic business line structures via Terraform.
  • Key Requirements: Immutable IDs, change control, external data sources, and clear naming policies.
  • Data Consolidation: Query and combine user statuses into a single dataset for simplicity.
  • Attribute Mapping: Map business structure levels (e.g., Cost Center, Division) with IDs and names.
  • Dynamic Groups: Automatically create and manage groups and rules using mapped attributes.
  • Flexibility: Prevent resource deletion with lifecycle { prevent_destroy = true } when needed.
  • Future Topics: Transitioning to manual group assignments with okta_group_membership.
  • Resources: Join #okta-terraform on MacAdmins for community support and ideas.

This week, we will be discussing how to automate and manage core groups to the business.

The Implementation

Automate Group Memberships

A good group structure is one of the most important things you can have. This seems like the most straightforward task, but given the chaos of groups and organizational structures inside a company, it is anything but that. Below, I outline the complications we experienced and how we built it using Terraform.

Some of this was done with the help of ChatGPT, for example, by creating lowercase, hyphenated names of cities, offices, countries, etc. However, this is still completely doable without the use of ChatGPT.

Managing Key / Core Group Memberships

We will manage several key groups via Terraform, which Okta uses to push to downstream services / systems.

First, we need to import the groups we want to manage. We will use the import resource rather than an import CLI Command.

1
2
3
4
import {
 to = okta_group.groups["internal-testing"]
 id = "00g1i851nd912frRefadh8"
}

Getting this information for groups is easy enough, but what do we do about Group Rules? It’s a bit trickier to obtain their IDs. We have two options:

With the URL, what we are specifically after is ?filter=id+eq+%2200gq516ejcp3Jbgm74x6%22 and the bits immediately after and before the %22, which will return; 00gq516ejcp3Jbgm74x6

OR

  • Generate an API key, and query the following API Endpoint: https://{yourOktaDomain}/api/v1/groups/rules, which will return the following (an example taken from Okta’s Developer Documents):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[
 {
    "type": "group_rule",
    "id": "0pr3f7zMZZHPgUoWO0g4",
    "status": "INACTIVE",
    "name": "Engineering group rule",
    "created": "2016-12-01T14:40:04.000Z",
    "lastUpdated": "2016-12-01T14:40:04.000Z",
    "conditions": {
      "people": {
        "users": {
          "exclude": [
            "00u22w79JPMEeeuLr0g4"
 ]
 },
        "groups": {
          "exclude": []
 }
 },
      "expression": {
        "value": "user.role==\"Engineer\"",
        "type": "urn:okta:expression:1.0"
 }
 },
    "actions": {
      "assignUserToGroups": {
        "groupIds": [
          "00gjitX9HqABSoqTB0g3"
 ]
 }
 }
 }
]

Then, you can just pick the ID from the API response. We found that it is easier to generate a list or use a module to create the groups and group rules we have so that their naming policies match:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
locals {
  okta_groups_with_rules = [
    {
      name        = "internal-testing"
      description = "Voluntary Internal Testing Group / DogFood"
      admin_notes = "Provides an internal testing group, for individuals that sign up or become members of"
      group_owner = "[email protected]"
      dynamic     = true
      create_rule = true
      rule_expression = "String.stringContains(user.Dogfood, \"true\")"
    }
  ]
}

resource "okta_group" "groups" {
  for_each = { for group in local.okta_groups_with_rules : group.name => group }

  name        = each.value.name
  description = each.value.description
  custom_profile_attributes = jsonencode(
    {
    adminNotes   = each.value.admin_notes
    groupOwner   = each.value.group_owner
    groupDynamic = each.value.dynamic
  }
 )
}

resource "okta_group_rule" "group_rules" {
  for_each = {
    for group in local.okta_groups_with_rules : group.name => group
    if group.create_rule
    }

    name              = "tf-${each.value.name}-rule"
    status            = "ACTIVE"
    group_assignments = [okta_group.groups[each.value.name].id]
    expression_type   = "urn:okta:expression:1.0"
    expression_value  = each.value.rule_expression
    users_excluded    = []
    lifecycle {
      create_before_destroy = true
    }
}

This way, we still maintain flexibility.

We can also keep group rules without a corresponding Terraform-managed group in the same file with its own list of local data.

Automate Office Groups

This is honestly fairly simple to achieve. The largest issue or problem is just getting accurate data and maintaining that data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Define a variable to store the list of cities
locals {
  cities = [
    { city = "sanfrancisco", name = "San Francisco", ref_id = "SanFrancisco_Location" },
    { city = "london", name = "London", ref_id = "London_Location" },
    { city = "austin", name = "Austin", ref_id = "Austin_Location" },
    { city = "barcelona", name = "Barcelona", ref_id = "Barcelona_Location" },
  ]
}

# Iterate over the list of cities to create Okta groups and group rules
resource "okta_group" "office_groups" {
  for_each    = { for city in local.cities : city.name => city }
  description = "Contains all Employees located in the ${each.value.name} Office"
  name        = lower("companyname-office-${each.value.city}")
  custom_profile_attributes = jsonencode({
    "adminNotes"   = "Created by TF, maintained by the list of Office Locations provided by the HR team.",
    "groupOwner"   = "HR",
    "groupDynamic" = true
    }
  )
}

resource "okta_group_rule" "office_group_rules" {
  for_each          = { for city in local.cities : city.name => city }
  name              = substr("TF - Rule for companyname-office-${each.value.city}", 0, 49)
  status            = "ACTIVE"
  group_assignments = [okta_group.office_groups[each.key].id]
  expression_type   = "urn:okta:expression:1.0"
  expression_value  = "user.Office == \"${each.value.name}\" and user.userType == \"Employee\""
  users_excluded    = []
}

To advance on this, we could utilize several different features that could help us automatically obtain and manage the data and automatically create a JSON using something like csvdecode, which is a native Terraform function.

Automate Country Groups

Automating countries is a bit more tricky. You need to verify a few things first:

  1. Do you want to use the country’s English-based name or its local variant?
  2. How do you plan on using colloquial names for a country?
    1. An example of this is South Korea. While commonly and generally referred to as South Korea, it is officially known as Republic of Korea and is represented this way in international standardization.
  3. How do you want to deal with non-ASCII characters? For example, do they appear in the Latin alphabet or in UTF-8, 16, or 32?
    1. For example, Côte d'Ivoire may produce problems for usability, even if it is an officially recognized name. What is the Holy See? Would you know that this is actually Vatican City?

Considering some of these, I recommend following the ISO3166 documentation as much as possible and adding an Alt_Name field to your local values to allow the description to be written with the more common equivalent of the name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Define a variable to store the list of Countries in ISO-3166 format, including lowercase normalized names and alternative/common names
locals {
  countries = [
    { ISO3166_A2 = "KR", ISO3166_A3 = "KOR", ISO3166_Name = "Korea, Republic of", Lowercase_Name = "korearepublicof", Alt_Name = "South Korea" },  
    { ISO3166_A2 = "SE", ISO3166_A3 = "SWE", ISO3166_Name = "Sweden", Lowercase_Name = "sweden", Alt_Name = "Sweden" },
    { ISO3166_A2 = "GB", ISO3166_A3 = "GBR", ISO3166_Name = "United Kingdom", Lowercase_Name = "unitedkingdom", Alt_Name = "United Kingdom" },
    { ISO3166_A2 = "US", ISO3166_A3 = "USA", ISO3166_Name = "United States of America", Lowercase_Name = "unitedstatesofamerica", Alt_Name = "United States" }
  ]
}


# Iterate over the list of cities to create Okta groups and group rules
resource "okta_group" "country_groups" {
  for_each    = { for country in local.countries : country.ISO3166_A2 => country }
  description = "Contains all Employees located in: ${each.value.ISO3166_A2} - ${each.value.ISO3166_Name} - aka: ${each.value.Alt_Name} "
  name        = lower("companyname-${each.value.Lowercase_Name}")
  custom_profile_attributes = jsonencode({
    "adminNotes"   = "Created by TF, maintained by the list of country locations provided by the HR team.",
    "groupOwner"   = "HR",
    "groupDynamic" = true
    }
  )
}

resource "okta_group_rule" "country_group_rules" {
  for_each          = { for country in local.countries : country.ISO3166_A2 => country }
  name              = substr("TF - Rule for companyname-${each.value.Lowercase_Name}", 0, 49)
  status            = "ACTIVE"
  group_assignments = [okta_group.country_groups[each.value.ISO3166_A2].id]
  expression_type   = "urn:okta:expression:1.0"
  expression_value  = "user.countryCode == \"${each.value.ISO3166_A2}\" and user.userType== \"Employee\""
  users_excluded    = []
}

From here, again, we can use a variety of local exec resources, to be able to download and obtain the lists of countries from whatever system we are using so that this can be updated automatically. Alerts can be fired off when a drift or apply change behavior is found that would either delete or add new entities in the state.

Unfortunately, Terraform and Okta don’t have transliterate functionality, so there is no way to achieve the possibility of something like this natively in either service. You could, however, add this as a pre-hook in a Git commit or a GitHub action.

If you need to automate legal entities, while we have code for this, there isn’t much of a change in any of the codes above.

Come up with a scheme that can be followed and easily adjusted for your needs while sticking with the naming standard that the business owner of the legal entities (likely FP&A, Finance, Legal, etc.) maintains.

And that is it

Thanks for taking the time to read this, be on the lookout next week for another blog post about automating and managing Network Zones (EG: Policy Network Zones, Blocklist Network Zones, proxies, office networks, etc).

A lot will be covered over the next several parts, which sums up how we have terraformed certain pieces of our Okta environment. If you have questions and are looking for a community resource, I would heavily recommend reaching out to #okta-terraform on MacAdmins, as I would say at least 30% (note, I made this statistic up) of the organizations using Terraform hang out in this channel. Otherwise, you can always find an alternative unofficial community for assistance or ideas.

Licensed under CC BY-NC-SA 4.0
Last updated on January 28, 2025 at 11:30 CET
 
Thanks for stopping by!
Built with Hugo