Skip to content

Commit ad7ecd8

Browse files
authored
Merge pull request #38 from sprahasingh/fix/github-actions-temporary-ssh
fix: secure GitHub Actions deployment with temporary SSH access
2 parents 46734dd + a100a3e commit ad7ecd8

5 files changed

Lines changed: 990 additions & 5 deletions

File tree

‎.github/workflows/production.yml‎

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7+
schedule:
8+
- cron: "*/15 * * * *"
79
workflow_dispatch:
810
inputs:
911
operation:
1012
description: Production action
1113
type: choice
12-
options: [deploy, deploy_existing, rollback]
14+
options: [deploy, deploy_existing, rollback, recover_ssh]
1315
default: deploy
1416
required: true
1517
publication_run_id:
@@ -38,7 +40,9 @@ concurrency:
3840

3941
jobs:
4042
test:
41-
if: github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy' || inputs.operation == 'deploy_existing'
43+
if: >-
44+
github.event_name == 'push' || github.event_name == 'pull_request' ||
45+
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'deploy' || inputs.operation == 'deploy_existing'))
4246
runs-on: ubuntu-latest
4347
permissions:
4448
contents: read
@@ -96,8 +100,10 @@ jobs:
96100
--volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro"
97101
--volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro"
98102
--volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro"
103+
--volume "$GITHUB_WORKSPACE/docs/ghcr-deployment.md:/tmp/ghcr-deployment.md:ro"
99104
--volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro"
100105
--env CODELENS_WORKFLOW_FILE=/tmp/production.yml
106+
--env CODELENS_DEPLOYMENT_DOC=/tmp/ghcr-deployment.md
101107
--env PYTHONPATH=/tmp
102108
--env DEBUG=false
103109
--env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test
@@ -111,7 +117,10 @@ jobs:
111117
112118
publish:
113119
needs: test
114-
if: github.ref == 'refs/heads/main' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy')
120+
if: >-
121+
github.ref == 'refs/heads/main' &&
122+
((github.event_name == 'push') ||
123+
(github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy'))
115124
runs-on: ubuntu-latest
116125
permissions:
117126
contents: read
@@ -150,8 +159,10 @@ jobs:
150159
--volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro"
151160
--volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro"
152161
--volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro"
162+
--volume "$GITHUB_WORKSPACE/docs/ghcr-deployment.md:/tmp/ghcr-deployment.md:ro"
153163
--volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro"
154164
--env CODELENS_WORKFLOW_FILE=/tmp/production.yml
165+
--env CODELENS_DEPLOYMENT_DOC=/tmp/ghcr-deployment.md
155166
--env PYTHONPATH=/tmp
156167
--env DEBUG=false
157168
--env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test
@@ -228,14 +239,31 @@ jobs:
228239
(github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && needs.verify_existing.result == 'success') ||
229240
(github.event_name == 'push' && needs.publish.result == 'success' && vars.CODELENS_AUTO_DEPLOY == 'true'))
230241
runs-on: ubuntu-latest
242+
timeout-minutes: 60
231243
environment: production
232244
permissions:
233245
contents: read
234246
packages: read
247+
id-token: write
235248
steps:
236249
- uses: actions/checkout@v7.0.1
237250
with:
238251
persist-credentials: false
252+
- name: Assume the scoped AWS deployment role
253+
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
254+
with:
255+
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
256+
aws-region: eu-north-1
257+
audience: sts.amazonaws.com
258+
role-session-name: codelens-${{ github.run_id }}-${{ github.run_attempt }}
259+
mask-aws-account-id: true
260+
- name: Authorize this runner's temporary SSH rule
261+
id: ssh_access
262+
env:
263+
RUN_ID: ${{ github.run_id }}
264+
RUN_ATTEMPT: ${{ github.run_attempt }}
265+
shell: bash
266+
run: python3 scripts/manage_temporary_ssh.py authorize --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
239267
- name: Configure verified SSH host key
240268
env:
241269
EC2_SSH_PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
@@ -304,17 +332,42 @@ jobs:
304332
host="$EC2_USER@$EC2_HOST"
305333
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
306334
ssh "${ssh_args[@]}" "$host" "rm -f '/tmp/codelens-deploy-${RUN_ID}-${RUN_ATTEMPT}.sh' '/tmp/codelens-compose-${RUN_ID}-${RUN_ATTEMPT}.yml' '/tmp/codelens-ghcr-token-${RUN_ID}-${RUN_ATTEMPT}'" || true
335+
- name: Revoke this run's temporary SSH rule
336+
if: always() && steps.ssh_access.outputs.ssh_rule_id != ''
337+
env:
338+
SSH_RULE_ID: ${{ steps.ssh_access.outputs.ssh_rule_id }}
339+
RUN_ID: ${{ github.run_id }}
340+
RUN_ATTEMPT: ${{ github.run_attempt }}
341+
shell: bash
342+
run: python3 scripts/manage_temporary_ssh.py revoke --rule-id "$SSH_RULE_ID" --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
307343

308344
rollback:
309345
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' && github.ref == 'refs/heads/main'
310346
runs-on: ubuntu-latest
347+
timeout-minutes: 60
311348
environment: production
312349
permissions:
313350
contents: read
351+
id-token: write
314352
steps:
315353
- uses: actions/checkout@v7.0.1
316354
with:
317355
persist-credentials: false
356+
- name: Assume the scoped AWS deployment role
357+
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
358+
with:
359+
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
360+
aws-region: eu-north-1
361+
audience: sts.amazonaws.com
362+
role-session-name: codelens-rollback-${{ github.run_id }}-${{ github.run_attempt }}
363+
mask-aws-account-id: true
364+
- name: Authorize this runner's temporary SSH rule
365+
id: ssh_access
366+
env:
367+
RUN_ID: ${{ github.run_id }}
368+
RUN_ATTEMPT: ${{ github.run_attempt }}
369+
shell: bash
370+
run: python3 scripts/manage_temporary_ssh.py authorize --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
318371
- name: Configure verified SSH host key
319372
env:
320373
EC2_SSH_PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
@@ -359,3 +412,38 @@ jobs:
359412
host="$EC2_USER@$EC2_HOST"
360413
ssh_args=(-i "$SSH_DIR/id_ed25519" -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$SSH_DIR/known_hosts")
361414
ssh "${ssh_args[@]}" "$host" "rm -f '/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.sh' '/tmp/codelens-rollback-${RUN_ID}-${RUN_ATTEMPT}.yml'" || true
415+
- name: Revoke this run's temporary SSH rule
416+
if: always() && steps.ssh_access.outputs.ssh_rule_id != ''
417+
env:
418+
SSH_RULE_ID: ${{ steps.ssh_access.outputs.ssh_rule_id }}
419+
RUN_ID: ${{ github.run_id }}
420+
RUN_ATTEMPT: ${{ github.run_attempt }}
421+
shell: bash
422+
run: python3 scripts/manage_temporary_ssh.py revoke --rule-id "$SSH_RULE_ID" --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT"
423+
424+
recover_ssh:
425+
if: >-
426+
github.ref == 'refs/heads/main' &&
427+
((github.event_name == 'schedule') ||
428+
(github.event_name == 'workflow_dispatch' && inputs.operation == 'recover_ssh'))
429+
runs-on: ubuntu-latest
430+
timeout-minutes: 10
431+
environment: production-ssh-recovery
432+
permissions:
433+
contents: read
434+
id-token: write
435+
steps:
436+
- uses: actions/checkout@v7.0.1
437+
with:
438+
persist-credentials: false
439+
- name: Assume the scoped AWS cleanup role
440+
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
441+
with:
442+
role-to-assume: ${{ vars.CODELENS_AWS_ROLE_ARN }}
443+
aws-region: eu-north-1
444+
audience: sts.amazonaws.com
445+
role-session-name: codelens-ssh-recovery-${{ github.run_id }}-${{ github.run_attempt }}
446+
mask-aws-account-id: true
447+
- name: Remove expired CodeLens runner SSH rules
448+
shell: bash
449+
run: python3 scripts/manage_temporary_ssh.py recover-expired

‎docs/ghcr-deployment.md‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,213 @@ cancels an in-progress deployment.
115115
GitHub Actions and GHCR have account-specific included usage and storage limits.
116116
Monitor the account's current quotas; immutable commit tags and rollback images
117117
are intentionally retained rather than automatically deleted.
118+
119+
## Temporary GitHub Actions SSH access with AWS OIDC
120+
121+
The EC2 security group sg-05c1462a133b8d00f is managed by a dedicated
122+
GitHub Actions role. For deploy and rollback runs, the workflow obtains a
123+
short-lived AWS session through GitHub OIDC, reads the current runner IPv4 from
124+
https://checkip.amazonaws.com/ over verified HTTPS, validates that the
125+
response is exactly one globally routable IPv4 address, and adds one TCP/22
126+
/32 ingress rule. The current fixed rule TTL is two hours. The helper stores
127+
the AWS-returned sgr-... rule ID as a job output and passes that exact ID to
128+
the cleanup command. It never searches by or revokes the Mac rule
129+
sgr-070a3d2767818f890; the existing Mac, HTTP, and HTTPS rules are otherwise
130+
left alone.
131+
132+
The revoke helper first describes the requested rule in the fixed security
133+
group and checks its group, rule ID, direction, TCP/22 ports, public /32 source,
134+
management tags, description, and this workflow run identity. It then calls
135+
revoke-security-group-ingress with that one AWS rule ID and verifies the rule
136+
is absent. Errors are reduced to fixed messages. A failed revoke fails the job.
137+
The workflow's always() cleanup step handles normal failures and cancellation.
138+
A scheduled job runs every 15 minutes in the separate
139+
production-ssh-recovery GitHub environment and removes only expired rules with
140+
the exact CodeLens owner/run/expiry tags and matching run description. The
141+
recovery environment must be restricted
142+
to main, have no secrets, and have no reviewer gate so cleanup can run
143+
unattended. It receives the same narrowly scoped AWS role. A manual recover_ssh
144+
dispatch from main is also available.
145+
146+
### GitHub setup
147+
148+
1. Keep the existing protected production environment and its required
149+
reviewers. Its deployment branches must remain restricted to main.
150+
2. Create production-ssh-recovery. Restrict its deployment branches to main;
151+
add no secrets and no required reviewers. It is used only by the 15-minute
152+
stale-rule reaper and manual recover_ssh operation.
153+
3. Add repository variable CODELENS_AWS_ROLE_ARN with the ARN of the IAM role
154+
described below. It is an ARN, not a credential. Do not add AWS access keys.
155+
4. Keep EC2_HOST, EC2_USER, EC2_SSH_PRIVATE_KEY, and EC2_KNOWN_HOSTS secrets
156+
only in production. No EC2 SSH secret is used by the recovery job.
157+
5. The IAM OIDC provider URL is
158+
https://token.actions.githubusercontent.com, with audience sts.amazonaws.com.
159+
Add the provider only if it does not already exist.
160+
161+
The verified GitHub repository OIDC settings are `use_default: true`,
162+
`use_immutable_subject: true`, and immutable subject prefix
163+
`repo@99238925/CodeLens@1334470875`. No custom include-claim-key list is
164+
configured. With an environment, the exact subject values are
165+
`repo:sprahasingh@99238925/CodeLens@1334470875:environment:production` and
166+
`repo:sprahasingh@99238925/CodeLens@1334470875:environment:production-ssh-recovery`.
167+
GitHub documents this immutable `repo:OWNER@OWNER-ID/REPO@REPO-ID` syntax and
168+
the `:environment:ENVIRONMENT` suffix for environment jobs. The trust policy
169+
below accepts only those two subjects, the expected audience, repository and
170+
owner IDs, and `main`.
171+
172+
### IAM role trust policy
173+
174+
Create the IAM role `CodeLensGitHubActionsSSH` and use this trust policy.
175+
The account and GitHub IDs below are the verified values. The OIDC provider
176+
must exist in AWS account `510155707736`, region `eu-north-1`.
177+
178+
{
179+
"Version": "2012-10-17",
180+
"Statement": [
181+
{
182+
"Effect": "Allow",
183+
"Principal": {
184+
"Federated": "arn:aws:iam::510155707736:oidc-provider/token.actions.githubusercontent.com"
185+
},
186+
"Action": "sts:AssumeRoleWithWebIdentity",
187+
"Condition": {
188+
"StringEquals": {
189+
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
190+
"token.actions.githubusercontent.com:repository_owner_id": "99238925",
191+
"token.actions.githubusercontent.com:repository_id": "1334470875",
192+
"token.actions.githubusercontent.com:ref": "refs/heads/main",
193+
"token.actions.githubusercontent.com:environment": [
194+
"production",
195+
"production-ssh-recovery"
196+
],
197+
"token.actions.githubusercontent.com:sub": [
198+
"repo:sprahasingh@99238925/CodeLens@1334470875:environment:production",
199+
"repo:sprahasingh@99238925/CodeLens@1334470875:environment:production-ssh-recovery"
200+
]
201+
}
202+
}
203+
}
204+
]
205+
}
206+
207+
### IAM permissions policy
208+
209+
Attach only this inline policy to that role. The helper authorizes rules with
210+
`TagSpecifications` on the `AuthorizeSecurityGroupIngress` API call; it does
211+
not make a separate `CreateTags` API call. EC2 performs dependent authorization
212+
for `ec2:CreateTags` when tags are supplied during rule creation. This policy
213+
allows that tag-on-create path only, limits ingress authorization and
214+
revocation to the CodeLens security group, and permits read-only rule
215+
inspection in `eu-north-1`. It grants no instance, key, network-interface, or
216+
other security-group management permissions.
217+
218+
{
219+
"Version": "2012-10-17",
220+
"Statement": [
221+
{
222+
"Sid": "DescribeSecurityGroupRules",
223+
"Effect": "Allow",
224+
"Action": "ec2:DescribeSecurityGroupRules",
225+
"Resource": "*",
226+
"Condition": {
227+
"StringEquals": {"ec2:Region": "eu-north-1"}
228+
}
229+
},
230+
{
231+
"Sid": "AuthorizeIngressOnCodeLensGroup",
232+
"Effect": "Allow",
233+
"Action": "ec2:AuthorizeSecurityGroupIngress",
234+
"Resource": "arn:aws:ec2:eu-north-1:510155707736:security-group/sg-05c1462a133b8d00f",
235+
"Condition": {
236+
"StringEquals": {
237+
"ec2:SecurityGroupID": "sg-05c1462a133b8d00f",
238+
"ec2:Region": "eu-north-1"
239+
}
240+
}
241+
},
242+
{
243+
"Sid": "AuthorizeTaggedRuleCreation",
244+
"Effect": "Allow",
245+
"Action": "ec2:AuthorizeSecurityGroupIngress",
246+
"Resource": "arn:aws:ec2:eu-north-1:510155707736:security-group-rule/*",
247+
"Condition": {
248+
"StringEquals": {
249+
"aws:RequestTag/CodeLensManagedBy": "CodeLensGitHubActions",
250+
"ec2:Region": "eu-north-1"
251+
},
252+
"ForAllValues:StringEquals": {
253+
"aws:TagKeys": ["CodeLensManagedBy", "CodeLensRun", "CodeLensExpiresAt"]
254+
},
255+
"Null": {
256+
"aws:RequestTag/CodeLensRun": "false",
257+
"aws:RequestTag/CodeLensExpiresAt": "false"
258+
}
259+
}
260+
},
261+
{
262+
"Sid": "TagOnlyRulesCreatedByIngressAuthorization",
263+
"Effect": "Allow",
264+
"Action": "ec2:CreateTags",
265+
"Resource": "arn:aws:ec2:eu-north-1:510155707736:security-group-rule/*",
266+
"Condition": {
267+
"StringEquals": {
268+
"ec2:CreateAction": "AuthorizeSecurityGroupIngress",
269+
"aws:RequestTag/CodeLensManagedBy": "CodeLensGitHubActions",
270+
"ec2:Region": "eu-north-1"
271+
},
272+
"ForAllValues:StringEquals": {
273+
"aws:TagKeys": ["CodeLensManagedBy", "CodeLensRun", "CodeLensExpiresAt"]
274+
},
275+
"Null": {
276+
"aws:RequestTag/CodeLensRun": "false",
277+
"aws:RequestTag/CodeLensExpiresAt": "false"
278+
}
279+
}
280+
},
281+
{
282+
"Sid": "RevokeIngressOnlyOnCodeLensGroup",
283+
"Effect": "Allow",
284+
"Action": "ec2:RevokeSecurityGroupIngress",
285+
"Resource": "arn:aws:ec2:eu-north-1:510155707736:security-group/sg-05c1462a133b8d00f",
286+
"Condition": {
287+
"StringEquals": {
288+
"ec2:SecurityGroupID": "sg-05c1462a133b8d00f",
289+
"ec2:Region": "eu-north-1"
290+
}
291+
}
292+
}
293+
]
294+
}
295+
296+
AWS authorizes ingress authorization and revocation at the security-group
297+
level; it does not let IAM constrain a request to TCP/22, a /32, or one specific
298+
rule ID. `ec2:SecurityGroupID` is applied only to the security-group resource
299+
authorization check; AWS lists request-tag keys for the separate
300+
security-group-rule resource check. The helper enforces the port, /32, and
301+
rule-ownership checks, revokes by exact rule ID, and explicitly protects both
302+
the known Mac rule ID and its current /32 source. A compromised workflow role
303+
could still add another ingress rule or revoke another rule in this one group;
304+
isolating runner SSH rules in a dedicated security group would remove that
305+
IAM-level limitation but requires a separately approved infrastructure
306+
change. `DescribeSecurityGroupRules` has no resource-level ARN, so its read
307+
permission uses `Resource: "*"`; the region condition is the narrowest IAM
308+
scope available, while the helper filters requests to the CodeLens group.
309+
310+
### Retry and recovery
311+
312+
After configuring the role, provider, repository variable, and recovery
313+
environment, rerun Actions → Test, publish, and deploy CodeLens → Run workflow
314+
from main with operation=deploy_existing and the same verified publication
315+
run ID, source commit, and image digest. Do not select deploy; that mode
316+
publishes a new image. The workflow still runs tests, validates the previous
317+
successful publication, waits for production approval, then applies the
318+
existing Celery idle gate and deployment-helper rollback protections.
319+
320+
On the next scheduled recovery run, expired managed rules are removed. If
321+
cleanup fails, inspect the sanitized recovery result and manually dispatch
322+
recover_ssh from main; do not remove a rule by CIDR or description. If an
323+
expired managed rule cannot pass validation, the reaper fails closed and
324+
requires manual inspection of that exact tagged rule. No automated process
325+
can guarantee cleanup during a prolonged GitHub Actions outage; the two-hour
326+
expiry marker and scheduled reaper bound expected exposure when Actions is
327+
available.

0 commit comments

Comments
 (0)