Skip to content

Commit

Permalink
GitHub App authentication (#1830)
Browse files Browse the repository at this point in the history
* add config options for github_app_*

* add github app auth

* add/adapt integration tests for github app auth

* fix scope of fixture

* pass env vars in tox

* add unit test to ensure coverage

* change tests to run additionally for github app auth

* add reason to skipif

* skip some tests for github app auth

* fix if condition

* bump minor version

* update docs

* Kick off CI build

* try out github app on cbartz-org/cbartz-repo-policy-compliance-tests

* update README on test repository requirements

* skip non-applicable tests

* Revert "try out github app on cbartz-org/cbartz-repo-policy-compliance-tests"

This reverts commit 5a9ce51.

* cleanup

* update docs

* use AuthMode enum and remove asserts

* try out github app on cbartz-org/cbartz-repo-policy-compliance-tests

* Revert "try out github app on cbartz-org/cbartz-repo-policy-compliance-tests"

This reverts commit 6db8f17.
  • Loading branch information
cbartz authored Aug 5, 2024
1 parent 4b9e9ca commit 070afea
Show file tree
Hide file tree
Showing 16 changed files with 482 additions and 61 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ jobs:
- name: Run tests (unit + integration)
id: run-tests
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
AUTH_GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
AUTH_GITHUB_APP_ID : ${{ secrets.TEST_GITHUB_APP_ID }}
AUTH_GITHUB_APP_INSTALLATION_ID : ${{ secrets.TEST_GITHUB_APP_INSTALLATION_ID }}
AUTH_GITHUB_APP_PRIVATE_KEY : ${{ secrets.TEST_GITHUB_APP_PRIVATE_KEY }}
run: |
# Ensure that stdout appears as normal and redirect to file and exit depends on exit code of first command
STDOUT_LOG=$(mktemp --suffix=stdout.log)
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,21 @@ failing check to be used for testing purposes.
There are two types of test: the application test and the charm test.

### Application tests
To run the application tests, the `GITHUB_TOKEN` environment variable must be set. This
To run the application tests, the `AUTH_GITHUB_TOKEN` environment variable must be set. This
should be a token of a user with full repo permissions for the test repository.
You can also pass in `AUTH_APP_ID`, `AUTH_INSTALLATION_ID`, and `AUTH_PRIVATE_KEY`
to test the authentication using GitHub App Auth. In that case, the tests will additionally
be executed using GitHub app auth. Note that the GitHub app should be installed
in the test repository organisation/user namespace, with access granted to the test repository.

The command `tox -e test` can be used to run all tests, which are primarily integration tests.
You can also select the repository against which to run the tests by setting
the `--repository` flag. The tests will fork the repository and create PRs against it.
Note that the tests are currently designed to work for specific Canonical repositories,
and may need to be for other repositories
and may need to be adapted for other repositories
(e.g. `tests.app.integration.test_target_branch_protection.test_fail`
assumes that certain collaborators are in the `users_bypass_pull_request_allowances` list).
assumes that certain collaborators are in the `users_bypass_pull_request_allowances` list).
The test repository must also have a branch protection defined for the main branch.
Also note that the forks are created in the personal space of the user whose token is being used,
and that the forks are not deleted after the run.
The reason for this is that it is only possible to create one fork of a repository,
Expand All @@ -66,6 +72,8 @@ bot to test things like comments from a user with no write permissions or above.
GitHub actions should have access to the GitHub token via a secret
called `PERSONAL_GITHUB_TOKEN`. It is recommended to use either a fine-grained PAT or a
token that is short-lived, e.g. 7 days. When it expires, a new token must be set.
For the GitHub App Auth, the `TEST_GITHUB_APP_ID`, `TEST_GIHUB_APP_INSTALLATION_ID`, and
`TEST_GITHUB_APP_PRIVATE_KEY` should be set as secrets.

### Charm tests

Expand Down
26 changes: 26 additions & 0 deletions charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,35 @@ config:
write and higher permissions for the repository to run jobs from forks.
type: boolean
default: false
github_app_id:
description: >-
The app or client ID of the GitHub App to use for communication with GitHub.
If provided, the other github_app_* options must also be provided.
The Github App needs to have read permission for Administration. If private repositories
are checked, the Github App does also need read permission for Contents and Pull request.
Either this or the github_token must be provided.
type: string
github_app_installation_id:
description: >-
The installation ID of the GitHub App to use for communication with GitHub.
If provided, the other github_app_* options must also be provided.
The Github App needs to have read permission for Administration. If private repositories
are checked, the Github App does also need read permission for Contents and Pull request.
Either this or the github_token must be provided.
type: string
github_app_private_key:
# this will become a juju user secret once paas-app-charmer supports it
description: >-
The private key of the GitHub App to use for communication with GitHub.
If provided, the other github_app_* options must also be provided.
The Github App needs to have read permission for Administration. If private repositories
are checked, the Github App does also need read permission for Contents and Pull request.
Either this or the github_token must be provided.
type: string
github_token:
description: >-
The token to use for communication with GitHub. This can be a PAT (with repo scope)
or a fine-grained token with read permission for Administration. If private repositories
are checked, the fine-grained token does also need read permission for Contents and
Pull request.
Either this or the GitHub App configuration must be provided.
30 changes: 30 additions & 0 deletions charm/docs/reference/github-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# GitHub Authentication

