Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ 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`.

### GitHub Enterprise Server endpoints

GitHub.com is the default deployment and does not require endpoint configuration.
To collect from GitHub Enterprise Server, set both API endpoints in `config.toml`:

```toml
[sources.github]
rest_api_url = "https://ghe.example/api/v3"
graphql_url = "https://ghe.example/api/graphql"
```

Both values must be provided together when overriding the GitHub.com defaults.
Endpoint URLs must use HTTPS and share the same origin. GitHub App
`credentials.api_uri` may override only the path on that same origin.

### Enterprise SCIM and hybrid correlations

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
10 changes: 10 additions & 0 deletions src/openhound_github/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ def refresh_request(self, request: requests.PreparedRequest) -> bool:
return True

def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
try:
request_origin = _normalized_http_origin(
request.url or "", allow_query=True
)
except ValueError:
return request

if request_origin != self._api_origin:
return request

request.headers["Authorization"] = f"Bearer {self.token()}"
return request

Expand Down
32 changes: 30 additions & 2 deletions src/openhound_github/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import time
from collections.abc import Iterator
from typing import Any, Optional
from typing import Any, Optional, overload
from urllib.parse import urlparse

from dlt.common import jsonpath
Expand All @@ -17,11 +17,38 @@

logger = logging.getLogger(__name__)

DEFAULT_GITHUB_REST_API_URL = "https://api.github.com"
DEFAULT_GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"


class GraphQLPaginationError(RuntimeError):
pass


@overload
def graphql_client_and_path(
rest_client: RESTClient,
graphql_client: RESTClient | None,
) -> tuple[RESTClient, str]: ...


@overload
def graphql_client_and_path(
rest_client: RESTClient | None,
graphql_client: RESTClient | None,
) -> tuple[RESTClient | None, str]: ...


def graphql_client_and_path(
rest_client: RESTClient | None,
graphql_client: RESTClient | None,
) -> tuple[RESTClient | None, str]:
"""Return the configured GraphQL client and its request path."""
if graphql_client:
return graphql_client, ""
return rest_client, "/graphql"


class AdaptiveGraphQLPageError(RuntimeError):
"""A terminal GraphQL page failure with adaptive pagination context."""

Expand Down Expand Up @@ -184,6 +211,7 @@ def _graphql_gateway_status(exception: BaseException) -> int | None:
def adaptive_graphql_paginate(
client: RESTClient,
*,
graphql_path: str = "/graphql",
query: str,
variables: dict[str, Any],
page_info_path: str,
Expand Down Expand Up @@ -247,7 +275,7 @@ def adaptive_graphql_paginate(
}
try:
response = client.post(
"/graphql",
graphql_path,
json={"query": query, "variables": request_variables},
)
response.raise_for_status()
Expand Down
42 changes: 30 additions & 12 deletions src/openhound_github/resources/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
ENTERPRISE_QUERY,
ENTERPRISE_SAML_QUERY,
)
from openhound_github.helpers import GraphQLCursorPaginator, scim_skip_reason
from openhound_github.helpers import (
GraphQLCursorPaginator,
graphql_client_and_path,
scim_skip_reason,
)
from openhound_github.main import app
from openhound_github.models import (
BaseUser,
Expand Down Expand Up @@ -53,14 +57,24 @@ class SourceContext:
"""Shared context for GitHub API access."""

client: RESTClient
graphql_client: RESTClient | None = None
sso_client: RESTClient | None = None
sso_graphql_client: RESTClient | None = None
org_name: str | None = None
enterprise_name: str | None = None
emit_legacy_scim_correlations: bool = False
github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID
github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN


def _graphql_client(ctx: SourceContext) -> tuple[RESTClient, str]:
return graphql_client_and_path(ctx.client, ctx.graphql_client)


def _sso_graphql_client(ctx: SourceContext) -> tuple[RESTClient | None, str]:
return graphql_client_and_path(ctx.sso_client, ctx.sso_graphql_client)


def iter_enterprise_scim_resources(
client: RESTClient,
enterprise_slug: str,
Expand Down Expand Up @@ -111,8 +125,9 @@ def enterprise(ctx: SourceContext):
"variables": {"slug": ctx.enterprise_name, "after": None},
}

client, graphql_path = _graphql_client(ctx)
try:
response = ctx.client.post("/graphql", json=data).json()
response = client.post(graphql_path, json=data).json()
page_enterprise = (response.get("data") or {}).get("enterprise")
if page_enterprise:
yield page_enterprise
Expand All @@ -139,9 +154,10 @@ def enterprise_organizations(enterprise_data: Enterprise, ctx: SourceContext):
"variables": {"slug": ctx.enterprise_name, "after": None},
}

client, graphql_path = _graphql_client(ctx)
try:
for page_data in ctx.client.paginate(
"/graphql",
for page_data in client.paginate(
graphql_path,
method="POST",
json=data,
paginator=paginator,
Expand Down Expand Up @@ -263,8 +279,9 @@ def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext):
"query": ENTERPRISE_MEMBERS_QUERY,
"variables": {"slug": ctx.enterprise_name, "count": 100, "after": None},
}
for page_data in ctx.client.paginate(
"/graphql",
client, graphql_path = _graphql_client(ctx)
for page_data in client.paginate(
graphql_path,
method="POST",
json=data,
paginator=paginator,
Expand Down Expand Up @@ -635,8 +652,9 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext):
"query": ENTERPRISE_ADMINS_QUERY,
"variables": {"slug": ctx.enterprise_name, "count": 100, "after": None},
}
for page_data in ctx.client.paginate(
"/graphql",
client, graphql_path = _graphql_client(ctx)
for page_data in client.paginate(
graphql_path,
method="POST",
json=data,
paginator=paginator,
Expand Down Expand Up @@ -665,7 +683,7 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext):
parallelized=True
)
def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext):
client = ctx.sso_client
client, graphql_path = _sso_graphql_client(ctx)
if not client:
logger.info(
"Skipping enterprise_saml_provider for enterprise '%s': no SSO client configured",
Expand All @@ -679,7 +697,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext):
}

try:
response = client.post("/graphql", json=data).json()
response = client.post(graphql_path, json=data).json()
except Exception as e:
logger.error(
f"Error in resource 'enterprise_saml_provider' processing enterprise '{ctx.enterprise_name}': {e}",
Expand Down Expand Up @@ -779,7 +797,7 @@ def enterprise_saml_issuer(saml_provider: SamlProvider, ctx: SourceContext):
def enterprise_external_identity(
saml_provider: SamlProvider, ctx: SourceContext
):
client = ctx.sso_client
client, graphql_path = _sso_graphql_client(ctx)
if not client:
logger.info(
"Skipping enterprise_external_identity for enterprise '%s': no SSO client configured",
Expand All @@ -801,7 +819,7 @@ def enterprise_external_identity(

try:
for page_data in client.paginate(
"/graphql",
graphql_path,
method="POST",
json=data,
paginator=paginator,
Expand Down
Loading