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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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.
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.

`SOURCES__GITHUB__EMIT_LEGACY_SCIM_CORRELATIONS=true` temporarily reproduces GitHound-style Okta-to-SCIM correlation relationships. It defaults to false because a dedicated hybrid correlator should own IdP-to-SCIM matching; GitHub remains authoritative for GitHub's SCIM resources and target-system provisioning relationships.

Expand Down
13 changes: 13 additions & 0 deletions src/openhound_github/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ class GraphQLPaginationError(RuntimeError):
pass


def scim_skip_reason(exception: BaseException) -> str | None:
"""Return a user-facing reason for expected SCIM API unavailability."""
if not isinstance(exception, requests.HTTPError) or exception.response is None:
return None

status_code = exception.response.status_code
if status_code in (401, 403):
return "the configured credentials do not have SCIM access"
if status_code == 404:
return "the GitHub scope does not expose SCIM endpoints"
return None


class GraphQLCursorPaginator(JSONResponseCursorPaginator):
def __init__(
self,
Expand Down
28 changes: 24 additions & 4 deletions src/openhound_github/lookup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import re
from functools import lru_cache

import duckdb
Expand All @@ -8,10 +9,20 @@
from openhound_github.runner_ids import runner_group_node_id, runner_node_id


_SCHEMA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def _validate_schema_identifier(schema: str) -> str:
if not _SCHEMA_IDENTIFIER_RE.fullmatch(schema):
raise ValueError(f"Invalid DuckDB schema identifier: {schema!r}")
return schema


class GithubLookup(LookupManager):
def __init__(self, client: DuckDBPyConnection, schema: str = "github"):
super().__init__(client, schema)
self.schema = schema
validated_schema = _validate_schema_identifier(schema)
super().__init__(client, validated_schema)
self.schema = validated_schema
self.client = client

def _find_single_row(self, *args):
Expand Down Expand Up @@ -188,12 +199,21 @@ def org_login_for_id(self, org_node_id: str) -> str | None:
)

@lru_cache
def projected_enterprise_team_exists(self, org_login: str, slug: str):
def projected_enterprise_team_id(self, org_login: str, slug: str) -> str | None:
return self._find_single_object(
f"""SELECT slug FROM {self.schema}.projected_enterprise_teams WHERE org_login = ? AND slug = ?""",
f"""SELECT node_id FROM {self.schema}.projected_enterprise_teams WHERE org_login = ? AND slug = ?""",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[org_login, slug],
)

@lru_cache
def external_identity_id_for_guid(
self, guid: str, environment_slug: str
) -> str | None:
return self._find_single_object(
f"""SELECT id FROM {self.schema}.external_identities WHERE guid = ? AND environment_slug = ?""",
[guid, environment_slug],
)