This section describes the GitHub authentication options available for the charm.

You can either choose to use

- classic personal access tokens
- fine-grained personal access tokens
- a GitHub app

for authentication. The latter two options are recommended for better security and access control.
They require the fine-grained permissions as mentioned below.

**Note**: If you are using a personal access tokens rather than a GitHub app,
the user who owns the token must have administrative access to the organisation or repository,
in addition to having a token with the necessary permissions.


## Classic personal access token scopes

If you want to use classic personal access tokens, you will need to select the `repo`
scope when generating them.

## Fine grained permissions

For fine-grained access control, the following repository permissions are required:

- Administration: read
- Contents: read (if you want to check private repositories)
- Pull requests: read (if you want to check private repositories)
20 changes: 0 additions & 20 deletions charm/docs/reference/token-permissions.md

This file was deleted.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

[tool.poetry]
name = "repo-policy-compliance"
version = "1.9.2"
version = "1.10.0"
description = "Checks GitHub repository settings for compliance with policy"
authors = ["Canonical IS DevOps <launchpad.net/~canonical-is-devops>"]
license = "Apache 2.0"
Expand Down
167 changes: 155 additions & 12 deletions repo_policy_compliance/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
# See LICENSE file for licensing details.

"""Module for GitHub client."""

import enum
import functools
import logging
import os
from enum import Enum
from typing import Callable, Concatenate, Literal, ParamSpec, TypeVar, cast
from urllib import parse

from github import BadCredentialsException, Github, GithubException, RateLimitExceededException
from github.Auth import Token
from github.Auth import AppAuth, AppInstallationAuth, Auth, Token
from github.Branch import Branch
from github.Repository import Repository
from urllib3 import Retry
Expand All @@ -27,23 +28,49 @@

# Bandit thinks this constant is the real Github token
GITHUB_TOKEN_ENV_NAME = "GITHUB_TOKEN" # nosec
GITHUB_APP_ID_ENV_NAME = "GITHUB_APP_ID"
GITHUB_APP_INSTALLATION_ID_ENV_NAME = "GITHUB_APP_INSTALLATION_ID"
GITHUB_APP_PRIVATE_KEY_ENV_NAME = "GITHUB_APP_PRIVATE_KEY"

MISSING_GITHUB_CONFIG_ERR_MSG = (
f"Either the {GITHUB_TOKEN_ENV_NAME} or not all of {GITHUB_APP_ID_ENV_NAME},"
f" {GITHUB_APP_INSTALLATION_ID_ENV_NAME}, {GITHUB_APP_PRIVATE_KEY_ENV_NAME} "
f"environment variables were provided or are empty, "
"the variables are needed for interactions with GitHub, "
)
NOT_ALL_GITHUB_APP_CONFIG_ERR_MSG = (
f"Not all of {GITHUB_APP_ID_ENV_NAME}, {GITHUB_APP_INSTALLATION_ID_ENV_NAME},"
f" {GITHUB_APP_PRIVATE_KEY_ENV_NAME} environment variables were provided, "
)
# the following is no hardcoded password
PROVIDED_GITHUB_TOKEN_AND_APP_CONFIG_ERR_MSG = ( # nosec
"Provided github app config and github token, only one of them should be provided, "
)


class _AuthMode(Enum):
"""Enum to represent the auth mode to use.
Attributes:
TOKEN: Using GitHub token auth.
APP: Using GitHub App auth.
"""

TOKEN = enum.auto()
APP = enum.auto()


