Build a FedRAMP Bash Access Toolkit

Build a Bash toolkit for simulated access changes, audit logs, and reviews.

Introduction

30 Second Summary

Employee access should follow each person from their first day to their last. A typo or missing record can leave a manager unable to prove who approved what.

In this project, you will build a Bash toolkit in AWS CloudShell that simulates a controlled employee access lifecycle. You will publish an honest FedRAMP evidence walkthrough to GitHub without exposing generated identity data.

What You'll Build

Picture showing a reviewer a GitHub repository where your Bash toolkit turns guarded employee access changes into a clear access review.

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

  • A guarded onboarding command that creates an approved synthetic employee record while rejecting invalid or duplicate requests with visible audit results.
  • A safe offboarding workflow that archives an active record while preserving evidence of the lifecycle decision.
  • A documented GitHub repository that explains how your generated access review maps to selected FedRAMP principles without claiming compliance.
  • Secret Mission: Add an audited role-change command that completes the joiner-mover-leaver lifecycle.

Are there any prerequisites?

You'll need an AWS account plus a GitHub account. No local installation is required because AWS CloudShell provides the managed tools used in this project.

Before We Start

Manual onboarding and offboarding can produce inconsistent decisions with no reviewable evidence for a manager or auditor. Before any hands-on work, this step helps you define a fictional organization and a clear business purpose for the toolkit.

Set Up a Safe CloudShell Workspace

A lifecycle toolkit needs a safe place to prove its behavior before it touches real identities. AWS CloudShell gives you a browser-based workspace while your AWS permissions remain unknown.

Signing in may feel like the start of a cloud deployment. This step creates no IAM or other AWS service resources.

In this step, get ready to:
  • Launch AWS CloudShell in one selected AWS Region.
  • Identify the active AWS caller with the AWS CLI.
  • Create the local CloudShell workspace.
Launch AWS CloudShell in one Region

CloudShell home storage is tied to the AWS Region you use. Keeping one Region selected makes the same workspace available when you return.

  • Sign in to the AWS Management Console.
  • Check the AWS Region shown in the console header.
  • Record the selected Region here: your selected AWS Region.
  • Choose the CloudShell icon on the Console Toolbar.

You'll see a terminal running Bash. CloudShell also provides the AWS CLI.

The edit command opens CloudShell's visual editor. On macOS, Cmd+S saves changes in that editor.

Why Local Bash Before Live IAM?

Local Bash lets you practice input validation without IAM write permissions. It also lets you model lifecycle states safely.

Direct IAM changes can fail under unknown permissions. This simulation keeps the focus on access safeguards.

Verify the runtime and caller

Caller identity is the accountability anchor for later audit events. The identity check returns the Arn of the current AWS caller without requiring permission for that operation.

Before you run the check, which identity type do you expect your AWS account to reveal?

  • Check the managed shell and active caller by running this command:
bash --version && aws sts get-caller-identity

What Does This Check Prove?

  • The first check prints the version of Bash managed by AWS.
  • The && operator starts the identity check only after the Bash check succeeds.
  • The identity response includes UserId, Account, and Arn fields.

You'll see Bash version details first. The identity response then displays the active AWS caller.

Check for Root Access

If the Arn identifies the root user, AWS recommends avoiding root for everyday administration. This project still makes no AWS service changes.

Caller Details Missing?

  • Wait for the CloudShell prompt to return.
  • Run the check again using the command above.

Help me troubleshoot a missing AWS caller response in CloudShell.

Create the local workspace

A named directory gives every later script a predictable home. This directory begins with no project files.

  • Create the CloudShell project directory by running these commands:
mkdir -p ~/access-lifecycle-toolkit
cd ~/access-lifecycle-toolkit

What Do These Commands Do?

  • The first line creates the project directory in your CloudShell home storage.
  • The -p flag keeps the operation safe if the directory already exists.
  • The second line moves your shell into the new directory.

Before you run the final check, what should identify your managed shell, active caller, and workspace?

  • Verify the completed workspace by running these checks:
bash --version && aws sts get-caller-identity
pwd

What Does the Final Check Prove?

  • The first output confirms that Bash is available.
  • The identity response confirms which AWS caller is active.
  • The final output confirms that your shell is inside the project directory.

You'll see Bash version details. The identity output includes an Arn field.

The final line is a path ending in /access-lifecycle-toolkit. That's the setup complete: CloudShell can identify the caller from inside the correct project directory.

Path Looks Wrong?

  • Return to the directory creation commands above.
  • Run both lines again from the same CloudShell session.
  • Repeat the final workspace check.

Help me fix my CloudShell project directory.

  • Redact account-specific values from your screenshot if you prefer.

Your safe workspace is ready. Next up, you'll run a manual onboarding demo that exposes the role-validation gap.

Expose Manual Onboarding Risks

Your AWS CloudShell workspace is ready. You can now test a manual onboarding process without changing any AWS identities.

A text file can capture an employee record quickly. This step tests whether that process controls role values or leaves reliable evidence of each access decision.

In this step, get ready to:
  • Create a manual demo with two synthetic employee records.
  • Navigate the CloudShell workspace with Linux file commands.
  • Inspect the role values to identify missing safeguards.
Create the manual records

Each onboarding record uses plain key-value text. The synthetic employee IDs keep this demonstration separate from real identity data.

  • Create the manual-demo directory plus both record files by running these Bash commands:
mkdir -p manual-demo
printf 'EMPLOYEE_ID=alex-001\nROLE=analyst\n' > manual-demo/alex-001.record
printf 'EMPLOYEE_ID=sam-002\nROLE=developr\n' > manual-demo/sam-002.record

What Do These Commands Create?

  • The mkdir command creates the manual-demo directory.
  • Each printf command writes one employee record to its own file.
  • The newline sequences place the employee ID and role on separate lines.
  • Confirm that both record files exist by running this command:
ls manual-demo

What Should You See?

Good start. You should see alex-001.record plus sam-002.record in the output.

Missing a Record File?

  • Confirm that your CloudShell prompt is inside ~/access-lifecycle-toolkit.
  • Run the creation command block again if either record name is missing.

Get help checking the file creation commands.

Navigate the workspace

Linux navigation commands help you confirm where files live before you inspect them. You will enter manual-demo briefly before returning to the project workspace.

  • Trace your location through the workspace by running these commands:
pwd
ls
cd manual-demo
pwd
ls
cd ..
pwd

What Does This Navigation Prove?

  • The first pwd output identifies your access-lifecycle-toolkit workspace.
  • The first ls output shows the manual-demo directory.
  • The next path points inside manual-demo.
  • The final path confirms that you returned to the project workspace.

Still Inside the Manual Demo?

If your final path ends with manual-demo, use cd to return to the parent directory. Confirm that your prompt points to ~/access-lifecycle-toolkit before continuing.

Help me return to the CloudShell project workspace.

Inspect the onboarding result

The two records now let you test whether a plain file workflow enforces an approved role list. The result reveals what the manual process can preserve.

Before you inspect the records, do you expect the filesystem to reject a role it does not recognize?

  • Display both employee records by running this command:
cat manual-demo/*.record

What Did the Manual Process Allow?

You will see the approved value ROLE=analyst. You will also see the mistyped value ROLE=developr.

The redirection writes to a predictable employee file. A repeated manual request can replace the earlier contents without preserving the original decision.

The directory contains current record contents only. It has no consistent lifecycle event showing the timestamp, actor, action, or result.

Cannot Display Both Records?

  • Check that ls manual-demo shows both record filenames.
  • Check that the file extension is .record for both files.

Help me inspect the manual onboarding records.

✔️ Awesome, I've got everything!

Your manual-demo folder contains both employee records. The terminal output exposes the unapproved developr role.

ⓧ I'd like to double check the full code

These are the complete record files for comparison.

EMPLOYEE_ID=alex-001
ROLE=analyst
EMPLOYEE_ID=sam-002
ROLE=developr

You have exposed the exact weakness that guarded automation needs to solve. Next, you will replace manual writes with a Bash command that validates roles and records each decision.

Automate Guarded Onboarding

Your manual demo made the access risk visible. The filesystem accepted an unapproved role without recording a consistent lifecycle decision.

This step replaces that weak process with a guarded Bash workflow in AWS CloudShell. Every approved or rejected request becomes a traceable CSV audit event.

In this step, get ready to:
  • Initialize the toolkit's record and evidence directories.
  • Validate onboarding requests before writing employee records.
  • Prove every onboarding decision appears in the audit trail.
Initialize the toolkit workspace

The toolkit needs predictable locations for active records, archived records, logs, and generated evidence. Initialization creates those locations before any lifecycle command runs.

  • Create accessctl.sh inside ~/access-lifecycle-toolkit by running this editor command:
edit accessctl.sh

What does this command do?

The edit command opens the visual CloudShell editor with accessctl.sh ready for editing.

  • Paste this initialization section into the empty editor:
#!/usr/bin/env bash
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DATA_DIR="$PROJECT_DIR/data"
ACTIVE_DIR="$DATA_DIR/active"
ARCHIVED_DIR="$DATA_DIR/archived"
LOG_DIR="$PROJECT_DIR/logs"
EVIDENCE_DIR="$PROJECT_DIR/evidence"
AUDIT_LOG="$LOG_DIR/audit.csv"

mkdir -p "$ACTIVE_DIR" "$ARCHIVED_DIR" "$LOG_DIR" "$EVIDENCE_DIR"

if [ ! -f "$AUDIT_LOG" ]; then
  printf 'timestamp_utc,actor,action,employee_id,role,result\n' > "$AUDIT_LOG"
fi

What does this initialization do?

  • The directory variables keep every generated artifact under the same project path.
  • The mkdir -p line creates the active, archived, log, and evidence directories safely.
  • The file check creates logs/audit.csv once with a six-column header.
  • Press Cmd+S to save accessctl.sh.
  • Return to the CloudShell prompt.
  • Initialize the toolkit and display its audit header by running:
bash accessctl.sh
cat logs/audit.csv

What should you see?

You should see timestamp_utc,actor,action,employee_id,role,result in the terminal. This confirms that the script created the audit file before processing any requests.

Didn't see the CSV header?

Check that accessctl.sh is saved inside ~/access-lifecycle-toolkit. A misplaced script creates its directories beside the misplaced file.

Compare the quotation marks around each path if the terminal reports a syntax problem. Help me check the initialization section in accessctl.sh.

Add validation and audit helpers

Guarded onboarding needs shared rules for timestamps, errors, employee IDs, roles, and actors. Keeping these rules in functions gives every request the same checks.

  • Return to accessctl.sh in the CloudShell editor.
  • Place your cursor below the initialization section.
  • Add the timestamp, error, and usage helpers by pasting:
timestamp_utc() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  bash accessctl.sh onboard EMPLOYEE_ID ROLE

Approved roles: analyst, developer, auditor
Use synthetic employee IDs only.
USAGE
}

How do these helpers protect the workflow?

  • The timestamp_utc() function gives every event a consistent UTC timestamp.
  • The fail() function prints an error to the error stream before stopping the request.
  • The usage() function documents the onboarding format and approved roles.
  • Add the employee ID, role, and actor helpers directly below usage() by pasting:
validate_employee_id() {
  local employee_id=${1:-}

  case "$employee_id" in
    ""|*[!a-z0-9-]*|-*|*-)
      fail "Employee ID must use lowercase letters, numbers, or internal hyphens."
      ;;
  esac
}

is_allowed_role() {
  local role=${1:-}

  case "$role" in
    analyst|developer|auditor) return 0 ;;
    *) return 1 ;;
  esac
}

current_actor() {
  aws sts get-caller-identity --query Arn --output text 2>/dev/null || printf '%s' "${USER:-unknown}"
}

What gets validated?

  • The validate_employee_id() function accepts lowercase letters, numbers, and internal hyphens.
  • The is_allowed_role() function limits requests to analyst, developer, or auditor.
  • The current_actor() function asks the AWS CLI for the caller ARN. The CloudShell user becomes the fallback actor if that lookup fails.
  • Press Cmd+S to save accessctl.sh.
  • Return to the CloudShell prompt.
  • Confirm the helpers load without interrupting initialization by running:
bash accessctl.sh
cat logs/audit.csv

What does this check prove?

You should see the CSV header once. The script loaded each helper successfully while preserving the original audit file.

  • Return to accessctl.sh in the editor.
  • Add the event logger below current_actor() by pasting:
log_event() {
  local action=$1
  local employee_id=$2
  local role=$3
  local result=$4
  local actor

  actor=$(current_actor)
  printf '%s,%s,%s,%s,%s,%s\n' \
    "$(timestamp_utc)" "$actor" "$action" "$employee_id" "$role" "$result" >> "$AUDIT_LOG"
}

How is an audit event built?

The log_event() function receives the action, employee ID, role, and result. It adds the current UTC timestamp plus the actor before appending one CSV row.

  • Press Cmd+S to save accessctl.sh.
  • Return to the CloudShell prompt.
  • Confirm the logger definition loads successfully by running:
bash accessctl.sh
cat logs/audit.csv

What should remain in the log?

You should still see only the CSV header. Defining the logger does not create a lifecycle event because no onboarding decision has happened.

  • Return to accessctl.sh in the editor.
  • Place your cursor below log_event().
  • Paste the first section of the onboarding function:
onboard() {
  [ "$#" -eq 2 ] || fail "Onboard requires EMPLOYEE_ID and ROLE."

  local employee_id=$1
  local role=$2
  local active_file
  local archived_file
  local created_at

  validate_employee_id "$employee_id"

  if ! is_allowed_role "$role"; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_INVALID_ROLE"
    fail "Role must be analyst, developer, or auditor."
  fi

  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ -e "$active_file" ] || [ -e "$archived_file" ]; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_DUPLICATE"
    fail "Employee ID already exists in the lifecycle records."
  fi

What does the first section guard?

This section requires exactly two inputs before validating the employee ID. It records invalid roles and duplicate identities as rejected events.

The function remains open at this point. The next block completes it before you save or run the script.

  • Complete the onboarding function below the duplicate check by pasting:
  created_at=$(timestamp_utc)
  {
    printf 'EMPLOYEE_ID=%s\n' "$employee_id"
    printf 'ROLE=%s\n' "$role"
    printf 'CREATED_AT=%s\n' "$created_at"
  } > "$active_file"

  log_event "ONBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Onboarded %s with role %s.\n' "$employee_id" "$role"
}

command=${1:-}

case "$command" in
  onboard)
    shift
    onboard "$@"
    ;;
  help|-h|--help|"")
    usage
    ;;
  *)
    fail "Unknown command. Run bash accessctl.sh help."
    ;;
esac

How is onboarding completed?

  • An approved request writes the employee ID, role, and creation timestamp into an active record.
  • The successful decision becomes an ONBOARD event with the result SUCCESS.
  • The command dispatcher sends onboarding arguments into onboard(). It also handles help and unknown commands.
  • Press Cmd+S to save the completed accessctl.sh file.
  • Return to the CloudShell prompt.
Test approved and rejected requests

The script now treats each onboarding request as a decision. A successful request creates one active record while failed requests leave the lifecycle state unchanged.

  • Submit the approved request followed by the same request again by running:
bash accessctl.sh onboard alex-001 analyst
bash accessctl.sh onboard alex-001 analyst

What should happen?

The first command prints Onboarded alex-001 with role analyst. and creates the active record. The repeated request prints an error because the employee ID already exists.

Both decisions enter the audit log. The second result is recorded as REJECTED_DUPLICATE.

Before you run the mistyped role request, do you expect the filesystem to accept developr again?

  • Test the role guardrail by running:
bash accessctl.sh onboard sam-002 developr

What does the rejection prove?

The command prints an error explaining that the role must be analyst, developer, or auditor. The audit result is REJECTED_INVALID_ROLE.

The failed request creates no active record for sam-002.

Before you inspect the final state, which employee record and three audit results do you expect to see?

  • Verify the active records and audit decisions by running:
ls data/active
cat data/active/alex-001.record
cat logs/audit.csv

What should you see?

You should see only alex-001.record under data/active. Its contents show the employee ID, the approved role, and a UTC creation timestamp.

The audit file should list SUCCESS, REJECTED_DUPLICATE, and REJECTED_INVALID_ROLE in that order. Each row also includes its UTC timestamp and actor.

Are the records or events different?

Remove any accidental edits that changed the approved role spellings or audit result names. Check that both rejection branches call log_event() before fail().

Confirm that you ran each test exactly once and in the order shown. Help me troubleshoot my guarded onboarding results.

That guardrail is working. Approved data creates a record while every rejected decision remains visible for review.

✔️ Awesome, I've got everything!

Your accessctl.sh file now initializes the workspace, validates onboarding requests, protects against duplicates, and records each result in logs/audit.csv.

ⓧ I'd like to double check the full code

Compare your complete accessctl.sh file with this version:

#!/usr/bin/env bash
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DATA_DIR="$PROJECT_DIR/data"
ACTIVE_DIR="$DATA_DIR/active"
ARCHIVED_DIR="$DATA_DIR/archived"
LOG_DIR="$PROJECT_DIR/logs"
EVIDENCE_DIR="$PROJECT_DIR/evidence"
AUDIT_LOG="$LOG_DIR/audit.csv"

mkdir -p "$ACTIVE_DIR" "$ARCHIVED_DIR" "$LOG_DIR" "$EVIDENCE_DIR"

if [ ! -f "$AUDIT_LOG" ]; then
  printf 'timestamp_utc,actor,action,employee_id,role,result\n' > "$AUDIT_LOG"
fi

timestamp_utc() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  bash accessctl.sh onboard EMPLOYEE_ID ROLE

Approved roles: analyst, developer, auditor
Use synthetic employee IDs only.
USAGE
}

validate_employee_id() {
  local employee_id=${1:-}

  case "$employee_id" in
    ""|*[!a-z0-9-]*|-*|*-)
      fail "Employee ID must use lowercase letters, numbers, or internal hyphens."
      ;;
  esac
}

is_allowed_role() {
  local role=${1:-}

  case "$role" in
    analyst|developer|auditor) return 0 ;;
    *) return 1 ;;
  esac
}

current_actor() {
  aws sts get-caller-identity --query Arn --output text 2>/dev/null || printf '%s' "${USER:-unknown}"
}

log_event() {
  local action=$1
  local employee_id=$2
  local role=$3
  local result=$4
  local actor

  actor=$(current_actor)
  printf '%s,%s,%s,%s,%s,%s\n' \
    "$(timestamp_utc)" "$actor" "$action" "$employee_id" "$role" "$result" >> "$AUDIT_LOG"
}

onboard() {
  [ "$#" -eq 2 ] || fail "Onboard requires EMPLOYEE_ID and ROLE."

  local employee_id=$1
  local role=$2
  local active_file
  local archived_file
  local created_at

  validate_employee_id "$employee_id"

  if ! is_allowed_role "$role"; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_INVALID_ROLE"
    fail "Role must be analyst, developer, or auditor."
  fi

  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ -e "$active_file" ] || [ -e "$archived_file" ]; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_DUPLICATE"
    fail "Employee ID already exists in the lifecycle records."
  fi

  created_at=$(timestamp_utc)
  {
    printf 'EMPLOYEE_ID=%s\n' "$employee_id"
    printf 'ROLE=%s\n' "$role"
    printf 'CREATED_AT=%s\n' "$created_at"
  } > "$active_file"

  log_event "ONBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Onboarded %s with role %s.\n' "$employee_id" "$role"
}

command=${1:-}

case "$command" in
  onboard)
    shift
    onboard "$@"
    ;;
  help|-h|--help|"")
    usage
    ;;
  *)
    fail "Unknown command. Run bash accessctl.sh help."
    ;;
esac

What should you compare?

Check the function names, approved roles, audit result strings, and directory variables character by character. Small spelling changes can redirect records or alter the evidence format.

Your onboarding workflow now blocks bad roles and preserves rejected decisions. Next, you will add offboarding that archives records while keeping the lifecycle evidence intact.

Add Safe Audited Offboarding

Your guarded onboarding command now rejects bad roles. It also blocks duplicate identities.

This step adds archive-based offboarding to your Bash toolkit in AWS CloudShell. Deleting an active record would erase context that an investigation needs.

In this step, get ready to:
  • Add role lookup to the lifecycle toolkit.
  • Archive active identities during offboarding.
  • Verify both offboarding outcomes in the CSV audit log.
Build the archive workflow

An offboarding event needs the employee's approved role in its audit row. The new role reader extracts that value from the active record before the record moves.

  • Return to accessctl.sh in the CloudShell editor from earlier.
  • Paste this function directly above onboard():
get_role() {
  local record_file=$1
  local key
  local value

  while IFS='=' read -r key value; do
    if [ "$key" = "ROLE" ]; then
      printf '%s' "$value"
      return 0
    fi
  done < "$record_file"

  return 1
}

What does this function do?

  • The record_file variable holds the record that the function reads.
  • The loop splits each record line at its first = character.
  • The function prints the stored role when it finds the ROLE field.
  • Save accessctl.sh with Cmd+S.
  • Confirm that the script still loads its usage guide by running this command:
bash accessctl.sh help

What should you see?

You should see the toolkit usage guide with the approved roles. This confirms that Bash can parse the new function.

Does the usage guide fail to load?

Check that get_role() has both closing fi and done lines. Confirm that its final closing brace sits above onboard().

Help me check my role reader.

The offboarding function treats an active record as lifecycle evidence. It moves the record into the archive before appending the offboarding timestamp.

  • Paste this function directly below onboard():
offboard() {
  [ "$#" -eq 1 ] || fail "Offboard requires EMPLOYEE_ID."

  local employee_id=$1
  local active_file
  local archived_file
  local role

  validate_employee_id "$employee_id"
  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ ! -f "$active_file" ]; then
    log_event "OFFBOARD" "$employee_id" "unknown" "REJECTED_NOT_ACTIVE"
    fail "Employee is not active."
  fi

  role=$(get_role "$active_file")
  mv "$active_file" "$archived_file"
  printf 'OFFBOARDED_AT=%s\n' "$(timestamp_utc)" >> "$archived_file"

  log_event "OFFBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Offboarded %s and archived the lifecycle record.\n' "$employee_id"
}

How does safe offboarding work?

  • The function validates the synthetic employee ID before building its record paths.
  • An inactive identity produces a rejected audit event with the role recorded as unknown.
  • An active record moves into data/archived before receiving its OFFBOARDED_AT timestamp.
  • A successful request records the original role in the audit log.
  • Save accessctl.sh with Cmd+S.
  • Confirm that Bash can parse the expanded script by running this command:
bash accessctl.sh help

What does this check prove?

You should see the usage guide again. The offboarding function now loads without interrupting the existing command behavior.

Did the script stop at a syntax error?

Compare the closing fi with the closing brace at the bottom of offboard(). Check that the two record paths keep their double quotes.

Help me fix my offboarding function.

A dispatcher connects the command typed in the terminal to the matching function. The new branch sends one employee ID to offboard().

  • Find the onboard) branch near the bottom of accessctl.sh.
  • Paste this branch immediately after the onboard) branch:
  offboard)
    shift
    offboard "$@"
    ;;

How does the route work?

The branch removes the command name with shift. It passes the remaining employee ID to offboard().

  • Save accessctl.sh with Cmd+S.

✔️ Awesome, I've got everything!

Your saved script now includes the role reader, archive workflow, and offboarding route.

ⓧ I'd like to double check the full code

Compare your cumulative accessctl.sh file with this reference.

#!/usr/bin/env bash
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DATA_DIR="$PROJECT_DIR/data"
ACTIVE_DIR="$DATA_DIR/active"
ARCHIVED_DIR="$DATA_DIR/archived"
LOG_DIR="$PROJECT_DIR/logs"
EVIDENCE_DIR="$PROJECT_DIR/evidence"
AUDIT_LOG="$LOG_DIR/audit.csv"

mkdir -p "$ACTIVE_DIR" "$ARCHIVED_DIR" "$LOG_DIR" "$EVIDENCE_DIR"

if [ ! -f "$AUDIT_LOG" ]; then
  printf 'timestamp_utc,actor,action,employee_id,role,result\n' > "$AUDIT_LOG"
fi

timestamp_utc() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  bash accessctl.sh onboard EMPLOYEE_ID ROLE
  bash accessctl.sh offboard EMPLOYEE_ID
  bash accessctl.sh report

Approved roles: analyst, developer, auditor
Use synthetic employee IDs only.
USAGE
}

validate_employee_id() {
  local employee_id=${1:-}

  case "$employee_id" in
    ""|*[!a-z0-9-]*|-*|*-)
      fail "Employee ID must use lowercase letters, numbers, or internal hyphens."
      ;;
  esac
}

is_allowed_role() {
  local role=${1:-}

  case "$role" in
    analyst|developer|auditor) return 0 ;;
    *) return 1 ;;
  esac
}

current_actor() {
  aws sts get-caller-identity --query Arn --output text 2>/dev/null || printf '%s' "${USER:-unknown}"
}

log_event() {
  local action=$1
  local employee_id=$2
  local role=$3
  local result=$4
  local actor

  actor=$(current_actor)
  printf '%s,%s,%s,%s,%s,%s\n' \
    "$(timestamp_utc)" "$actor" "$action" "$employee_id" "$role" "$result" >> "$AUDIT_LOG"
}

get_role() {
  local record_file=$1
  local key
  local value

  while IFS='=' read -r key value; do
    if [ "$key" = "ROLE" ]; then
      printf '%s' "$value"
      return 0
    fi
  done < "$record_file"

  return 1
}

onboard() {
  [ "$#" -eq 2 ] || fail "Onboard requires EMPLOYEE_ID and ROLE."

  local employee_id=$1
  local role=$2
  local active_file
  local archived_file
  local created_at

  validate_employee_id "$employee_id"

  if ! is_allowed_role "$role"; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_INVALID_ROLE"
    fail "Role must be analyst, developer, or auditor."
  fi

  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ -e "$active_file" ] || [ -e "$archived_file" ]; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_DUPLICATE"
    fail "Employee ID already exists in the lifecycle records."
  fi

  created_at=$(timestamp_utc)
  {
    printf 'EMPLOYEE_ID=%s\n' "$employee_id"
    printf 'ROLE=%s\n' "$role"
    printf 'CREATED_AT=%s\n' "$created_at"
  } > "$active_file"

  log_event "ONBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Onboarded %s with role %s.\n' "$employee_id" "$role"
}

offboard() {
  [ "$#" -eq 1 ] || fail "Offboard requires EMPLOYEE_ID."

  local employee_id=$1
  local active_file
  local archived_file
  local role

  validate_employee_id "$employee_id"
  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ ! -f "$active_file" ]; then
    log_event "OFFBOARD" "$employee_id" "unknown" "REJECTED_NOT_ACTIVE"
    fail "Employee is not active."
  fi

  role=$(get_role "$active_file")
  mv "$active_file" "$archived_file"
  printf 'OFFBOARDED_AT=%s\n' "$(timestamp_utc)" >> "$archived_file"

  log_event "OFFBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Offboarded %s and archived the lifecycle record.\n' "$employee_id"
}

command=${1:-}

case "$command" in
  onboard)
    shift
    onboard "$@"
    ;;
  offboard)
    shift
    offboard "$@"
    ;;
  help|-h|--help|"")
    usage
    ;;
  *)
    fail "Unknown command. Run bash accessctl.sh help."
    ;;
esac

What should match?

The cumulative script places get_role() before onboard(). It places offboard() before the dispatcher.

The usage guide keeps report as a placeholder for the next step. The current dispatcher routes onboarding, offboarding, help, and unknown commands.

Route and test offboarding

The first request should archive the active record. Before you run it, where do you expect alex-001.record to exist afterward?

  • Offboard the active identity by running this command:
bash accessctl.sh offboard alex-001

What should you see?

You should see Offboarded alex-001 and archived the lifecycle record. The toolkit has preserved the record while removing it from active status.

Was alex-001 reported as inactive?

Check that data/active/alex-001.record existed before this test. Confirm that the employee ID in the command uses lowercase letters and numbers.

Help me diagnose the missing active record.

The second request is a deliberate safety test. Before you run it, do you expect the toolkit to repeat the move or protect the archived record?

  • Repeat the same offboarding request by running this command:
bash accessctl.sh offboard alex-001

The rejection is expected

You should see ERROR: Employee is not active. The toolkit records REJECTED_NOT_ACTIVE instead of modifying the archived record again.

Inspect the preserved evidence

A safe lifecycle workflow needs proof of the state change. The archive shows what happened to the identity record while the audit log records both decisions.

Before you check, which directory should contain alex-001.record now?

  • Compare the active and archived directories by running this command:
ls data/active data/archived

What should you see?

You should see alex-001.record under data/archived. No record for alex-001 should remain under data/active.

  • Expand the data folder in the CloudShell editor from earlier.
  • Expand the archived folder.
  • Select alex-001.record to inspect the preserved fields.

You should see the original EMPLOYEE_ID, ROLE, and CREATED_AT fields. A new OFFBOARDED_AT field contains the UTC offboarding timestamp.

  • Inspect the complete audit history by running this command:
cat logs/audit.csv

What does the audit history prove?

You should see one OFFBOARD row for alex-001 with the role analyst and the result SUCCESS.

You should also see a second OFFBOARD row with the role unknown and the result REJECTED_NOT_ACTIVE. Each row includes its UTC timestamp and actor.

That is a strong lifecycle safeguard. Your toolkit now revokes active status while preserving the record and every decision around it.

Your offboarding workflow now preserves evidence through successful and rejected requests. Next, you will turn that lifecycle history into a review artifact for operators and auditors.

Generate Evidence and Publish the Portfolio

Your Bash workflow now preserves offboarding evidence. Operators still need one place to review current identities, archived identities, lifecycle decisions, and implementation gaps.

This step turns your local AWS CloudShell output into a human-readable review. You will document its honest FedRAMP learning alignment before publishing the safe source files to GitHub.

In this step, get ready to:
  • Generate an access review from lifecycle records and audit events.
  • Document the toolkit while excluding generated evidence from publication.
  • Publish the three safe portfolio files in a public GitHub repository.
Generate the access review

Raw records and a CSV audit log are useful to scripts. A reviewer needs a summary that combines those sources while stating which safeguards remain outside this learning project.

  • Use the edit editor in CloudShell to return to accessctl.sh.
  • Find the closing brace of the offboard() function.
  • Add this record-counting helper below offboard():
count_records() {
  local directory=$1
  local count=0
  local record

  for record in "$directory"/*.record; do
    [ -e "$record" ] || continue
    count=$((count + 1))
  done

  printf '%s' "$count"
}

What does this helper do?

  • The directory argument identifies the active or archived records to count.
  • The file loop skips an unmatched wildcard when the directory contains no records.
  • The final printf returns the count without adding extra report text.
  • Save accessctl.sh with Cmd+S.
  • Add this identity-row helper immediately below count_records():
write_records() {
  local status=$1
  local directory=$2
  local record
  local file_name
  local employee_id
  local role

  for record in "$directory"/*.record; do
    [ -e "$record" ] || continue
    file_name=${record##*/}
    employee_id=${file_name%.record}
    role=$(get_role "$record")
    printf '%s,%s,%s\n' "$status" "$employee_id" "$role"
  done
}

How are review rows created?

  • The status argument labels each identity as active or archived.
  • The filename becomes the employee ID after the .record suffix is removed.
  • The existing get_role() helper reads the approved role from each lifecycle record.
  • Save accessctl.sh with Cmd+S.
  • Check that the script still loads by running this command:
bash accessctl.sh help

What does this check prove?

The command asks the existing dispatcher for its usage text. Seeing that text confirms Bash can load the two new helper definitions without a syntax error.

You should see the toolkit usage instructions. Your existing lifecycle commands remain available.

Seeing a syntax error?

Check that each helper has one opening brace and one closing brace. Confirm that write_records() sits outside count_records().

Compare every quote around $directory and $record with the snippets above.

Help me find the syntax error in my two Bash report helpers.

  • Find the end of write_records().
  • Add the report generator directly below it:
report() {
  local report_file="$EVIDENCE_DIR/access-review.txt"
  local generated_at

  generated_at=$(timestamp_utc)
  {
    printf 'Access Lifecycle Review\n'
    printf 'Generated UTC: %s\n' "$generated_at"
    printf 'Training data only: yes\n\n'
    printf 'Active records: %s\n' "$(count_records "$ACTIVE_DIR")"
    printf 'Archived records: %s\n\n' "$(count_records "$ARCHIVED_DIR")"
    printf 'status,employee_id,role\n'
    write_records "active" "$ACTIVE_DIR"
    write_records "archived" "$ARCHIVED_DIR"
    printf '\nAudit events:\n'
    while IFS= read -r event; do
      printf '%s\n' "$event"
    done < "$AUDIT_LOG"
    printf '\nFedRAMP 2026 learning alignment:\n'
    printf 'KSI-IAM-AAM: simulated lifecycle actions are automated.\n'
    printf 'KSI-IAM-ELP: approved roles and review evidence are demonstrated, but real resource permissions are not enforced.\n'
    printf 'KSI-MLA-LET: ONBOARD and OFFBOARD event types are defined and recorded.\n'
    printf 'KSI-MLA-RVL: this report supports review, but persistent organizational review is not implemented.\n'
    printf 'KSI-MLA-OSM: not implemented; local CSV is not a centralized, tamper-resistant SIEM.\n'
    printf '\nThis learning project is not a FedRAMP authorization package or proof of compliance.\n'
  } > "$report_file"

  printf 'Evidence report written to %s\n' "$report_file"
}

