Okta Workforce Identity Zero Trust

Automate workforce identity lifecycle, SSO, MFA, and SCIM controls in Okta.

Introduction

30 Second Summary

When someone joins a company or changes roles, their access should move with them. A missed permission can leave sensitive tools exposed after the business need disappears.

In this project, you will build an eight-person workforce identity lab in Okta. The lab proves that human access and machine access stay controlled through every employee lifecycle change.

What You'll Build

You'll demo a synthetic employee moving from account creation to governed access to verified offboarding, with each control leaving evidence you can show.

By the end of this project, you'll have:

  • An automated joiner flow powered by a scoped OAuth service identity. Department attributes grant eight users birthright access. SCIM records downstream provisioning.
  • A federated sign-in demo covering SAML and OpenID Connect. Organization-wide MFA prompts users at every sign-in. A phishing-resistant rule protects the sensitive admin portal.
  • A mover control that exposes privilege creep before remediation. A leaver control clears sessions before deactivation. CSV reports capture the final control state.
  • Secret Mission: Separate a read-only AI service identity from a group-scoped human Help Desk Administrator. Positive tests prove their allowed actions. Negative tests capture their denied actions.

Are there any prerequisites?

You'll need a business email address that qualifies for an Okta Integrator Free Plan. You'll also need permission to install applications on a Mac.

Before We Start

Before you begin the hands-on work, define the workforce identity problem this lab solves. Your statement gives every later control a clear purpose in your IAM portfolio.

Set Up the Lab and SCIM Endpoint

Your Step 0 statement defines the identity risks this lab needs to control. You now need an isolated environment where those controls can fail safely.

This step creates a free Okta Integrator Free Plan org. It also creates a local SCIM target that stays outside production infrastructure.

In this step, get ready to:
  • Activate an Okta lab org with a protected Super Administrator account.
  • Install the pinned macOS tools for the project.
  • Run the local SCIM service through a temporary HTTPS tunnel.
Prepare your identity lab

The Okta org becomes the control plane for your synthetic workforce. Its documented active-user limit is 10 users, which leaves room for the eight required employees.

The Super Administrator account has broad access. Protecting it with Okta Verify gives the rest of the lab a safer foundation.

  • Open the Okta Integrator Free Plan signup page in your browser.
  • Enter your eligible business email address.
  • Complete the account activation instructions sent to your email.
  • Sign in with the Super Administrator account you just activated.
  • Choose Okta Verify when the enrollment flow asks for an authenticator.
  • Approve the enrollment in the existing Okta Verify Desktop app.
  • Confirm the Okta Admin Console opens under your Super Administrator account.

Why use a separate Okta org?

The lab creates synthetic employees plus privileged integrations. A dedicated org keeps every lifecycle change away from real workforce accounts.

The automation scripts require Python version 3.15.2. Checking first reveals whether macOS is still resolving an older system installation.

  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search bar.
  • Press Enter to open Terminal.
  • Check the Python version by running this command:
python3 --version

What does this check do?

The command prints the Python installation currently selected by your shell. This project needs the result to show 3.15.2.

✔️ I see the required Python version

Python 3.15.2 is ready for the project.

ⓧ I see an older Python version

macOS can keep an older Apple-controlled Python installation alongside the current release. Installing the official package updates the default path without removing the system copy.

  • Open the Python downloads page in your browser.
  • Download the macOS installer for Python 3.15.2.
  • Open the downloaded installer package.
  • Complete the installation with its default options.
  • Close the current Terminal window.
  • Open a new Terminal window through macOS search.
  • Check the selected Python version again by running:
python3 --version

What should you see?

The new Terminal session should resolve the python.org installation. The output should show Python 3.15.2.

ⓧ Python is not found

Your shell cannot find a usable Python installation. The official macOS package installs the interpreter plus the tools needed for virtual environments.

  • Open the Python downloads page in your browser.
  • Download the macOS installer for Python 3.15.2.
  • Open the downloaded installer package.
  • Complete the installation with its default options.
  • Open a new Terminal window through macOS search.
  • Confirm the installation by running:
python3 --version

What should you see?

The output should identify Python 3.15.2. That confirms the interpreter is available from Terminal.

You will use Visual Studio Code to manage the repository files. Check whether the app is already available before installing it.

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code into the search bar.
  • Select the tab that matches what you see.

✔️ Visual Studio Code opens

Visual Studio Code is installed and ready for the repository.

  • Keep Visual Studio Code open for the next substep.

ⓧ Visual Studio Code is missing

  • Open the Visual Studio Code download page in your browser.
  • Download the macOS .dmg file.
  • Open the downloaded .dmg file.
  • Move Visual Studio Code.app into your Applications folder.
  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code into the search bar.
  • Press Enter to confirm the app opens.

A Cloudflare Tunnel gives the local SCIM server a temporary HTTPS address. The project pins cloudflared to version 2026.10.7 so the tunnel behavior stays consistent.

  • Return to Terminal.
  • Check the installed cloudflared version by running:
cloudflared version

What does this check do?

The command prints the installed cloudflared version plus its build information. Compare the version number with the project pin.

✔️ I see the required cloudflared version

Version 2026.10.7 is ready to create the Quick Tunnel.

ⓧ I see an older cloudflared version

Replace the older installation with the pinned release package for your Mac processor.

cloudflared version

What should you see?

The output should show cloudflared version 2026.10.7.

ⓧ cloudflared is not found

Install the pinned package that matches your Mac processor. The package places the cloudflared command on your shell path.

cloudflared version

What should you see?

The output should show cloudflared version 2026.10.7.

Still seeing the wrong tool version?

Close Terminal after installing a tool. A new Terminal session reloads the command path.

If the old version remains, help me identify which Python or cloudflared installation Terminal is using.

Create the repository and install dependencies

The repository keeps the SCIM server beside the automation and evidence templates that later steps use. GitHub Desktop creates the local Git boundary before any credential file exists.

  • Press Cmd+Space to open macOS search.
  • Type GitHub Desktop into the search bar.
  • Press Enter to open GitHub Desktop.
  • Click File in the macOS menu bar.
  • Select New repository....
  • Enter okta-workforce-nhi-lab as the repository name.
  • Set the local path to your Desktop.
  • Create the repository.
  • Switch to Visual Studio Code.
  • Click File in the macOS menu bar.
  • Select Open Folder....
  • Choose the okta-workforce-nhi-lab folder on your Desktop.
  • Create each supplied Complete Code file inside the okta-workforce-nhi-lab folder.
  • Save every file.

Use these tabs to confirm the repository matches the supplied project files.

✔️ Awesome, I've got everything!

Your Explorer sidebar should show the complete project file set. Keep every file directly inside okta-workforce-nhi-lab.

ⓧ I'd like to double check the full code

Flask==3.1.3
requests==2.34.2
PyJWT==2.15.1
cryptography==50.0.2

What does this file control?

The requirements file pins the four runtime dependencies. These exact pins keep every learner on the same package versions.

.venv/
__pycache__/
.env
.env.*
!.env.example
private_key.pem
scim_lab.db
evidence/raw/
.DS_Store

What does this file protect?

The ignore rules keep local environments plus credential-bearing files outside Git. They also exclude generated database state plus raw evidence.

OKTA_ORG_URL=https://integrator-0000000.okta.com
OKTA_CLIENT_ID=your-api-service-client-id
OKTA_PRIVATE_KEY_PATH=private_key.pem
OKTA_KEY_ID=your-jwk-key-id
OKTA_SCOPES=okta.users.manage okta.groups.manage okta.apps.read okta.apiTokens.read
OKTA_TEMP_PASSWORD=replace-with-a-lab-only-password
LEGACY_PRIVILEGE_GROUP_ID=replace-with-app-admin-sensitive-group-id
SCIM_BEARER_TOKEN=replace-with-a-long-random-lab-token
SCIM_BASE_URL=https://replace-me.trycloudflare.com/scim/v2

What does this template hold?

The template names every environment value used by the scripts. The tracked file contains placeholders only.

firstName,lastName,login,email,department,title,employeeNumber
Maya,Patel,maya.patel@example.com,maya.patel@example.com,Finance,Financial Analyst,E1001
Noah,Williams,noah.williams@example.com,noah.williams@example.com,Operations,Operations Specialist,E1002
Ava,Nguyen,ava.nguyen@example.com,ava.nguyen@example.com,Engineering,Software Engineer,E1003
Liam,Chen,liam.chen@example.com,liam.chen@example.com,Finance,Accountant,E1004
Sofia,Martinez,sofia.martinez@example.com,sofia.martinez@example.com,Operations,Service Manager,E1005
Ethan,Brown,ethan.brown@example.com,ethan.brown@example.com,Engineering,Platform Engineer,E1006
Zoe,Johnson,zoe.johnson@example.com,zoe.johnson@example.com,Finance,Procurement Analyst,E1007
Lucas,Kim,lucas.kim@example.com,lucas.kim@example.com,Operations,Business Operations Analyst,E1008

What does this data represent?

The CSV is the authoritative HR source for eight synthetic employees. Department values later drive birthright group assignments.

import os
from pathlib import Path


def load_env() -> None:
    env_path = Path(os.environ.get("ENV_FILE", ".env"))
    if not env_path.exists():
        raise FileNotFoundError(f"Missing environment file: {env_path}")
    for raw_line in env_path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        value = value.strip().strip('"').strip("'")
        os.environ.setdefault(key.strip(), value)

What does this loader do?

The loader reads key-value pairs from the selected environment file. Existing process variables keep priority over file values.

import os
import time
import uuid
from pathlib import Path
from typing import Any, Iterator

import jwt
import requests

from env_loader import load_env


