diff --git a/cicd/2-cicd/authorizer/__init__.py b/cicd/2-cicd/authorizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cicd/2-cicd/authorizer/authorizer.py b/cicd/2-cicd/authorizer/authorizer.py new file mode 100644 index 0000000..741f110 --- /dev/null +++ b/cicd/2-cicd/authorizer/authorizer.py @@ -0,0 +1,51 @@ +import os +import json +import boto3 +from urllib import request + +# Lambda handler for PR authorizer +# Fetches GitHub token from Secrets Manager, checks PR author permission, +# and starts CodeBuild if writer/maintainer/admin. + +def handler(event, context): + # Load env vars + secret_arn = os.environ['GITHUB_TOKEN_SECRET_ARN'] + owner = os.environ['GitHubOwner'] + repo = os.environ['GitHubRepo'] + branch = os.environ['GitHubBranch'] + project = os.environ['CODEBUILD_PROJECT'] + + # Fetch GitHub PAT from Secrets Manager + sm = boto3.client('secretsmanager') + secret = sm.get_secret_value(SecretId=secret_arn) + token = json.loads(secret['SecretString'])['token'] + + # Extract PR event details + detail = event.get('detail', {}) + pr = detail.get('pull_request', {}) + + # Only handle events on the configured branch + if pr.get('base', {}).get('ref') != branch: + return + + login = pr.get('user', {}).get('login') + if not login: + return + + # Build GitHub API URL for collaborator permission + url = f"https://api.github.com/repos/{owner}/{repo}/collaborators/{login}/permission" + req = request.Request( + url, + headers={ + 'Authorization': f'token {token}', + 'Accept': 'application/vnd.github.v3+json' + } + ) + # Call GitHub + with request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + + # Only allow write/maintain/admin + if data.get('permission') in ['write', 'maintain', 'admin']: + cb = boto3.client('codebuild') + cb.start_build(projectName=project) diff --git a/cicd/2-cicd/authorizer/tests/test_authorizer_integration.py b/cicd/2-cicd/authorizer/tests/test_authorizer_integration.py new file mode 100644 index 0000000..ea5aae3 --- /dev/null +++ b/cicd/2-cicd/authorizer/tests/test_authorizer_integration.py @@ -0,0 +1,106 @@ +import os +import json +import boto3 +import pytest +from moto import mock_secretsmanager, mock_codebuild +from authorizer.authorizer import handler +from urllib.error import URLError + +# Simulated HTTP response for GitHub API +class DummyResponse: + def __init__(self, data): + self._data = data + def read(self): + return json.dumps(self._data).encode('utf-8') + def __enter__(self): + return self + def __exit__(self, exc_type, exc_val, exc_tb): + pass + +@pytest.fixture(autouse=True) +def set_env_vars(monkeypatch): + monkeypatch.setenv('GITHUB_TOKEN_SECRET_ARN', 'arn:aws:secretsmanager:us-east-1:123456:secret:githtoken') + monkeypatch.setenv('GitHubOwner', 'code-dot-org') + monkeypatch.setenv('GitHubRepo', 'aiproxy') + monkeypatch.setenv('GitHubBranch', 'main') + monkeypatch.setenv('CODEBUILD_PROJECT', 'pr-build-project') + +@mock_secretsmanager +@mock_codebuild +def test_integration_starts_codebuild(monkeypatch): + # Setup SecretsManager + sm = boto3.client('secretsmanager', region_name='us-east-1') + sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'})) + + # Create CodeBuild project + cb = boto3.client('codebuild', region_name='us-east-1') + cb.create_project( + name='pr-build-project', + source={'type':'CODEPIPELINE'}, + artifacts={'type':'NO_ARTIFACTS'}, + environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'} + ) + + # Stub urlopen to return write permission + def fake_urlopen(req): + return DummyResponse({'permission':'maintain'}) + monkeypatch.setattr('authorizer.authorizer.request.urlopen', fake_urlopen) + + # Simulate event + event = { 'detail': { 'pull_request': { 'base': { 'ref': 'main' }, 'user': { 'login': 'octocat' } } } } + handler(event, None) + + # List builds to confirm start + builds = cb.list_builds_for_project(projectName='pr-build-project')['ids'] + assert len(builds) == 1 + +@mock_secretsmanager +@mock_codebuild +def test_integration_no_start_on_bad_permission(monkeypatch): + # Setup SecretsManager + sm = boto3.client('secretsmanager', region_name='us-east-1') + sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'})) + + # Create CodeBuild project + cb = boto3.client('codebuild', region_name='us-east-1') + cb.create_project( + name='pr-build-project', + source={'type':'CODEPIPELINE'}, + artifacts={'type':'NO_ARTIFACTS'}, + environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'} + ) + + # Stub urlopen to return read permission + def fake_urlopen(req): + return DummyResponse({'permission':'read'}) + monkeypatch.setattr('authorizer.authorizer.request.urlopen', fake_urlopen) + + event = { 'detail': { 'pull_request': { 'base': { 'ref': 'main' }, 'user': { 'login': 'octocat' } } } } + handler(event, None) + + builds = cb.list_builds_for_project(projectName='pr-build-project')['ids'] + assert len(builds) == 0 + +@mock_secretsmanager +@mock_codebuild +def test_integration_no_start_on_wrong_branch(monkeypatch): + # Setup SecretsManager and CodeBuild + sm = boto3.client('secretsmanager', region_name='us-east-1') + sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'})) + cb = boto3.client('codebuild', region_name='us-east-1') + cb.create_project( + name='pr-build-project', + source={'type':'CODEPIPELINE'}, + artifacts={'type':'NO_ARTIFACTS'}, + environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'} + ) + + # Stub urlopen + monkeypatch.setattr('authorizer.authorizer.request.urlopen', lambda req: DummyResponse({'permission':'admin'})) + + # Wrong branch + event = { 'detail': { 'pull_request': { 'base': { 'ref': 'feature' }, 'user': { 'login': 'octocat' } } } } + handler(event, None) + + builds = cb.list_builds_for_project(projectName='pr-build-project')['ids'] + assert len(builds) == 0 diff --git a/cicd/2-cicd/authorizer/tests/test_authorizer_unit.py b/cicd/2-cicd/authorizer/tests/test_authorizer_unit.py new file mode 100644 index 0000000..9b6ee52 --- /dev/null +++ b/cicd/2-cicd/authorizer/tests/test_authorizer_unit.py @@ -0,0 +1,83 @@ +import os +import json +import pytest +from unittest.mock import patch, MagicMock +from authorizer.authorizer import handler + +# Helper to build a minimal PR event +def make_event(login, ref="main"): + return { + "detail": { + "pull_request": { + "base": {"ref": ref}, + "user": {"login": login} + } + } + } + +@pytest.fixture(autouse=True) +def set_env_vars(monkeypatch): + monkeypatch.setenv('GITHUB_TOKEN_SECRET_ARN', 'arn:aws:secretsmanager:us-east-1:123:secret:test') + monkeypatch.setenv('GitHubOwner', 'code-dot-org') + monkeypatch.setenv('GitHubRepo', 'aiproxy') + monkeypatch.setenv('GitHubBranch', 'main') + monkeypatch.setenv('CODEBUILD_PROJECT', 'pr-build-project') + +@patch('authorizer.authorizer.boto3') +@patch('authorizer.authorizer.request.urlopen') +def test_handler_auth_start_build(mock_urlopen, mock_boto3): + # Mock SecretsManager get_secret_value + sm = MagicMock() + sm.get_secret_value.return_value = {'SecretString': json.dumps({'token': 'fake'})} + # Mock CodeBuild client + cb = MagicMock() + # Configure boto3.client side effects + def client_factory(name, **kwargs): + if name == 'secretsmanager': + return sm + if name == 'codebuild': + return cb + raise ValueError(f"Unexpected client {name}") + mock_boto3.client.side_effect = client_factory + + # Mock GitHub API response: permission = write + response = MagicMock() + response.read.return_value = json.dumps({'permission': 'write'}).encode() + mock_urlopen.return_value.__enter__.return_value = response + + # Invoke handler + evt = make_event(login='octocat', ref='main') + handler(evt, None) + + # Assert start_build was called + cb.start_build.assert_called_once_with(projectName='pr-build-project') + +@patch('authorizer.authorizer.boto3') +@patch('authorizer.authorizer.request.urlopen') +def test_handler_no_build_on_wrong_branch(mock_urlopen, mock_boto3): + # Wrong branch: should not call build + cb = MagicMock() + sm = MagicMock() + mock_boto3.client.side_effect = lambda name, **kwargs: sm if name=='secretsmanager' else cb + + evt = make_event(login='octocat', ref='feature') + handler(evt, None) + cb.start_build.assert_not_called() + +@patch('authorizer.authorizer.boto3') +@patch('authorizer.authorizer.request.urlopen') +def test_handler_no_build_on_insufficient_permission(mock_urlopen, mock_boto3): + # Insufficient GitHub permission: read only + sm = MagicMock() + sm.get_secret_value.return_value = {'SecretString': json.dumps({'token': 'fake'})} + cb = MagicMock() + mock_boto3.client.side_effect = lambda name, **kwargs: sm if name=='secretsmanager' else cb + + # Mock GitHub API permission read + resp = MagicMock() + resp.read.return_value = json.dumps({'permission': 'read'}).encode() + mock_urlopen.return_value.__enter__.return_value = resp + + evt = make_event(login='octocat', ref='main') + handler(evt, None) + cb.start_build.assert_not_called() diff --git a/cicd/2-cicd/cicd.template.yml b/cicd/2-cicd/cicd.template.yml index 5bbb4a7..ade77c5 100644 --- a/cicd/2-cicd/cicd.template.yml +++ b/cicd/2-cicd/cicd.template.yml @@ -31,6 +31,9 @@ Parameters: Description: A 'production' cicd stack includes automated tests in the pipeline and deploys 'test' and 'production' environments. Whereas a 'development' type will only deploy a development environment. Default: production AllowedValues: [development, production] + GitHubTokenSecretArn: + Type: String + Description: ARN of the SecretsManager secret containing a GitHub personal-access token with collaborator:read scope Conditions: TargetsMainBranch: !Equals [ !Ref GitHubBranch, main ] @@ -140,18 +143,106 @@ Resources: Artifacts: Type: NO_ARTIFACTS Triggers: - Webhook: true - FilterGroups: - - - Pattern: !Sub ^refs/heads/${GitHubBranch}$ - Type: BASE_REF - - Pattern: PULL_REQUEST_CREATED,PULL_REQUEST_UPDATED,PULL_REQUEST_REOPENED - Type: EVENT - # Manual PAUSE button, to disable non-GitHib-maintainers from triggering (we need to find a replacement for CodeBuild for this repo's CI, or make it not public) - - Pattern: ^(31292421|113540108|10283727|105933103|16494556|11708250|11284819|8747128|25372625|46464143|2205926|131809324|7014619|7144482|5107622|68714964|8001765|1372238|5184438|2933346|137330041|208083|26844240|12300669|4108328|107423305|1859238|244100|37230822|82185575|8324574|38662275|137838584|95503833|117784268|9256643|24883357|22244040|25193259|8573958|29001621|113938636|66776217|43474485|33666587|5454101|98911841|8847422|5552007|65205145|108825710|1382374|126921802|85528507|769225|223277|2157034|14046120|1466175|137829631|142271809|56283563|146779710|124813947|31674)$ - Type: ACTOR_ACCOUNT_ID - - # The CodeBuild Project is used in the CodePipeline pipeline to prepare for a release. - # It will perform any steps defined in the referenced buildspec.yml file. + Webhook: false + + # Add Lambda authorizer to call GitHub API and start build only for maintainers + PullRequestAuthorizerFunction: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: python3.9 + InlineCode: | + # Lambda authorizer for PR builds + # 1. Import modules + import os + import json + import boto3 + from urllib import request + + def handler(event, context): + # Fetch GitHub token from Secrets Manager + sm = boto3.client('secretsmanager') + secret = sm.get_secret_value(SecretId=os.environ['GITHUB_TOKEN_SECRET_ARN']) + token = json.loads(secret['SecretString'])['token'] + + # Parse PR event details + detail = event.get('detail', {}) + pr = detail.get('pull_request', {}) + + # Only process PRs against configured branch + if pr.get('base', {}).get('ref') != os.environ['GitHubBranch']: + return + + # Get PR author login + login = pr.get('user', {}).get('login') + + # Call GitHub API to check permission + url = ( + f"https://api.github.com/repos/" + f"{os.environ['GitHubOwner']}/{os.environ['GitHubRepo']}/" + f"collaborators/{login}/permission" + ) + req = request.Request( + url, + headers={ + 'Authorization': f"token {token}", + 'Accept': 'application/vnd.github.v3+json' + } + ) + with request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + + # If user has write/maintain/admin access, start CodeBuild + if data.get('permission') in ['write', 'maintain', 'admin']: + cb = boto3.client('codebuild') + cb.start_build(projectName=os.environ['CODEBUILD_PROJECT']) + Environment: + Variables: + GITHUB_TOKEN_SECRET_ARN: !Ref GitHubTokenSecretArn + GitHubOwner: !Ref GitHubOwner + GitHubRepo: !Ref GitHubRepo + GitHubBranch: !Ref GitHubBranch + CODEBUILD_PROJECT: !Ref PullRequestBuildProject + Policies: + - AWSLambdaBasicExecutionRole + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: !Ref GitHubTokenSecretArn + - Effect: Allow + Action: codebuild:StartBuild + Resource: !GetAtt PullRequestBuildProject.Arn + Timeout: 60 + + # Add EventBridge rule to trigger the authorizer on PR events + PullRequestEventRule: + Type: AWS::Events::Rule + Properties: + Name: !Sub "${AWS::StackName}-pr-event-rule" + EventPattern: + source: + - !Sub "aws.partner/github.com/${GitHubOwner}/${GitHubRepo}" + detail-type: + - "Pull Request State Change" + detail: + action: [opened,reopened,synchronize] + pull_request: + base: + ref: [!Ref GitHubBranch] + Targets: + - Id: PullRequestAuthorizer + Arn: !GetAtt PullRequestAuthorizerFunction.Arn + + # Grant EventBridge permission to invoke the lambda + PermissionForEventsToInvokeLambda: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref PullRequestAuthorizerFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt PullRequestEventRule.Arn + AppBuildProject: Type: AWS::CodeBuild::Project Properties: diff --git a/cicd/3-app/aiproxy/pr-buildspec.yml b/cicd/3-app/aiproxy/pr-buildspec.yml index 768521a..d6dd276 100644 --- a/cicd/3-app/aiproxy/pr-buildspec.yml +++ b/cicd/3-app/aiproxy/pr-buildspec.yml @@ -25,16 +25,26 @@ phases: - echo Logging in to Docker Hub... - echo $DOCKER_HUB_TOKEN | docker login -u $DOCKER_HUB_USERNAME --password-stdin - build: commands: - - set -e + - | + set -e + # Determine branch and commit + BRANCH_NAME=${CODEBUILD_WEBHOOK_HEAD_REF#"refs/heads/"} + COMMIT_HASH=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7} - - BRANCH_NAME=${CODEBUILD_WEBHOOK_HEAD_REF#"refs/heads/"} - - COMMIT_HASH=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7} + # Change to source directory + cd $CODEBUILD_SRC_DIR - - cd $CODEBUILD_SRC_DIR + # Run lint, build, and tests; abort on first failure + ./ci-lint.sh + ./ci-build.sh + ./ci-test.sh - - ./ci-lint.sh - - ./ci-build.sh - - ./ci-test.sh + post_build: + commands: + - echo "Verifying build success before moving on: $CODEBUILD_BUILD_SUCCEEDING" + - | + if [ "$CODEBUILD_BUILD_SUCCEEDING" != "1" ]; then + echo "Previous phase failed; aborting post_build" + exit 1 diff --git a/cicd/README.md b/cicd/README.md index 3ebf5ae..0c97901 100644 --- a/cicd/README.md +++ b/cicd/README.md @@ -74,6 +74,51 @@ By setting the `TARGET_BRANCH` you can create a new CI/CD pipeline that watches TARGET_BRANCH=mybranch cicd/2-cicd/deploy-cicd.sh ``` +## Pull Request Authorizer Hook + +This CI/CD stack now includes an EventBridge‐driven Lambda that verifies PR authors via the GitHub API before kicking off CodeBuild. It replaces the old `AllowedActorAccountIds` regex hack. + ++-------------------+ +--------------------+ +----------------------+ +--------------+ +| GitHub PullReq |----->| CodeStar Connection |----->| EventBridge Rule |----->| Lambda | +| (opened/update) | | (AWS integration) | | (filters branch & | | (authorizes | ++-------------------+ +--------------------+ | action) | +--------------+ + | + v + +---------------+ + | CodeBuild | + | (start_build) | + +---------------+ + +### GitHub Setup +1. Create a CodeStar Connection to your repo (`code-dot-org/aiproxy`). +2. In your AWS account, ensure that connection exists and the ARN is passed as `CodeStarConnectionResourceId`. +3. No manual webhooks needed: AWS EventBridge will subscribe to PR events under `aws.partner/github.com/${GitHubOwner}/${GitHubRepo}`. + +### How It Works +1. **EventBridge Rule** listens for `Pull Request State Change` events (actions: opened, reopened, synchronize) on the target branch. +2. **Lambda Authorizer** fetches your GitHub PAT from Secrets Manager, calls `/repos/{owner}/{repo}/collaborators/{login}/permission`, and checks if permission is `write`, `maintain`, or `admin`. +3. If authorized, Lambda calls `StartBuild` on your `PullRequestBuildProject`. + +### Testing Locally Without a Real PR +1. **Simulate EventBridge event** with AWS CLI: + ```shell + aws events put-events --entries '[{ + "Source": "aws.partner/github.com/code-dot-org/aiproxy", + "DetailType": "Pull Request State Change", + "Detail": "{ \"action\": \"opened\", \"pull_request\": { \"base\": { \"ref\": \"main\" }, \"user\": { \"login\": \"YOUR_GITHUB_ID\" } } }" + }]' --region us-east-1 + ``` +2. **Invoke Lambda locally** (with SAM CLI): + - Install [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html). + - `cd cicd/2-cicd` + - `sam local invoke PullRequestAuthorizerFunction --event event.json` + where `event.json` contains the same structure as above. +3. **Verify**: check CloudWatch logs for the Lambda, and see if CodeBuild start was called (check in AWS Console). + +### Important +- Make sure your GitHub PAT has `repo` and `read:org`/`collaborator:read` scopes. +- The Secret ARN must be passed as `GitHubTokenSecretArn` when deploying the stack. + ## Debugging & Troubleshooting ### Debugging `template.yml.erb` diff --git a/requirements.txt b/requirements.txt index 47ef2f0..b1f3aff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ requests-mock==1.11.0 coverage==7.3.2 scikit-learn~=1.3.2 boto3==1.34.30 +moto==4.0.0 esprima==4.0.1