@lru_cache
def repository_node_ids(self):
return self._find_all_objects(
Expand Down
1 change: 1 addition & 0 deletions src/openhound_github/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def preproc(ctx: PreProcContext):
"teams": "teams",
"team_members": "team_members",
"saml_provider": "saml_provider",
"external_identities": "external_identities",
"applications": "applications",
"enterprise": "enterprise",
"enterprise_organizations": "enterprise_organizations",
Expand Down
11 changes: 10 additions & 1 deletion src/openhound_github/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@
from .saml_provider import SamlProvider
from .saml_service_provider import SamlServiceProvider
from .saml_issuer import SamlIssuer
from .scim_user import ScimGroup, ScimOrganization, ScimResource, ScimUser
from .scim_user import (
EnterpriseScimOrganization,
EnterpriseScimUser,
ScimGroup,
ScimOrganization,
ScimResource,
ScimUser,
)
from .secret_scanning_alert import SecretScanningAlert
from .team import Team
from .team_member import TeamMember
Expand Down Expand Up @@ -117,6 +124,8 @@
"ScimUser",
"ScimGroup",
"ScimOrganization",
"EnterpriseScimUser",
"EnterpriseScimOrganization",
"RepoRoleAssignment",
"Environment",
"EnvironmentSecret",
Expand Down
4 changes: 4 additions & 0 deletions src/openhound_github/models/enterprise_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ def enterprise_team_node_id(enterprise_id: str, team_id: str | int) -> str:
return f"GH_EnterpriseTeam_{enterprise_id}_{team_id}"


def projected_enterprise_team_node_id(org_id: str | None, team_node_id: str) -> str:
return f"GH_Team_{org_id}_{team_node_id}"


def enterprise_role_node_id(enterprise_id: str, role_id: str | int) -> str:
return f"GH_EnterpriseRole_{enterprise_id}_{role_id}"
27 changes: 15 additions & 12 deletions src/openhound_github/models/enterprise_team_organization.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from openhound.core.asset import BaseAsset, EdgeDef
from openhound.core.models.entries_dataclass import (
ConditionalEdgePath,
Edge,
EdgePath,
EdgeProperties,
PropertyMatch,
)

from openhound_github.kinds import edges as ek
from openhound_github.kinds import nodes as nk
from openhound_github.main import app
from openhound_github.models.enterprise_helpers import enterprise_team_node_id
from openhound_github.models.enterprise_helpers import (
enterprise_team_node_id,
projected_enterprise_team_node_id,
)


@app.asset(
Expand Down Expand Up @@ -59,18 +60,20 @@ def _assigned_to_edge(self):
@property
def member_of_team_edges(self):
org_login = self.login or self._lookup.org_login_for_id(self.node_id)
if org_login and self._lookup.projected_enterprise_team_exists(
org_login, self.projected_slug
):
projected_team_id = (
self._lookup.projected_enterprise_team_id(org_login, self.projected_slug)
if org_login
else None
)
if projected_team_id:
yield Edge(
kind=ek.MEMBER_OF,
start=EdgePath(value=self.enterprise_team_node_id, match_by="id"),
end=ConditionalEdgePath(
kind=nk.TEAM,
property_matchers=[
PropertyMatch(key="environmentid", value=self.node_id),
PropertyMatch(key="slug", value=self.projected_slug),
],
end=EdgePath(
value=projected_enterprise_team_node_id(
self.node_id, projected_team_id
),
match_by="id",
),
properties=EdgeProperties(traversable=True),
)
Expand Down
3 changes: 2 additions & 1 deletion src/openhound_github/models/projected_enterprise_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from openhound_github.graph import GHNode
from openhound_github.kinds import nodes as nk
from openhound_github.main import app
from openhound_github.models.enterprise_helpers import projected_enterprise_team_node_id
from openhound_github.models.team import GHTeamProperties


Expand Down Expand Up @@ -43,7 +44,7 @@ def as_node(self) -> GHNode:
properties=GHTeamProperties(
name=self.name,
displayname=self.name,
node_id=f"GH_Team_{self.org_node_id}_{self.node_id}",
node_id=projected_enterprise_team_node_id(self.org_node_id, self.node_id),
github_team_id=self.node_id,
collected=False,
slug=self.slug,
Expand Down
33 changes: 24 additions & 9 deletions src/openhound_github/models/scim_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ def edges(self):
return []


@app.asset()
class EnterpriseScimOrganization(ScimOrganization):
"""Enterprise-scoped SCIM organization input model.

This remains a distinct asset class so the converter can map enterprise and
organization SCIM tables independently while emitting the same graph kind.
"""


@app.asset(
node=NodeDef(
kind=nk.SCIM_USER,
Expand Down Expand Up @@ -189,7 +198,7 @@ def as_node(self) -> ScimNode:
id=self.id,
kinds=[nk.SCIM_USER],
properties=ScimNodeProperties(
name=self.id,
name=self.user_name or self.id,
displayname=display_name,
environmentid=self.scope_node_id,
external_id=self.external_id,
Expand All @@ -213,15 +222,16 @@ def edges(self):
end=EdgePath(value=self.id, match_by="id"),
properties=EdgeProperties(traversable=True),
)
yield Edge(
kind=ek.SCIM_PROVISIONED,
start=EdgePath(value=self.id, match_by="id"),
end=ConditionalEdgePath(
kind=nk.EXTERNAL_IDENTITY,
property_matchers=[PropertyMatch(key="guid", value=self.id)],
),
properties=EdgeProperties(traversable=True),
external_identity_id = self._lookup.external_identity_id_for_guid(
self.id, self.scope_name
)
if external_identity_id:
yield Edge(
kind=ek.SCIM_PROVISIONED,
start=EdgePath(value=self.id, match_by="id"),
end=EdgePath(value=external_identity_id, match_by="id"),
properties=EdgeProperties(traversable=True),
)
if self.emit_legacy_correlation and self.external_id:
yield Edge(
kind=ek.SCIM_PROVISIONED,
Expand All @@ -231,6 +241,11 @@ def edges(self):
)


@app.asset()
class EnterpriseScimUser(ScimUser):
"""Enterprise-scoped SCIM user input model."""


@app.asset(
node=NodeDef(
kind=nk.SCIM_GROUP,
Expand Down
Loading
Loading