Skip to content

Commit

Permalink
Merge branch 'main' into chore/use-juju-3.5
Browse files Browse the repository at this point in the history
  • Loading branch information
cbartz authored Aug 5, 2024
2 parents 1a832dd + 070afea commit 0dfed38
Show file tree
Hide file tree
Showing 17 changed files with 594 additions and 150 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
35 changes: 30 additions & 5 deletions charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,35 @@ config:
write and higher permissions for the repository to run jobs from forks.
type: boolean
default: false
github_token:
github_app_id:
description: >-
The token to use for comms with GitHub. This can be a PAT or a fine-grained token
with permissions to read collaborators (and collaborators' permissions) and branches
for all repositories that need to be checked.
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
required: true
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)
2 changes: 1 addition & 1 deletion charm/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
paas-app-charmer==1.0.3
paas-app-charmer==1.0.4
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.1"
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
Loading

0 comments on commit 0dfed38

Please sign in to comment.