How does the report stay honest?

The report combines identity counts, status rows, and every audit event into evidence/access-review.txt. It labels all identities as training data.

The mapping describes simulated or partial safeguards. It explicitly states that the local log does not implement a centralized tamper-resistant SIEM.

  • Find the current report) placeholder in the command dispatcher.
  • Replace that placeholder with this route:
  report)
    shift
    [ "$#" -eq 0 ] || fail "Report does not accept additional arguments."
    report
    ;;

What does the report route enforce?

The route removes the command name before checking the remaining arguments. It rejects unexpected input before calling report().

  • Save accessctl.sh with Cmd+S.

Before you run the report, which archived identity and lifecycle decisions do you expect it to include?

  • Generate the access review by running:
bash accessctl.sh report

What does this command create?

The command reads the current lifecycle directories and audit log. It writes a fresh review to evidence/access-review.txt.

You should see a message ending with the report path. That message confirms the evidence file was written.

  • Use the CloudShell editor to open evidence/access-review.txt.
  • Confirm that the review shows zero active records.
  • Confirm that the review shows one archived record.
  • Confirm that archived,alex-001,analyst appears in the identity table.
  • Confirm that the audit section includes the onboarding decisions and offboarding decisions.
  • Confirm that the final mapping states the local CSV is not a centralized tamper-resistant SIEM.

