Skip to content

Commit 7e327b8

Browse files
authored
Merge pull request #156 from MagicRB/github_app
Add GitHub App support
2 parents f8e87d5 + 0ac5dcb commit 7e327b8

File tree

16 files changed

+921
-59
lines changed

16 files changed

+921
-59
lines changed

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,36 @@ We have the following two roles:
6666

6767
### Integration with GitHub
6868

69-
To integrate with GitHub:
69+
#### GitHub App
70+
71+
To integrate with GitHub using app authentication:
72+
73+
1. **GitHub App**: Set up a GitHub app for Buildbot to enable GitHub user
74+
authentication on the Buildbot dashboard. Enable the following permissions:
75+
- Contents: Read-only
76+
- Metadata: Read-only
77+
- Commit statuses: Read and write
78+
- Webhooks: Read and write
79+
2. **GitHub App private key**: Get the app private key and app ID from GitHub,
80+
configure using the buildbot-nix NixOS module.
81+
3. **Install App**: Install the app for an organization or specific user.
82+
4. **Refresh GitHub Projects**: Currently buildbot-nix doesn't respond to
83+
changes (new repositories or installations) automatically, it is therefore
84+
necessary to manually trigger a reload or wait for the next periodic reload.
85+
86+
#### Legacy Token Auth
87+
88+
To integrate with GitHub using legacy token authentication:
7089

7190
1. **GitHub Token**: Obtain a GitHub token with `admin:repo_hook` and `repo`
7291
permissions. For GitHub organizations, it's advisable to create a separate
7392
GitHub user for managing repository webhooks.
7493

75-
#### Optional when using GitHub login
94+
### Optional when using GitHub login
7695

7796
1. **GitHub App**: Set up a GitHub app for Buildbot to enable GitHub user
78-
authentication on the Buildbot dashboard.
97+
authentication on the Buildbot dashboard. (can be the same as for GitHub App
98+
auth)
7999
2. **OAuth Credentials**: After installing the app, generate OAuth credentials
80100
and configure them in the buildbot-nix NixOS module. Set the callback url to
81101
`https://<your-domain>/auth/login`.

buildbot_nix/__init__.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,8 @@ def configure(self, config: dict[str, Any]) -> None:
872872
],
873873
)
874874
config["services"].append(backend.create_reporter())
875+
config.setdefault("secretProviders", [])
876+
config["secretsProviders"].extend(backend.create_secret_providers())
875877

876878
systemd_secrets = SecretInAFile(
877879
dirname=os.environ["CREDENTIALS_DIRECTORY"],
@@ -887,13 +889,15 @@ def configure(self, config: dict[str, Any]) -> None:
887889
backend.create_change_hook()
888890
)
889891

890-
if "auth" not in config["www"]:
891-
config["www"].setdefault("avatar_methods", [])
892+
config["www"].setdefault("avatar_methods", [])
893+
894+
for backend in backends.values():
895+
avatar_method = backend.create_avatar_method()
896+
print(avatar_method)
897+
if avatar_method is not None:
898+
config["www"]["avatar_methods"].append(avatar_method)
892899

893-
for backend in backends.values():
894-
avatar_method = backend.create_avatar_method()
895-
if avatar_method is not None:
896-
config["www"]["avatar_methods"].append(avatar_method)
900+
if "auth" not in config["www"]:
897901
# TODO one cannot have multiple auth backends...
898902
if auth is not None:
899903
config["www"]["auth"] = auth

buildbot_nix/common.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
import http.client
33
import json
44
import urllib.request
5+
from pathlib import Path
6+
from tempfile import NamedTemporaryFile
57
from typing import Any
68

79

810
def slugify_project_name(name: str) -> str:
911
return name.replace(".", "-").replace("/", "-")
1012

1113

12-
def paginated_github_request(url: str, token: str) -> list[dict[str, Any]]:
14+
def paginated_github_request(
15+
url: str, token: str, subkey: None | str = None
16+
) -> list[dict[str, Any]]:
1317
next_url: str | None = url
1418
items = []
1519
while next_url:
@@ -29,7 +33,10 @@ def paginated_github_request(url: str, token: str) -> list[dict[str, Any]]:
2933
link_parts = link.split(";")
3034
if link_parts[1].strip() == 'rel="next"':
3135
next_url = link_parts[0][1:-1]
32-
items += res.json()
36+
if subkey is not None:
37+
items += res.json()[subkey]
38+
else:
39+
items += res.json()
3340
return items
3441

3542

@@ -78,3 +85,15 @@ def http_request(
7885
msg = f"Request for {method} {url} failed with {e.code} {e.reason}: {resp_body}"
7986
raise HttpError(msg) from e
8087
return HttpResponse(resp)
88+
89+
90+
def atomic_write_file(file: Path, data: str) -> None:
91+
with NamedTemporaryFile("w", delete=False, dir=file.parent) as f:
92+
path = Path(f.name)
93+
try:
94+
f.write(data)
95+
f.flush()
96+
path.rename(file)
97+
except OSError:
98+
path.unlink()
99+
raise

buildbot_nix/github/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

buildbot_nix/github/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

buildbot_nix/github/auth/_type.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
4+
5+
@dataclass
6+
class AuthType:
7+
pass
8+
9+
10+
@dataclass
11+
class AuthTypeLegacy(AuthType):
12+
token_secret_name: str = "github-token"
13+
14+
15+
@dataclass
16+
class AuthTypeApp(AuthType):
17+
app_id: int
18+
app_secret_key_name: str = "github-app-secret-key"
19+
app_installation_token_map_name: Path = Path(
20+
"github-app-installation-token-map.json"
21+
)
22+
app_project_id_map_name: Path = Path("github-app-project-id-map-name.json")
23+
app_jwt_token_name: Path = Path("github-app-jwt-token")
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import json
2+
from datetime import UTC, datetime, timedelta
3+
from pathlib import Path
4+
from typing import Any
5+
6+
from buildbot_nix.common import (
7+
HttpResponse,
8+
atomic_write_file,
9+
http_request,
10+
)
11+
12+
from .jwt_token import JWTToken
13+
from .repo_token import RepoToken
14+
15+
16+
class InstallationToken(RepoToken):
17+
GITHUB_TOKEN_LIFETIME: timedelta = timedelta(minutes=60)
18+
19+
jwt_token: JWTToken
20+
installation_id: int
21+
22+
token: str
23+
expiration: datetime
24+
installations_token_map_name: Path
25+
26+
@staticmethod
27+
def _create_installation_access_token(
28+
jwt_token: JWTToken, installation_id: int
29+
) -> HttpResponse:
30+
return http_request(
31+
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
32+
data={},
33+
headers={"Authorization": f"Bearer {jwt_token.get()}"},
34+
method="POST",
35+
)
36+
37+
@staticmethod
38+
def _generate_token(
39+
jwt_token: JWTToken, installation_id: int
40+
) -> tuple[str, datetime]:
41+
token = InstallationToken._create_installation_access_token(
42+
jwt_token, installation_id
43+
).json()["token"]
44+
expiration = datetime.now(tz=UTC) + InstallationToken.GITHUB_TOKEN_LIFETIME
45+
46+
return token, expiration
47+
48+
def __init__(
49+
self,
50+
jwt_token: JWTToken,
51+
installation_id: int,
52+
installations_token_map_name: Path,
53+
installation_token: None | tuple[str, datetime] = None,
54+
) -> None:
55+
self.jwt_token = jwt_token
56+
self.installation_id = installation_id
57+
self.installations_token_map_name = installations_token_map_name
58+
59+
if installation_token is None:
60+
self.token, self.expiration = InstallationToken._generate_token(
61+
self.jwt_token, self.installation_id
62+
)
63+
self._save()
64+
else:
65+
self.token, self.expiration = installation_token
66+
67+
def get(self) -> str:
68+
self.verify()
69+
return self.token
70+
71+
def get_as_secret(self) -> str:
72+
return f"%(secret:github-token-{self.installation_id})"
73+
74+
def verify(self) -> None:
75+
if datetime.now(tz=UTC) - self.expiration > self.GITHUB_TOKEN_LIFETIME * 0.8:
76+
self.token, self.expiration = InstallationToken._generate_token(
77+
self.jwt_token, self.installation_id
78+
)
79+
self._save()
80+
81+
def _save(self) -> None:
82+
# of format:
83+
# {
84+
# 123: {
85+
# expiration: <datetime>,
86+
# token: "token"
87+
# }
88+
# }
89+
installations_token_map: dict[str, Any]
90+
if self.installations_token_map_name.exists():
91+
installations_token_map = json.loads(
92+
self.installations_token_map_name.read_text()
93+
)
94+
else:
95+
installations_token_map = {}
96+
97+
installations_token_map.update(
98+
{
99+
str(self.installation_id): {
100+
"expiration": self.expiration.isoformat(),
101+
"token": self.token,
102+
}
103+
}
104+
)
105+
106+
atomic_write_file(
107+
self.installations_token_map_name, json.dumps(installations_token_map)
108+
)

buildbot_nix/github/jwt_token.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import base64
2+
import json
3+
import os
4+
import subprocess
5+
from datetime import UTC, datetime, timedelta
6+
from typing import Any
7+
8+
from .repo_token import RepoToken
9+
10+
11+
class JWTToken(RepoToken):
12+
app_id: int
13+
app_private_key: str
14+
lifetime: timedelta
15+
16+
expiration: datetime
17+
token: str
18+
19+
def __init__(
20+
self,
21+
app_id: int,
22+
app_private_key: str,
23+
lifetime: timedelta = timedelta(minutes=10),
24+
) -> None:
25+
self.app_id = app_id
26+
self.app_private_key = app_private_key
27+
self.lifetime = lifetime
28+
29+
self.token, self.expiration = JWTToken.generate_token(
30+
self.app_id, self.app_private_key, lifetime
31+
)
32+
33+
@staticmethod
34+
def generate_token(
35+
app_id: int, app_private_key: str, lifetime: timedelta
36+
) -> tuple[str, datetime]:
37+
def build_jwt_payload(
38+
app_id: int, lifetime: timedelta
39+
) -> tuple[dict[str, Any], datetime]:
40+
jwt_iat_drift: timedelta = timedelta(seconds=60)
41+
now: datetime = datetime.now(tz=UTC)
42+
iat: datetime = now - jwt_iat_drift
43+
exp: datetime = iat + lifetime
44+
jwt_payload = {
45+
"iat": int(iat.timestamp()),
46+
"exp": int(exp.timestamp()),
47+
"iss": str(app_id),
48+
}
49+
return (jwt_payload, exp)
50+
51+
def rs256_sign(data: str, private_key: str) -> str:
52+
signature = subprocess.run(
53+
["openssl", "dgst", "-binary", "-sha256", "-sign", private_key],
54+
input=data.encode("utf-8"),
55+
stdout=subprocess.PIPE,
56+
check=True,
57+
cwd=os.environ.get("CREDENTIALS_DIRECTORY"),
58+
).stdout
59+
return base64url(signature)
60+
61+
def base64url(data: bytes) -> str:
62+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
63+
64+
jwt, expiration = build_jwt_payload(app_id, lifetime)
65+
jwt_payload = json.dumps(jwt).encode("utf-8")
66+
json_headers = json.dumps({"alg": "RS256", "typ": "JWT"}).encode("utf-8")
67+
encoded_jwt_parts = f"{base64url(json_headers)}.{base64url(jwt_payload)}"
68+
encoded_mac = rs256_sign(encoded_jwt_parts, app_private_key)
69+
return (f"{encoded_jwt_parts}.{encoded_mac}", expiration)
70+
71+
# installations = paginated_github_request("https://api.github.com/app/installations?per_page=100", generated_jwt)
72+
73+
# return list(map(lambda installation: create_installation_access_token(installation['id']).json()["token"], installations))
74+
75+
def get(self) -> str:
76+
if datetime.now(tz=UTC) - self.expiration > self.lifetime * 0.8:
77+
self.token, self.expiration = JWTToken.generate_token(
78+
self.app_id, self.app_private_key, self.lifetime
79+
)
80+
81+
return self.token
82+
83+
def get_as_secret(self) -> str:
84+
return "%(secret:github-jwt-token)"

buildbot_nix/github/legacy_token.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from .repo_token import RepoToken
2+
3+
4+
class LegacyToken(RepoToken):
5+
token: str
6+
7+
def __init__(self, token: str) -> None:
8+
self.token = token
9+
10+
def get(self) -> str:
11+
return self.token
12+
13+
def get_as_secret(self) -> str:
14+
return "%(secret:github-token)"

buildbot_nix/github/repo_token.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from abc import abstractmethod
2+
3+
4+
class RepoToken:
5+
@abstractmethod
6+
def get(self) -> str:
7+
pass
8+
9+
@abstractmethod
10+
def get_as_secret(self) -> str:
11+
pass

0 commit comments

Comments
 (0)