Skip to content

Commit c95fe38

Browse files
nickking-brtkvanzuijlen
authored andcommitted
feat: add ability to delegate authorization to external sources (runatlantis#4864)
Signed-off-by: kvanzuijlen <[email protected]>
1 parent 85c9d60 commit c95fe38

24 files changed

+817
-86
lines changed

runatlantis.io/.vitepress/sidebars.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const en = [
3737
{ text: "Post Workflow Hooks", link: "/docs/post-workflow-hooks" },
3838
{ text: "Conftest Policy Checking", link: "/docs/policy-checking" },
3939
{ text: "Custom Workflows", link: "/docs/custom-workflows" },
40+
{ text: "Repo and Project Permissions", link: "/docs/repo-and-project-permissions" },
4041
{ text: "Repo Level atlantis.yaml", link: "/docs/repo-level-atlantis-yaml" },
4142
{ text: "Upgrading atlantis.yaml", link: "/docs/upgrading-atlantis-yaml" },
4243
{ text: "Command Requirements", link: "/docs/command-requirements" },
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Repo and Project Permissions
2+
3+
Sometimes it may be necessary to limit who can run which commands, such as
4+
restricting who can apply changes to production, while allowing more
5+
freedom for dev and test environments.
6+
7+
## Authorization Workflow
8+
9+
Atlantis performs two authorization checks to verify a user has the necessary
10+
permissions to run a command:
11+
12+
1. After a command has been validated, before var files, repo metadata, or
13+
pull request statuses are checked and validated.
14+
2. After pre workflow hooks have run, repo configuration processed, and
15+
affected projects determined.
16+
17+
::: tip Note
18+
The first check should be considered as validating the user for a repository
19+
as a whole, while the second check is for validating a user for a specific
20+
project in that repo.
21+
:::
22+
23+
### Why check permissions twice?
24+
The way Atlantis is currently designed, not all relevant information may be
25+
available when the first check happens. In particular, affected projects
26+
are not known because pre workflow hooks haven't run yet, so repositories
27+
that use hooks to generate or modify repo configurations won't know which
28+
projects to check permissions for.
29+
30+
## Configuring permissions
31+
32+
Atlantis has two options for allowing instance administrators to configure
33+
permissions.
34+
35+
### Server option [`--gh-team-allowlist`](server-configuration.md#gh-team-allowlist)
36+
37+
The `--gh-team-allowlist` option allows administrators to configure a global
38+
set of permissions that apply to all repositories. For most use cases, this
39+
should be sufficient.
40+
41+
### External command
42+
43+
For administrators that require more granular and specific permission
44+
definitions, an external command can be defined in the [server side repo
45+
configuration](server-side-repo-config.md#teamauthz). This command will receive
46+
information about the command, repo, project, and GitHub teams the user is a
47+
member of, allowing administrators to integrate the permissions validation
48+
with other systems or business requirements. An example would be allowing
49+
users to apply changes to lower environments like dev and test environments
50+
while restricting changes to production or other sensitive environments.
51+
52+
::: warning
53+
These options are mutually exclusive. If an external command is defined,
54+
the `--gh-team-allowlist` option is ignored.
55+
:::
56+
57+
## Example
58+
59+
### Restrict production changes
60+
This example shows a simple example of how a script could be used to restrict
61+
production changes to a specific team, while allowing anyone to work on other
62+
environments. For brevity, this example assumes each user is a member of a
63+
single team.
64+
65+
`server-side-repo-config.yaml`
66+
```yaml
67+
team_authz:
68+
command: "/scripts/example.sh"
69+
```
70+
71+
`example.sh`
72+
```shell
73+
#!/bin/bash
74+
75+
# Define name of team allowed to make production changes
76+
PROD_TEAM="example-org/prod-deployers"
77+
78+
# Set variables from command-line arguments for convenience
79+
COMMAND="$1"
80+
REPO="$2"
81+
TEAM="$3"
82+
83+
# Check if we are running the 'apply' command on prod
84+
if [ "${COMMAND}" == "apply" -a "${PROJECT_NAME}" == "prod" ]
85+
then
86+
# Only the prod team can make this change
87+
if [ "${TEAM}" == "${PROD_TEAM}" ]
88+
then
89+
echo "pass"
90+
exit 0
91+
fi
92+
93+
# Print reason for failing and exit
94+
echo "user \"${USER_NAME}\" must be a member of \"${PROD_TEAM}\" to apply changes to production."
95+
exit 0
96+
fi
97+
98+
# Any other command and environment is okay
99+
echo "pass"
100+
exit 0
101+
```
102+
## Reference
103+
104+
### External Command Execution
105+
106+
External commands are executed on every authorization check with arguments and
107+
environment variables containing context about the command being checked. The
108+
command is executed using the following format:
109+
110+
```shell
111+
external_command [external_args...] atlantis_command repo [teams...]
112+
```
113+
114+
| Key | Optional | Description |
115+
|--------------------|----------|-------------------------------------------------------------------------------------------|
116+
| `external_command` | no | Command defined in [server side repo configuration](server-side-repo-config.md) |
117+
| `external_args` | yes | Command arguments defined in [server side repo configuration](server-side-repo-config.md) |
118+
| `atlantis_command` | no | The atlantis command being run (`plan`, `apply`, etc) |
119+
| `repo` | no | The full name of the repo being executed (format: `owner/repo_name`) |
120+
| `teams` | yes | A list of zero or more teams of the user executing the command |
121+
122+
123+
The following environment variables are passed to the command on every execution:
124+
125+
| Key | Description |
126+
|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
127+
| `BASE_REPO_NAME` | Name of the repository that the pull request will be merged into, ex. `atlantis`. |
128+
| `BASE_REPO_OWNER` | Owner of the repository that the pull request will be merged into, ex. `runatlantis`. |
129+
| `COMMAND_NAME` | The name of the command that is being executed, i.e. `plan`, `apply` etc. |
130+
| `USER_NAME` | Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. |
131+
132+
133+
The following environment variables are also passed to the command when checking project authorization:
134+
135+
| Key | Description |
136+
|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
137+
| `BASE_BRANCH_NAME` | Name of the base branch of the pull request (the branch that the pull request is getting merged into) |
138+
| `COMMENT_ARGS` | Any additional flags passed in the comment on the pull request. Flags are separated by commas and every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`. |
139+
| `HEAD_REPO_NAME` | Name of the repository that is getting merged into the base repository, ex. `atlantis`. |
140+
| `HEAD_REPO_OWNER` | Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. |
141+
| `HEAD_BRANCH_NAME` | Name of the head branch of the pull request (the branch that is getting merged into the base) |
142+
| `HEAD_COMMIT` | The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. |
143+
| `PROJECT_NAME` | Name of the project the command is being executed on |
144+
| `PULL_NUM` | Pull request number or ID, ex. `2`. |
145+
| `PULL_URL` | Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. |
146+
| `PULL_AUTHOR` | Username of the pull request author, ex. `acme-user`. |
147+
| `REPO_ROOT` | The absolute path to the root of the cloned repository. |
148+
| `REPO_REL_PATH` | Path to the project relative to `REPO_ROOT` |
149+
150+
### External Command Result Handling
151+
152+
Atlantis determines if a user is authorized to run the requested command by
153+
checking if the external command exited with code `0` and if the last line
154+
of output is `pass`.
155+
156+
```
157+
# Psuedo-code of Atlantis evaluation of external commands
158+
159+
user_authorized =
160+
external_command.exit_code == 0
161+
&& external_command.output.last_line == 'pass'
162+
```
163+
164+
::: tip
165+
* A non-zero exit code means the command failed to evaluate the request for
166+
some reason (bad configuration, missing dependencies, solar flares, etc).
167+
* If the command was able to run successfully, but determined the user is not
168+
authorized, it should still exit with code `0`.
169+
* The command output could contain the reasoning for the authorization failure.
170+
:::

runatlantis.io/docs/server-side-repo-config.md

+14-6
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,13 @@ Each servers handle different repository config files.
480480

481481
### Top-Level Keys
482482

483-
| Key | Type | Default | Required | Description |
484-
|-----------|-------------------------------------------------------|-----------|----------|---------------------------------------------------------------------------------------|
485-
| repos | array[[Repo](#repo)] | see below | no | List of repos to apply settings to. |
486-
| workflows | map[string: [Workflow](custom-workflows.md#workflow)] | see below | no | Map from workflow name to workflow. Workflows override the default Atlantis commands. |
487-
| policies | Policies. | none | no | List of policy sets to run and associated metadata |
488-
| metrics | Metrics. | none | no | Map of metric configuration |
483+
| Key | Type | Default | Required | Description |
484+
|------------|-------------------------------------------------------|-----------|----------|---------------------------------------------------------------------------------------|
485+
| repos | array[[Repo](#repo)] | see below | no | List of repos to apply settings to. |
486+
| workflows | map[string: [Workflow](custom-workflows.md#workflow)] | see below | no | Map from workflow name to workflow. Workflows override the default Atlantis commands. |
487+
| policies | Policies. | none | no | List of policy sets to run and associated metadata |
488+
| metrics | Metrics. | none | no | Map of metric configuration |
489+
| team_authz | [TeamAuthz](#teamauthz) | none | no | Configuration of team permission checking |
489490

490491
::: tip A Note On Defaults
491492

@@ -633,3 +634,10 @@ mode: on_apply
633634
| Key | Type | Default | Required | Description |
634635
| -------- | ------ | ------- | -------- | -------------------------------------- |
635636
| endpoint | string | none | yes | path to metrics endpoint |
637+
638+
### TeamAuthz
639+
640+
| Key | Type | Default | Required | Description |
641+
|---------|----------|---------|----------|---------------------------------------------|
642+
| command | string | none | yes | full path to external authorization command |
643+
| args | []string | none | no | optional arguments to pass to `command` |

server/core/config/parser_validator_test.go

+31-1
Original file line numberDiff line numberDiff line change
@@ -1326,10 +1326,13 @@ func TestParseGlobalCfg(t *testing.T) {
13261326
},
13271327
},
13281328
Workflows: defaultCfg.Workflows,
1329+
TeamAuthz: valid.TeamAuthz{
1330+
Args: make([]string, 0),
1331+
},
13291332
},
13301333
},
13311334
"disable repo locks": {
1332-
input: `repos:
1335+
input: `repos:
13331336
- id: /.*/
13341337
repo_locks:
13351338
mode: disabled`,
@@ -1342,6 +1345,9 @@ func TestParseGlobalCfg(t *testing.T) {
13421345
},
13431346
},
13441347
Workflows: defaultCfg.Workflows,
1348+
TeamAuthz: valid.TeamAuthz{
1349+
Args: make([]string, 0),
1350+
},
13451351
},
13461352
},
13471353
"no workflows key": {
@@ -1362,6 +1368,9 @@ workflows:
13621368
"default": defaultCfg.Workflows["default"],
13631369
"name": defaultWorkflow("name"),
13641370
},
1371+
TeamAuthz: valid.TeamAuthz{
1372+
Args: make([]string, 0),
1373+
},
13651374
},
13661375
},
13671376
"workflow stages empty": {
@@ -1380,6 +1389,9 @@ workflows:
13801389
"default": defaultCfg.Workflows["default"],
13811390
"name": defaultWorkflow("name"),
13821391
},
1392+
TeamAuthz: valid.TeamAuthz{
1393+
Args: make([]string, 0),
1394+
},
13831395
},
13841396
},
13851397
"workflow steps empty": {
@@ -1403,6 +1415,9 @@ workflows:
14031415
"default": defaultCfg.Workflows["default"],
14041416
"name": defaultWorkflow("name"),
14051417
},
1418+
TeamAuthz: valid.TeamAuthz{
1419+
Args: make([]string, 0),
1420+
},
14061421
},
14071422
},
14081423
"all keys specified": {
@@ -1509,6 +1524,9 @@ policies:
15091524
},
15101525
},
15111526
},
1527+
TeamAuthz: valid.TeamAuthz{
1528+
Args: make([]string, 0),
1529+
},
15121530
},
15131531
},
15141532
"id regex with trailing slash": {
@@ -1526,6 +1544,9 @@ repos:
15261544
Workflows: map[string]valid.Workflow{
15271545
"default": defaultCfg.Workflows["default"],
15281546
},
1547+
TeamAuthz: valid.TeamAuthz{
1548+
Args: make([]string, 0),
1549+
},
15291550
},
15301551
},
15311552
"referencing default workflow": {
@@ -1545,6 +1566,9 @@ repos:
15451566
Workflows: map[string]valid.Workflow{
15461567
"default": defaultCfg.Workflows["default"],
15471568
},
1569+
TeamAuthz: valid.TeamAuthz{
1570+
Args: make([]string, 0),
1571+
},
15481572
},
15491573
},
15501574
"redefine default workflow": {
@@ -1620,6 +1644,9 @@ workflows:
16201644
},
16211645
},
16221646
},
1647+
TeamAuthz: valid.TeamAuthz{
1648+
Args: make([]string, 0),
1649+
},
16231650
},
16241651
},
16251652
}
@@ -1841,6 +1868,9 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) {
18411868
},
18421869
},
18431870
},
1871+
TeamAuthz: valid.TeamAuthz{
1872+
Args: make([]string, 0),
1873+
},
18441874
},
18451875
},
18461876
}

server/core/config/raw/global_cfg.go

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type GlobalCfg struct {
1717
Workflows map[string]Workflow `yaml:"workflows" json:"workflows"`
1818
PolicySets PolicySets `yaml:"policies" json:"policies"`
1919
Metrics Metrics `yaml:"metrics" json:"metrics"`
20+
TeamAuthz TeamAuthz `yaml:"team_authz" json:"team_authz"`
2021
}
2122

2223
// Repo is the raw schema for repos in the server-side repo config.
@@ -161,6 +162,7 @@ func (g GlobalCfg) ToValid(defaultCfg valid.GlobalCfg) valid.GlobalCfg {
161162
Workflows: workflows,
162163
PolicySets: g.PolicySets.ToValid(),
163164
Metrics: g.Metrics.ToValid(),
165+
TeamAuthz: g.TeamAuthz.ToValid(),
164166
}
165167
}
166168

server/core/config/raw/team_authz.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package raw
2+
3+
import "github.com/runatlantis/atlantis/server/core/config/valid"
4+
5+
type TeamAuthz struct {
6+
Command string `yaml:"command" json:"command"`
7+
Args []string `yaml:"args" json:"args"`
8+
}
9+
10+
func (t *TeamAuthz) ToValid() valid.TeamAuthz {
11+
var v valid.TeamAuthz
12+
v.Command = t.Command
13+
v.Args = make([]string, 0)
14+
if t.Args != nil {
15+
v.Args = append(v.Args, t.Args...)
16+
}
17+
18+
return v
19+
}

server/core/config/valid/global_cfg.go

+4
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type GlobalCfg struct {
4747
Workflows map[string]Workflow
4848
PolicySets PolicySets
4949
Metrics Metrics
50+
TeamAuthz TeamAuthz
5051
}
5152

5253
type Metrics struct {
@@ -249,6 +250,9 @@ func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg {
249250
Workflows: map[string]Workflow{
250251
DefaultWorkflowName: defaultWorkflow,
251252
},
253+
TeamAuthz: TeamAuthz{
254+
Args: make([]string, 0),
255+
},
252256
}
253257
}
254258

server/core/config/valid/global_cfg_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ func TestNewGlobalCfg(t *testing.T) {
8989
Workflows: map[string]valid.Workflow{
9090
"default": expDefaultWorkflow,
9191
},
92+
TeamAuthz: valid.TeamAuthz{
93+
Args: make([]string, 0),
94+
},
9295
}
9396

9497
cases := []struct {
+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package valid
2+
3+
type TeamAuthz struct {
4+
Command string `yaml:"command" json:"command"`
5+
Args []string `yaml:"args" json:"args"`
6+
}

0 commit comments

Comments
 (0)