That is the evidence layer working. A reviewer can now connect the archived identity to the decisions that changed its lifecycle state.

Report missing records or events?

Confirm that report() calls write_records once for $ACTIVE_DIR and once for $ARCHIVED_DIR.

Check that the audit loop reads from $AUDIT_LOG. A different path produces an incomplete review.

Help me diagnose missing identity rows or audit events in my access review.

✔️ Awesome, I've got everything!

Your saved accessctl.sh now generates evidence/access-review.txt from the lifecycle records and audit log.

What have you completed?

The toolkit now supports guarded onboarding, archival offboarding, and human-readable evidence generation. Its FedRAMP mapping also records the safeguards that remain unimplemented.

ⓧ I'd like to double check the full code

Compare your saved accessctl.sh with this complete pre-mission version.

#!/usr/bin/env bash
set -eu

PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DATA_DIR="$PROJECT_DIR/data"
ACTIVE_DIR="$DATA_DIR/active"
ARCHIVED_DIR="$DATA_DIR/archived"
LOG_DIR="$PROJECT_DIR/logs"
EVIDENCE_DIR="$PROJECT_DIR/evidence"
AUDIT_LOG="$LOG_DIR/audit.csv"

mkdir -p "$ACTIVE_DIR" "$ARCHIVED_DIR" "$LOG_DIR" "$EVIDENCE_DIR"

if [ ! -f "$AUDIT_LOG" ]; then
  printf 'timestamp_utc,actor,action,employee_id,role,result\n' > "$AUDIT_LOG"
fi

timestamp_utc() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

usage() {
  cat <<'USAGE'
Usage:
  bash accessctl.sh onboard EMPLOYEE_ID ROLE
  bash accessctl.sh offboard EMPLOYEE_ID
  bash accessctl.sh report

Approved roles: analyst, developer, auditor
Use synthetic employee IDs only.
USAGE
}

validate_employee_id() {
  local employee_id=${1:-}

  case "$employee_id" in
    ""|*[!a-z0-9-]*|-*|*-)
      fail "Employee ID must use lowercase letters, numbers, or internal hyphens."
      ;;
  esac
}

is_allowed_role() {
  local role=${1:-}

  case "$role" in
    analyst|developer|auditor) return 0 ;;
    *) return 1 ;;
  esac
}

current_actor() {
  aws sts get-caller-identity --query Arn --output text 2>/dev/null || printf '%s' "${USER:-unknown}"
}

log_event() {
  local action=$1
  local employee_id=$2
  local role=$3
  local result=$4
  local actor

  actor=$(current_actor)
  printf '%s,%s,%s,%s,%s,%s\n' \
    "$(timestamp_utc)" "$actor" "$action" "$employee_id" "$role" "$result" >> "$AUDIT_LOG"
}

get_role() {
  local record_file=$1
  local key
  local value

  while IFS='=' read -r key value; do
    if [ "$key" = "ROLE" ]; then
      printf '%s' "$value"
      return 0
    fi
  done < "$record_file"

  return 1
}

onboard() {
  [ "$#" -eq 2 ] || fail "Onboard requires EMPLOYEE_ID and ROLE."

  local employee_id=$1
  local role=$2
  local active_file
  local archived_file
  local created_at

  validate_employee_id "$employee_id"

  if ! is_allowed_role "$role"; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_INVALID_ROLE"
    fail "Role must be analyst, developer, or auditor."
  fi

  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ -e "$active_file" ] || [ -e "$archived_file" ]; then
    log_event "ONBOARD" "$employee_id" "$role" "REJECTED_DUPLICATE"
    fail "Employee ID already exists in the lifecycle records."
  fi

  created_at=$(timestamp_utc)
  {
    printf 'EMPLOYEE_ID=%s\n' "$employee_id"
    printf 'ROLE=%s\n' "$role"
    printf 'CREATED_AT=%s\n' "$created_at"
  } > "$active_file"

  log_event "ONBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Onboarded %s with role %s.\n' "$employee_id" "$role"
}

offboard() {
  [ "$#" -eq 1 ] || fail "Offboard requires EMPLOYEE_ID."

  local employee_id=$1
  local active_file
  local archived_file
  local role

  validate_employee_id "$employee_id"
  active_file="$ACTIVE_DIR/${employee_id}.record"
  archived_file="$ARCHIVED_DIR/${employee_id}.record"

  if [ ! -f "$active_file" ]; then
    log_event "OFFBOARD" "$employee_id" "unknown" "REJECTED_NOT_ACTIVE"
    fail "Employee is not active."
  fi

  role=$(get_role "$active_file")
  mv "$active_file" "$archived_file"
  printf 'OFFBOARDED_AT=%s\n' "$(timestamp_utc)" >> "$archived_file"

  log_event "OFFBOARD" "$employee_id" "$role" "SUCCESS"
  printf 'Offboarded %s and archived the lifecycle record.\n' "$employee_id"
}

count_records() {
  local directory=$1
  local count=0
  local record

  for record in "$directory"/*.record; do
    [ -e "$record" ] || continue
    count=$((count + 1))
  done

  printf '%s' "$count"
}

write_records() {
  local status=$1
  local directory=$2
  local record
  local file_name
  local employee_id
  local role

  for record in "$directory"/*.record; do
    [ -e "$record" ] || continue
    file_name=${record##*/}
    employee_id=${file_name%.record}
    role=$(get_role "$record")
    printf '%s,%s,%s\n' "$status" "$employee_id" "$role"
  done
}

report() {
  local report_file="$EVIDENCE_DIR/access-review.txt"
  local generated_at

  generated_at=$(timestamp_utc)
  {
    printf 'Access Lifecycle Review\n'
    printf 'Generated UTC: %s\n' "$generated_at"
    printf 'Training data only: yes\n\n'
    printf 'Active records: %s\n' "$(count_records "$ACTIVE_DIR")"
    printf 'Archived records: %s\n\n' "$(count_records "$ARCHIVED_DIR")"
    printf 'status,employee_id,role\n'
    write_records "active" "$ACTIVE_DIR"
    write_records "archived" "$ARCHIVED_DIR"
    printf '\nAudit events:\n'
    while IFS= read -r event; do
      printf '%s\n' "$event"
    done < "$AUDIT_LOG"
    printf '\nFedRAMP 2026 learning alignment:\n'
    printf 'KSI-IAM-AAM: simulated lifecycle actions are automated.\n'
    printf 'KSI-IAM-ELP: approved roles and review evidence are demonstrated, but real resource permissions are not enforced.\n'
    printf 'KSI-MLA-LET: ONBOARD and OFFBOARD event types are defined and recorded.\n'
    printf 'KSI-MLA-RVL: this report supports review, but persistent organizational review is not implemented.\n'
    printf 'KSI-MLA-OSM: not implemented; local CSV is not a centralized, tamper-resistant SIEM.\n'
    printf '\nThis learning project is not a FedRAMP authorization package or proof of compliance.\n'
  } > "$report_file"

  printf 'Evidence report written to %s\n' "$report_file"
}

command=${1:-}

case "$command" in
  onboard)
    shift
    onboard "$@"
    ;;
  offboard)
    shift
    offboard "$@"
    ;;
  report)
    shift
    [ "$#" -eq 0 ] || fail "Report does not accept additional arguments."
    report
    ;;
  help|-h|--help|"")
    usage
    ;;
  *)
    fail "Unknown command. Run bash accessctl.sh help."
    ;;
esac

How should you use this reference?

Check function order, directory names, report text, and dispatcher routes. Keep your generated timestamps and existing audit rows unchanged because those values belong in the runtime files.

Document and protect the portfolio

