From a545f93a091760a1d08c80873a3800b6ae6c99ff Mon Sep 17 00:00:00 2001 From: Jim Sykora <14374121+JimSycurity@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:25:58 +0000 Subject: [PATCH] BED-9166: accept app_id for GitHub App JWT issuer - allow enterprise App credentials to use client_id or app_id - prefer client_id and reuse the selected issuer for installation JWTs - tolerate older GHES installation responses without client_id - add regression coverage for identifier selection and propagation - document identifier precedence --- README.md | 6 ++ src/openhound_github/auth.py | 40 ++++++--- src/openhound_github/source.py | 17 ++-- tests/test_app_auth.py | 156 +++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 tests/test_app_auth.py diff --git a/README.md b/README.md index 3fec3ab..5bcc9a0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ The openhound-github extension collects resources from Github organizations and edges for BloodHound. +### GitHub App JWT issuer + +Enterprise GitHub App credentials accept either `client_id` or `app_id` as the +JWT issuer. When both are configured, `client_id` is preferred. At least one +identifier must be supplied together with `key_path` and `enterprise_name`. + ### Enterprise SCIM and hybrid correlations When `SOURCES__GITHUB__COLLECT_ENTERPRISE_SCIM=true`, a token with enterprise SCIM access is used to collect both `/scim/v2/enterprises/{enterprise}/Users` and `/scim/v2/enterprises/{enterprise}/Groups`. The collector emits normalized `SCIM_Organization`, `SCIM_User`, and `SCIM_Group` nodes plus `SCIM_Contains`, `SCIM_MemberOf`, and `SCIM_Provisioned` relationships. Install the BloodHound SCIM extension alongside this extension to register the shared SCIM kinds. diff --git a/src/openhound_github/auth.py b/src/openhound_github/auth.py index 9818328..c899def 100644 --- a/src/openhound_github/auth.py +++ b/src/openhound_github/auth.py @@ -27,7 +27,7 @@ class AccountConfig(BaseModel): class InstallationResponse(BaseModel): id: int - client_id: str + client_id: str | None = None account: AccountConfig target_type: str app_id: int | None = None @@ -39,15 +39,32 @@ class TokenResponse(BaseModel): expires_at: datetime +def resolve_github_app_jwt_issuer( + *, client_id: str | None, app_id: str | int | None +) -> str: + """Select the configured identifier for GitHub App JWT authentication.""" + normalized_client_id = str(client_id).strip() if client_id is not None else "" + if normalized_client_id: + return normalized_client_id + + normalized_app_id = str(app_id).strip() if app_id is not None else "" + if normalized_app_id: + return normalized_app_id + + raise ValueError( + "GitHub App credentials require either client_id or app_id for the JWT issuer" + ) + + class GithubSession: def __init__( self, - client_id: str, + jwt_issuer: str, private_key_path: str, api_uri: str = "https://api.github.com/", ): self.api_uri = api_uri - self.client_id = client_id + self.jwt_issuer = jwt_issuer self.private_key_path = private_key_path self.client = RESTClient( base_url=self.api_uri, @@ -59,9 +76,11 @@ def jwt(self) -> str: now_utc = datetime.now(timezone.utc).timestamp() header = {"alg": "RS256", "typ": "JWT"} claims = { - "iss": self.client_id, + "iss": self.jwt_issuer, "iat": int(now_utc - 10), # Issued 10 seconds in the past - "exp": int(now_utc + 540), # Expires in 9 minutes (GitHub max is 10, leaving room for clock drift) + "exp": int( + now_utc + 540 + ), # Expires in 9 minutes (GitHub max is 10, leaving room for clock drift) } try: @@ -86,12 +105,12 @@ class GithubInstallation(GithubSession): def __init__( self, installation_id: str, - client_id: str, + jwt_issuer: str, private_key_path: str, api_uri: str = "https://api.github.com/", ): self.installation_id = installation_id - super().__init__(client_id, private_key_path, api_uri) + super().__init__(jwt_issuer, private_key_path, api_uri) @property def token(self) -> TokenResponse: @@ -108,14 +127,11 @@ def token(self) -> TokenResponse: class GithubApp(GithubSession): def __init__( self, - client_id: str, + jwt_issuer: str, private_key_path: str, api_uri: str = "https://api.github.com/", ): - self.client_id = client_id - self.private_key_path = private_key_path - self.api_uri = api_uri - super().__init__(client_id, private_key_path, api_uri) + super().__init__(jwt_issuer, private_key_path, api_uri) @property def installations(self) -> Iterator[InstallationResponse]: diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index f6e6989..bd08782 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -18,6 +18,7 @@ GithubApp, GitHubAppInstallationAuth, GithubInstallation, + resolve_github_app_jwt_issuer, ) from openhound_github.helpers import github_retry_policy from openhound_github.main import app @@ -75,8 +76,8 @@ def auth(self): @configspec class GithubEnterpriseAppCredentials(CredentialsConfiguration): - client_id: str = None - app_id: str = None + client_id: str | None = None + app_id: str | None = None key_path: str = None enterprise_name: str = None pat_token: str | None = None @@ -151,6 +152,10 @@ def token_client(token: str) -> RESTClient: return client(BearerTokenAuth(token=token)) if credentials.auth == "enterprise_app": + jwt_issuer = resolve_github_app_jwt_issuer( + client_id=credentials.client_id, + app_id=credentials.app_id, + ) ctx = SourceContext( enterprise_name=credentials.enterprise_name, collect_enterprise_scim=bool(collect_enterprise_scim), @@ -165,14 +170,14 @@ def token_client(token: str) -> RESTClient: elif credentials.pat_token: ctx.scim_client = ctx.sso_client github_app_session = GithubApp( - client_id=credentials.client_id, + jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, ) for installation in github_app_session.installations: if installation.target_type == "Organization": org_installation = GithubInstallation( installation_id=installation.id, - client_id=installation.client_id, + jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, ) ctx.organizations.append( @@ -189,7 +194,7 @@ def token_client(token: str) -> RESTClient: if installation.target_type == "Enterprise": es_installation = GithubInstallation( installation_id=installation.id, - client_id=installation.client_id, + jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, ) ctx.client = client( @@ -206,7 +211,7 @@ def token_client(token: str) -> RESTClient: ) org_installation = GithubInstallation( installation_id=credentials.install_id, - client_id=credentials.client_id, + jwt_issuer=credentials.client_id, private_key_path=credentials.key_path, ) ctx.organizations.append( diff --git a/tests/test_app_auth.py b/tests/test_app_auth.py new file mode 100644 index 0000000..ddfb35c --- /dev/null +++ b/tests/test_app_auth.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import pytest +from dlt.common.configuration.resolve import resolve_configuration + +from openhound_github import auth +from openhound_github.auth import ( + AccountConfig, + GithubSession, + InstallationResponse, + resolve_github_app_jwt_issuer, +) +from openhound_github.source import GithubEnterpriseAppCredentials + + +@pytest.mark.parametrize( + ("client_id", "app_id", "expected"), + ( + ("Iv1.client-id", None, "Iv1.client-id"), + (None, "123456", "123456"), + ("Iv1.preferred", "123456", "Iv1.preferred"), + (" Iv1.trimmed ", "123456", "Iv1.trimmed"), + ), +) +def test_resolve_github_app_jwt_issuer_prefers_client_id( + client_id: str | None, + app_id: str | None, + expected: str, +) -> None: + assert resolve_github_app_jwt_issuer(client_id=client_id, app_id=app_id) == expected + + +def test_resolve_github_app_jwt_issuer_requires_an_identifier() -> None: + with pytest.raises( + ValueError, + match="require either client_id or app_id for the JWT issuer", + ): + resolve_github_app_jwt_issuer(client_id=None, app_id=None) + + +@pytest.mark.parametrize( + ("client_id", "app_id"), + (("Iv1.client-id", None), (None, "123456")), +) +def test_enterprise_app_configuration_accepts_either_identifier( + client_id: str | None, + app_id: str | None, +) -> None: + credentials = resolve_configuration( + GithubEnterpriseAppCredentials( + client_id=client_id, + app_id=app_id, + key_path="/tmp/github-app.pem", + enterprise_name="example-enterprise", + ) + ) + + assert credentials.is_partial() is False + + +def test_github_session_uses_explicit_jwt_issuer( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + key_path = tmp_path / "github-app.pem" + key_path.write_text("test-private-key", encoding="utf-8") + captured_claims: dict[str, object] = {} + + monkeypatch.setattr(auth.RSAKey, "import_key", lambda _: object()) + + def fake_encode(header, claims, key): + captured_claims.update(claims) + return "encoded-jwt" + + monkeypatch.setattr(auth.jwt, "encode", fake_encode) + + session = GithubSession( + jwt_issuer="123456", + private_key_path=str(key_path), + ) + + assert session.jwt == "encoded-jwt" + assert captured_claims["iss"] == "123456" + + +def test_legacy_installation_response_does_not_require_client_id() -> None: + installation = InstallationResponse( + id=42, + account=AccountConfig(id=7, login="example-org"), + target_type="Organization", + app_id=123456, + ) + + assert installation.client_id is None + assert installation.app_id == 123456 + + +def test_enterprise_source_reuses_selected_issuer_for_installation_tokens( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_issuers: list[str] = [] + + class FakeGithubApp: + def __init__(self, jwt_issuer: str, private_key_path: str) -> None: + captured_issuers.append(jwt_issuer) + self.installations = ( + SimpleNamespace( + id=11, + target_type="Organization", + account=SimpleNamespace(login="example-org"), + ), + SimpleNamespace( + id=12, + target_type="Enterprise", + account=SimpleNamespace(slug="example-enterprise"), + ), + ) + + class FakeGithubInstallation: + def __init__( + self, + installation_id: int, + jwt_issuer: str, + private_key_path: str, + ) -> None: + captured_issuers.append(jwt_issuer) + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + pass + + monkeypatch.setattr(source_module, "GithubApp", FakeGithubApp) + monkeypatch.setattr(source_module, "GithubInstallation", FakeGithubInstallation) + monkeypatch.setattr( + source_module, "GitHubAppInstallationAuth", lambda **_: object() + ) + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "enterprise_resources", lambda _: ()) + monkeypatch.setattr(source_module, "organization_resources", lambda _: ()) + + resources = source_module.source.__wrapped__( + credentials=GithubEnterpriseAppCredentials( + app_id="123456", + key_path="/tmp/github-app.pem", + enterprise_name="example-enterprise", + ), + host="https://api.github.com", + collect_enterprise_scim=False, + emit_legacy_scim_correlations=False, + ) + + assert resources == () + assert captured_issuers == ["123456", "123456", "123456"]