Skip to content

Commit 40de52a

Browse files
BED-9446: collect enterprise SCIM by default (#40)
* BED-9446: collect enterprise SCIM by default * BED-9446: use installation client for enterprise SCIM * BED-9446: preserve enterprise SCIM conversion tables * BED-9446: use SCIM username for user node names * BED-9446: preserve SCIM user_name property * BED-9446: resolve projected enterprise teams by id * BED-9446: resolve SCIM external identities by id * BED-9446: address SCIM review feedback
1 parent f884fe8 commit 40de52a

19 files changed

Lines changed: 599 additions & 140 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ identifier must be supplied together with `key_path` and `enterprise_name`.
4242

4343
### Enterprise SCIM and hybrid correlations
4444

45-
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.
45+
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.
4646

4747
`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.
4848

src/openhound_github/helpers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ class GraphQLPaginationError(RuntimeError):
2020
pass
2121

2222

23+
def scim_skip_reason(exception: BaseException) -> str | None:
24+
"""Return a user-facing reason for expected SCIM API unavailability."""
25+
if not isinstance(exception, requests.HTTPError) or exception.response is None:
26+
return None
27+
28+
status_code = exception.response.status_code
29+
if status_code in (401, 403):
30+
return "the configured credentials do not have SCIM access"
31+
if status_code == 404:
32+
return "the GitHub scope does not expose SCIM endpoints"
33+
return None
34+
35+
2336
class GraphQLCursorPaginator(JSONResponseCursorPaginator):
2437
def __init__(
2538
self,

src/openhound_github/lookup.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import re
23
from functools import lru_cache
34

45
import duckdb
@@ -8,10 +9,20 @@
89
from openhound_github.runner_ids import runner_group_node_id, runner_node_id
910

1011

12+
_SCHEMA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
13+
14+
15+
def _validate_schema_identifier(schema: str) -> str:
16+
if not _SCHEMA_IDENTIFIER_RE.fullmatch(schema):
17+
raise ValueError(f"Invalid DuckDB schema identifier: {schema!r}")
18+
return schema
19+
20+
1121
class GithubLookup(LookupManager):
1222
def __init__(self, client: DuckDBPyConnection, schema: str = "github"):
13-
super().__init__(client, schema)
14-
self.schema = schema
23+
validated_schema = _validate_schema_identifier(schema)
24+
super().__init__(client, validated_schema)
25+
self.schema = validated_schema
1526
self.client = client
1627

1728
def _find_single_row(self, *args):
@@ -188,12 +199,21 @@ def org_login_for_id(self, org_node_id: str) -> str | None:
188199
)
189200

190201
@lru_cache
191-
def projected_enterprise_team_exists(self, org_login: str, slug: str):
202+
def projected_enterprise_team_id(self, org_login: str, slug: str) -> str | None:
192203
return self._find_single_object(
193-
f"""SELECT slug FROM {self.schema}.projected_enterprise_teams WHERE org_login = ? AND slug = ?""",
204+
f"""SELECT node_id FROM {self.schema}.projected_enterprise_teams WHERE org_login = ? AND slug = ?""",
194205
[org_login, slug],
195206
)
196207

208+
@lru_cache
209+
def external_identity_id_for_guid(
210+
self, guid: str, environment_slug: str
211+
) -> str | None:
212+
return self._find_single_object(
213+
f"""SELECT id FROM {self.schema}.external_identities WHERE guid = ? AND environment_slug = ?""",
214+
[guid, environment_slug],
215+
)
216+
197217
@lru_cache
198218
def repository_node_ids(self):
199219
return self._find_all_objects(

src/openhound_github/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def preproc(ctx: PreProcContext):
6363
"teams": "teams",
6464
"team_members": "team_members",
6565
"saml_provider": "saml_provider",
66+
"external_identities": "external_identities",
6667
"applications": "applications",
6768
"enterprise": "enterprise",
6869
"enterprise_organizations": "enterprise_organizations",

src/openhound_github/models/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@
5353
from .saml_provider import SamlProvider
5454
from .saml_service_provider import SamlServiceProvider
5555
from .saml_issuer import SamlIssuer
56-
from .scim_user import ScimGroup, ScimOrganization, ScimResource, ScimUser
56+
from .scim_user import (
57+
EnterpriseScimOrganization,
58+
EnterpriseScimUser,
59+
ScimGroup,
60+
ScimOrganization,
61+
ScimResource,
62+
ScimUser,
63+
)
5764
from .secret_scanning_alert import SecretScanningAlert
5865
from .team import Team
5966
from .team_member import TeamMember
@@ -117,6 +124,8 @@
117124
"ScimUser",
118125
"ScimGroup",
119126
"ScimOrganization",
127+
"EnterpriseScimUser",
128+
"EnterpriseScimOrganization",
120129
"RepoRoleAssignment",
121130
"Environment",
122131
"EnvironmentSecret",

src/openhound_github/models/enterprise_helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@ def enterprise_team_node_id(enterprise_id: str, team_id: str | int) -> str:
22
return f"GH_EnterpriseTeam_{enterprise_id}_{team_id}"
33

44

5+
def projected_enterprise_team_node_id(org_id: str | None, team_node_id: str) -> str:
6+
return f"GH_Team_{org_id}_{team_node_id}"
7+
8+
59
def enterprise_role_node_id(enterprise_id: str, role_id: str | int) -> str:
610
return f"GH_EnterpriseRole_{enterprise_id}_{role_id}"

src/openhound_github/models/enterprise_team_organization.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from openhound.core.asset import BaseAsset, EdgeDef
22
from openhound.core.models.entries_dataclass import (
3-
ConditionalEdgePath,
43
Edge,
54
EdgePath,
65
EdgeProperties,
7-
PropertyMatch,
86
)
97

108
from openhound_github.kinds import edges as ek
119
from openhound_github.kinds import nodes as nk
1210
from openhound_github.main import app
13-
from openhound_github.models.enterprise_helpers import enterprise_team_node_id
11+
from openhound_github.models.enterprise_helpers import (
12+
enterprise_team_node_id,
13+
projected_enterprise_team_node_id,
14+
)
1415

1516

1617
@app.asset(
@@ -59,18 +60,20 @@ def _assigned_to_edge(self):
5960
@property
6061
def member_of_team_edges(self):
6162
org_login = self.login or self._lookup.org_login_for_id(self.node_id)
62-
if org_login and self._lookup.projected_enterprise_team_exists(
63-
org_login, self.projected_slug
64-
):
63+
projected_team_id = (
64+
self._lookup.projected_enterprise_team_id(org_login, self.projected_slug)
65+
if org_login
66+
else None
67+
)
68+
if projected_team_id:
6569
yield Edge(
6670
kind=ek.MEMBER_OF,
6771
start=EdgePath(value=self.enterprise_team_node_id, match_by="id"),
68-
end=ConditionalEdgePath(
69-
kind=nk.TEAM,
70-
property_matchers=[
71-
PropertyMatch(key="environmentid", value=self.node_id),
72-
PropertyMatch(key="slug", value=self.projected_slug),
73-
],
72+
end=EdgePath(
73+
value=projected_enterprise_team_node_id(
74+
self.node_id, projected_team_id
75+
),
76+
match_by="id",
7477
),
7578
properties=EdgeProperties(traversable=True),
7679
)

src/openhound_github/models/projected_enterprise_team.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from openhound_github.graph import GHNode
88
from openhound_github.kinds import nodes as nk
99
from openhound_github.main import app
10+
from openhound_github.models.enterprise_helpers import projected_enterprise_team_node_id
1011
from openhound_github.models.team import GHTeamProperties
1112

1213

@@ -43,7 +44,7 @@ def as_node(self) -> GHNode:
4344
properties=GHTeamProperties(
4445
name=self.name,
4546
displayname=self.name,
46-
node_id=f"GH_Team_{self.org_node_id}_{self.node_id}",
47+
node_id=projected_enterprise_team_node_id(self.org_node_id, self.node_id),
4748
github_team_id=self.node_id,
4849
collected=False,
4950
slug=self.slug,

src/openhound_github/models/scim_user.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,15 @@ def edges(self):
137137
return []
138138

139139

140+
@app.asset()
141+
class EnterpriseScimOrganization(ScimOrganization):
142+
"""Enterprise-scoped SCIM organization input model.
143+
144+
This remains a distinct asset class so the converter can map enterprise and
145+
organization SCIM tables independently while emitting the same graph kind.
146+
"""
147+
148+
140149
@app.asset(
141150
node=NodeDef(
142151
kind=nk.SCIM_USER,
@@ -189,7 +198,7 @@ def as_node(self) -> ScimNode:
189198
id=self.id,
190199
kinds=[nk.SCIM_USER],
191200
properties=ScimNodeProperties(
192-
name=self.id,
201+
name=self.user_name or self.id,
193202
displayname=display_name,
194203
environmentid=self.scope_node_id,
195204
external_id=self.external_id,
@@ -213,15 +222,16 @@ def edges(self):
213222
end=EdgePath(value=self.id, match_by="id"),
214223
properties=EdgeProperties(traversable=True),
215224
)
216-
yield Edge(
217-
kind=ek.SCIM_PROVISIONED,
218-
start=EdgePath(value=self.id, match_by="id"),
219-
end=ConditionalEdgePath(
220-
kind=nk.EXTERNAL_IDENTITY,
221-
property_matchers=[PropertyMatch(key="guid", value=self.id)],
222-
),
223-
properties=EdgeProperties(traversable=True),
225+
external_identity_id = self._lookup.external_identity_id_for_guid(
226+
self.id, self.scope_name
224227
)
228+
if external_identity_id:
229+
yield Edge(
230+
kind=ek.SCIM_PROVISIONED,
231+
start=EdgePath(value=self.id, match_by="id"),
232+
end=EdgePath(value=external_identity_id, match_by="id"),
233+
properties=EdgeProperties(traversable=True),
234+
)
225235
if self.emit_legacy_correlation and self.external_id:
226236
yield Edge(
227237
kind=ek.SCIM_PROVISIONED,
@@ -231,6 +241,11 @@ def edges(self):
231241
)
232242

233243

244+
@app.asset()
245+
class EnterpriseScimUser(ScimUser):
246+
"""Enterprise-scoped SCIM user input model."""
247+
248+
234249
@app.asset(
235250
node=NodeDef(
236251
kind=nk.SCIM_GROUP,

0 commit comments

Comments
 (0)