Your repository needs enough context for someone to understand the business problem and safeguards. It also needs an exclusion boundary that keeps generated identity records, caller details, audit events, and reports local.

  • Use the CloudShell edit editor to create README.md inside ~/access-lifecycle-toolkit.
  • Add the project overview and operating instructions:
# FedRAMP-Aligned Access Lifecycle Evidence Toolkit

This repository contains a Bash learning project that simulates employee onboarding and offboarding with guardrails and audit evidence. It is designed to demonstrate business and security thinking without changing AWS IAM resources.

## Business value

Manual access changes are easy to mistype, repeat, or leave undocumented. This toolkit standardizes approved roles, rejects duplicate requests, archives offboarded records, and creates reviewable evidence.

## Safeguards

- Uses synthetic employee IDs only
- Allows only `analyst`, `developer`, and `auditor`
- Rejects duplicate onboarding
- Rejects offboarding for identities that are not active
- Preserves offboarded records instead of silently deleting them
- Records UTC time, actor, action, employee ID, role, and result
- Excludes generated records and logs from the repository

## Run it

```bash
aws sts get-caller-identity
bash accessctl.sh onboard alex-001 analyst
bash accessctl.sh onboard alex-001 analyst
bash accessctl.sh onboard sam-002 developr
bash accessctl.sh offboard alex-001
bash accessctl.sh report
```

Generated output is stored under `data/`, `logs/`, and `evidence/`.

What does this documentation establish?

The opening sections explain the manual-access problem and the safeguards that address it. The run sequence gives reviewers a compact demonstration path.

The generated-output note separates source code from local identity evidence. That boundary prepares the repository for safe publication.

  • Save README.md with Cmd+S.
  • Confirm that the editor shows the Business value section.
  • Confirm that the editor shows the Safeguards section.

Markdown sections look incomplete?

Check that every heading begins with the same number of hash characters shown above. Confirm that the command list has an opening fence and a closing fence.

Help me check the structure of the first half of my README.

  • Place the remaining documentation below the generated-output sentence:
## FedRAMP 2026 learning alignment

- KSI-IAM-AAM: practices automated account lifecycle handling
- KSI-IAM-ELP: practices approved-role validation and access review
- KSI-MLA-LET: defines lifecycle event types for logging
- KSI-MLA-RVL: creates an artifact for reviewing events and current access
- KSI-MLA-OSM: intentionally not implemented because a local CSV file is not a centralized, tamper-resistant SIEM

Official references:

- https://preview.fedramp.gov/2026/providers/20x/key-security-indicators/identity-and-access-management/
- https://preview.fedramp.gov/2026/providers/20x/key-security-indicators/monitoring-logging-and-auditing/

## Important limitation

This is a control-aligned learning simulation. It is not a FedRAMP authorization package, a production identity system, or proof that an environment is FedRAMP compliant. A production design would use centralized workforce identity, real policy enforcement, protected centralized logs, persistent validation, approvals, and independent assessment.

## Interview talking points

- Why automation reduces inconsistent access decisions
- How allowlisting and duplicate checks create guardrails
- Why offboarding archives evidence instead of silently deleting it
- The difference between generating an audit log and operating a tamper-resistant SIEM
- How this Bash workflow can be extended to AWS IAM Identity Center, SCIM, temporary credentials, and CloudTrail

Why document the limitations?

The alignment section connects each learning safeguard to a named principle. The limitation section prevents the repository from claiming authorization or production readiness.

The interview prompts help you explain the engineering decisions behind the script. They keep the conversation focused on business value and security judgment.

  • Save README.md with Cmd+S.
  • Confirm that the document ends with five interview talking points.
  • Confirm that the limitation rejects any claim of FedRAMP authorization or compliance.

Missing the alignment or limitation sections?

Make sure the second snippet begins on a new line after the generated-output sentence. Check that each KSI entry starts with a Markdown list marker.

Help me find a missing section in my completed README.

✔️ Awesome, I've got everything!

Your README.md explains the workflow, safeguards, learning alignment, limitations, and interview talking points.

What is ready?

The portfolio documentation now explains what the toolkit demonstrates. It also distinguishes a learning simulation from a production authorization package.

ⓧ I'd like to double check the full code

Compare your saved README.md with this complete file.

# FedRAMP-Aligned Access Lifecycle Evidence Toolkit

This repository contains a Bash learning project that simulates employee onboarding and offboarding with guardrails and audit evidence. It is designed to demonstrate business and security thinking without changing AWS IAM resources.

## Business value

Manual access changes are easy to mistype, repeat, or leave undocumented. This toolkit standardizes approved roles, rejects duplicate requests, archives offboarded records, and creates reviewable evidence.

## Safeguards

- Uses synthetic employee IDs only
- Allows only `analyst`, `developer`, and `auditor`
- Rejects duplicate onboarding
- Rejects offboarding for identities that are not active
- Preserves offboarded records instead of silently deleting them
- Records UTC time, actor, action, employee ID, role, and result
- Excludes generated records and logs from the repository

## Run it

```bash
aws sts get-caller-identity
bash accessctl.sh onboard alex-001 analyst
bash accessctl.sh onboard alex-001 analyst
bash accessctl.sh onboard sam-002 developr
bash accessctl.sh offboard alex-001
bash accessctl.sh report
```

Generated output is stored under `data/`, `logs/`, and `evidence/`.

## FedRAMP 2026 learning alignment

- KSI-IAM-AAM: practices automated account lifecycle handling
- KSI-IAM-ELP: practices approved-role validation and access review
- KSI-MLA-LET: defines lifecycle event types for logging
- KSI-MLA-RVL: creates an artifact for reviewing events and current access
- KSI-MLA-OSM: intentionally not implemented because a local CSV file is not a centralized, tamper-resistant SIEM

Official references:

- https://preview.fedramp.gov/2026/providers/20x/key-security-indicators/identity-and-access-management/
- https://preview.fedramp.gov/2026/providers/20x/key-security-indicators/monitoring-logging-and-auditing/

## Important limitation

This is a control-aligned learning simulation. It is not a FedRAMP authorization package, a production identity system, or proof that an environment is FedRAMP compliant. A production design would use centralized workforce identity, real policy enforcement, protected centralized logs, persistent validation, approvals, and independent assessment.

## Interview talking points

- Why automation reduces inconsistent access decisions
- How allowlisting and duplicate checks create guardrails
- Why offboarding archives evidence instead of silently deleting it
- The difference between generating an audit log and operating a tamper-resistant SIEM
- How this Bash workflow can be extended to AWS IAM Identity Center, SCIM, temporary credentials, and CloudTrail

How should you use this reference?

Compare headings, list items, command order, official references, and limitation wording. Preserve the text exactly so the repository communicates the project boundary clearly.

A .gitignore file tells Git-based publishing workflows which local paths should stay outside the repository. These rules protect the generated lifecycle data while leaving your source and documentation available.

  • Use the CloudShell edit editor to create .gitignore inside ~/access-lifecycle-toolkit.
  • Add these exclusion rules:
manual-demo/
data/
logs/
evidence/

