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

GitHub App authentication #1830

Merged
merged 24 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
22d47cd
add config options for github_app_*
cbartz Jul 31, 2024
cde2475
add github app auth
cbartz Jul 31, 2024
ec37ec2
add/adapt integration tests for github app auth
cbartz Jul 31, 2024
04a6369
fix scope of fixture
cbartz Jul 31, 2024
17ef3e5
pass env vars in tox
cbartz Jul 31, 2024
db7d14c
add unit test to ensure coverage
cbartz Jul 31, 2024
4836602
change tests to run additionally for github app auth
cbartz Jul 31, 2024
5e1dc45
add reason to skipif
cbartz Jul 31, 2024
c00d4fc
Merge branch 'refs/heads/main' into feat/github-app-auth-ISD2110
cbartz Jul 31, 2024
bccad20
skip some tests for github app auth
cbartz Jul 31, 2024
c77a572
fix if condition
cbartz Jul 31, 2024
f5f619a
bump minor version
cbartz Jul 31, 2024
2148cdd
update docs
cbartz Aug 1, 2024
a61108c
Kick off CI build
cbartz Aug 1, 2024
5a9ce51
try out github app on cbartz-org/cbartz-repo-policy-compliance-tests
cbartz Aug 1, 2024
2792fee
update README on test repository requirements
cbartz Aug 1, 2024
20dc596
skip non-applicable tests
cbartz Aug 1, 2024
3b54797
Revert "try out github app on cbartz-org/cbartz-repo-policy-complianc…
cbartz Aug 1, 2024
5938fd3
cleanup
cbartz Aug 1, 2024
d1d6742
update docs
cbartz Aug 1, 2024
a058f19
use AuthMode enum and remove asserts
cbartz Aug 2, 2024
6db8f17
try out github app on cbartz-org/cbartz-repo-policy-compliance-tests
cbartz Aug 1, 2024
90b735a
Revert "try out github app on cbartz-org/cbartz-repo-policy-complianc…
cbartz Aug 2, 2024
eb4c64b
Merge branch 'refs/heads/main' into feat/github-app-auth-ISD2110
cbartz Aug 2, 2024
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
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(
amandahla marked this conversation as resolved.
Show resolved Hide resolved
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