class OktaClient:
    def __init__(self) -> None:
        load_env()
        self.org_url = os.environ["OKTA_ORG_URL"].rstrip("/")
        self.client_id = os.environ["OKTA_CLIENT_ID"]
        self.key_id = os.environ["OKTA_KEY_ID"]
        self.scopes = os.environ["OKTA_SCOPES"]
        self.private_key = Path(os.environ["OKTA_PRIVATE_KEY_PATH"]).read_text(encoding="utf-8")
        self.session = requests.Session()
        self._access_token = ""
        self._token_expires_at = 0.0

    def access_token(self) -> str:
        if self._access_token and time.time() < self._token_expires_at - 60:
            return self._access_token
        token_url = f"{self.org_url}/oauth2/v1/token"
        now = int(time.time())
        assertion = jwt.encode(
            {"aud": token_url, "iss": self.client_id, "sub": self.client_id, "iat": now, "exp": now + 300, "jti": str(uuid.uuid4())},
            self.private_key,
            algorithm="RS256",
            headers={"kid": self.key_id},
        )
        response = self.session.post(
            token_url,
            headers={"Accept": "application/json"},
            data={"grant_type": "client_credentials", "scope": self.scopes, "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", "client_assertion": assertion},
            timeout=30,
        )
        response.raise_for_status()
        payload = response.json()
        self._access_token = payload["access_token"]
        self._token_expires_at = time.time() + int(payload.get("expires_in", 3600))
        return self._access_token

    def request(self, method: str, path_or_url: str, **kwargs: Any) -> requests.Response:
        url = path_or_url if path_or_url.startswith("http") else f"{self.org_url}{path_or_url}"
        for attempt in range(4):
            headers = dict(kwargs.pop("headers", {}))
            headers.update({"Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {self.access_token()}"})
            response = self.session.request(method, url, headers=headers, timeout=30, **kwargs)
            if response.status_code == 401 and attempt == 0:
                self._access_token = ""
                continue
            if response.status_code == 429 and attempt < 3:
                reset = int(response.headers.get("X-Rate-Limit-Reset", int(time.time()) + 1))
                time.sleep(max(1, reset - int(time.time()) + 1))
                continue
            response.raise_for_status()
            return response
        raise RuntimeError(f"Request failed after retries: {method} {url}")

    def paginate(self, path: str, params: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]:
        next_url = path
        next_params = params
        while next_url:
            response = self.request("GET", next_url, params=next_params)
            for item in response.json():
                yield item
            next_url = response.links.get("next", {}).get("url") or ""
            next_params = None

What does this client do?

The client exchanges a signed private key JWT for a scoped access token. It also centralizes authenticated requests plus retry behavior.

import hmac
import json
import logging
import os
import re
import sqlite3
import uuid
from typing import Any

from flask import Flask, request

from env_loader import load_env

load_env()
app = Flask(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
SCIM_PREFIX = "/scim/v2"
SCIM_CONTENT_TYPE = "application/scim+json"
SCIM_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error"
SCIM_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User"
DB_PATH = os.environ.get("SCIM_DB_PATH", "scim_lab.db")
BEARER_TOKEN = os.environ["SCIM_BEARER_TOKEN"]


def db() -> sqlite3.Connection:
    connection = sqlite3.connect(DB_PATH)
    connection.row_factory = sqlite3.Row
    return connection


def init_db() -> None:
    with db() as connection:
        connection.execute("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, active INTEGER NOT NULL, payload TEXT NOT NULL)")


def scim_response(payload: dict[str, Any], status: int = 200, location: str | None = None):
    response = app.response_class(response=json.dumps(payload), status=status, mimetype=SCIM_CONTENT_TYPE)
    if location:
        response.headers["Location"] = location
    return response


def scim_error(status: int, detail: str, scim_type: str | None = None):
    payload: dict[str, Any] = {"schemas": [SCIM_ERROR], "detail": detail, "status": str(status)}
    if scim_type:
        payload["scimType"] = scim_type
    return scim_response(payload, status)


def row_to_user(row: sqlite3.Row) -> dict[str, Any]:
    user = json.loads(row["payload"])
    user.update({"id": row["id"], "userName": row["username"], "active": bool(row["active"])})
    user.setdefault("schemas", [SCIM_USER])
    user.setdefault("meta", {"resourceType": "User"})
    return user


def get_row(user_id: str) -> sqlite3.Row | None:
    with db() as connection:
        return connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()


@app.before_request
def authenticate_and_log():
    if not request.path.startswith(SCIM_PREFIX):
        return None
    logging.info("REQUEST method=%s path=%s query=%s", request.method, request.path, request.query_string.decode())
    if not hmac.compare_digest(request.headers.get("Authorization", ""), f"Bearer {BEARER_TOKEN}"):
        return scim_error(401, "Missing or invalid bearer token")
    return None


@app.after_request
def log_response(response):
    if request.path.startswith(SCIM_PREFIX):
        response.headers["Content-Type"] = SCIM_CONTENT_TYPE
        logging.info("RESPONSE method=%s path=%s status=%s", request.method, request.path, response.status_code)
    return response


@app.get(f"{SCIM_PREFIX}/Users")
def list_users():
    start_index = max(1, request.args.get("startIndex", 1, type=int))
    count = max(0, request.args.get("count", 100, type=int))
    filter_value = request.args.get("filter")
    username = None
    if filter_value:
        match = re.fullmatch(r'userName\s+eq\s+"([^"]+)"', filter_value, flags=re.IGNORECASE)
        if not match:
            return scim_error(400, "Only userName eq filtering is supported", "invalidFilter")
        username = match.group(1)
    with db() as connection:
        rows = connection.execute("SELECT * FROM users WHERE lower(username) = lower(?) ORDER BY username", (username,)).fetchall() if username else connection.execute("SELECT * FROM users ORDER BY username").fetchall()
    page = rows[start_index - 1 : start_index - 1 + count]
    return scim_response({"schemas": [SCIM_LIST], "totalResults": len(rows), "startIndex": start_index, "itemsPerPage": len(page), "Resources": [row_to_user(row) for row in page]})


@app.post(f"{SCIM_PREFIX}/Users")
def create_user():
    payload = request.get_json(silent=True) or {}
    username = str(payload.get("userName", "")).strip()
    if not username:
        return scim_error(400, "userName is required", "invalidValue")
    user_id = str(uuid.uuid4())
    payload.update({"id": user_id, "active": bool(payload.get("active", True)), "meta": {"resourceType": "User"}})
    payload.setdefault("schemas", [SCIM_USER])
    try:
        with db() as connection:
            connection.execute("INSERT INTO users (id, username, active, payload) VALUES (?, ?, ?, ?)", (user_id, username, int(payload["active"]), json.dumps(payload)))
    except sqlite3.IntegrityError:
        return scim_error(409, f"A user with userName {username} already exists", "uniqueness")
    logging.info("CREATE id=%s userName=%s active=%s", user_id, username, payload["active"])
    return scim_response(payload, 201, f"{request.url_root.rstrip('/')}{SCIM_PREFIX}/Users/{user_id}")


@app.get(f"{SCIM_PREFIX}/Users/<user_id>")
def get_user(user_id: str):
    row = get_row(user_id)
    return scim_response(row_to_user(row)) if row else scim_error(404, f"Resource {user_id} not found")


@app.patch(f"{SCIM_PREFIX}/Users/<user_id>")
def patch_user(user_id: str):
    row = get_row(user_id)
    if row is None:
        return scim_error(404, f"Resource {user_id} not found")
    active_value = None
    for operation in (request.get_json(silent=True) or {}).get("Operations", []):
        if str(operation.get("op", "")).lower() not in {"add", "replace"}:
            continue
        path, value = operation.get("path"), operation.get("value")
        if isinstance(path, str) and path.lower() == "active":
            active_value = bool(value)
        elif path is None and isinstance(value, dict) and "active" in value:
            active_value = bool(value["active"])
    if active_value is None:
        return scim_error(400, "This lab endpoint only supports updates to active", "invalidValue")
    user = row_to_user(row)
    user["active"] = active_value
    with db() as connection:
        connection.execute("UPDATE users SET active = ?, payload = ? WHERE id = ?", (int(active_value), json.dumps(user), user_id))
    logging.info("PATCH id=%s userName=%s active=%s", user_id, user["userName"], active_value)
    return scim_response(user)


@app.put(f"{SCIM_PREFIX}/Users/<user_id>")
def replace_user(user_id: str):
    row = get_row(user_id)
    if row is None:
        return scim_error(404, f"Resource {user_id} not found")
    payload = request.get_json(silent=True) or {}
    username = str(payload.get("userName", row["username"])).strip()
    payload.update({"id": user_id, "userName": username, "active": bool(payload.get("active", bool(row["active"]))), "meta": {"resourceType": "User"}})
    payload.setdefault("schemas", [SCIM_USER])
    try:
        with db() as connection:
            connection.execute("UPDATE users SET username = ?, active = ?, payload = ? WHERE id = ?", (username, int(payload["active"]), json.dumps(payload), user_id))
    except sqlite3.IntegrityError:
        return scim_error(409, f"A user with userName {username} already exists", "uniqueness")
    logging.info("PUT id=%s userName=%s active=%s", user_id, username, payload["active"])
    return scim_response(payload)


if __name__ == "__main__":
    init_db()
    app.run(host="127.0.0.1", port=8080, debug=False)

What does this server provide?

The Flask service implements the SCIM user routes needed by Okta. Bearer authentication protects every SCIM request.

SQLite preserves user identifiers plus active states across local server restarts. The database begins with an empty user collection.

import argparse
import csv
import os
import sys
import time
from pathlib import Path
from urllib.parse import quote

import requests

from env_loader import load_env
from okta_client import OktaClient


def get_user(client: OktaClient, login: str):
    try:
        return client.request("GET", f"/api/v1/users/{quote(login, safe='')}").json()
    except requests.HTTPError as error:
        if error.response is not None and error.response.status_code == 404:
            return None
        raise


def list_group_ids(client: OktaClient, user_id: str) -> set[str]:
    return {group["id"] for group in client.request("GET", f"/api/v1/users/{user_id}/groups", params={"limit": 200}).json()}


def wait_for_scim(login: str, expected_active: bool, timeout: int = 60) -> bool:
    load_env()
    headers = {"Authorization": f"Bearer {os.environ['SCIM_BEARER_TOKEN']}", "Accept": "application/scim+json"}
    deadline = time.time() + timeout
    while time.time() < deadline:
        response = requests.get(f"{os.environ['SCIM_BASE_URL'].rstrip('/')}/Users", headers=headers, params={"filter": f'userName eq "{login}"', "startIndex": 1, "count": 100}, timeout=30)
        response.raise_for_status()
        resources = response.json().get("Resources", [])
        if resources and bool(resources[0].get("active")) is expected_active:
            print(f"SCIM_VALIDATED login={login} active={expected_active}")
            return True
        time.sleep(2)
    return False


def joiner(client: OktaClient) -> int:
    load_env()
    rows = list(csv.DictReader(Path("workforce.csv").open(encoding="utf-8")))
    for row in rows:
        existing = get_user(client, row["login"])
        if existing:
            print(f"EXISTS login={row['login']} id={existing['id']} status={existing['status']}")
            continue
        payload = {"profile": {key: row[key] for key in ["firstName", "lastName", "login", "email", "department", "title", "employeeNumber"]}, "credentials": {"password": {"value": os.environ["OKTA_TEMP_PASSWORD"]}}}
        user = client.request("POST", "/api/v1/users", params={"activate": "true"}, json=payload).json()
        print(f"CREATED login={row['login']} id={user['id']} status={user['status']}")
    if not wait_for_scim(rows[0]["login"], True):
        raise RuntimeError("Timed out waiting for SCIM create evidence")
    return 0


def mover(client: OktaClient, login: str, department: str, title: str, remediate: bool) -> int:
    user = get_user(client, login)
    if not user:
        raise RuntimeError(f"User not found: {login}")
    client.request("POST", f"/api/v1/users/{user['id']}", json={"profile": {"department": department, "title": title}})
    print(f"PROFILE_UPDATED login={login} department={department} title={title}")
    time.sleep(4)
    legacy_group_id = os.environ["LEGACY_PRIVILEGE_GROUP_ID"]
    if legacy_group_id not in list_group_ids(client, user["id"]):
        print("CONTROL_PASS legacy sensitive group is absent")
        return 0
    print("PRIVILEGE_CREEP_DETECTED legacy sensitive group survived the department change")
    if not remediate:
        return 2
    client.request("DELETE", f"/api/v1/groups/{legacy_group_id}/users/{user['id']}")
    if legacy_group_id in list_group_ids(client, user["id"]):
        raise RuntimeError("Remediation failed: sensitive group is still assigned")
    print("REMEDIATED legacy sensitive group removed")
    return 0


def leaver(client: OktaClient, login: str) -> int:
    user = get_user(client, login)
    if not user:
        raise RuntimeError(f"User not found: {login}")
    client.request("DELETE", f"/api/v1/users/{user['id']}/sessions", params={"oauthTokens": "true", "forgetDevices": "true"})
    print(f"SESSIONS_CLEARED login={login}")
    client.request("POST", f"/api/v1/users/{user['id']}/lifecycle/deactivate", params={"sendEmail": "false"})
    for _ in range(15):
        current = get_user(client, login)
        if current and current.get("status") == "DEPROVISIONED":
            print(f"DEPROVISIONED login={login}")
            break
        time.sleep(2)
    else:
        raise RuntimeError("Timed out waiting for Okta deactivation")
    if not wait_for_scim(login, False):
        raise RuntimeError("Timed out waiting for SCIM active=false")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Okta HR joiner, mover, and leaver lab")
    subparsers = parser.add_subparsers(dest="action", required=True)
    subparsers.add_parser("joiner")
    mover_parser = subparsers.add_parser("mover")
    mover_parser.add_argument("--login", required=True)
    mover_parser.add_argument("--department", required=True)
    mover_parser.add_argument("--title", required=True)
    mover_parser.add_argument("--remediate", action="store_true")
    leaver_parser = subparsers.add_parser("leaver")
    leaver_parser.add_argument("--login", required=True)
    args = parser.parse_args()
    client = OktaClient()
    if args.action == "joiner":
        return joiner(client)
    if args.action == "mover":
        return mover(client, args.login, args.department, args.title, args.remediate)
    return leaver(client, args.login)


if __name__ == "__main__":
    sys.exit(main())

What does this automation cover?

The script provides the joiner, mover plus leaver workflows. Each path validates a different workforce lifecycle control.

import argparse
import csv
from pathlib import Path

import requests

from okta_client import OktaClient


def main() -> None:
    parser = argparse.ArgumentParser(description="Export Okta MFA enrollment posture")
    parser.add_argument("--output", default="evidence/mfa_report.csv")
    args = parser.parse_args()
    client = OktaClient()
    rows = []
    for user in client.paginate("/api/v1/users", {"limit": 200}):
        try:
            factors = client.request("GET", f"/api/v1/users/{user['id']}/factors").json()
        except requests.HTTPError as error:
            factors = [{"factorType": "ERROR", "provider": "", "status": str(error.response.status_code)}]
        if not factors:
            factors = [{"factorType": "NONE", "provider": "", "status": "NOT_ENROLLED"}]
        for factor in factors:
            rows.append({"login": user["profile"]["login"], "user_status": user["status"], "factor_type": factor.get("factorType", ""), "provider": factor.get("provider", ""), "factor_status": factor.get("status", "")})
    output = Path(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)
    with output.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)
    print(f"WROTE {output} rows={len(rows)}")


if __name__ == "__main__":
    main()

What does this report capture?

The script exports each user's status plus enrolled authentication factors. Later evidence uses the CSV to describe the lab's MFA posture.

import argparse
import csv
from pathlib import Path

from okta_client import OktaClient


def main() -> None:
    parser = argparse.ArgumentParser(description="Inventory lab API tokens and OIDC applications")
    parser.add_argument("--output", default="evidence/nhi_inventory.csv")
    args = parser.parse_args()
    client = OktaClient()
    rows = []
    for token in client.paginate("/api/v1/api-tokens", {"limit": 200}):
        rows.append({"record_type": "SSWS_TOKEN", "name": token.get("name", ""), "client_or_app_id": token.get("clientName", ""), "label": "", "status": "ACTIVE_METADATA_RETURNED", "created": token.get("created", ""), "expires_at": token.get("expiresAt", ""), "review_note": "Human-bound token. Confirm owner privilege and replacement plan."})
    for app in client.paginate("/api/v1/apps", {"limit": 200, "filter": 'status eq "ACTIVE"'}):
        if app.get("signOnMode") == "OPENID_CONNECT":
            rows.append({"record_type": "OIDC_APP_REVIEW", "name": app.get("name", ""), "client_or_app_id": app.get("id", ""), "label": app.get("label", ""), "status": app.get("status", ""), "created": app.get("created", ""), "expires_at": "", "review_note": "Confirm whether this is interactive or a non-human API service app."})
    output = Path(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = ["record_type", "name", "client_or_app_id", "label", "status", "created", "expires_at", "review_note"]
    with output.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)
    print(f"WROTE {output} rows={len(rows)}")


if __name__ == "__main__":
    main()

What does this inventory capture?

The report inventories active API-token metadata plus active OIDC applications. Its review notes help distinguish human-bound tokens from service identities.

# Okta Workforce Identity + NHI Zero Trust Lab

## Summary

[Describe the workforce identity problem, the eight-person synthetic company, and the controls you personally configured.]

## Validated Outcomes

- [ ] Joiner created eight active Okta users through OAuth private key JWT
- [ ] Attribute-driven department rules assigned department and birthright workforce-access groups
- [ ] SAML sign-in succeeded against the RSA SAML Test Service Provider
- [ ] OIDC Authorization Code with PKCE sign-in succeeded through OpenID Connect Debugger
- [ ] Global MFA and a phishing-resistant sensitive-app policy were validated
- [ ] SCIM created the joiner and set the leaver to active=false
- [ ] Leaver run cleared Okta sessions before deactivation
- [ ] Mover privilege creep was detected and remediated
- [ ] Super Admin SSWS token risk was recorded, replaced, revoked, and captured in the System Log

## Configured Integrations

| Integration | What I configured | Evidence |
| --- | --- | --- |
| SAML | [Fill in] | [Redacted link or screenshot] |
| OIDC | [Fill in] | [Redacted link or screenshot] |
| SCIM 2.0 | [Fill in] | [SCIM log excerpt] |
| OAuth service app | [Fill in] | [Scope and role screenshot] |

Do not list integrations that were not configured and validated.

## JML Evidence

| Event | Expected control | Evidence |
| --- | --- | --- |
| Joiner | User, attribute-driven birthright groups, app assignment, SCIM create | [Fill in] |
| Mover | Rule change, privilege-creep failure, remediation | [Fill in] |
| Leaver | Okta session clearing, Okta deactivation, SCIM active=false | [Fill in] |

## Non-Human Identity Findings

| State | Finding | Risk | Remediation |
| --- | --- | --- | --- |
| Before | [Fill from nhi inventory] | [Fill in] | [Fill in] |
| After | [Fill from nhi inventory] | [Fill in] | [Fill in] |

## NIST SP 800-53 Rev. 5 Mapping

This is an interpretive project mapping, not a certification or assessment.

| Control | Official title | Hands-on lab evidence |
| --- | --- | --- |
| AC-2 | Account Management | Automated joiner creation, mover updates, session clearing, deactivation, and downstream SCIM deprovisioning |
| AC-6 | Least Privilege | Scoped OAuth service-app role, SSWS token revocation, and mover privilege-creep remediation |
| IA-2 | Identification and Authentication (Organizational Users) | SAML and OIDC sign-ins, global MFA, and phishing-resistant Okta FastPass validation |
| IA-5 | Authenticator Management | Okta Verify enrollment, protected private-key JWT material, and revocation of the temporary SSWS authenticator |

## Top Three Troubleshooting Notes

1. SCIM 401: Confirm the Okta API Token field and `.env` use the same `Bearer <token>` value.
2. SCIM empty or 404 result: Confirm the base URL ends in `/scim/v2` and the server accepts `userName eq` filtering.
3. Okta 429: Wait until `X-Rate-Limit-Reset`; the scripts retry after that epoch time.

## Resume-Ready Bullets

- Built an eight-user Okta workforce identity lab with Python Joiner-Mover-Leaver automation, attribute-driven birthright access, and OAuth private key JWT authentication.
- Configured and validated SAML, OIDC Authorization Code with PKCE, MFA, and SCIM 2.0 provisioning, including downstream `active=false` deprovisioning.
- Replaced and revoked a Super Admin SSWS token, detected and remediated mover privilege creep, cleared leaver sessions, and captured System Log plus SCIM evidence.

## Cleanup

[Record what you stopped, revoked, deactivated, retained, and redacted.]

What does this template document?

The README limits claims to outcomes the learner validates. It also provides evidence tables plus the interpretive NIST mapping used in the final step.

A virtual environment isolates this repository's packages from other Python projects. The .venv folder is already excluded from Git.

  • Switch back to GitHub Desktop.
  • Click Repository in the macOS menu bar.
  • Select Open in Terminal.
  • Create the project virtual environment by running:
python3 -m venv .venv

What does this command do?

Python creates an isolated interpreter plus package directory inside .venv. The repository can now use its pinned dependencies without changing the Mac's global packages.

  • Activate the virtual environment by running:
source .venv/bin/activate

What does activation change?

Activation points Python commands at the interpreter inside .venv. Your Terminal prompt should now begin with the environment name.

The dependency installation can take a minute while Python downloads compatible packages. A short pause in the terminal is expected.

  • Install the pinned project dependencies by running:
python3 -m pip install -r requirements.txt

What does this install?

  • Flask 3.1.3 runs the local SCIM web service.
  • Requests 2.34.2 sends Okta plus SCIM HTTP requests.
  • PyJWT 2.15.1 signs the OAuth client assertion.
  • cryptography 50.0.2 supplies the asymmetric signing support.

The terminal output should list all four pinned packages. Your repository now has an isolated runtime for the SCIM service.

Dependency installation failed?

Confirm the Terminal prompt begins with (.venv). An absent prefix means the virtual environment is inactive.

Confirm requirements.txt sits directly inside okta-workforce-nhi-lab.

If installation still fails, help me diagnose my pinned Python dependency installation.

Run the SCIM service through a Quick Tunnel

The local Flask service stores SCIM users in a SQLite database. Bearer authentication prevents unauthenticated requests from reading or changing that state.

The next file holds a lab bearer token. It remains only in your local .env file because the repository already excludes that file from Git.

  • Switch back to Visual Studio Code.
  • Duplicate .env.example in the Explorer sidebar.
  • Rename the duplicate .env.
  • Replace replace-with-a-long-random-lab-token with a long lab-only token from your password manager.
  • Keep the remaining placeholder values unchanged.
  • Save .env.
  • Return to GitHub Desktop.
  • Confirm .env does not appear in the changed-files list.

Keep the token private

Keep the bearer token out of screenshots plus committed files. Later Okta provisioning requests use it as the credential for this lab endpoint.

  • Return to the Terminal window with the active virtual environment.
  • Start the local SCIM service by running:
python3 scim_server.py

What does this command start?

The script initializes scim_lab.db with an empty users table. It then serves the SCIM routes on http://127.0.0.1:8080.

Keep this Terminal window running. The server uses it to display each request plus its response status.

SCIM server did not start?

Confirm .env contains a value for SCIM_BEARER_TOKEN.

Confirm the active Terminal is inside the okta-workforce-nhi-lab folder.

If the service still stops, help me diagnose why scim_server.py will not run.

  • Switch back to GitHub Desktop.
  • Click Repository in the macOS menu bar.
  • Select Open in Terminal to create a second Terminal window.
  • Replace the placeholder in the command below with the token stored in .env.
  • Export the edited token value by running:
export SCIM_BEARER_TOKEN='replace-with-a-long-random-lab-token'

Why export the token?

Exporting makes the token available to commands launched from this Terminal window. The following curl command can use the variable without displaying the secret value.

  • Keep the export command outside any screenshot you capture.

Before you run the local check, predict whether the bearer token should produce an authenticated response.

  • Test the local SCIM user collection by running:
curl -i -H "Authorization: Bearer $SCIM_BEARER_TOKEN" "http://127.0.0.1:8080/scim/v2/Users?startIndex=1&count=2"

What should you see?

The response should have HTTP status 200 plus the SCIM content type. Its JSON body should identify a ListResponse with zero results because no workforce users exist yet.

Local request rejected?

A response mentioning authentication usually means the exported token differs from the value in .env.

A connection failure usually means scim_server.py is no longer running in the first Terminal window.

If the response still fails, help me troubleshoot my authenticated local SCIM request.

The local response proves the SCIM implementation works on your Mac. The Quick Tunnel now gives Okta a temporary HTTPS route to the same service.

  • Start the temporary tunnel from the second Terminal window by running:
cloudflared tunnel --url http://localhost:8080

What does this tunnel do?

Cloudflared forwards a temporary trycloudflare.com hostname to the local service on port 8080.

The hostname stops when cloudflared stops. A new hostname is generated after a restart.

  • Record the generated HTTPS URL with /scim/v2 appended here: https://your-subdomain.trycloudflare.com/scim/v2.
  • Switch back to .env in Visual Studio Code.
  • Replace the current SCIM_BASE_URL value with https://your-subdomain.trycloudflare.com/scim/v2.
  • Save .env.
  • Return to GitHub Desktop.
  • Click Repository in the macOS menu bar.
  • Select Open in Terminal to create a third Terminal window.
  • Replace the placeholder in the command below with the same token stored in .env.
  • Export the edited token value in the third Terminal window by running:
export SCIM_BEARER_TOKEN='replace-with-a-long-random-lab-token'

Why export it again?

Environment variables belong to one Terminal session. The third window needs its own exported copy before it can authenticate the tunnel request.

Before you run the public check, predict whether the tunnel should return the same empty SCIM collection as the local endpoint.

  • Test the HTTPS tunnel by running:
curl -i -H "Authorization: Bearer $SCIM_BEARER_TOKEN" "[[YOUR_SCIM_BASE_URL="https://your-subdomain.trycloudflare.com/scim/v2"]]/Users?startIndex=1&count=2"

What should you see?

The tunnel response should return HTTP status 200. The JSON body should contain the same empty SCIM ListResponse you received locally.

This matching result proves the temporary HTTPS hostname reaches your authenticated local service.

Tunnel request failed?

Confirm the cloudflared Terminal window is still running. Closing that process immediately removes the temporary hostname.

Confirm the URL ends in /scim/v2/Users?startIndex=1&count=2.

If the request still fails, help me trace my Quick Tunnel request to the local SCIM server.

That is the lab foundation complete. Your Okta tenant plus authenticated HTTPS SCIM endpoint are ready for the directory automation in the next step.

Build the Directory and Run the Joiner

Your SCIM endpoint is reachable from Okta. The workforce directory is still empty.

Birthright access should follow authoritative profile attributes. HR automation also needs less standing privilege than a human Super Administrator token.

In this step, get ready to:
  • Create five access groups with three attribute-driven department rules.
  • Replace a temporary Super Administrator SSWS token with a scoped OAuth service app.
  • Connect Okta to the SCIM endpoint before creating eight workforce users.
Create the groups and department rules

Okta Universal Directory stores the workforce profiles that drive access. Active group rules turn each employee's department into repeatable birthright membership.

  • Return to the Okta Admin Console from the previous step.
  • Select Directory from the navigation menu.
  • Select Groups.
  • Create an Okta group named Dept-Finance.
  • Create an Okta group named Dept-Engineering.
  • Create an Okta group named Dept-Operations.
  • Create an Okta group named APP-Workforce-SSO.
  • Create an Okta group named APP-Admin-Sensitive.

You should now see all five groups in the directory. These groups separate department access from shared workforce access and sensitive access.

  • Open the details page for APP-Admin-Sensitive.
  • Record the group identifier shown in the page URL as your APP-Admin-Sensitive group ID.

The department rules use the profile's department value as their source of truth. Each rule also grants the shared workforce group used for application assignment.

  • Select the Rules tab on the Groups page.
  • Click Add Rule.
  • Enter a descriptive name that identifies Finance access.
  • Set a basic condition that matches department to Finance.
  • Add Dept-Finance as an assigned group.
  • Add APP-Workforce-SSO as an assigned group.
  • Save the Finance rule.
  • Activate the Finance rule.

