From 35796b1069d434f34b9f5b8f1d21363b4e1e7e41 Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Wed, 21 Jan 2026 23:07:02 -0500 Subject: [PATCH] feat(forgejo): set Actions secrets at user/org scope - Add --scope {user,org,repo} (default user) to upsert Actions secrets\n- Keep repo support and add --org for org scope\n- Include security caveat in CLI help and warning output --- scripts/forgejo_set_actions_secret.py | 131 +++++++++++++++++++++----- 1 file changed, 106 insertions(+), 25 deletions(-) diff --git a/scripts/forgejo_set_actions_secret.py b/scripts/forgejo_set_actions_secret.py index 01a834f..8e2eac8 100644 --- a/scripts/forgejo_set_actions_secret.py +++ b/scripts/forgejo_set_actions_secret.py @@ -8,7 +8,6 @@ import re import subprocess import sys import tempfile -from typing import Optional, Tuple from urllib.parse import urljoin import requests @@ -38,8 +37,38 @@ def _api_headers(token: str) -> dict: } -def get_repo_public_key(base_url: str, token: str, owner: str, repo: str) -> dict: - url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/public-key") +def _public_key_url(base_url: str, scope: str, owner: str | None, repo: str | None, org: str | None) -> str: + base = base_url.rstrip("/") + "/" + if scope == "repo": + if not owner or not repo: + raise ValueError("owner/repo required for repo scope") + return urljoin(base, f"api/v1/repos/{owner}/{repo}/actions/secrets/public-key") + if scope == "org": + if not org: + raise ValueError("org required for org scope") + return urljoin(base, f"api/v1/orgs/{org}/actions/secrets/public-key") + if scope == "user": + return urljoin(base, "api/v1/user/actions/secrets/public-key") + raise ValueError(f"unsupported scope: {scope}") + + +def _secret_url(base_url: str, scope: str, owner: str | None, repo: str | None, org: str | None, name: str) -> str: + base = base_url.rstrip("/") + "/" + if scope == "repo": + if not owner or not repo: + raise ValueError("owner/repo required for repo scope") + return urljoin(base, f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}") + if scope == "org": + if not org: + raise ValueError("org required for org scope") + return urljoin(base, f"api/v1/orgs/{org}/actions/secrets/{name}") + if scope == "user": + return urljoin(base, f"api/v1/user/actions/secrets/{name}") + raise ValueError(f"unsupported scope: {scope}") + + +def get_public_key(base_url: str, token: str, scope: str, owner: str | None, repo: str | None, org: str | None) -> dict: + url = _public_key_url(base_url, scope, owner, repo, org) r = requests.get(url, headers=_api_headers(token), timeout=30) # Some Forgejo/Gitea versions do not expose a public-key endpoint. @@ -56,13 +85,22 @@ def get_repo_public_key(base_url: str, token: str, owner: str, repo: str) -> dic return data -def _put_repo_secret_auto(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None: +def _put_secret_auto( + base_url: str, + token: str, + scope: str, + owner: str | None, + repo: str | None, + org: str | None, + name: str, + value: str, +) -> None: try: - public = get_repo_public_key(base_url, token, owner, repo) + public = get_public_key(base_url, token, scope, owner, repo, org) encrypted = encrypt_secret(public["key"], value) - put_repo_secret(base_url, token, owner, repo, name, encrypted, public["key_id"]) + put_secret(base_url, token, scope, owner, repo, org, name, encrypted, public["key_id"]) except NotImplementedError: - put_repo_secret_plain(base_url, token, owner, repo, name, value) + put_secret_plain(base_url, token, scope, owner, repo, org, name, value) def encrypt_secret(public_key_b64: str, secret_value: str) -> str: @@ -110,8 +148,18 @@ def _ensure_ssh_keypair(private_key_path: str, comment: str, force: bool) -> Non raise RuntimeError("ssh-keygen failed to generate keypair") from e -def put_repo_secret(base_url: str, token: str, owner: str, repo: str, name: str, encrypted_value_b64: str, key_id: str) -> None: - url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}") +def put_secret( + base_url: str, + token: str, + scope: str, + owner: str | None, + repo: str | None, + org: str | None, + name: str, + encrypted_value_b64: str, + key_id: str, +) -> None: + url = _secret_url(base_url, scope, owner, repo, org, name) payload = { "encrypted_value": encrypted_value_b64, "key_id": key_id, @@ -120,15 +168,24 @@ def put_repo_secret(base_url: str, token: str, owner: str, repo: str, name: str, r.raise_for_status() -def put_repo_secret_plain(base_url: str, token: str, owner: str, repo: str, name: str, value: str) -> None: - url = urljoin(base_url.rstrip("/") + "/", f"api/v1/repos/{owner}/{repo}/actions/secrets/{name}") +def put_secret_plain( + base_url: str, + token: str, + scope: str, + owner: str | None, + repo: str | None, + org: str | None, + name: str, + value: str, +) -> None: + url = _secret_url(base_url, scope, owner, repo, org, name) # Some Forgejo/Gitea instances accept a plaintext secret value and encrypt server-side. payload = {"data": value} r = requests.put(url, headers=_api_headers(token), json=payload, timeout=30) r.raise_for_status() -def _read_ansible_vault_yaml(vault_file: str, vault_password_file: Optional[str]) -> dict: +def _read_ansible_vault_yaml(vault_file: str, vault_password_file: str | None) -> dict: if yaml is None: raise RuntimeError("PyYAML is required to read tokens from ansible-vault. Install with: pip install pyyaml") @@ -150,7 +207,7 @@ def _read_ansible_vault_yaml(vault_file: str, vault_password_file: Optional[str] return data -def _read_ansible_vault_text(vault_file: str, vault_password_file: Optional[str]) -> str: +def _read_ansible_vault_text(vault_file: str, vault_password_file: str | None) -> str: cmd = ["ansible-vault", "view", vault_file] if vault_password_file: cmd.extend(["--vault-password-file", vault_password_file]) @@ -164,7 +221,7 @@ def _read_ansible_vault_text(vault_file: str, vault_password_file: Optional[str] return p.stdout -def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_file: Optional[str]) -> None: +def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_file: str | None) -> None: with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8", prefix="vault_", suffix=".yml") as tf: tmp_path = tf.name tf.write(plaintext) @@ -185,7 +242,7 @@ def _write_ansible_vault_text(vault_file: str, plaintext: str, vault_password_fi pass -def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vault_password_file: Optional[str]) -> None: +def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vault_password_file: str | None) -> None: plaintext = _read_ansible_vault_text(vault_file, vault_password_file) if plaintext and not plaintext.endswith("\n"): plaintext += "\n" @@ -205,7 +262,7 @@ def _upsert_ansible_vault_scalar_line(vault_file: str, key: str, value: str, vau _write_ansible_vault_text(vault_file, plaintext, vault_password_file) -def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_password_file: Optional[str]) -> Tuple[Optional[str], list[str]]: +def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_password_file: str | None) -> tuple[str | None, list[str]]: data = _read_ansible_vault_yaml(vault_file, vault_password_file) keys = sorted([str(k) for k in data.keys()]) val = data.get(token_var) @@ -215,13 +272,21 @@ def _load_token_from_ansible_vault(vault_file: str, token_var: str, vault_passwo def args_parse(): - p = argparse.ArgumentParser(description="Create/update a Forgejo Actions secret in a repository.") + p = argparse.ArgumentParser( + description="Create/update Forgejo Actions secrets for app_ssh_access (SSH register/deregister keys).", + epilog=( + "Security caveat: user/org scoped secrets are available to workflows in all repos under that user/org. " + "Only store deploy keys here if you trust the workflows/repositories that can access them." + ), + ) p.add_argument("--base-url", default=os.environ.get("FORGEJO_BASE_URL", "https://git.jfraeys.com")) p.add_argument("--token", default=os.environ.get("FORGEJO_API_TOKEN")) p.add_argument("--vault-file", default="./secrets/vault.yml") p.add_argument("--vault-password-file", default="./secrets/.vault_pass") p.add_argument("--token-var", default="FORGEJO_API_TOKEN") - p.add_argument("--repo", required=True, help="owner/repo") + p.add_argument("--scope", choices=["user", "org", "repo"], default="user") + p.add_argument("--org", help="Organization name (required when --scope org)") + p.add_argument("--repo", help="owner/repo (required when --scope repo)") p.add_argument("--force-generate-ssh-key", action="store_true") p.add_argument( "--generate-ssh-keys", @@ -259,11 +324,26 @@ def main() -> int: ) return 2 - if "/" not in args.repo: - print("--repo must be in the form owner/repo", file=sys.stderr) - return 2 + owner: str | None = None + repo: str | None = None + org: str | None = None - owner, repo = args.repo.split("/", 1) + if args.scope == "repo": + if not args.repo or "/" not in args.repo: + print("--repo must be in the form owner/repo when --scope repo", file=sys.stderr) + return 2 + owner, repo = args.repo.split("/", 1) + elif args.scope == "org": + if not args.org: + print("--org is required when --scope org", file=sys.stderr) + return 2 + org = args.org + + if args.scope in ("user", "org"): + print( + "WARNING: Using user/org scoped secrets. Ensure only trusted workflows/repositories can access them.", + file=sys.stderr, + ) if args.generate_ssh_keys: _ensure_ssh_keypair(args.register_key_path, "actions-register", args.force_generate_ssh_key) @@ -286,8 +366,8 @@ def main() -> int: with open(args.deregister_key_path, "r", encoding="utf-8") as f: deregister_secret = f.read() - _put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_REGISTER", register_secret) - _put_repo_secret_auto(args.base_url, token, owner, repo, "SERVICE_SSH_KEY_DEREGISTER", deregister_secret) + _put_secret_auto(args.base_url, token, args.scope, owner, repo, org, "SERVICE_SSH_KEY_REGISTER", register_secret) + _put_secret_auto(args.base_url, token, args.scope, owner, repo, org, "SERVICE_SSH_KEY_DEREGISTER", deregister_secret) if args.update_vault_both_public_keys: with open(args.register_key_path + ".pub", "r", encoding="utf-8") as f: @@ -306,7 +386,8 @@ def main() -> int: args.vault_password_file, ) - print(f"Updated Actions secrets SERVICE_SSH_KEY_REGISTER and SERVICE_SSH_KEY_DEREGISTER for {owner}/{repo}") + target = "user" if args.scope == "user" else (f"org:{org}" if args.scope == "org" else f"repo:{owner}/{repo}") + print(f"Updated Actions secrets SERVICE_SSH_KEY_REGISTER and SERVICE_SSH_KEY_DEREGISTER for {target}") return 0