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
1 change: 0 additions & 1 deletion src/openhound_github/kinds/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
HAS_EXTERNAL_IDENTITY = "GH_HasExternalIdentity"
HAS_SAML_IDENTITY_PROVIDER = "GH_HasSamlIdentityProvider"
MAPS_TO_USER = "GH_MapsToUser"
SYNCED_TO_GH_USER = "GH_SyncedTo"

# Normalized OpenGraph-SCIM edges
SCIM_CONTAINS = "SCIM_Contains"
Expand Down
2 changes: 0 additions & 2 deletions src/openhound_github/kinds/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,4 @@
SCIM_ROLE = "SCIM_Role"

# External node kinds used only as correlation endpoints
AZ_USER = "AZUser"
OKTA_USER = "Okta_User"
PINGONE_USER = "PingOne_User"
86 changes: 10 additions & 76 deletions src/openhound_github/models/external_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

from openhound.core.asset import BaseAsset, EdgeDef, NodeDef
from openhound.core.models.entries_dataclass import (
ConditionalEdgePath,
Edge,
EdgePath,
EdgeProperties,
)
from pydantic import BaseModel, ConfigDict, Field

from openhound_github.graph import GHEdgeProperties, GHNode, GHNodeProperties
from openhound_github.graph import GHNode, GHNodeProperties
from openhound_github.kinds import edges as ek
from openhound_github.kinds import nodes as nk
from openhound_github.main import app
Expand All @@ -18,9 +17,6 @@
DEFAULT_GITHUB_DEPLOYMENT_ID,
ENTRA_OBJECT_ID_CLAIM,
SAML_CONTRACT_VERSION,
detect_foreign_idp,
foreign_user_kind,
foreign_user_matchers,
github_saml_service_provider_id,
saml_account_match_values,
saml_attribute_match_values,
Expand Down Expand Up @@ -113,13 +109,6 @@ class User(BaseModel):
description="External identity maps to a user",
traversable=False,
),
EdgeDef(
start=nk.EXTERNAL_IDENTITY,
end=nk.USER,
kind=ek.SYNCED_TO_GH_USER,
description="Foreign IdP user is synced to a GitHub user",
traversable=True,
),
EdgeDef(
start=nk.SAML_SERVICE_PROVIDER,
end=nk.USER,
Expand Down Expand Up @@ -150,12 +139,19 @@ def node_id(self) -> str:

@property
def as_node(self) -> GHNode:
display_name = (
self.saml_identity.username
if self.saml_identity and self.saml_identity.username
else self.scim_identity.username
if self.scim_identity and self.scim_identity.username
else self.guid or self.node_id
)

return GHNode(
kinds=[nk.EXTERNAL_IDENTITY],
properties=GHExternalIdentityProperties(
name=self.guid or self.node_id,
displayname=self.guid or self.node_id,
name=display_name,
displayname=display_name,
node_id=self.node_id,
guid=self.guid,
saml_identity_username=self.saml_identity.username
Expand Down Expand Up @@ -209,66 +205,6 @@ def idp(self) -> dict:
"environment_type": environment_type,
}

@property
def _maps_to_user_edges(self):
foreign_idp_type, foreign_env_id = detect_foreign_idp(
issuer=self.idp["issuer"],
sso_url=self.idp["sso_url"],
)
foreign_kind = foreign_user_kind(foreign_idp_type)

foreign_username = None
if self.saml_identity and self.saml_identity.username:
foreign_username = self.saml_identity.username
elif self.scim_identity and self.scim_identity.username:
foreign_username = self.scim_identity.username

matchers = foreign_user_matchers(
foreign_kind,
foreign_env_id,
foreign_username,
self.saml_identity.attributes if self.saml_identity else [],
)
if foreign_kind and matchers:
yield Edge(
kind=ek.MAPS_TO_USER,
start=EdgePath(value=self.node_id, match_by="id"),
end=ConditionalEdgePath(
kind=foreign_kind,
property_matchers=matchers,
),
properties=EdgeProperties(traversable=False),
)

# SyncedToGHUser: foreign IdP user → GitHub user (traversable, with composition)
if matchers and self.user and self.user.id:
composition_matchers = [
matcher for matcher in matchers if matcher.key in {"name", "objectid"}
]
composition_predicates = " OR ".join(
f"n.{matcher.key} = '{matcher.value}'"
for matcher in composition_matchers
)
q = (
f"MATCH p=()<-[:GH_SyncedToEnvironment]-(:GH_SamlIdentityProvider)"
f"-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)"
f"-[:GH_MapsToUser]->(n) "
f"WHERE {composition_predicates} RETURN p"
)
yield Edge(
kind=ek.SYNCED_TO_GH_USER,
start=ConditionalEdgePath(
kind=foreign_kind,
property_matchers=matchers,
),
end=EdgePath(value=self.user.id, match_by="id"),
properties=GHEdgeProperties(
traversable=True,
composed=True,
query_composition=q,
),
)

@property
def service_provider_node_id(self) -> str | None:
return github_saml_service_provider_id(
Expand Down Expand Up @@ -363,5 +299,3 @@ def edges(self):
end=EdgePath(value=self.user.id, match_by="id"),
properties=EdgeProperties(traversable=False),
)

yield from self._maps_to_user_edges
56 changes: 1 addition & 55 deletions src/openhound_github/models/saml_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,14 @@
from typing import Any
from urllib.parse import quote, urlparse

from openhound.core.models.entries_dataclass import EdgeProperties, PropertyMatch

from openhound_github.kinds import nodes as nk
from openhound.core.models.entries_dataclass import EdgeProperties


SAML_CONTRACT_VERSION = "opengraph-saml-v0.3.0"
ENTRA_OBJECT_ID_CLAIM = "http://schemas.microsoft.com/identity/claims/objectidentifier"
ENTRA_TENANT_ID_CLAIM = "http://schemas.microsoft.com/identity/claims/tenantid"
DEFAULT_GITHUB_DEPLOYMENT_ID = "github.com"
DEFAULT_GITHUB_WEB_ORIGIN = "https://github.com"

_FOREIGN_USER_KIND = {
"entra": nk.AZ_USER,
"okta": nk.OKTA_USER,
"pingone": nk.PINGONE_USER,
}
_FOREIGN_USER_ENVIRONMENT_PROPERTY = {
nk.OKTA_USER: "tenant_domain",
nk.PINGONE_USER: "environmentid",
}


@dataclass
class SAMLRelationshipEdgeProperties(EdgeProperties):
Expand Down Expand Up @@ -67,47 +54,6 @@ def detect_foreign_idp(

return None, None

def foreign_user_kind(foreign_idp_type: str | None) -> str:
return _FOREIGN_USER_KIND.get(foreign_idp_type or "", "")


def foreign_user_matchers(
foreign_kind: str | None,
foreign_environment_id: str | None,
foreign_username: str | None,
saml_attributes: list[Any] | None = None,
) -> list[PropertyMatch]:
"""Return tenant-scoped matchers for foreign IdP user correlation."""
if foreign_kind == nk.AZ_USER:
tenant_ids = saml_attribute_match_values(
saml_attributes or [],
ENTRA_TENANT_ID_CLAIM,
)
object_ids = saml_attribute_match_values(
saml_attributes or [],
ENTRA_OBJECT_ID_CLAIM,
)
if (
not foreign_environment_id
or len(tenant_ids) != 1
or len(object_ids) != 1
or tenant_ids[0].casefold() != foreign_environment_id.casefold()
):
return []
return [
PropertyMatch(key="tenantid", value=tenant_ids[0].upper()),
PropertyMatch(key="objectid", value=object_ids[0].upper()),
]

environment_property = _FOREIGN_USER_ENVIRONMENT_PROPERTY.get(foreign_kind or "")
if not environment_property or not foreign_environment_id or not foreign_username:
return []
return [
PropertyMatch(key=environment_property, value=foreign_environment_id),
PropertyMatch(key="name", value=foreign_username.upper()),
]


def github_deployment_context(host: str) -> tuple[str, str]:
"""Return a stable deployment ID and browser origin for a GitHub API host."""
parsed = urlparse(host)
Expand Down
Loading
Loading