The first active rule now represents Finance birthright access. New Finance profiles can receive both groups without a manual assignment.

  • Add a second rule with a descriptive name that identifies Engineering access.
  • Set its basic condition to match department to Engineering.
  • Add Dept-Engineering as an assigned group.
  • Add APP-Workforce-SSO as an assigned group.
  • Save the Engineering rule.
  • Activate the Engineering rule.
  • Add a third rule with a descriptive name that identifies Operations access.
  • Set its basic condition to match department to Operations.

Two department rules are now active. The Operations rule needs its target groups before the directory model is complete.

  • Add Dept-Operations as an assigned group for the Operations rule.
  • Add APP-Workforce-SSO as an assigned group for the Operations rule.
  • Save the Operations rule.
  • Activate the Operations rule.
  • Confirm the Groups page shows three active rules.

How do the rules control access?

Each rule evaluates the authoritative department profile attribute. A matching employee receives one department group plus APP-Workforce-SSO.

A later profile change makes Okta reevaluate the rules. This keeps department membership tied to HR data.

The temporary token is a credential with the same privilege level as its creator. You will create it only long enough to document that risk before the scoped replacement takes over.

  • Select Security in the Admin Console.
  • Select API.
  • Select Tokens.
  • Click Create token.
  • Enter a name that clearly identifies the token as temporary.
  • Create the token.
  • Dismiss the one-time token value without adding it to .env.
  • Confirm the token lists your Super Administrator account as its creator.

Why is the SSWS token risky?

An SSWS token inherits the privilege level of the administrator who creates it. This token therefore carries Super Administrator authority.

The token also stays human-bound until it is revoked. The OAuth service app gives the automation its own identity with defined scopes.

Replace the Super Admin token with scoped OAuth

A private key JWT lets the automation authenticate as its own machine identity. A custom role limits what that identity can do inside the lab.

Why private key JWT?

Okta OAuth service apps use private_key_jwt for client authentication. The private key signs a short-lived assertion before the app requests an access token.

OAuth scopes define the allowed API families. The custom role and resource set narrow those permissions to the lab's users and resources.

  • Return to Security in the Admin Console.
  • Select Administrators.
  • Use the custom role controls to create a role for HR lifecycle automation.
  • Enter a descriptive name for the custom role.
  • Select Create users.
  • Select Edit users' profile attributes.
  • Select Edit users' lifecycle states.
  • Select Clear users' sessions.

What do these permissions cover?

These permissions support the joiner, mover, and leaver lifecycle. They allow account creation, profile updates, deactivation, and session clearing.

  • Select View users and their details.
  • Select Edit users' group membership.
  • Select View groups.
  • Select Manage group membership.
  • Select View applications and their details.
  • Select View API tokens.
  • Save the custom role.

The custom role now describes the automation's permitted actions. A resource set supplies the users and groups where those actions apply.

  • Select Resources in the Administrators area.
  • Click Create new resource set.
  • Enter a descriptive name for the HR lifecycle resource set.
  • Add all users as a user resource.
  • Add Dept-Finance as a group resource.
  • Add Dept-Engineering as a group resource.
  • Add Dept-Operations as a group resource.
  • Add APP-Workforce-SSO as a group resource.

The resource set now covers all workforce accounts and four lab groups. Add the sensitive group before saving the boundary.

  • Add APP-Admin-Sensitive as a group resource.
  • Save the resource set.

The API Services app becomes the non-human principal for the HR scripts. Its role assignment links the machine identity to the custom permissions and resource boundary.

  • Select Applications and Resources in the Admin Console.
  • Select Applications.
  • Click Create App Integration.
  • Select API Services.
  • Enter HR Lifecycle Automation as the app name.
  • Create the integration.
  • Record the displayed client ID as your API service client ID.
  • Open the app's Admin Roles area.
  • Click Edit assignments.
  • Select the custom HR lifecycle role.
  • Select the HR lifecycle resource set.
  • Save the role assignment.
  • Return to the HR lifecycle resource set.
  • Add HR Lifecycle Automation as an application resource.
  • Save the updated resource set.

The private key is a live credential. Save its only downloaded copy in the repository location already excluded by .gitignore.

  • Return to the HR Lifecycle Automation app.
  • Generate a Public key/Private key pair for the service app.
  • Download the one-time PEM private key.
  • Save the downloaded key as private_key.pem inside the okta-workforce-nhi-lab repository.
  • Record the generated key ID as your JWK key ID.

The repository's existing ignore rules keep private_key.pem outside version control. The file remains available to the local OAuth client.

  • Open the service app's OAuth scope controls.
  • Grant okta.users.manage.
  • Grant okta.groups.manage.
  • Grant okta.apps.read.
  • Grant okta.apiTokens.read.
  • Confirm all four scopes show as granted.

Your local environment file connects the existing scripts to this service app. Keep every credential inside the ignored .env file.

  • Record your Okta organization base URL as your Okta org URL.
  • From GitHub Desktop open the local okta-workforce-nhi-lab repository in Visual Studio Code.
  • Select .env in the Explorer sidebar.
  • Replace the OKTA_ORG_URL value with your Okta org URL.
  • Replace the OKTA_CLIENT_ID value with your API service client ID.
  • Set OKTA_PRIVATE_KEY_PATH to private_key.pem.
  • Replace the OKTA_KEY_ID value with your JWK key ID.
  • Set OKTA_SCOPES to okta.users.manage okta.groups.manage okta.apps.read okta.apiTokens.read.

The service identity values are now connected to the OAuth client. The HR script also needs a lab-only password for the synthetic users.

  • Replace the OKTA_TEMP_PASSWORD value with a new lab-only password that satisfies your Okta password policy.
  • Replace the LEGACY_PRIVILEGE_GROUP_ID value with your APP-Admin-Sensitive group ID.
  • Leave the existing SCIM_BEARER_TOKEN value unchanged.
  • Leave the existing SCIM_BASE_URL value unchanged.
  • Save .env.

Before you test the service app, predict whether its scoped OAuth identity can inventory the temporary SSWS token without using that token.

  • Create the pre-revocation inventory by running this command in the repository terminal:
python3 nhi_inventory.py --output evidence/nhi_before.csv

What does this command prove?

The script requests an OAuth access token with a signed private key assertion. It reads API-token metadata plus active OIDC applications.

The output file captures the temporary SSWS token before revocation. A successful export proves the scoped replacement can perform its inventory task.

  • Confirm the terminal prints a line beginning with WROTE evidence/nhi_before.csv rows=.
  • Confirm evidence/nhi_before.csv appears in the Explorer sidebar.
  • Open evidence/nhi_before.csv.
  • Confirm the temporary SSWS token appears as an SSWS_TOKEN inventory row.

OAuth inventory failed?

Check that the client ID and key ID in .env match the service app. Confirm the private key remains at the configured path.

Check that all four OAuth scopes show as granted. Confirm the custom role includes API-token and application viewing permissions.

Still blocked? Help me diagnose my Okta private key JWT inventory failure.

Revoke the token and run the joiner

The OAuth inventory proves the scoped service identity works. You can now revoke the standing Super Administrator token and preserve its audit trail.

  • Return to Security in the Admin Console.
  • Select API.
  • Select Tokens.
  • Locate the temporary token you created earlier.
  • Click Revoke token for the temporary token.
  • Confirm the revocation.

That high-privilege credential is now invalid. The next inventory should succeed through the OAuth service app alone.

  • Select Reports in the Admin Console.
  • Select System Log.
  • Filter the events for API token revoked.
  • Confirm the result identifies the temporary token revocation.

Before you rerun the inventory, predict whether revoking the SSWS token affects the separate OAuth identity.

  • Create the post-revocation inventory by running this command:
python3 nhi_inventory.py --output evidence/nhi_after.csv

What changed in this inventory?

The command uses the same OAuth service identity as the first inventory. The revoked human-bound token should be absent from active API-token metadata.

The successful export also proves that lifecycle automation no longer depends on the SSWS credential.

  • Confirm the terminal prints a line beginning with WROTE evidence/nhi_after.csv rows=.
  • Confirm evidence/nhi_after.csv appears in the Explorer sidebar.
  • Open evidence/nhi_after.csv.
  • Confirm the revoked temporary token is absent from the active token rows.

Post-revocation inventory failed?

Confirm the scripts use the OAuth values in .env. Remove any terminal-level Okta credential overrides that point to a different identity.

Confirm the private key file still exists locally. Verify that the service app remains assigned to the custom role and resource set.

Need a second pair of eyes? Help me separate my revoked SSWS token from my OAuth service app configuration.

The SCIM application connects Okta lifecycle events to your running local service. The Quick Tunnel supplies the HTTPS endpoint while the Flask terminal records each request.

  • Return to Applications and Resources in the Admin Console.
  • Select Applications.
  • Click Browse App Catalog.
  • Search for SCIM 2.0 Test App(Header Auth).
  • Select the matching catalog integration.
  • Add the integration to your Okta org.
  • Clear Import Groups.

Why clear Import Groups?

The local SCIM service implements user routes only. Clearing Import Groups keeps Okta from calling an unsupported groups endpoint.

  • Open the integration's Provisioning area.
  • Select Configure API Integration.
  • Enable Enable API Integration.
  • Paste the existing SCIM_BASE_URL value from .env into the base URL field.
  • Enter the word Bearer followed by the existing SCIM_BEARER_TOKEN value in the HTTP Header token field.
  • Run the integration's credential test.

You should see a successful credential result. The Flask terminal should log a request for GET /scim/v2/Users?startIndex=1&count=2.

SCIM credential test failed?

Confirm the base URL ends in /scim/v2. Check that the Quick Tunnel and Flask service are still running.

Confirm the header value starts with Bearer . Verify that the remaining token value matches SCIM_BEARER_TOKEN in .env.

