diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca4f0e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: +- package-ecosystem: "pip" + directory: / + schedule: + interval: weekly + # Assignees to set on pull requests + assignees: + - "githubofkrishnadhas" + # prefix specifies a prefix for all commit messages. When you specify a prefix for commit messages, + # GitHub will automatically add a colon between the defined prefix and the commit message provided the + # defined prefix ends with a letter, number, closing parenthesis, or closing bracket. + commit-message: + prefix: "dependabot python package" + # Raise pull requests for version updates to pip against the `main` branch + target-branch: "main" + # Labels on pull requests for version updates only + labels: + - "pip dependencies" + # Increase the version requirements for Composer only when required + versioning-strategy: increase-if-necessary + # Dependabot opens a maximum of five pull requests for version updates. Once there are five open pull requests from Dependabot, + # Dependabot will not open any new requests until some of those open requests are merged or closed. + # Use open-pull-requests-limit to change this limit. + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8fc96b9..3abe31c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Container image that runs your code -FROM python:3.10-slim-bullseye +FROM python:3.11-slim-bullseye WORKDIR /app # Copies your code file from your action repository to the filesystem path `/` of the container diff --git a/Pipfile.lock b/Pipfile.lock index d647c37..b710563 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "73def19ab14311a626a7adea02b0aaf7c26ce8037f8982202853d83bbcf786ad" + "sha256": "60c56d56221691f7c5027ae171ca316ee45e17471c49843dbe5a3e858558b62d" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,12 @@ "default": { "certifi": { "hashes": [ - "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516", - "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56" + "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", + "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" ], + "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==2024.6.2" + "version": "==2024.7.4" }, "cffi": { "hashes": [ diff --git a/README.md b/README.md index 4bcc67a..ae5d732 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,144 @@ # github-access-using-githubapp -github-access-using-githubapp Once your GitHub App is installed on an account, you can make it authenticate as an app installation for API requests. This allows the app to access resources owned by that installation, as long as the app was granted the necessary repository access and permissions. API requests made by an app installation are attributed to the app. -:pushpin: This action will help in creating github app installation token for both **user accounts** and **Github organizations** +:pushpin: This action will help in creating GitHub app installation token for both **user accounts** and **Github organizations** + +> [!IMPORTANT] +> An installation access token expires after 1 hour. Please find suitable alternative approaches if you have long-running processes.. # Parameters of action -| Parameter name | Description | Required | -|----------------|-------------|--------------------| -| github_app_private_key | Github App Private key | :heavy_check_mark: | -| github_app_id | Your GitHub App ID | :heavy_check_mark: | -| github_account_type | Github account whether `user` account or `organization` | :heavy_check_mark: | +| Parameter name | Description | Required | +|----------------|----------------------------------------------------------------------------------------------------------------|-------------------| +| github_app_private_key | Github App Private key | :heavy_check_mark: | +| github_app_id | Your GitHub App ID | :heavy_check_mark: | +| owner | Github account owner name. if not specified takes owner of current repository where action is ran | ❌ | +| repositories | List of github repositores to generte token for. if not specified takes current repository where action is ran. | ❌ | + +* Store your `Github App Id` and `Github App Private key` as github secret and pass the secret names as inputs for action. -* Store your `Github App Id` and `Github App Private key` as github secret and pass the secret names as inuts for action. +* ❌ 👉 Means optional values + +> [!NOTE] +> If the owner is set but repositories are empty, access will include all repositories for that owner. +> If both the owner and repositories are empty, access will be limited to the current repository. # What's New Please refer to the [release](https://github.com/githubofkrishnadhas/github-access-using-githubapp/releases) page for the latest release notes. -# Usage +# Usage ```commandline -- uses: githubofkrishnadhas/github-access-using-githubapp@v1 +- uses: githubofkrishnadhas/github-access-using-githubapp@v2 + id: token-generation with: # Your GitHub App ID - interger value github_app_id: 1234567 - # Github App Private key + # GitHub App Private key github_app_private_key : '' - # Gituhb account type `user` or `organization` only - github_account_type: '' + # GitHub account Owner name - Optional + owner: '' + + # GitHub repositories names seperated by comma if more than 1 - optional + repositories: '' ``` # output -The token generated will be available as a Environment variable `GH_APP_TOKEN` which can be used while running api calls +* The token generated will be available as a ${{ steps.token-generation.outputs.token }} which can be used in later stages as required + +# Example usages + +## Create a token for the current repository + +```commandline +uses: githubofkrishnadhas/github-access-using-githubapp@v2 + id: token-generation + with: + github_app_id: ${{ secrets.APP_ID }} + github_app_private_key : ${{ secrets.PRIVATE_KEY }} +``` +* To create a Token in the scope of current repository where action is run, you do not need to specify `owner` or `repositores` +* Assuming both GitHub App ID and Private key are present as github secrets with names `APP_ID` and `PRIVATE_KEY` +* You can substitute your secrets names with above +* The token generated will be available as a ${{ steps.token-generation.outputs.token }} which can be used in later stages as required + + +## Create a token for the current user or organization level + +```commandline +uses: githubofkrishnadhas/github-access-using-githubapp@v2 + id: token-generation + with: + github_app_id: ${{ secrets.APP_ID }} + github_app_private_key : ${{ secrets.PRIVATE_KEY }} + owner: 'github' +``` +* To create a Token in the scope of current user or organization where your Github app has access, you need only to specify `owner` +* Assuming both GitHub App ID and Private key are present as github secrets with names `APP_ID` and `PRIVATE_KEY` +* You can substitute your secrets names with above +* The token generated will be available as a ${{ steps.token-generation.outputs.token }} which can be used in later stages as required + + +## Create a token for a differnt user or organization scoped to specific repos + +```commandline +uses: githubofkrishnadhas/github-access-using-githubapp@v2 + id: token-generation + with: + github_app_id: ${{ secrets.APP_ID }} + github_app_private_key : ${{ secrets.PRIVATE_KEY }} + owner: 'github' + repositories: 'test1,test2,test3' +``` +* To create a Token in the scope of provided repositories and owner where your Github app has access you need only to specify `owner` and `repositories` +* The above will generate token which are scoped to repositores named `test1, test2, test3` on `github` org +* Assuming both GitHub App ID and Private key are present as github secrets with names `APP_ID` and `PRIVATE_KEY` +* You can substitute your secrets names with above +* The token generated will be available as a ${{ steps.token-generation.outputs.token }} which can be used in later stages as required + + +## Using the token generated with other actions + +```commandline +name: Clone Repository + +on: + workflow_dispatch: + +jobs: + clone: + runs-on: ubuntu-latest + + steps: + + - name: Token generator + uses: githubofkrishnadhas/github-access-using-githubapp@v2 + id: token-generation + with: + github_app_id: ${{ secrets.APP_ID }} + github_app_private_key : ${{ secrets.PRIVATE_KEY }} + + - name: Checkout Repository + uses: actions/checkout@v4 + with: + repository: 'devwithkrishna/azure-terraform-modules' + token: ${{ steps.token-generation.outputs.token }} + fetch-depth: 1 +``` +* The above workflow generates a github app installation access token using the action - `githubofkrishnadhas/github-access-using-githubapp@v2` +* The token generated will be available as a ${{ steps.token-generation.outputs.token }} which can be used in later stages as shown above +* The workflow is to clone a repository named `azure-terraform-modules` inside `devwithkrishna` organization + # References -[generating-an-installation-access-token](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app#generating-an-installation-access-token) -[get-a-user-installation-for-the-authenticated-app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app) -[get-a-repository-installation-for-the-authenticated-app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app) +* [generating-an-installation-access-token](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app#generating-an-installation-access-token) +* [get-a-user-installation-for-the-authenticated-app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app) +* [get-a-repository-installation-for-the-authenticated-app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app) All the above API's uses [JWT](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app#authenticating-as-a-github-app) as access token. diff --git a/action.yml b/action.yml index 4598991..8af6b7a 100644 --- a/action.yml +++ b/action.yml @@ -10,12 +10,19 @@ inputs: required: true github_app_private_key: description: "Github App private key" - github_account_type: - description: "Github account user or organization" + required: true + owner: + description: "The owner of the GitHub App installation. If empty, defaults to the current repository owner" + required: false + repositories: + description: "Comma-separated list of repositories to grant access to" + required: false + runs: using: 'docker' image: 'Dockerfile' args: - ${{ inputs.github_app_id }} - ${{ inputs.github_app_private_key }} - - ${{ inputs.github_account_type }} + - ${{ inputs.owner }} + - ${{ inputs.repositories }} diff --git a/entrypoint.sh b/entrypoint.sh index a352387..4c69514 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,7 +3,26 @@ # installng pipenv and creating pipenv venv cd /app && pipenv install --skip-lock -# run python program to generate token -pipenv run python3 /app/generate_jwt.py --github_app_id "$1" --github_app_private_key "$2" --github_account_type "$3" +# Capture arguments +GITHUB_APP_ID="$1" +GITHUB_APP_PRIVATE_KEY="$2" +OWNER="$3" +REPOSITORIES="$4" + +# Build the command based on available parameters +CMD="pipenv run python3 /app/generate_jwt.py --github_app_id \"$GITHUB_APP_ID\" --github_app_private_key \"$GITHUB_APP_PRIVATE_KEY\"" + +if [ -n "$OWNER" ]; then + CMD="$CMD --owner \"$OWNER\"" +fi + +if [ -n "$REPOSITORIES" ]; then + CMD="$CMD --repositories \"$REPOSITORIES\"" +fi + +# Print and execute the command +echo "Executing command: $CMD" +eval "$CMD" + diff --git a/generate_jwt.py b/generate_jwt.py index 3bdfbe5..ad83c1b 100644 --- a/generate_jwt.py +++ b/generate_jwt.py @@ -2,115 +2,174 @@ import time import argparse import os +import sys import requests from dotenv import load_dotenv def create_jwt(private_key, app_id): """ - function to create JWT from GitHub app id and pvt key - :param private_key: - :param app_id: - :return: + Function to create JWT from GitHub app id and private key. + :param private_key: Path to the PEM file containing the private key. + :param app_id: GitHub App ID. + :return: Encoded JWT. """ # Open PEM # with open(private_key, 'rb') as pem_file: # signing_key = jwk_from_pem(pem_file.read()) signing_key = jwk_from_pem(private_key.encode('utf-8')) + payload = { - # Issued at time - 'iat': int(time.time()), - # JWT expiration time (10 minutes maximum) - 'exp': int(time.time()) + 600, - # GitHub App's identifier - 'iss': app_id + 'iat': int(time.time()), # Issued at time + 'exp': int(time.time()) + 600, # JWT expiration time (10 minutes maximum) + 'iss': app_id # GitHub App's identifier } # Create JWT jwt_instance = JWT() encoded_jwt = jwt_instance.encode(payload, signing_key, alg='RS256') - # Set JWT as environment variable - # os.environ["GITHUB_JWT"] = encoded_jwt - - # print(f"JWT token created successfully") return encoded_jwt -def get_app_installation_id(jwt:str, github_account_type:str): +def get_app_installation_id(jwt: str, owner: str): """ - returns github app installation id on user and org accounts - :param jwt: - :return: + Get GitHub app installation ID on user and org accounts. + :param jwt: JWT token. + :param owner: GitHub owner (user or organization). + :return: Installation ID. """ - GITHUB_REPOSITORY = os.getenv('GITHUB_REPOSITORY') - GITHUB_REPOSITORY_OWNER = os.getenv('GITHUB_REPOSITORY_OWNER') - org_url = f'https://api.github.com/repos/{GITHUB_REPOSITORY}/installation' - user_url = f'https://api.github.com/users/{GITHUB_REPOSITORY_OWNER}/installation' - if github_account_type == 'user': - url = user_url - else: - url = org_url + results = [] + per_page = 50 + page = 1 + url = 'https://api.github.com/app/installations' headers = { "Accept": "application/vnd.github+json", "Authorization": f"Bearer {jwt}", "X-GitHub-Api-Version": "2022-11-28" } - response = requests.get(url= url, headers=headers) - if response.status_code == 200: - print(f'Okay. Received proper response.Got installation id') - response_json = response.json() - elif response.status_code == 301: - print(f'Moved permanently. Cant get a response') - else: - print(f'Resource Not Found!') + while True: + params = {'per_page': per_page, 'page': page} + response = requests.get(url=url, headers=headers, params=params) + + if response.status_code == 200: + response_json = response.json() + elif response.status_code == 301: + print('Moved permanently. Cannot get a response.') + sys.exit(1) + else: + print('Resource not found!') + sys.exit(1) - # Installation id of github app - installation_id = response_json['id'] - return installation_id + results.extend(response_json) -def generate_token_by_post_call(installation_id:int, jwt:str): + if len(response_json) < per_page: + break + page += 1 + + for result in results: + result_owner = result['account']['login'] + if owner == result_owner: + installation_id = result['id'] + print(f'Installation ID is {installation_id} - {owner} {result["target_type"]}') + return installation_id + + print(f'Installation ID for owner {owner} not found.') + sys.exit(1) + +def generate_token_by_post_call(installation_id: int, jwt: str, repositories: str): """ - create a app installation token by doing a rest api post call with permissions for application - :return: + Create an app installation token by making a POST request with permissions for the application. + :param installation_id: Installation ID of the GitHub App. + :param jwt: JWT token. + :param repositories: Comma-separated list of repositories. """ + input_repositories = [item.strip() for item in repositories.split(',')] url = f'https://api.github.com/app/installations/{installation_id}/access_tokens' headers = { "Accept": "application/vnd.github+json", "Authorization": f"Bearer {jwt}", "X-GitHub-Api-Version": "2022-11-28" } - response = requests.post(url=url, headers=headers) + + if input_repositories == ['all']: + response = requests.post(url=url, headers=headers) + else: + data = { + "repositories": input_repositories + } + response = requests.post(url=url, headers=headers, json=data) + response_json = response.json() + if response.status_code == 201: - print(f'Github app installation token generate succcessfully, expires at {response_json["expires_at"]}') - os.environ['GH_APP_TOKEN'] = response_json['token'] - # Write the token to GITHUB_ENV to be available in subsequent steps - with open(os.environ['GITHUB_ENV'], 'a') as fh: - fh.write(f"GH_APP_TOKEN={response_json['token']}\n") + print(f'GitHub app installation token generated successfully for scope {repositories} - expires at {response_json["expires_at"]}') + elif response.status_code == 401: + print('Authentication is required') + elif response.status_code == 403: + print('Forbidden action') + elif response.status_code == 404: + print('Resource not found') + else: + print(f"Validation failed, {response_json.get('message', 'Unknown error')} or the endpoint has been spammed.") + print(f'Aborting GitHub App installation token generation') + sys.exit(1) + + token = response_json['token'] # setting the output token as token inside github output + with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: + fh.write(f'token={token}\n') + def main(): """ - to test the code - :return: + Main function to run the script. """ load_dotenv() parser = argparse.ArgumentParser(description="Create JWT for GitHub App authentication") - parser.add_argument("--github_app_private_key",required=True, type=str, help="Github App Private key") - parser.add_argument("--github_account_type",required=True, choices=['user','organization'], help="Github account whether user account ot github org") - parser.add_argument("--github_app_id",required=True, type=str, help="Your GitHub App ID") + parser.add_argument("--github_app_private_key", required=True, type=str, help="GitHub App Private key") + parser.add_argument("--github_app_id", required=True, type=str, help="Your GitHub App ID") + parser.add_argument("--owner", required=False, type=str, help="Target GitHub owner") + parser.add_argument("--repositories", required=False, type=str, help="Repos to which token will be generated, for multiple separate by comma") args = parser.parse_args() private_key = args.github_app_private_key app_id = args.github_app_id - github_account_type = args.github_account_type - # function call - jwt = create_jwt(private_key=private_key, app_id=app_id) - installation_id = get_app_installation_id(jwt=jwt, github_account_type=github_account_type) - generate_token_by_post_call(installation_id=installation_id, jwt=jwt) + # Handle --owner argument + if args.owner is None: + print("No owner specified. Considering current repository owner as Owner.") + owner = os.getenv('GITHUB_REPOSITORY_OWNER') # Assign a default or dynamically determined value + print(f"Taking owner as {owner}") + else: + owner = args.owner + print(f"Owner: {owner}") + # Handle --repositories argument + if args.repositories is None: + if args.owner is not None: + # If owner is provided but repositories are not + print(f"No repositories specified for the provided owner: {owner}.") + repositories = 'all' # You can decide on a default behavior here, e.g., an empty list or a specific action + print(f"Selecting all repos under owner {owner}") + else: + # If neither owner nor repositories are specified + print("No repositories & owner specified. Considering current repository as repositories.") + os_repositories = os.getenv('GITHUB_REPOSITORY') # Get the current repository + if os_repositories: + parts = os_repositories.split('/') + repositories = parts[1] + print(f"Taking repositories as {repositories}") + else: + print("Current repository information is not available.") + sys.exit(1) + else: + repositories = args.repositories + print(f"Will generate tokens for {repositories}") + # Function calls + jwt = create_jwt(private_key=private_key, app_id=app_id) + installation_id = get_app_installation_id(jwt=jwt, owner=owner) + generate_token_by_post_call(installation_id=installation_id, jwt=jwt, repositories=repositories) if __name__ == "__main__": main()