What stays out of GitHub?

  • The manual-demo/ rule excludes intentionally inconsistent records.
  • The data/ rule excludes active and archived identity records.
  • The logs/ rule excludes audit events that can contain the AWS caller ARN.
  • The evidence/ rule excludes generated access reviews.
  • Save .gitignore with Cmd+S.
  • Confirm that the editor shows exactly four exclusion paths.

Seeing extra characters in the rules?

Remove bullets and quotation marks from .gitignore. Each line should contain only one directory name followed by a forward slash.

Help me check whether my gitignore rules exclude the four generated directories.

✔️ Awesome, I've got everything!

Your .gitignore protects every generated directory named in the project boundary.

What is protected?

Runtime records, audit events, reports, and the manual demonstration remain in CloudShell. Only the reusable source and documentation move to the public repository.

ⓧ I'd like to double check the full code

Compare your saved .gitignore with this complete file.

manual-demo/
data/
logs/
evidence/

How should you use this reference?

Confirm that all four directory names match exactly. A missing slash or misspelled directory can expose generated project data.

The source files are ready. Downloading them individually keeps the evidence boundary visible before anything reaches GitHub.

  • Choose Actions in CloudShell.
  • Choose Download file.
  • Enter ~/access-lifecycle-toolkit/accessctl.sh as the file path.
  • Choose Download.

Your Mac now has the toolkit script. Repeat the same controlled download for each remaining portfolio file.

  • Choose Actions in CloudShell.
  • Choose Download file.
  • Enter ~/access-lifecycle-toolkit/README.md as the file path.
  • Choose Download.

Keep generated evidence local

Download only the three named portfolio files. Leave manual-demo, data, logs, and evidence inside CloudShell.

  • Choose Actions in CloudShell.
  • Choose Download file.
  • Enter ~/access-lifecycle-toolkit/.gitignore as the file path.
  • Choose Download.

You should now have accessctl.sh, README.md, and .gitignore on your Mac. No generated identity or audit data has left CloudShell.

Publish the repository on GitHub

A public repository lets reviewers inspect the automation and its documentation. The upload stays limited to reusable project files so the portfolio demonstrates safe evidence handling as well as scripting.

  • Navigate to the GitHub website in your browser.
  • Sign in to your GitHub account.
  • Open the upper-right menu.
  • Choose New repository.
  • Choose your account as the repository owner.
  • Enter fedramp-access-lifecycle-toolkit as the repository name.
  • Set the repository visibility to public.
  • Leave the optional initialization choices unselected.

The empty repository keeps your first commit focused on the files you reviewed. You remain in control of exactly what gets published.

  • Click Create repository.

Your public repository now exists without generated starter files. The next upload creates its first meaningful snapshot.

  • Click Add file in the repository.
  • Click Upload files.
  • Choose the downloaded accessctl.sh file.
  • Choose the downloaded README.md file.
  • Choose the downloaded .gitignore file.
  • Enter Publish access lifecycle evidence toolkit as the commit message.
  • Click Propose changes.

Can't see the gitignore file?

The leading dot can make .gitignore less visible in a Mac file picker. Use the file picker's control for showing hidden files before selecting it.

Help me upload a dot-prefixed gitignore file from my Mac to GitHub.

Before you inspect the repository, which three files should appear in its file list?

  • Review the file list in fedramp-access-lifecycle-toolkit.
  • Confirm that the repository contains only accessctl.sh, README.md, and .gitignore.
  • Confirm that manual-demo is absent.
  • Confirm that data is absent.
  • Confirm that logs is absent.
  • Confirm that evidence is absent.

You have published the toolkit without publishing its generated identity evidence. The repository now shows both your lifecycle automation and your judgment about safe disclosure.

Repository showing an extra file?

Compare the repository list with the three approved filenames. Generated folders or local evidence do not belong in this public repository.

Confirm that the commit finished after you selected Propose changes. An unfinished upload leaves the repository empty.

Help me verify that my public repository contains only the three approved portfolio files.

Secret mission

Complete the Mover Workflow

An employee can move into a new role long before they leave an organization. Add an audited role-change command that validates the request, preserves the original onboarding timestamp, records the transition, and completes the simulated joiner-mover-leaver lifecycle.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ongoing cost is $0 because AWS CloudShell has no additional charge while GitHub Free costs $0 per month.

Resources you used:

  • Regional AWS CloudShell workspace at ~/access-lifecycle-toolkit containing source files, synthetic records, audit events, and generated evidence.
  • Public GitHub repository named fedramp-access-lifecycle-toolkit containing source code, documentation, and the Secret Mission commit.

Keep everything running

No action is needed. Choose this option if you want to retain the finished toolkit for Part 2.

  • Leave ~/access-lifecycle-toolkit in AWS CloudShell.
  • Leave fedramp-access-lifecycle-toolkit public on GitHub.
  • Return to the same AWS Region when you continue working in CloudShell.

Pause - I'll come back to this later

Closing your browser tabs pauses your work without deleting anything. No toolkit process keeps running after a command finishes.

  • Close the AWS CloudShell browser tab.
  • Close the GitHub browser tab.
  • Return to the same AWS Region when you resume the project.

Delete - I don't want to use this again

Removing these resources is permanent. The checks below help you confirm the CloudShell path before deleting the workspace.

  • Confirm that CloudShell is still inside ~/access-lifecycle-toolkit by running this command:
pwd

Why Check the Path?

The command prints your current location. The output should end with /access-lifecycle-toolkit.

You have confirmed that the terminal points to the project workspace created during this project.

  • Remove only the ~/access-lifecycle-toolkit workspace by running these commands:
cd ~
rm -rf ~/access-lifecycle-toolkit
ls

What Do These Commands Do?

  • The first command moves CloudShell out of the project directory.
  • The second command permanently removes only the named project directory.
  • The final command lists the remaining contents of your CloudShell home directory.

You should no longer see access-lifecycle-toolkit in the directory listing.

Repository deletion also removes the published files and commit history. Continue only if you no longer need the GitHub copy.

  • Switch back to the fedramp-access-lifecycle-toolkit repository on GitHub.
  • Click Settings in the repository navigation.
  • Select General in the settings sidebar.
  • Scroll to Danger Zone to locate the repository deletion controls.
  • Click Delete this repository.
  • Complete the explicit confirmation steps to permanently delete the repository.
  • Return to the repository URL to confirm that the repository is no longer available.

Nice Work!

Nice Work!

You did it! Your Bash toolkit now controls the full joiner-mover-leaver lifecycle with traceable evidence. Your public GitHub repository presents the result as a documented FedRAMP-aligned learning simulation.

You've learned how to:

  • Built a guarded Bash onboarding workflow for synthetic employee records. The role allowlist rejects mistyped roles. Duplicate protection preserves the first lifecycle record.
  • Created auditable lifecycle evidence through safe offboarding. Active records move into data/archived. Every success or rejection writes a UTC event to logs/audit.csv. The generated access review shows current state alongside honest implementation gaps.
  • Published a documented portfolio repository containing accessctl.sh, README.md, and .gitignore. Generated records remain outside the repository. Audit logs remain outside the repository. Evidence files remain outside the repository.
  • Completed the Secret Mission by adding audited role changes. The change_role function preserves CREATED_AT while safely replacing the active record. Each ROLE_CHANGE event records the transition in old_role->new_role form.

Ready to quiz yourself?