def get() -> Github:
"""Get a GitHub client.
Returns:
A GitHub client that is configured with a token from the environment.
A GitHub client that is configured with a token or GitHub app from the environment.
Raises:
ConfigurationError: If the GitHub token environment variable is not provided or empty.
"""
github_token = os.getenv(GITHUB_TOKEN_ENV_NAME) or os.getenv(f"FLASK_{GITHUB_TOKEN_ENV_NAME}")
if not github_token:
raise ConfigurationError(
f"The {GITHUB_TOKEN_ENV_NAME} environment variable was not provided or empty, "
f"it is needed for interactions with GitHub, got: {github_token!r}"
)
ConfigurationError: If the GitHub auth config is not valid.
""" # noqa: DCO051 error raised is useful to know for the user of the public interface
auth = _get_auth()

# Only retry on 5xx and only retry once after 20 secs
retry_config = Retry(
total=1,
Expand All @@ -53,7 +80,123 @@ def get() -> Github:
raise_on_status=False,
raise_on_redirect=False,
)
return Github(auth=Token(github_token), retry=retry_config)
return Github(auth=auth, retry=retry_config)


def _get_auth() -> Auth:
"""Get a GitHub auth object.
Returns:
A GitHub auth object that is configured with a token from the environment.
"""
github_token = os.getenv(GITHUB_TOKEN_ENV_NAME) or os.getenv(f"FLASK_{GITHUB_TOKEN_ENV_NAME}")
github_app_id = os.getenv(GITHUB_APP_ID_ENV_NAME) or os.getenv(
f"FLASK_{GITHUB_APP_ID_ENV_NAME}"
)
github_app_installation_id_str = os.getenv(GITHUB_APP_INSTALLATION_ID_ENV_NAME) or os.getenv(
f"FLASK_{GITHUB_APP_INSTALLATION_ID_ENV_NAME}"
)
github_app_private_key = os.getenv(GITHUB_APP_PRIVATE_KEY_ENV_NAME) or os.getenv(
f"FLASK_{GITHUB_APP_PRIVATE_KEY_ENV_NAME}"
)

auth_mode = _get_auth_mode(
github_token=github_token,
github_app_id=github_app_id,
github_app_installation_id_str=github_app_installation_id_str,
github_app_private_key=github_app_private_key,
)

auth: Auth
if auth_mode == _AuthMode.APP:
auth = _get_github_app_installation_auth(
github_app_id=cast(str, github_app_id),
github_app_installation_id_str=cast(str, github_app_installation_id_str),
github_app_private_key=cast(str, github_app_private_key),
)
else:
assert github_token is not None # nosec
auth = Token(github_token)

return auth


def _get_auth_mode(
github_token: str | None,
github_app_id: str | None,
github_app_installation_id_str: str | None,
github_app_private_key: str | None,
) -> _AuthMode:
"""Get the auth mode to use.
Args:
github_token: The GitHub token.
github_app_id: The GitHub App ID or Client ID.
github_app_installation_id_str: The GitHub App Installation ID as a string.
github_app_private_key: The GitHub App private key.
Raises:
ConfigurationError: If the configuration is not valid, e.g. if both a token and app config
are provided.
Returns:
The auth mode to use.
"""
if not github_token and not (
github_app_id or github_app_installation_id_str or github_app_private_key
):
raise ConfigurationError(
f"{MISSING_GITHUB_CONFIG_ERR_MSG}"
f"got: {github_token!r}, {github_app_id!r},"
f" {github_app_installation_id_str!r}, {github_app_private_key!r}"
)
if github_token and (
github_app_id or github_app_installation_id_str or github_app_private_key
):
raise ConfigurationError(
f"{PROVIDED_GITHUB_TOKEN_AND_APP_CONFIG_ERR_MSG}"
f"got: {github_token!r}, {github_app_id!r}, {github_app_installation_id_str!r},"
f" {github_app_private_key!r}"
)

if github_app_id or github_app_installation_id_str or github_app_private_key:
if not (github_app_id and github_app_installation_id_str and github_app_private_key):
raise ConfigurationError(
f"{NOT_ALL_GITHUB_APP_CONFIG_ERR_MSG}"
f"got: {github_app_id!r}, {github_app_installation_id_str!r}, "
f"{github_app_private_key!r}"
)

if github_token:
return _AuthMode.TOKEN
return _AuthMode.APP


def _get_github_app_installation_auth(
github_app_id: str, github_app_installation_id_str: str, github_app_private_key: str
) -> AppInstallationAuth:
"""Get a GitHub App Installation Auth object.
Args:
github_app_id: The GitHub App ID or Client ID.
github_app_installation_id_str: The GitHub App Installation ID as a string.
github_app_private_key: The GitHub App private key.
Returns:
A GitHub App Installation Auth object.
Raises:
ConfigurationError: If the GitHub App Installation Auth config is not valid.
"""
try:
github_app_installation_id = int(github_app_installation_id_str)
except ValueError as exc:
raise ConfigurationError(
f"Invalid github app installation id {github_app_installation_id_str!r}, "
f"it should be an integer."
) from exc
app_auth = AppAuth(app_id=github_app_id, private_key=github_app_private_key)
return AppInstallationAuth(app_auth=app_auth, installation_id=github_app_installation_id)


def inject(func: Callable[Concatenate[Github, P], R]) -> Callable[P, R]:
Expand Down
2 changes: 1 addition & 1 deletion rockcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

name: repo-policy-compliance
base: [email protected]
version: '1.9.2'
version: '1.10.0'
summary: Check the repository setup for policy compliance
description: |
Used to check whether a GitHub repository complies with expected policies.
Expand Down
Loading

0 comments on commit 070afea

Please sign in to comment.