Still seeing a failed test? Help me troubleshoot my Okta SCIM header-auth connection.

  • Enable Create Users in the To App settings.
  • Enable Update User Attributes in the To App settings.
  • Enable Deactivate Users in the To App settings.
  • Save the provisioning settings.
  • Assign APP-Workforce-SSO to the SCIM application.
  • Return to the HR lifecycle resource set.
  • Add SCIM 2.0 Test App(Header Auth) as an application resource.
  • Save the updated resource set.

The application is ready to receive every user who enters APP-Workforce-SSO. The joiner now tests directory creation, rule evaluation, and downstream provisioning in one run.

Before you run the joiner, predict which two rule-managed groups Maya should receive from her Finance profile.

  • Create the eight workforce users by running this command:
python3 hr_sync.py joiner

What does the joiner do?

The script reads all eight rows from workforce.csv. It creates each Okta user with the department and title from that row.

Okta evaluates the active department rules after each profile arrives. The SCIM assignment then pushes users from APP-Workforce-SSO to the local service.

  • Confirm the terminal prints eight lines beginning with CREATED login=.
  • Confirm the final output includes SCIM_VALIDATED login=maya.patel@example.com active=True.
  • Switch back to the Flask SCIM terminal.
  • Confirm the log includes a CREATE entry for maya.patel@example.com with active=True.

Joiner or SCIM creation failed?

If user creation fails, confirm the service app has okta.users.manage plus the required custom-role permissions. Check that OKTA_TEMP_PASSWORD satisfies the org password policy.

If the users appear in Okta without SCIM records, confirm APP-Workforce-SSO is assigned to the SCIM app. Verify that the department rules are active.

Need help reading the failure? Help me troubleshoot my Okta joiner and SCIM provisioning run.

Group-rule reconciliation can take a few moments after the profiles are created. The directory should settle with eight active users and department-based membership.

  • Return to the Okta user directory.
  • Confirm eight synthetic workforce users show an active status.
  • Open Maya Patel's user profile.
  • Confirm Maya belongs to Dept-Finance.
  • Confirm Maya belongs to APP-Workforce-SSO.
  • Confirm Maya does not belong to either of the other department groups.

The rule-managed memberships prove that Maya's Finance attribute drives birthright access. A separate manual assignment creates the legacy sensitive exception used by the mover control test.

  • Manually add Maya Patel to APP-Admin-Sensitive.
  • Confirm Maya now has three memberships named Dept-Finance, APP-Workforce-SSO, and APP-Admin-Sensitive.
  • Return to .env in Visual Studio Code.
  • Confirm LEGACY_PRIVILEGE_GROUP_ID contains your APP-Admin-Sensitive group ID.
  • Save .env.

You now have eight active Okta users with attribute-driven department access. Maya has the expected Finance birthright groups plus the deliberate sensitive-group exception.

Your workforce directory and scoped automation identity are ready. Next, you will prove SAML and OIDC access before raising assurance with layered MFA.

Validate Federation and MFA

Your Okta directory now creates workforce users with birthright access. However, those users have not proved that they can reach applications through federated sign-in.

A password-only sign-in provides limited assurance for sensitive access. This step validates federation through SAML and OpenID Connect. It also raises assurance with MFA and biometric Okta FastPass verification.

In this step, get ready to:
  • Configure the RSA SAML Test Service Provider and prove Maya can complete a federated sign-in.
  • Create Sensitive Admin Portal and validate Authorization Code with PKCE through OpenID Connect Debugger.
  • Require MFA for every sign-in and enforce phishing-resistant biometric verification for sensitive access.
Configure and test SAML

SAML lets Okta authenticate Maya before passing an identity assertion to a service provider. A successful assertion proves that application assignment and federated authentication work together.

  • In the Okta Admin Console from earlier, select Applications and Resources.
  • Select Applications.
  • Select Browse App Catalog.
  • Search for Okta/RSA SAML Test Service Provider.
  • Select the matching catalog integration.
  • Complete the integration wizard with its default SAML settings.
  • Open the integration's Assignments section.
  • Add APP-Workforce-SSO as the assigned group.

What does the group assignment do?

Maya already belongs to APP-Workforce-SSO through the Finance group rule. Assigning the SAML integration to that group turns birthright membership into application access.

This test uses Maya's lab-only credentials. Keep the password and any unredacted assertion details out of the evidence you upload.

  • Open a private browser window.
  • Visit your Okta organization sign-in page.
  • Sign in as maya.patel@example.com with the lab-only temporary password.
  • Select the Okta/RSA SAML Test Service Provider tile from Maya's dashboard.
  • Complete any authentication prompt shown by Okta.
  • Confirm the service provider displays a successful assertion with Maya's identity attributes.

That is your first federation proof complete. Maya reached the service provider through an Okta-issued SAML assertion.

  • Capture the successful assertion page with your operating system screenshot tool.
  • Store the original assertion screenshot inside evidence/raw/.
  • Create a redacted copy with personal details covered by opaque shapes.
  • Store the redacted assertion screenshot inside evidence/.
  • Return to the Okta Admin Console from earlier.
  • Select Reports.
  • Select System Log.
  • Filter the log for user.authentication.sso.
  • Locate the event associated with Maya's successful SAML sign-in.
  • Capture the related System Log event with your operating system screenshot tool.
  • Store the original System Log screenshot inside evidence/raw/.
  • Create a redacted copy inside evidence/.

SAML sign-in not reaching the assertion page?

Confirm that Maya still belongs to APP-Workforce-SSO. Confirm that the same group appears in the SAML integration's assignments.

A stale administrator session can also hide the workforce sign-in flow. Repeat the test in a private browser window with Maya's login.

Need help tracing the assignment? Help me troubleshoot why Maya cannot access the RSA SAML Test Service Provider through Okta.

Create and test the OIDC application

A single-page application cannot safely keep a client secret in browser code. Authorization Code with PKCE binds the token exchange to the browser that started the sign-in.

  • Return to Applications and Resources in the Okta Admin Console.
  • Select Applications.
  • Select Create App Integration.
  • Select Classic experience.
  • Select OIDC - OpenID Connect as the sign-in method.
  • Select Single-Page Application as the application type.
  • Select Next.
  • Enter Sensitive Admin Portal in the App integration name field.
  • Enable Authorization Code under Grant type.
  • Enter https://oidcdebugger.com/debug under Sign-in redirect URIs.
  • Select Limit access to selected groups under Assignments.
  • Add APP-Workforce-SSO as the selected group.
  • Select Save.
  • Record the displayed Client ID here: your Sensitive Admin Portal client ID.

Why use PKCE?

The browser creates a code verifier before it starts authentication. Okta checks the matching challenge when the authorization code is exchanged.

A stolen authorization code cannot be redeemed without that verifier. This gives a public browser client protection without a stored client secret.

OpenID Connect Debugger needs your organization authorization endpoint. Its format is your Okta organization URL followed by /oauth2/v1/authorize.

  • Record your organization authorization endpoint here: https://your-okta-domain/oauth2/v1/authorize.
  • Open OpenID Connect Debugger in a private browser window.
  • Enter https://your-okta-domain/oauth2/v1/authorize in the Authorize URI field.
  • Enter your Sensitive Admin Portal client ID in the Client ID field.
  • Enter openid profile in the Scope field.
  • Select code as the Response type.
  • Enable Use PKCE?.

Before you send the request, do you expect Okta to accept the redirect URI and return a successful authorization response?

  • Select Send Request.
  • Sign in as maya.patel@example.com.
  • Complete the authentication flow.

You should return to OpenID Connect Debugger with a success result from the Authorization Code with PKCE flow. This proves that Sensitive Admin Portal trusts Okta for interactive sign-in.

The success screen contains live tokens. Keep the original inside the ignored raw-evidence folder and expose only a fully redacted copy.

  • Capture the successful debugger result with your operating system screenshot tool.
  • Store the original OIDC screenshot inside evidence/raw/.
  • Create a copy with every token value covered by opaque shapes.
  • Store the redacted OIDC screenshot inside evidence/.

OIDC request rejected?

Confirm that the debugger redirect URI exactly matches https://oidcdebugger.com/debug in Sensitive Admin Portal. Confirm that Authorization Code remains enabled.

Check that the Client ID comes from Sensitive Admin Portal. A Client ID from HR Lifecycle Automation belongs to a different application type.

Still blocked? Help me troubleshoot my Okta Authorization Code with PKCE flow in OpenID Connect Debugger.

Enforce layered MFA

The first two tests prove federation. Layered policies now require stronger evidence of identity for every sign-in and add a phishing-resistant biometric check for the sensitive application.

The policy controls live in two different areas, so this part is easy to mix up. The global session policy establishes the organization-wide baseline.

  • Return to the Okta Admin Console from earlier.
  • Select Security.
  • Select Global Session Policy.
  • Open the active global session policy.
  • Edit its sign-in rule.
  • Set Multifactor authentication (MFA) is to Required.
  • Set Users will be prompted for MFA to At every sign in.
  • Save the global session rule.

How do the policies work together?

The global session policy requires MFA whenever users establish an Okta session. The application policy evaluates the assurance required when a user opens Sensitive Admin Portal.

The sensitive rule adds phishing resistance and biometric verification for members of APP-Admin-Sensitive. Maya matches that rule because her manual group membership remains in place.

  • Return to Security.
  • Select Authentication Policies.
  • Select App sign-in.
  • Open the policy used by Sensitive Admin Portal.
  • Select Rules.
  • Select Add rule.
  • Scope the rule to APP-Admin-Sensitive.
  • Configure access to require Any 2 factor types.
  • Select Phishing-resistant as the possession constraint.
  • Enable Require user interaction.
  • Enable Require biometric user verification.
  • Save the app sign-in rule.
  • Move the new rule above the broader rules in the policy.

