-
Notifications
You must be signed in to change notification settings - Fork 55
feat: add support for syncing secrets to GitHub Actions & Dependabot #716
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
Changes from 11 commits
e996e53
b1e7ef2
99a6626
28b4e85
147fc90
1ab0c60
e578944
3e3aabe
09ec80b
a983068
d885021
90d07c7
ecc0852
c2476fc
965cff5
ce18083
9078eb9
338c0ca
62391d1
2383ef8
280d8d7
df156be
f1bc5be
3a12700
d78a7e1
220eacd
91172bd
180dc9d
4c9ee56
05ff6fe
1e14c63
7f3691f
e4f440c
5900c6f
62e04b6
bda63e0
c364131
cb3aed2
0d0e5e1
be60fe4
033bf69
2762078
c9414c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,11 @@ class GitHubRepoType(ObjectType): | |
| type = graphene.String() | ||
|
|
||
|
|
||
| class GitHubOrgType(ObjectType): | ||
| name = graphene.String() | ||
| role = graphene.String() | ||
|
|
||
|
|
||
| def normalize_api_host(api_host): | ||
| if not api_host or api_host.strip() == "": | ||
| api_host = GITHUB_CLOUD_API_URL | ||
|
|
@@ -87,6 +92,59 @@ def serialize_repos(repos): | |
| return all_repos | ||
|
|
||
|
|
||
| def list_orgs(credential_id): | ||
| ProviderCredentials = apps.get_model("api", "ProviderCredentials") | ||
|
|
||
| pk, sk = get_server_keypair() | ||
| credential = ProviderCredentials.objects.get(id=credential_id) | ||
|
|
||
| access_token = decrypt_asymmetric( | ||
| credential.credentials["access_token"], sk.hex(), pk.hex() | ||
| ) | ||
|
|
||
| api_host = GITHUB_CLOUD_API_URL | ||
| if "host" in credential.credentials: | ||
| api_host = decrypt_asymmetric( | ||
| credential.credentials["api_url"], sk.hex(), pk.hex() | ||
| ) | ||
|
|
||
| api_host = normalize_api_host(api_host) | ||
|
|
||
| headers = { | ||
| "Authorization": f"Bearer {access_token}", | ||
| "Accept": "application/vnd.github+json", | ||
| } | ||
|
|
||
| all_orgs = [] | ||
| page = 1 | ||
|
|
||
| while True: | ||
| response = requests.get( | ||
| f"{api_host}/user/orgs?per_page=100&page={page}", headers=headers | ||
| ) | ||
| if response.status_code != 200: | ||
| raise Exception(f"Error fetching organizations: {response.text}") | ||
|
|
||
| orgs_on_page = response.json() | ||
| if not orgs_on_page: | ||
| break | ||
|
|
||
| for org in orgs_on_page: | ||
| # Get org membership to determine role | ||
| membership_response = requests.get( | ||
| f"{api_host}/user/memberships/orgs/{org['login']}", headers=headers | ||
| ) | ||
| role = "member" | ||
| if membership_response.status_code == 200: | ||
| role = membership_response.json().get("role", "member") | ||
|
||
|
|
||
| all_orgs.append({"name": org["login"], "role": role}) | ||
nimish-ks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| page += 1 | ||
|
|
||
| return all_orgs | ||
|
|
||
|
|
||
| def get_gh_actions_credentials(environment_sync): | ||
| pk, sk = get_server_keypair() | ||
|
|
||
|
|
@@ -301,3 +359,101 @@ def sync_github_secrets( | |
|
|
||
| traceback.print_exc() | ||
| return False, {"message": f"An unexpected error occurred: {str(e)}"} | ||
|
|
||
|
|
||
| def get_all_org_secrets(org, headers, api_host=GITHUB_CLOUD_API_URL): | ||
| api_host = normalize_api_host(api_host) | ||
| all_secrets = [] | ||
| page = 1 | ||
| while True: | ||
| response = requests.get( | ||
| f"{api_host}/orgs/{org}/actions/secrets?page={page}", | ||
| headers=headers, | ||
| ) | ||
| if response.status_code != 200 or not response.json().get("secrets"): | ||
| break | ||
| all_secrets.extend(response.json()["secrets"]) | ||
nimish-ks marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| page += 1 | ||
| return all_secrets | ||
|
|
||
|
|
||
| def sync_github_org_secrets( | ||
| secrets, | ||
| access_token, | ||
| org, | ||
| api_host=GITHUB_CLOUD_API_URL, | ||
| visibility="all", | ||
| ): | ||
| api_host = normalize_api_host(api_host) | ||
|
|
||
| try: | ||
| if not check_rate_limit(access_token, api_host): | ||
| return False, {"message": "Rate limit exceeded"} | ||
|
|
||
| headers = { | ||
| "Authorization": f"Bearer {access_token}", | ||
| "Accept": "application/vnd.github+json", | ||
| } | ||
|
|
||
| public_key_url = f"{api_host}/orgs/{org}/actions/secrets/public-key" | ||
|
|
||
| public_key_response = requests.get(public_key_url, headers=headers) | ||
| if public_key_response.status_code != 200: | ||
| if public_key_response.status_code == 404: | ||
| return False, { | ||
| "response_code": public_key_response.status_code, | ||
| "message": "Unable to access organization. Please verify the organization exists and your access token has the required permissions (admin:org scope).", | ||
| } | ||
| return False, { | ||
| "response_code": public_key_response.status_code, | ||
| "message": f"Failed to fetch organization public key: {public_key_response.text}", | ||
| } | ||
|
|
||
| public_key = public_key_response.json() | ||
| key_id = public_key["key_id"] | ||
| public_key_value = public_key["key"] | ||
|
|
||
| local_secrets = {k: v for k, v, _ in secrets} | ||
| existing_secrets = get_all_org_secrets(org, headers, api_host) | ||
| existing_secret_names = {secret["name"] for secret in existing_secrets} | ||
|
|
||
| for key, value in local_secrets.items(): | ||
| encrypted_value = encrypt_secret(public_key_value, value) | ||
| if len(encrypted_value) > 64 * 1024: | ||
| continue # Skip oversized secret | ||
nimish-ks marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| secret_data = { | ||
| "encrypted_value": encrypted_value, | ||
| "key_id": key_id, | ||
| "visibility": visibility, | ||
| } | ||
| secret_url = f"{api_host}/orgs/{org}/actions/secrets/{key}" | ||
| response = requests.put(secret_url, headers=headers, json=secret_data) | ||
|
|
||
| if response.status_code not in [201, 204]: | ||
| return False, { | ||
| "response_code": response.status_code, | ||
| "message": f"Error syncing secret '{key}': {response.text}", | ||
| } | ||
|
|
||
| for secret_name in existing_secret_names: | ||
| if secret_name not in local_secrets: | ||
| delete_url = f"{api_host}/orgs/{org}/actions/secrets/{secret_name}" | ||
| delete_response = requests.delete(delete_url, headers=headers) | ||
| if delete_response.status_code != 204: | ||
| return False, { | ||
| "response_code": delete_response.status_code, | ||
| "message": f"Error deleting secret '{secret_name}': {delete_response.text}", | ||
| } | ||
|
|
||
| return True, {"message": "Organization secrets synced successfully"} | ||
|
|
||
| except requests.RequestException as e: | ||
| return False, {"message": f"HTTP request error: {str(e)}"} | ||
| except json.JSONDecodeError: | ||
| return False, {"message": "Error decoding JSON response"} | ||
| except Exception as e: | ||
| import traceback | ||
|
|
||
| traceback.print_exc() | ||
| return False, {"message": f"An unexpected error occurred: {str(e)}"} | ||
Uh oh!
There was an error while loading. Please reload this page.