Atlantis: Enabling Different Policy Approvers per Directory in a Monorepo
version 0.1, 2026-04-29
|
Note
|
No AI was used to write this! |
In this document I go over what I had to do to enable policy approvals in a monorepo setup. I need different approvers depending on the directory within the monorepo, rather than a single global list.
├── test-collection-1 # owned by user_a
│ └── test-component-1
│ └── terraform
│ ├── main.tf
│ └── ...
└── test-collection-2 # owned by user_b
└── test-component-1
└── terraform
├── main.tf
└── ...
In the document, I intermix the walk-through with what I would like to see done in Atlantis to make the setup easier. Finally, I link to existing open issues that relate to this work.
Enabling Different Policy Approvers per Directory
The Atlantis Policy Configuration setup is done server-side. This is the first downside with it. It is a global setup that applies to all repos managed by a given instance.
Natively, Atlantis seems to support policy_sets, which might lead you to think you can have a different policy set per directory with a different set of approvers each.
In practice, all policy sets are run for all changes and you need them all approved, what this feature might give you is the ability to have different teams focusing on reviewing a subset of the policies.
After fumbling around with the policy sets, I finally found that Atlantis allows you to control permissions with external commands.
The external command is defined under the team_authz: command: "/path/to/command" in the config as shown below:
policies:
owners:
users:
- user_a # owns a given directory in the monorepo
- user_b # owns a given directory in the monorepo
teams:
# SRE Engineers own everything by default
# Using the org does not work to define team ownership
# - my-gh-org/sre-engineers-na
- sre-engineers-na
policy_sets:
- name: all_policies
path: $PATH_TO_WORKSPACE/policy
source: local
team_authz:
command: /etc/atlantis-scripts/authz.sh
|
Note
|
I believe that you might need to add all the possible approvers in your config above, user_a and user_b, but I haven’t fully tested if that is true or if as long as the authz.sh script knows how to handle the user, they don’t need to be part of that list.
|
I mount the authz.sh (AuthZ) script with a ConfigMap.
Initially, I tried to make the AuthZ script relative to the repo but it only resolves absolute paths, it doesn’t resolve Env vars like $PULL_NUM.
This is unlike the pre_workflow_hooks scripts.
I use one to generate the atlantis.yaml for the repo, generate_projects.sh, to bypass having to define it globally:
repos:
- id: github.com/my-gh-org/atlantis-test
allowed_overrides:
- workflow
- repo_locking
- delete_source_branch_on_merge
- import_requirements
- plan_requirements
- apply_requirements
- silence_pr_comments
# Ensure policy checks can run after atlantis plan, regardless of who runs the plan command.
- policy_check
allow_custom_workflows: true
apply_requirements:
- approved
- mergeable
- undiverged
pre_workflow_hooks:
- run: /atlantis-data/repos/my-gh-org/atlantis-test/${PULL_NUM}/default/atlantis/generate_projects.sh
description: Generate project configs
Doing that would have allowed me to define the policies per target repo and without the use of a global script.
|
Note
|
In the example above, I added policy_check as one of the repos allowed_overrides, otherwise this won’t work.
|
As a workaround, I ended up implementing a JSON file in each repo that controls my policy intent:
{
"*": {
"users": [],
"teams": [
"my-gh-org/sre-engineers-na"
]
},
"test-collection-1": {
"users": [
"user_a"
],
"teams": []
},
"test-collection-2": {
"users": [
"user_b"
],
"teams": []
}
}
|
Note
|
The AuthZ script gets a list of Github teams for the current user. The team here includes the GitHub Org, unlike in the server side config. |
The global AuthZ will read the atlantis-policy_owners.json file in each repo and decide if the user is allowed to run a command or not.
The atlantis-policy_owners.json file is protected by CODEOWNERS in the repos I deploy it to, so that it doesn’t get modified in a given PR without proper approval.
Below is my actual AuthZ script controlling policy approval permissions:
#!/bin/bash
# Write to the container's stdout, use /proc/1/fd/2 for stderr
LOG_FILE="/proc/1/fd/1"
if [[ "$(uname)" == "Darwin" ]]; then
LOG_FILE="/dev/stderr"
fi
# log outputs a structured JSON log line to $LOG_FILE.
# Usage: log <level> [key value]...
log() {
local msg="$1"
shift
# Alpine date command doesn't support ns, hardcode the timestamp to 000z
local json
json=$(jq -nc \
--arg level "info" \
--arg ts "$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" \
--arg caller "authz.sh" \
--arg msg "$msg" \
--arg command "$COMMAND" \
--arg comment_args "$COMMENT_ARGS" \
--arg repo "$REPO" \
--arg base_repo_name "$BASE_REPO_NAME" \
--arg base_repo_owner "$BASE_REPO_OWNER" \
--arg user "$USER_NAME" \
--arg teams "${TEAMS[*]}" \
--arg head_branch "$HEAD_BRANCH_NAME" \
--arg project "$PROJECT_NAME" \
--arg pull_num "$PULL_NUM" \
--arg pull_url "$PULL_URL" \
--arg pull_author "$PULL_AUTHOR" \
--arg repo_root "$REPO_ROOT" \
--arg repo_rel_path "$REPO_REL_PATH" \
'{
level: $level,
ts: $ts,
caller: $caller,
msg: $msg,
json: {
command: $command,
comment_args: $comment_args,
repo: $repo,
base_repo_name: $base_repo_name,
base_repo_owner: $base_repo_owner,
user: $user,
teams: $teams,
head_branch: $head_branch,
project: $project,
pull: $pull_num,
pull_url: $pull_url,
pull_author: $pull_author,
repo_root: $repo_root,
path: $repo_rel_path
}
}')
# Append optional extra key-value pairs
while [[ $# -ge 2 ]]; do
json=$(echo "$json" | jq -c --arg k "$1" --arg v "$2" '.json += {($k): $v}')
shift 2
done
echo "$json" >>"$LOG_FILE"
}
# Set variables from command-line arguments for convenience
COMMAND="$1"
REPO="$2"
TEAMS=("${@:3}")
# Always allow plans and applies to run for any user.
# Applies only run if there are no pending policy checks so this is safe.
# Additionally, atlantis has command requirements [1] that need to pass,
# for example, mergeable, that ensure that approvals are already met.
#
# [1] https://www.runatlantis.io/docs/command-requirements.html
if [[ $COMMAND == "plan" || $COMMAND == "apply" || $COMMAND == "policy_check" || $COMMAND == "unlock" ]]; then
log "allow: $COMMAND $COMMENT_ARGS"
echo "pass"
exit 0
fi
POLICY_FILE="$REPO_ROOT/atlantis-policy_owners.json"
if [[ $COMMAND == "approve_policies" ]]; then
# The auth workflow runs every command twice, the first time we allow any command, the second we validate
# https://www.runatlantis.io/docs/repo-and-project-permissions.html#authorization-workflow
if [[ "$PULL_NUM" == "0" ]]; then
log "allow: pre-hooks run of $COMMAND $COMMENT_ARGS"
echo "pass"
exit 0
elif [[ -f "$POLICY_FILE" ]]; then
# Expected format:
#
# {
# "path-to-collection-from-repo-root": {
# "users": ["user-a"],
# "teams": ["team-a"],
# }
# }
# Iterate over each path defined in the policy file
while read -r policy_path; do
# Check if REPO_REL_PATH is inside the policy path
if [[ "$REPO_REL_PATH" == "$policy_path"/* || "$REPO_REL_PATH" == "$policy_path" || "$policy_path" == "*" ]]; then
# Check TEAMS against the teams list
for team in "${TEAMS[@]}"; do
if jq -e --arg t "$team" '.["'"$policy_path"'"].teams | index($t)' "$POLICY_FILE" >/dev/null 2>&1; then
log "allow: run $COMMAND, team match $team -> $policy_path" "team" "$team" "policy_path" "$policy_path"
echo "pass"
exit 0
fi
done
# Check USER_NAME against the users list
if jq -e --arg u "$USER_NAME" '.["'"$policy_path"'"].users | index($u)' "$POLICY_FILE" >/dev/null 2>&1; then
log "allow: run $COMMAND, user match $USER_NAME -> $policy_path" "user" "$USER_NAME" "policy_path" "$policy_path"
echo "pass"
exit 0
fi
fi
done < <(jq -r 'keys[]' "$POLICY_FILE")
fi
fi
log "fail: $USER_NAME is not authorized to run $COMMAND $COMMENT_ARGS. Check atlantis-policy_owners.json for details"
echo "fail: $USER_NAME is not authorized to run $COMMAND $COMMENT_ARGS. Check atlantis-policy_owners.json for details"
exit 0
The log output is structured following the current Atlantis log structure. When running locally (in Darwin) I send the logs to stderr, otherwise to the container’s stdout so that it gets picked up by my logging framework.
This works as intended at a high level:
-
A developer opens a PR.
-
The policy checks run and there is a change that requires policy approval.
-
Only members of the
my-gh-org/sre-engineers-nateam or the owner of the directory are allowed to approve policies. -
If one of those approves the policies, and the PR is in a mergeable state, the developer can run
atlantis apply. -
If the developer tries to run
atlantis applybefore policy approval, it will be blocked by Atlantis. -
If someone runs
approve_policieswithout permissions, Atlantis blocks any subsequent applies.
However, I found a presentation bug in Atlantis for that last case, if someone runs approve_policies and the AuthZ script disallows them from it, Atlantis will clear the required atlantis/policy_check Github Status Check with the following messages:
events/approve_policies_command_runner.go:65 determined there was no project to run approve_policies in github/client.go:959 Updating GitHub Check status for 'atlantis/policy_check' to 'success'
Atlantis still prevents the apply, but it would be nice if the failing check was still there.
Existing GitHub Issues
Here are some existing issues that are related. I haven’t yet created an issue for the bug I describe above.