The next sign-in registers Maya with the existing Okta Verify installation on your Mac. Keep enrollment details and the lab password out of screenshots.

  • Return to Maya's private browser session.
  • Sign out of the existing Okta session.
  • Sign in again as maya.patel@example.com.
  • Follow the prompt to enroll Maya in Okta Verify on the Mac.
  • Complete the device's biometric verification.

Maya not offered Okta Verify?

Confirm that the global session rule requires MFA at every sign-in. Confirm that the higher-priority app rule targets APP-Admin-Sensitive.

Check that Maya still has the manually assigned sensitive-group membership. A user outside that group follows the broader application rule.

Need help with the policy order? Help me determine why Maya is not receiving the biometric Okta FastPass prompt.

  • Return to the OpenID Connect Debugger form from earlier.
  • Re-enter your Sensitive Admin Portal client ID in the Client ID field.
  • Re-enter https://your-okta-domain/oauth2/v1/authorize in the Authorize URI field.
  • Confirm Use PKCE? remains enabled.

Before you send the second request, which policy do you expect Maya's sensitive-group membership to trigger?

  • Select Send Request.
  • Sign in as maya.patel@example.com.
  • Capture the biometric Okta FastPass prompt without including credentials.
  • Complete biometric verification in Okta Verify.
  • Confirm OpenID Connect Debugger returns a successful authorization result.

You should receive an Okta FastPass prompt that requires biometric verification before the OIDC flow succeeds. That is the assurance jump complete for Sensitive Admin Portal.

Your workforce users now have proven SAML and OIDC access with layered MFA. Next, you will test whether a department change leaves Maya's manually assigned sensitive access behind.

Expose Mover Privilege Creep

Your SAML sign-in now proves federated access for Maya. Your OpenID Connect flow also proves that Okta FastPass can enforce stronger verification for sensitive access.

A department move tests whether access follows an employee's current role. Group rules govern Maya's department access while her manual sensitive entitlement follows a separate lifecycle path. This split creates the conditions for privilege creep.

In this step, get ready to:
  • Update Maya's department through the scoped OAuth service identity.
  • Inspect Maya's rule-managed access plus her manual sensitive entitlement.
  • Preserve the failed control check as redacted evidence.
Run the mover without remediation

The mover changes Maya's authoritative profile values through HR Lifecycle Automation. Its control check then inspects whether the legacy sensitive group is still assigned after group-rule reconciliation.

Before you run this, ask yourself whether every current membership will follow Maya's new department.

  • Switch back to the Terminal window for the existing okta-workforce-nhi-lab repository.
  • Update Maya's department to Engineering through the mover command by running:
python3 hr_sync.py mover --login maya.patel@example.com --department Engineering --title "Cloud Engineer"

What just happened?

  • The mover action changes Maya's department value to Engineering.
  • The same action changes Maya's title value to Cloud Engineer.
  • The profile update uses the scoped HR Lifecycle Automation identity through OktaClient.
  • The final check looks for the group identified by LEGACY_PRIVILEGE_GROUP_ID.

The terminal first reports a PROFILE_UPDATED result for Maya. It then prints PRIVILEGE_CREEP_DETECTED because the legacy sensitive group survived the department change.

The process exits with status 2. That nonzero status turns the retained entitlement into a failed control check.

You found the exact gap this test was designed to expose. Maya's profile changed successfully while her manual sensitive access remained attached.

Command stopped before the control check?

  • Confirm the OAuth values in .env still match the HR Lifecycle Automation app.
  • Confirm OKTA_PRIVATE_KEY_PATH still points to the saved private key.
  • Confirm Maya's login is exactly maya.patel@example.com.

If the command still stops early, help me diagnose the mover command.

Compare Maya's memberships

The terminal proves that the control failed. Maya's group list shows which access changed automatically and which entitlement escaped the mover lifecycle.

  • Return to the Okta Admin Console from earlier.
  • Find Maya Patel through the workforce user search.
  • Open Maya's user profile.
  • Display the section that lists Maya's current group memberships.
  • Confirm Dept-Finance is absent.
  • Confirm Dept-Engineering is present.
  • Confirm APP-Workforce-SSO is present.
  • Confirm APP-Admin-Sensitive is still present.

The department rule replaced Dept-Finance with Dept-Engineering. The manually assigned APP-Admin-Sensitive entitlement remained.

  • Capture a screenshot that shows Maya's profile plus the current membership list.
  • Save the original screenshot inside evidence/raw/.
  • Create a copy with visible tenant details redacted.
  • Save the redacted copy inside evidence/.

Memberships have not reconciled?

  • Wait a few seconds for the active rules to process Maya's updated profile.
  • Refresh Maya's membership list.
  • Confirm Maya's department is exactly Engineering.
  • Confirm the Engineering department rule remains active.

If the old department group remains, help me troubleshoot the group-rule reconciliation.

Preserve the failed control evidence

A saved failure record connects the automation result to the membership screenshot. Keeping the unredacted original under evidence/raw/ also prevents it from entering the published repository.

  • Press Cmd+Space on macOS or the Windows key on Windows to open the system search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to launch Visual Studio Code.
  • Use the folder picker to select the existing okta-workforce-nhi-lab repository.
  • Create mover_failure.txt inside the existing evidence/ folder.
  • Return to the Terminal window that ran the mover command.
  • Copy the PROFILE_UPDATED output plus the complete PRIVILEGE_CREEP_DETECTED output.
  • Switch back to mover_failure.txt in Visual Studio Code.

What belongs in the failure record?

The profile-update line proves that the authoritative mover change succeeded. The privilege-creep line proves that the sensitive manual entitlement survived the change.

Together, those lines separate an automation failure from an access-governance failure.

  • Paste the copied terminal output into evidence/mover_failure.txt.
  • Save evidence/mover_failure.txt.

Before you check the file, predict which terminal marker must be present for the record to prove a failed entitlement control.

  • Open evidence/mover_failure.txt from the repository's file sidebar.
  • Confirm the saved record contains PRIVILEGE_CREEP_DETECTED.
  • Confirm the redacted membership screenshot is present inside evidence/.
  • Confirm the original membership screenshot remains inside evidence/raw/.

Your mover evidence now proves that Maya moved to Engineering while retaining a manual sensitive entitlement. Next, you'll remove that excess access before offboarding a leaver and packaging the final audit evidence.

Remediate and Offboard

Your previous mover test proved that Maya's manually assigned sensitive access survived her department change in Okta. That failure gives you a concrete control gap to close.

A finding gains value when it triggers remediation. The same lifecycle process must also terminate a leaver's sessions.

You will remove Maya's excess access before offboarding Noah. You will prove downstream deactivation through SCIM before packaging the evidence you validated.

In this step, get ready to:
  • Remediate Maya's retained sensitive access.
  • Offboard Noah across the workforce lifecycle.
  • Publish a repository containing redacted audit evidence.
Remediate Maya and export final posture

The --remediate path turns the failed mover control into an enforced correction. It detects the retained manual entitlement before removing it directly.

Before you run this, predict which messages will prove that detection led to removal.

  • Run the mover remediation from the terminal in your okta-workforce-nhi-lab repository by using this command:
python3 hr_sync.py mover --login maya.patel@example.com --department Engineering --title "Cloud Engineer" --remediate

What does remediation prove?

  • The mover updates Maya's authoritative profile values before checking her current group memberships.
  • The remediation path removes the directly managed sensitive-group membership after detecting it.
  • A final membership check confirms that the removal succeeded.

You will see PRIVILEGE_CREEP_DETECTED followed by REMEDIATED. The second message proves that the retained entitlement was removed.

  • Return to Maya Patel's profile in the Okta Admin Console from earlier.
  • Confirm her department remains Engineering.
  • Confirm her title remains Cloud Engineer.
  • Confirm she remains in Dept-Engineering.
  • Confirm she remains in APP-Workforce-SSO.
  • Confirm APP-Admin-Sensitive is absent from her memberships.

Sensitive access still assigned?

  • Check that LEGACY_PRIVILEGE_GROUP_ID in .env contains the ID for APP-Admin-Sensitive.
  • Confirm the membership was assigned manually because the remediation call can only remove a directly managed Okta group membership.
  • Run the remediation command again after correcting the group ID.

Still stuck? Help me troubleshoot why Maya's sensitive group membership was not removed.

Final posture reports freeze the state of your OAuth service identity after remediation. They also record current MFA enrollment posture.

The non-human identity inventory captures active token metadata plus OIDC application review rows. Both scripts write structured CSV evidence into the repository.

  • Export the final non-human identity and MFA posture files by running these commands:
python3 nhi_inventory.py --output evidence/nhi_final.csv
python3 mfa_report.py --output evidence/mfa_report.csv

What do these reports capture?

  • The non-human identity report records active API-token metadata plus active OIDC applications that need identity review.
  • The MFA report records each user's status plus the factor type and enrollment status returned by Okta.

Each script prints WROTE with its target path. You should see evidence/nhi_final.csv and evidence/mfa_report.csv named in the terminal.

Report export stopped?

  • Confirm the private key path in .env still points to the saved PEM file.
  • Confirm the HR Lifecycle Automation service app still has its assigned custom role.
  • Confirm its granted scopes include the read access required by both reports.

Need help? Help me diagnose why my final Okta posture reports did not export.

Offboard Noah and capture audit evidence

A complete leaver control proves two separate outcomes. Okta must clear Noah's sessions before deactivation while the downstream SCIM target must record him as inactive.

Before you run this, predict which terminal messages will prove both outcomes.

  • Run Noah's leaver workflow from the repository terminal by using this command:
python3 hr_sync.py leaver --login noah.williams@example.com

What does the leaver workflow prove?

  • The script revokes Noah's Okta sessions before changing his lifecycle state.
  • The script waits until Noah reaches the deactivated workforce state.
  • The script queries the SCIM target until Noah's downstream record reports an inactive state.

You will see SESSIONS_CLEARED before DEPROVISIONED. The final SCIM_VALIDATED line should contain active=False.

  • Switch back to the Terminal window running scim_server.py from earlier.
  • Locate Noah's PATCH or PUT log entry.
  • Confirm the entry ends with active=False.
  • Return to the Okta Admin Console.
  • Select Reports from the Admin Console navigation.
  • Select System Log.
  • Filter the audit trail by pasting this query:
(eventType eq "application.user_membership.add" OR eventType eq "application.provision.user.push" OR eventType eq "user.lifecycle.deactivate" OR eventType eq "application.provision.user.deprovision" OR displayMessage eq "API token revoked")

What does this filter collect?

  • The membership and provisioning events capture application access changes across the workforce lifecycle.
  • The lifecycle and deprovisioning events capture Noah's Okta deactivation plus the downstream provisioning action.
  • The API token revoked display message preserves proof that the temporary SSWS token was removed.
  • Capture the filtered System Log results with your Mac's screenshot tool.
  • Save the original screenshot inside evidence/raw/.
  • Create a token-redacted copy of the screenshot.
  • Save the redacted copy inside evidence/.
  • Save Noah's SCIM deprovisioning log excerpt inside evidence/.
  • Confirm the retained evidence contains no access tokens or private-key material.

Missing lifecycle evidence?

  • Expand the System Log time range to include the earlier token revocation plus the current leaver run.
  • Confirm Noah reached DEPROVISIONED before searching for the lifecycle event.
  • Confirm the SCIM server was running when the leaver command completed.

Need help? Help me find the missing Okta or SCIM deprovisioning evidence.

Complete and publish the evidence package

Your repository should make every claim traceable to evidence. It should also keep local credentials and unredacted captures outside version control.

  • Open README.md from the okta-workforce-nhi-lab repository in your code editor.
  • Replace the Summary placeholder with the workforce identity problem you solved.
  • Mark only the outcomes you completed under Validated Outcomes.
  • Complete the evidence cells in Configured Integrations.
  • Complete the joiner row in JML Evidence.
  • Complete the mover row in JML Evidence.
  • Complete the leaver row in JML Evidence.

Keep every claim evidence-based

Each checked outcome should point to a redacted screenshot, report, or log excerpt. This keeps the portfolio aligned with the controls you personally configured.

  • Complete the Before finding in Non-Human Identity Findings from your earlier inventory.
  • Complete the After finding in Non-Human Identity Findings from evidence/nhi_final.csv.
  • Confirm the NIST SP 800-53 Rev. 5 table contains the four interpretive rows for AC-2, AC-6, IA-2, and IA-5.
  • Keep the resume bullets limited to work completed on the main lab path.
  • Save README.md.

Publishing deserves a careful pause because your local repository contains credentials and raw evidence that must stay on your Mac. Your .gitignore already defines the boundary.

Before you review the changes, predict which local artifacts must be absent from the commit.

  • Return to GitHub Desktop from earlier.
  • Review the repository's changed-file list.
  • Confirm .env is absent from the changed-file list.
  • Confirm private_key.pem is absent from the changed-file list.
  • Confirm scim_lab.db is absent from the changed-file list.
  • Confirm evidence/raw/ is absent from the changed-file list.
  • Confirm every visible evidence file is redacted.

You should see README.md plus the redacted evidence and report files. The credential files, database, and raw-evidence directory should be absent.

  • Enter Remediate mover and add leaver evidence as the commit summary.
  • Create the commit through GitHub Desktop.
  • Publish the repository through GitHub Desktop.
  • Open the published repository from GitHub Desktop.
  • Confirm the completed README is visible in the published repository.
  • Confirm the redacted evidence files are visible in the published repository.
  • Confirm the excluded credential files remain absent from the published repository.

That closes the full identity lifecycle. Your lab now proves remediation, session termination, downstream deactivation, and evidence hygiene with a published audit trail.

Strong work. You have turned a visible privilege-creep failure into a remediated control with verifiable leaver evidence.

Secret mission

Separate Human and AI Helpdesk Privileges

Separate automated support from human recovery work. You will create a read-only AI service identity plus a group-scoped Help Desk Administrator. Positive and negative authorization tests will prove each identity stays inside its own boundary.

Clean Up Your Resources

Clean Up Your Resources

Your Okta Integrator Free Plan org and GitHub repository can stay at no charge. The local Flask service and Cloudflare Quick Tunnel create no ongoing bill.

Decide whether to keep the lab ready for an immediate demo, pause its active pieces, or remove its lab data.

Resources you used:

  • Eight synthetic workforce users in Okta. Noah Williams is already deprovisioned.
  • One active support user named helpdesk.operator@example.com.
  • Five Okta groups named Dept-Finance, Dept-Engineering, Dept-Operations, APP-Workforce-SSO, and APP-Admin-Sensitive.
  • Three attribute-driven group rules for department access.
  • Three federation and provisioning integrations using SAML, OpenID Connect, and SCIM 2.0.
  • Two OAuth service apps named HR Lifecycle Automation and Helpdesk AI Read Only. The first uses private key JWT.
  • One custom administrator role with its resource set. One group-scoped Help Desk Administrator assignment.
  • One organization-wide MFA configuration with a higher-priority sensitive-app rule.
  • One local SCIM service backed by a SQLite database. Its local state includes .env, .env.ai, private-key files, and scim_lab.db.
  • One temporary Quick Tunnel with a generated HTTPS hostname.
  • One published redacted repository named okta-workforce-nhi-lab.

Keep everything running

No action needed. Choose this if you are still actively demonstrating the workforce identity lab.

  • Keep the Terminal processes for scim_server.py and cloudflared running.
  • Keep the Okta users, rules, integrations, policies, and administrator assignments active.
  • Keep .env, .env.ai, private-key files, and scim_lab.db outside Git.
  • Keep the published repository redacted. Its current exclusions prevent the local secrets and raw evidence from being committed.
  • Remember that the current Quick Tunnel hostname works only while its process remains running. A restarted tunnel generates a different hostname.

Pause - I'll come back to this later

Shut down the running processes to free up your Mac while preserving the lab configuration. Your local files and redacted evidence remain ready for another session.

  • Stop the Quick Tunnel by pressing Control-C in its Terminal window.
  • Stop the local SCIM service by pressing Control-C in the Terminal window running scim_server.py.
  • Deactivate the five lab app integrations from the Applications and Resources section of the Okta Admin Console under Applications.
  • Deactivate the seven remaining active synthetic workforce users from their Okta profiles.
  • Deactivate helpdesk.operator@example.com from its Okta profile.
  • Retain the groups, group rules, authentication policies, administrator configuration, and evidence.

A future Quick Tunnel uses a new hostname. Your next demonstration needs that replacement base URL in .env and the SCIM integration.

Delete - I don't want to use this again

Remove all lab resources and start fresh. Keep a redacted evidence export first if you want a record of the controls you proved.

  • Stop the Quick Tunnel by pressing Control-C in its Terminal window.
  • Stop the local SCIM service by pressing Control-C in the Terminal window running scim_server.py.
  • Use the administrator assignments page under Security to remove the assignments for HR Lifecycle Automation, Helpdesk AI Read Only, and helpdesk.operator@example.com.
  • Remove the custom administrator role after its assignments are gone.
  • Use the Resources page within the Administrators section of Security to remove the lab resource set.
  • Use the app sign-in rules page under Authentication Policies to remove the higher-priority sensitive-app rule.
  • Use the Global Session Policy page under Security to restore the settings that existed before lab-wide MFA was configured.
  • Use the Applications page under Applications and Resources to remove all five lab app integrations.
  • Use the Rules page within Groups under Directory to remove the three department rules.
  • Use the Groups page under Directory to remove the five lab groups.
  • Deactivate the seven active synthetic workforce users from their Okta profiles.
  • Deactivate helpdesk.operator@example.com from its Okta profile.
  • Delete all eight synthetic workforce user records plus helpdesk.operator@example.com from their Okta profiles.
  • Delete every local private-key file referenced by .env or .env.ai.
  • Delete .env, .env.ai, and scim_lab.db from your local lab files.
  • Open the repository settings for okta-workforce-nhi-lab on GitHub. Choose its archive option to retain history or its deletion option to remove the published repository.

Nice Work!

Nice Work!

You did it! You built an eight-person workforce identity lab in Okta that proves lifecycle controls across human identities and machine identities.

You've learned how to:

  • Build an eight-person Universal Directory with attribute-driven birthright access. Automate Joiner-Mover-Leaver controls through a scoped service identity. Detect mover privilege creep before removing the excess access. Prove downstream SCIM deactivation with active=false.
  • Validate a SAML sign-in against a test service provider. Complete an OIDC Authorization Code with PKCE sign-in through OpenID Connect Debugger. Enforce organization-wide MFA for workforce access. Require phishing-resistant biometric verification through Okta FastPass for sensitive access.
  • Replace a Super Admin SSWS token with a scoped OAuth service app using private key JWT. Export MFA posture evidence as a CSV report. Export a non-human identity inventory as a separate CSV report. Map the completed controls to NIST SP 800-53 Rev. 5. Publish a redacted GitHub repository that excludes secrets and raw evidence.
  • Secret Mission: Separate a read-only AI service identity from a group-scoped human Help Desk Administrator. Prove read access for the AI identity. Retain its denied profile-changing action as HTTP 403 evidence. Validate in-scope recovery controls for the human administrator. Confirm user deactivation remains unavailable.

Ready to quiz yourself?