Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 28 additions & 12 deletions src/openhound_github/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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]:
Expand Down
17 changes: 11 additions & 6 deletions src/openhound_github/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
156 changes: 156 additions & 0 deletions tests/test_app_auth.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading