From 70c6d06251ee6d2611f09d7439296a451a2d577c Mon Sep 17 00:00:00 2001 From: Eric Hills <53243273+ebhills@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:35:37 -0500 Subject: [PATCH] Use GitHub App authentication for deployments and update model access tests --- .github/workflows/deploy-dev.yml | 69 +++++++++++++++++--- .github/workflows/publish-tagged.yml | 41 ++++++++++-- .gitignore | 1 + docs/github-app-deployment.md | 98 ++++++++++++++++++++++++++++ tests/connectors/test_train.py | 67 +++++++++++++------ 5 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 docs/github-app-deployment.md diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index bd3b8263..b30adc50 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -28,6 +28,40 @@ jobs: outputs: rc_version: ${{ steps.compute.outputs.rc_version }} steps: + - name: Validate deployment app configuration + env: + DEPLOY_APP_CLIENT_ID: ${{ vars.DEPLOY_APP_CLIENT_ID }} + DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + run: | + MISSING=() + [[ -z "$DEPLOY_APP_CLIENT_ID" ]] && MISSING+=(DEPLOY_APP_CLIENT_ID) + [[ -z "$DEPLOY_APP_PRIVATE_KEY" ]] && MISSING+=(DEPLOY_APP_PRIVATE_KEY) + if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "::error::Deployment app configuration is missing: ${MISSING[*]}. See docs/github-app-deployment.md." + exit 1 + fi + + # Validate the installation and permissions before running tests or + # publishing. These tokens are revoked when this job finishes; each + # later job creates a fresh token immediately before it needs one. + - name: Verify schema repository app access + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: wrangleworks.github.io + permission-contents: write + + - name: Verify Lambda workflow app access + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: Lambda-Recipes + permission-actions: write + - name: Validate version input format run: | if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then @@ -151,11 +185,21 @@ jobs: - name: Save schema as schema_dev.json run: cp schema/schema.json schema/schema_dev.json + - name: Create schema publishing token + id: schema-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: wrangleworks.github.io + permission-contents: write + - name: Checkout wrangleworks.github.io uses: actions/checkout@v5 with: repository: wrangleworks/wrangleworks.github.io - token: ${{ secrets.CROSS_REPO_PAT_V2 }} + token: ${{ steps.schema-token.outputs.token }} path: wrangleworks.github.io - name: Copy schema_dev.json to wrangleworks.github.io @@ -164,8 +208,8 @@ jobs: - name: Commit and push schema_dev.json working-directory: wrangleworks.github.io run: | - git config user.name "mborodii-prog" - git config user.email "mborodii@binariks.com" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add schema/recipes/schema_dev.json if git diff --cached --quiet; then echo "No changes to schema_dev.json, skipping commit." @@ -228,9 +272,8 @@ jobs: run: twine upload --repository codeartifact dist/* # ── 5. Trigger DEV deployment in Lambda-Recipes ─────────────────────────── - # CROSS_REPO_PAT_V2: a GitHub PAT with Actions read/write on Lambda-Recipes - # (and Contents read/write on wrangleworks.github.io for schema publish), - # stored as a repository or org secret. Required to dispatch workflows across repositories. + # The deployment app issues a short-lived token limited to Lambda-Recipes + # workflow access. Installation and configuration: docs/github-app-deployment.md. trigger-deploy-dev: name: Trigger DEV Deploy runs-on: ubuntu-latest @@ -266,16 +309,26 @@ jobs: '{wrangles_version: $wrangles_version, deploy_user: $deploy_user, reason: $reason}') echo "payload=$payload" >> "$GITHUB_OUTPUT" + - name: Create Lambda deployment token + id: deploy-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: Lambda-Recipes + permission-actions: write + - name: Deploy Dev uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 with: owner: wrangleworks repo: Lambda-Recipes workflow_file_name: deploy-dev.yml - github_token: ${{ secrets.CROSS_REPO_PAT_V2 }} + github_token: ${{ steps.deploy-token.outputs.token }} ref: main wait_interval: 15 client_payload: ${{ steps.dispatch.outputs.payload }} propagate_failure: true trigger_workflow: true - wait_workflow: true \ No newline at end of file + wait_workflow: true diff --git a/.github/workflows/publish-tagged.yml b/.github/workflows/publish-tagged.yml index f4ed0878..e8bfefd3 100644 --- a/.github/workflows/publish-tagged.yml +++ b/.github/workflows/publish-tagged.yml @@ -25,6 +25,30 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: + - name: Validate deployment app configuration + env: + DEPLOY_APP_CLIENT_ID: ${{ vars.DEPLOY_APP_CLIENT_ID }} + DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + run: | + MISSING=() + [[ -z "$DEPLOY_APP_CLIENT_ID" ]] && MISSING+=(DEPLOY_APP_CLIENT_ID) + [[ -z "$DEPLOY_APP_PRIVATE_KEY" ]] && MISSING+=(DEPLOY_APP_PRIVATE_KEY) + if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "::error::Deployment app configuration is missing: ${MISSING[*]}. See docs/github-app-deployment.md." + exit 1 + fi + + # Check credentials and installation permissions before release tests + # or publishing. The dispatch job creates its own fresh token later. + - name: Verify Lambda workflow app access + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: Lambda-Recipes + permission-actions: write + - name: Checkout Repository uses: actions/checkout@v5 @@ -392,8 +416,8 @@ jobs: # ── Trigger PROD deployment in Lambda-Recipes ───────────────────────────── # Runs after publish-pypi (or when skipped) because Lambda-Recipes verify-setup # requires the version to exist on PyPI before the deploy pipeline starts. - # CROSS_REPO_PAT_V2: GitHub PAT with Actions read/write on Lambda-Recipes - # (and Contents read/write on wrangleworks.github.io for Deploy Dev schema publish). + # The deployment app issues a short-lived token limited to Lambda-Recipes + # workflow access. Installation and configuration: docs/github-app-deployment.md. trigger-deploy-prod: name: Trigger PROD Deploy runs-on: ubuntu-latest @@ -430,17 +454,26 @@ jobs: '{wrangles_version: $wrangles_version, deploy_user: $deploy_user}') echo "payload=$payload" >> "$GITHUB_OUTPUT" + - name: Create Lambda deployment token + id: deploy-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: wrangleworks + repositories: Lambda-Recipes + permission-actions: write + - name: Deploy Prod uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 with: owner: wrangleworks repo: Lambda-Recipes workflow_file_name: deploy-prod.yml - github_token: ${{ secrets.CROSS_REPO_PAT_V2 }} + github_token: ${{ steps.deploy-token.outputs.token }} ref: main wait_interval: 15 client_payload: ${{ steps.dispatch.outputs.payload }} propagate_failure: true trigger_workflow: true wait_workflow: true - diff --git a/.gitignore b/.gitignore index fd2d551e..2d1c4ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,7 @@ env.bak/ venv.bak/ docs/* !docs/extract_ai_user_guide.md +!docs/github-app-deployment.md .DS_Store # Spyder project settings diff --git a/docs/github-app-deployment.md b/docs/github-app-deployment.md new file mode 100644 index 00000000..53e5fdf0 --- /dev/null +++ b/docs/github-app-deployment.md @@ -0,0 +1,98 @@ +# GitHub App authentication for deployments + +The DEV and PROD deployment workflows use an organization-owned GitHub App for +operations in other repositories. Normal WranglesPY CI uses `GITHUB_TOKEN` for +checkout and GHCR publishing. AWS publishing and Lambda updates continue to use +GitHub OIDC. No personal access token is required by these deployment workflows. + +The registered organization app is +[Wrangleworks Deployments](https://github.com/organizations/wrangleworks/settings/apps/wrangleworks-deployments). +Its [installation](https://github.com/organizations/wrangleworks/settings/installations/160195536) +is restricted to `wrangleworks.github.io` and `Lambda-Recipes`. Reuse this app for +the workflow configuration below. + +## Register and install the app + +An administrator of `wrangleworks` performs this setup once: + +1. In the organization's developer settings, register a private GitHub App, for + example **Wrangleworks Deployments**. Set its homepage to + `https://github.com/wrangleworks/WranglesPY` and allow installation only on the + `wrangleworks` account. Disable webhooks; no callback URL, user authorization, + or event subscriptions are needed. +2. Grant these **repository permissions**: **Contents: Read and write** and + **Actions: Read and write**. GitHub includes **Metadata: Read-only**. Leave + other repository, organization, and account permissions unset. +3. Install the app on **Only select repositories** and select exactly + `wrangleworks.github.io` and `Lambda-Recipes`. It does not need installation + on WranglesPY to issue tokens for those two repositories. +4. Copy the app's **Client ID** into the WranglesPY Actions repository variable + `DEPLOY_APP_CLIENT_ID`. Use the Client ID, not the numeric App ID. +5. Generate an app private key and store the complete PEM, including its header + and footer, as the WranglesPY Actions repository secret + `DEPLOY_APP_PRIVATE_KEY`. Handle the key through GitHub's secret interface; + never paste it into a recipe, issue, log, workflow file, or committed file. + Do not put an installation token in this secret. + +Organization-scoped configuration may be used instead, provided both names are +available only to the repositories that need the app credentials. A repository +variable or secret with the same name takes precedence over organization +configuration, so remove stale overrides when moving configuration. + +## Token scope and lifetime + +The app's registration permissions are its maximum access across the two +installed repositories. Each workflow requests a narrower installation token: + +| Operation | Token repository | Token permission | +| --- | --- | --- | +| Publish `schema/recipes/schema_dev.json` | `wrangleworks.github.io` | Contents: write | +| Dispatch and wait for DEV or PROD deployment | `Lambda-Recipes` | Actions: write | + +The first DEV job validates both repository scopes; the first PROD job validates +the Lambda scope. Missing configuration, invalid app credentials, or insufficient +installation permissions stop the workflow before tests and package publishing. +These validation steps create tokens but do not write to either repository or +start a deployment. Repository branch rules can still reject a later schema push. + +Each publishing or dispatch job creates a fresh token immediately before use. +Tokens are limited to one repository and automatically revoked when their job +finishes; GitHub installation tokens also expire after one hour. The pinned +`actions/create-github-app-token` action handles generation, masking, and +revocation. Tokens are not passed between jobs or retained as secrets. + +Schema commits use `github-actions[bot]` as the author. This is commit attribution; +the app installation token supplies the actual authorization. Deployment payloads +continue to record the original initiating user through `github.actor`. + +## Validate the migration in DEV + +1. Configure the app, installation, variable, and secret before running the + updated deployment workflow. Merge the workflow changes to `main` first. +2. Run **Deploy Dev** from `main` with the intended base version. Record the + workflow's commit SHA and resulting RC version. An old failed run retains its + old workflow definition; rerunning it will not apply this migration. +3. Verify the early app checks, schema publication, RC publication, and downstream + Lambda-Recipes DEV workflow. Confirm its image tag, image digest, and + `execute-recipe-dev` update result before calling the deployment complete. + DEV validation does not require dispatching the PROD workflow. + +The existing test and publishing gates, downstream `main` refs, and AWS roles +remain in place. GHCR is the WranglesPY CI/test image; Lambda-Recipes builds the +image deployed to `execute-recipe-dev`. + +The workflows contain no fallback to `CROSS_REPO_PAT_V2`. After successful DEV +validation, an administrator may retire that old secret once its remaining +consumers elsewhere have been checked. Do not remove shared credentials merely +because WranglesPY no longer references them. + +For key rotation, create a replacement key on the same app, update +`DEPLOY_APP_PRIVATE_KEY`, validate the app checks, and then revoke the old key. +The app and Client ID can remain unchanged. + +## References + +- [GitHub App authentication in Actions](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow) +- [Installation token permissions and expiration](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app) +- [Pinned token action and inputs](https://github.com/actions/create-github-app-token/blob/bcd2ba49218906704ab6c1aa796996da409d3eb1/action.yml) +- [Repository scope of GITHUB_TOKEN](https://docs.github.com/en/actions/concepts/security/github_token) diff --git a/tests/connectors/test_train.py b/tests/connectors/test_train.py index dfdc2996..2be82db1 100644 --- a/tests/connectors/test_train.py +++ b/tests/connectors/test_train.py @@ -1028,26 +1028,53 @@ def test_insert_duplicate_keys(self): wrangles.recipe.run(recipe, dataframe=df) - def test_update_model_not_found(self): - """ - Test update fails when model doesn't exist - """ - df = pd.DataFrame({ - 'Key': ['Rachel', 'Dolores'], - 'Value': ['Blade Runner 2049', 'Westworld Updated'] - }) - - recipe = """ - write: - - train.lookup: - model_id: test-model-id - action: UPDATE - """ - - # This would test with an actual existing model - # For testing purposes, we'll catch the expected error - with pytest.raises(RuntimeError, match="Access denied to model test-model-id"): - wrangles.recipe.run(recipe, dataframe=df) + @pytest.mark.parametrize( + "status_code, error_type, message", + [ + pytest.param( + 404, + RuntimeError, + "Something went wrong trying to access model test-model-id", + id="missing-model", + ), + pytest.param( + 403, + wrangles.data.AuthorizationError, + "Access denied to model test-model-id. Check the user's model permissions.", + id="access-denied", + ), + ], + ) + def test_update_model_access_errors(self, mocker, status_code, error_type, message): + """Missing models return 404; existing models without access return 403.""" + response = _requests.Response() + response.status_code = status_code + mocker.patch("wrangles.data._auth.get_access_token", return_value="test-token") + request = mocker.patch("wrangles.data._utils.request_retries", return_value=response) + train_lookup = mocker.patch("wrangles.connectors.train._train.lookup") + + df = pd.DataFrame({ + 'Key': ['Rachel', 'Dolores'], + 'Value': ['Blade Runner 2049', 'Westworld Updated'], + }) + recipe = """ + write: + - train.lookup: + model_id: test-model-id + action: UPDATE + """ + + with pytest.raises(error_type) as error: + wrangles.recipe.run(recipe, dataframe=df) + + assert type(error.value) is error_type + assert str(error.value) == f"train.lookup (line 3) - {message}" + assert request.called + for call in request.call_args_list: + assert call.kwargs["request_type"] == "GET" + assert call.kwargs["url"].endswith("/model/metadata") + assert call.kwargs["params"] == {"id": "test-model-id"} + train_lookup.assert_not_called() def test_action_parameter_validation_recipe(self): """