-
Notifications
You must be signed in to change notification settings - Fork 86
feat: add a role-scoped GitHub automation gateway #559
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 4 commits
912bd40
7137fc1
c5396f8
b18b9b0
2f7ab0b
a429b56
41a47d8
748ec0e
f6420cf
d805306
7e7eefa
7951cbf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # GitHub role gateway | ||
|
|
||
| Use `scripts/github_factory_gateway.py` on the trusted control plane when Docker | ||
| automations need distinct GitHub grants. The gateway binds to one repository and | ||
| keeps the GitHub credential out of worker environments. Each worker receives a | ||
| separate random token for its role. | ||
|
|
||
| | Role | Granted operations | | ||
| | --- | --- | | ||
| | Triage | Read backlog, comment, create readiness labels, update issue labels/body | | ||
| | Developer | Read snapshots, comment, publish `factory/issue-N` branches, open PRs against `main` | | ||
| | Reviewer | Read snapshots, comment on a commit, publish the two factory acceptance statuses | | ||
| | Watchdog | Read repository and request a guarded merge | | ||
|
|
||
| No role can directly update main, force push, delete or administer the repository, | ||
| or invoke an unguarded merge. The developer can initialize an empty repository with | ||
| one empty `.gitkeep`, giving its first application PR a base. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Authenticate `gh` on the control plane with a credential restricted to the target | ||
| repository: contents, issues, pull requests, and commit statuses write access; checks | ||
| read access. Write a mode-0600 JSON file with cryptographically random tokens keyed | ||
| `triage`, `developer`, `reviewer`, and `watchdog`. Then run: | ||
|
|
||
| ```sh | ||
| FACTORY_REPOSITORY=owner/repository \ | ||
| FACTORY_CONTROL_FILE=/private/factory-role-tokens.json \ | ||
| FACTORY_BIND=172.17.0.1 python3 scripts/github_factory_gateway.py | ||
| ``` | ||
|
|
||
| The bind address must be reachable from Docker and restricted to that network. | ||
| The default port is 19102. Send POST requests with `Authorization: Bearer ROLE_TOKEN` | ||
| and JSON `{method, path, body}`. Paths are repository-relative REST paths, with | ||
| additional `/factory/archive`, `/factory/bootstrap`, and `/factory/merge` operations. | ||
| The gateway validates role grants before sending requests to GitHub. Never put the | ||
| control-plane credential or another role's token in worker configuration. | ||
|
|
||
| `/factory/archive` takes an exact commit SHA and returns a base64 tarball capped at | ||
| 25 MB, supporting private repositories without credential-bearing git remotes. | ||
| Workers must extract with a safe archive filter. `/factory/merge` takes a PR number | ||
| and reviewed SHA. It checks the current PR head, base ancestry, mergeability, | ||
| `software-factory/tests` and `software-factory/review`, and other CI results. Missing, | ||
| stale, failed, pending, or incompletely paginated checks reject the merge. The final | ||
| squash-merge request includes the reviewed SHA to prevent a head-update race. | ||
|
|
||
| Review uses COMMENT plus explicit acceptance statuses because GitHub does not allow | ||
| self-approval when the roles share an installation identity. Role separation must | ||
| also exist in execution: use fresh Docker runtimes and independent reviewer agents. | ||
| Code executing within a role's sandbox can access that role's token; use a separate | ||
| untrusted CI worker if stronger credential separation is required. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| """Repository-scoped GitHub gateway for the four factory roles. | ||
|
|
||
| Run in the trusted control plane. Workers receive role tokens, never the GitHub | ||
| credential. No arbitrary URLs, GraphQL, repository administration, or main-branch | ||
| writes are exposed. The merge operation independently checks current evidence. | ||
| """ | ||
|
|
||
| import base64 | ||
| import hashlib | ||
| import json | ||
| import os | ||
| import re | ||
| import secrets | ||
| import subprocess | ||
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| from pathlib import Path | ||
| from urllib.error import HTTPError | ||
| from urllib.request import Request, urlopen | ||
|
|
||
|
|
||
| CONTROL = {} | ||
| TOKEN = "" | ||
| ROOT = "" | ||
|
|
||
|
|
||
| def configure(): | ||
| global CONTROL, TOKEN, ROOT | ||
| repo = os.environ["FACTORY_REPOSITORY"] | ||
| if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): | ||
| raise ValueError("Expected owner/repository") | ||
| CONTROL = json.loads(Path(os.environ["FACTORY_CONTROL_FILE"]).read_text()) | ||
| TOKEN = subprocess.check_output(["gh", "auth", "token"], text=True).strip() | ||
| ROOT = f"https://api.github.com/repos/{repo}" | ||
|
|
||
|
|
||
| def github(method, path, body=None): | ||
| request = Request( | ||
| ROOT + path, | ||
| data=json.dumps(body).encode() if body is not None else None, | ||
| method=method, | ||
| headers={ | ||
| "Authorization": "Bearer " + TOKEN, | ||
| "Accept": "application/vnd.github+json", | ||
| "Content-Type": "application/json", | ||
| }, | ||
| ) | ||
| with urlopen(request, timeout=60) as response: | ||
| data = response.read() | ||
| return json.loads(data) if data else {} | ||
|
|
||
|
|
||
| def archive(sha): | ||
| if not re.fullmatch(r"[0-9a-f]{40}", sha): | ||
| raise ValueError("Archive requires an exact commit SHA") | ||
| req = Request( | ||
| ROOT + "/tarball/" + sha, headers={"Authorization": "Bearer " + TOKEN} | ||
| ) | ||
| with urlopen(req, timeout=90) as response: | ||
| content = response.read(25_000_001) | ||
| if len(content) > 25_000_000: | ||
| raise ValueError("Repository archive exceeds 25 MB") | ||
| return {"sha": sha, "tarball": base64.b64encode(content).decode()} | ||
|
|
||
|
|
||
| def latest_statuses(sha): | ||
| result = {} | ||
| for status in github("GET", f"/commits/{sha}/statuses?per_page=100"): | ||
| result.setdefault(status["context"], status) | ||
| return result | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def permitted(role, method, path, body): | ||
| route = path.split("?", 1)[0] | ||
| if method == "GET": | ||
| if role in ("triage", "developer", "reviewer") and re.fullmatch( | ||
| r"/issues(?:/\d+(?:/comments)?)?", route | ||
| ): | ||
| return True | ||
| if role == "triage" and route == "/labels": | ||
| return True | ||
| if role in ("developer", "reviewer", "watchdog") and ( | ||
| re.fullmatch(r"/pulls(?:/\d+(?:/reviews|/comments)?)?", route) | ||
| or re.fullmatch(r"/commits/[0-9a-f]{40}/(?:statuses|check-runs)", route) | ||
| ): | ||
| return True | ||
| if role in ("developer", "reviewer") and re.fullmatch( | ||
| r"/git/ref/heads/(?:main|factory/issue-\d+)", route | ||
| ): | ||
| return True | ||
| return role == "developer" and bool( | ||
| re.fullmatch(r"/git/commits/[0-9a-f]{40}", route) | ||
| ) | ||
| if re.fullmatch(r"/issues/\d+/comments", route) and method == "POST": | ||
| return role in ("triage", "developer", "reviewer") and set(body) == {"body"} | ||
| if role == "triage": | ||
| if method == "PATCH" and re.fullmatch(r"/issues/\d+", route): | ||
| return set(body) <= {"body", "labels"} | ||
| if method == "POST" and route == "/labels": | ||
| return body.get("name") in { | ||
| "ready-for-dev", | ||
| "priority:high", | ||
| "factory:attention", | ||
| } | ||
| if role == "developer": | ||
| if method == "POST" and route in {"/git/blobs", "/git/trees", "/git/commits"}: | ||
| return True | ||
| if method == "POST" and route == "/git/refs": | ||
| return bool( | ||
| re.fullmatch(r"refs/heads/factory/issue-\d+", body.get("ref", "")) | ||
| ) | ||
| if method == "PATCH" and re.fullmatch( | ||
| r"/git/refs/heads/factory/issue-\d+", route | ||
| ): | ||
| return body.get("force") is not True and set(body) <= {"sha", "force"} | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
| if method == "POST" and route == "/pulls": | ||
| return body.get("base") == "main" and bool( | ||
| re.fullmatch(r"factory/issue-\d+", body.get("head", "")) | ||
| ) | ||
| if role == "reviewer": | ||
| if method == "POST" and re.fullmatch(r"/pulls/\d+/reviews", route): | ||
| return body.get("event") == "COMMENT" and bool(body.get("commit_id")) | ||
| if method == "POST" and re.fullmatch(r"/statuses/[0-9a-f]{40}", route): | ||
| return body.get("context") in { | ||
| "software-factory/tests", | ||
| "software-factory/review", | ||
| } | ||
| return False | ||
|
|
||
|
|
||
| def merge(number, sha): | ||
| if number < 1 or not re.fullmatch(r"[0-9a-f]{40}", sha): | ||
| raise ValueError("Merge requires a PR number and exact commit SHA") | ||
| pr = github("GET", f"/pulls/{number}") | ||
| statuses = latest_statuses(sha) | ||
| contexts = ("software-factory/tests", "software-factory/review") | ||
| check_page = github("GET", f"/commits/{sha}/check-runs?per_page=100") | ||
| checks = check_page["check_runs"] | ||
| # Refuse a stale base and incomplete pagination rather than overlooking CI. | ||
| comparison = github("GET", f"/compare/{pr['base']['sha']}...{sha}") | ||
| eligible = ( | ||
| pr["state"] == "open" | ||
| and not pr["draft"] | ||
| and pr["head"]["sha"] == sha | ||
| and pr["base"]["ref"] == "main" | ||
| and re.fullmatch(r"factory/issue-\d+", pr["head"]["ref"]) | ||
| and pr["mergeable"] is True | ||
| and comparison["status"] in ("ahead", "identical") | ||
| and all(statuses.get(c, {}).get("state") == "success" for c in contexts) | ||
| and all(s["state"] == "success" for s in statuses.values()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| and check_page.get("total_count", len(checks)) == len(checks) | ||
| and all(c["conclusion"] in ("success", "neutral", "skipped") for c in checks) | ||
| ) | ||
| if not eligible: | ||
| raise ValueError("Current head lacks passing acceptance or current base") | ||
| return github( | ||
| "PUT", f"/pulls/{number}/merge", {"sha": sha, "merge_method": "squash"} | ||
| ) | ||
|
|
||
|
|
||
| class Handler(BaseHTTPRequestHandler): | ||
| def log_message(self, *_): | ||
| pass | ||
|
|
||
| def do_POST(self): | ||
| supplied = self.headers.get("Authorization", "").removeprefix("Bearer ") | ||
| role = next( | ||
| ( | ||
| r | ||
| for r in ("triage", "developer", "reviewer", "watchdog") | ||
| if secrets.compare_digest(supplied, CONTROL[r]) | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
| ), | ||
| None, | ||
| ) | ||
| if role is None: | ||
| return self.reply(401, {"error": "Invalid role credential"}) | ||
| if int(self.headers.get("Content-Length", "0")) > 12_000_000: | ||
| return self.reply(413, {"error": "Request too large"}) | ||
| try: | ||
| payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) | ||
|
neubig marked this conversation as resolved.
Outdated
|
||
| method, path = payload["method"], payload["path"] | ||
| body = payload.get("body") | ||
| if "%" in path or ".." in path or "#" in path or "\\" in path: | ||
| return self.reply(403, {"error": "Invalid path"}) | ||
| if path == "/factory/archive" and role in ("developer", "reviewer"): | ||
| return self.reply(200, archive(body["sha"])) | ||
| if path == "/factory/bootstrap" and role == "developer": | ||
| try: | ||
| return self.reply(200, github("GET", "/git/ref/heads/main")) | ||
| except HTTPError as exc: | ||
| if exc.code not in (404, 409): | ||
| raise | ||
| # GitHub's Git Database API rejects an empty repository. An | ||
| # empty placeholder gives the first application PR a base. | ||
| return self.reply( | ||
| 200, | ||
| github( | ||
| "PUT", | ||
| "/contents/.gitkeep", | ||
|
neubig marked this conversation as resolved.
neubig marked this conversation as resolved.
|
||
| { | ||
| "message": "Initialize software factory repository", | ||
| "content": "", | ||
| "branch": "main", | ||
| }, | ||
| ), | ||
| ) | ||
| if path == "/factory/merge" and role == "watchdog": | ||
| try: | ||
| result = merge(int(body["number"]), body["sha"]) | ||
| except ValueError as exc: | ||
| return self.reply(409, {"error": str(exc)}) | ||
| return self.reply(200, result) | ||
| if not permitted(role, method, path, body or {}): | ||
| return self.reply(403, {"error": "Operation outside role grant"}) | ||
| result = github(method, path, body) | ||
| if method != "GET": | ||
| print( | ||
| json.dumps( | ||
| { | ||
| "role": role, | ||
| "method": method, | ||
| "path": path, | ||
| "body_sha256": hashlib.sha256( | ||
| json.dumps(body).encode() | ||
| ).hexdigest(), | ||
| } | ||
| ), | ||
| flush=True, | ||
| ) | ||
| self.reply(200, result) | ||
| except HTTPError as exc: | ||
| self.reply(exc.code, {"error": exc.read().decode()[:1000]}) | ||
| except (ValueError, KeyError, TypeError) as exc: | ||
| self.reply(400, {"error": str(exc)}) | ||
|
neubig marked this conversation as resolved.
|
||
|
|
||
| def reply(self, status, data): | ||
| encoded = json.dumps(data).encode() | ||
| self.send_response(status) | ||
| self.send_header("Content-Type", "application/json") | ||
| self.send_header("Content-Length", str(len(encoded))) | ||
| self.end_headers() | ||
| self.wfile.write(encoded) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| configure() | ||
| ThreadingHTTPServer( | ||
| (os.environ.get("FACTORY_BIND", "172.17.0.1"), 19102), Handler | ||
| ).serve_forever() | ||
Uh oh!
There was an error while loading. Please reload this page.