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
This commit is contained in:
parent
0c6d09abcd
commit
35796b1069
1 changed files with 106 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue