Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to delegate authorization to external sources #4864

Merged
merged 4 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions runatlantis.io/.vitepress/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const en = [
{ text: "Post Workflow Hooks", link: "/docs/post-workflow-hooks" },
{ text: "Conftest Policy Checking", link: "/docs/policy-checking" },
{ text: "Custom Workflows", link: "/docs/custom-workflows" },
{ text: "Repo and Project Permissions", link: "/docs/repo-and-project-permissions" },
{ text: "Repo Level atlantis.yaml", link: "/docs/repo-level-atlantis-yaml" },
{ text: "Upgrading atlantis.yaml", link: "/docs/upgrading-atlantis-yaml" },
{ text: "Command Requirements", link: "/docs/command-requirements" },
Expand Down
170 changes: 170 additions & 0 deletions runatlantis.io/docs/repo-and-project-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Repo and Project Permissions

Sometimes it may be necessary to limit who can run which commands, such as
restricting who can apply changes to production, while allowing more
freedom for dev and test environments.

## Authorization Workflow

Atlantis performs two authorization checks to verify a user has the necessary
permissions to run a command:

1. After a command has been validated, before var files, repo metadata, or
pull request statuses are checked and validated.
2. After pre workflow hooks have run, repo configuration processed, and
affected projects determined.

::: tip Note
The first check should be considered as validating the user for a repository
as a whole, while the second check is for validating a user for a specific
project in that repo.
:::

### Why check permissions twice?
The way Atlantis is currently designed, not all relevant information may be
available when the first check happens. In particular, affected projects
are not known because pre workflow hooks haven't run yet, so repositories
that use hooks to generate or modify repo configurations won't know which
projects to check permissions for.

## Configuring permissions

Atlantis has two options for allowing instance administrators to configure
permissions.

### Server option [`--gh-team-allowlist`](server-configuration.md#gh-team-allowlist)

The `--gh-team-allowlist` option allows administrators to configure a global
set of permissions that apply to all repositories. For most use cases, this
should be sufficient.

### External command

For administrators that require more granular and specific permission
definitions, an external command can be defined in the [server side repo
configuration](server-side-repo-config.md#teamauthz). This command will receive
information about the command, repo, project, and GitHub teams the user is a
member of, allowing administrators to integrate the permissions validation
with other systems or business requirements. An example would be allowing
users to apply changes to lower environments like dev and test environments
while restricting changes to production or other sensitive environments.

::: warning
These options are mutually exclusive. If an external command is defined,
the `--gh-team-allowlist` option is ignored.
:::

## Example

### Restrict production changes
This example shows a simple example of how a script could be used to restrict
production changes to a specific team, while allowing anyone to work on other
environments. For brevity, this example assumes each user is a member of a
single team.

`server-side-repo-config.yaml`
```yaml
team_authz:
command: "/scripts/example.sh"
```

`example.sh`
```shell
#!/bin/bash

# Define name of team allowed to make production changes
PROD_TEAM="example-org/prod-deployers"

# Set variables from command-line arguments for convenience
COMMAND="$1"
REPO="$2"
TEAM="$3"

# Check if we are running the 'apply' command on prod
if [ "${COMMAND}" == "apply" -a "${PROJECT_NAME}" == "prod" ]
then
# Only the prod team can make this change
if [ "${TEAM}" == "${PROD_TEAM}" ]
then
echo "pass"
exit 0
fi

# Print reason for failing and exit
echo "user \"${USER_NAME}\" must be a member of \"${PROD_TEAM}\" to apply changes to production."
exit 0
fi

# Any other command and environment is okay
echo "pass"
exit 0
```
## Reference

### External Command Execution

External commands are executed on every authorization check with arguments and
environment variables containing context about the command being checked. The
command is executed using the following format:

```shell
external_command [external_args...] atlantis_command repo [teams...]
```

| Key | Optional | Description |
|--------------------|----------|-------------------------------------------------------------------------------------------|
| `external_command` | no | Command defined in [server side repo configuration](server-side-repo-config.md) |
| `external_args` | yes | Command arguments defined in [server side repo configuration](server-side-repo-config.md) |
| `atlantis_command` | no | The atlantis command being run (`plan`, `apply`, etc) |
| `repo` | no | The full name of the repo being executed (format: `owner/repo_name`) |
| `teams` | yes | A list of zero or more teams of the user executing the command |


The following environment variables are passed to the command on every execution:

| Key | Description |
|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `BASE_REPO_NAME` | Name of the repository that the pull request will be merged into, ex. `atlantis`. |
| `BASE_REPO_OWNER` | Owner of the repository that the pull request will be merged into, ex. `runatlantis`. |
| `COMMAND_NAME` | The name of the command that is being executed, i.e. `plan`, `apply` etc. |
| `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`. |


The following environment variables are also passed to the command when checking project authorization:

| Key | Description |
|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `BASE_BRANCH_NAME` | Name of the base branch of the pull request (the branch that the pull request is getting merged into) |
| `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`. |
| `HEAD_REPO_NAME` | Name of the repository that is getting merged into the base repository, ex. `atlantis`. |
| `HEAD_REPO_OWNER` | Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. |
| `HEAD_BRANCH_NAME` | Name of the head branch of the pull request (the branch that is getting merged into the base) |
| `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. |
| `PROJECT_NAME` | Name of the project the command is being executed on |
| `PULL_NUM` | Pull request number or ID, ex. `2`. |
| `PULL_URL` | Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. |
| `PULL_AUTHOR` | Username of the pull request author, ex. `acme-user`. |
| `REPO_ROOT` | The absolute path to the root of the cloned repository. |
| `REPO_REL_PATH` | Path to the project relative to `REPO_ROOT` |

### External Command Result Handling

Atlantis determines if a user is authorized to run the requested command by
checking if the external command exited with code `0` and if the last line
of output is `pass`.

```
# Psuedo-code of Atlantis evaluation of external commands

user_authorized =
external_command.exit_code == 0
&& external_command.output.last_line == 'pass'
```

::: tip
* A non-zero exit code means the command failed to evaluate the request for
some reason (bad configuration, missing dependencies, solar flares, etc).
* If the command was able to run successfully, but determined the user is not
authorized, it should still exit with code `0`.
* The command output could contain the reasoning for the authorization failure.
:::
20 changes: 14 additions & 6 deletions runatlantis.io/docs/server-side-repo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,12 +480,13 @@ Each servers handle different repository config files.

### Top-Level Keys

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

::: tip A Note On Defaults

Expand Down Expand Up @@ -633,3 +634,10 @@ mode: on_apply
| Key | Type | Default | Required | Description |
| -------- | ------ | ------- | -------- | -------------------------------------- |
| endpoint | string | none | yes | path to metrics endpoint |

### TeamAuthz

| Key | Type | Default | Required | Description |
|---------|----------|---------|----------|---------------------------------------------|
| command | string | none | yes | full path to external authorization command |
| args | []string | none | no | optional arguments to pass to `command` |
32 changes: 31 additions & 1 deletion server/core/config/parser_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1326,10 +1326,13 @@ func TestParseGlobalCfg(t *testing.T) {
},
},
Workflows: defaultCfg.Workflows,
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"disable repo locks": {
input: `repos:
input: `repos:
- id: /.*/
repo_locks:
mode: disabled`,
Expand All @@ -1342,6 +1345,9 @@ func TestParseGlobalCfg(t *testing.T) {
},
},
Workflows: defaultCfg.Workflows,
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"no workflows key": {
Expand All @@ -1362,6 +1368,9 @@ workflows:
"default": defaultCfg.Workflows["default"],
"name": defaultWorkflow("name"),
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"workflow stages empty": {
Expand All @@ -1380,6 +1389,9 @@ workflows:
"default": defaultCfg.Workflows["default"],
"name": defaultWorkflow("name"),
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"workflow steps empty": {
Expand All @@ -1403,6 +1415,9 @@ workflows:
"default": defaultCfg.Workflows["default"],
"name": defaultWorkflow("name"),
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"all keys specified": {
Expand Down Expand Up @@ -1509,6 +1524,9 @@ policies:
},
},
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"id regex with trailing slash": {
Expand All @@ -1526,6 +1544,9 @@ repos:
Workflows: map[string]valid.Workflow{
"default": defaultCfg.Workflows["default"],
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"referencing default workflow": {
Expand All @@ -1545,6 +1566,9 @@ repos:
Workflows: map[string]valid.Workflow{
"default": defaultCfg.Workflows["default"],
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
"redefine default workflow": {
Expand Down Expand Up @@ -1620,6 +1644,9 @@ workflows:
},
},
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
}
Expand Down Expand Up @@ -1841,6 +1868,9 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) {
},
},
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
},
},
}
Expand Down
2 changes: 2 additions & 0 deletions server/core/config/raw/global_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type GlobalCfg struct {
Workflows map[string]Workflow `yaml:"workflows" json:"workflows"`
PolicySets PolicySets `yaml:"policies" json:"policies"`
Metrics Metrics `yaml:"metrics" json:"metrics"`
TeamAuthz TeamAuthz `yaml:"team_authz" json:"team_authz"`
}

// Repo is the raw schema for repos in the server-side repo config.
Expand Down Expand Up @@ -161,6 +162,7 @@ func (g GlobalCfg) ToValid(defaultCfg valid.GlobalCfg) valid.GlobalCfg {
Workflows: workflows,
PolicySets: g.PolicySets.ToValid(),
Metrics: g.Metrics.ToValid(),
TeamAuthz: g.TeamAuthz.ToValid(),
}
}

Expand Down
19 changes: 19 additions & 0 deletions server/core/config/raw/team_authz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package raw

import "github.com/runatlantis/atlantis/server/core/config/valid"

type TeamAuthz struct {
Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"`
}

func (t *TeamAuthz) ToValid() valid.TeamAuthz {
var v valid.TeamAuthz
v.Command = t.Command
v.Args = make([]string, 0)
if t.Args != nil {
v.Args = append(v.Args, t.Args...)
}

return v
}
4 changes: 4 additions & 0 deletions server/core/config/valid/global_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type GlobalCfg struct {
Workflows map[string]Workflow
PolicySets PolicySets
Metrics Metrics
TeamAuthz TeamAuthz
}

type Metrics struct {
Expand Down Expand Up @@ -249,6 +250,9 @@ func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg {
Workflows: map[string]Workflow{
DefaultWorkflowName: defaultWorkflow,
},
TeamAuthz: TeamAuthz{
Args: make([]string, 0),
},
}
}

Expand Down
3 changes: 3 additions & 0 deletions server/core/config/valid/global_cfg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ func TestNewGlobalCfg(t *testing.T) {
Workflows: map[string]valid.Workflow{
"default": expDefaultWorkflow,
},
TeamAuthz: valid.TeamAuthz{
Args: make([]string, 0),
},
}

cases := []struct {
Expand Down
6 changes: 6 additions & 0 deletions server/core/config/valid/team_authz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package valid

type TeamAuthz struct {
Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"`
}
Loading
Loading