diff --git a/README.md b/README.md index 8287fdf..bfa6c84 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ A token with enterprise SCIM access is used to collect both `/scim/v2/enterprise Enterprise roles, including the built-in `members` role, are emitted through `GH_HasRole` and granular enterprise capability relationships. Only capability relationships with a confirmed privilege path are traversable; descriptive permissions such as `GH_WriteEnterpriseSso` remain non-traversable. +### Enterprise OIDC collection + +A supplemental classic PAT owned by an enterprise owner with `read:enterprise` +enables discovery of `enterprise.ownerInfo.oidcProvider`. The collector stores +provider metadata in `oidc_provider` and emits `GH_OidcIdentityProvider` with +`provider_type` and `tenant_id`, linked to the enterprise through the +non-traversable `GH_HasOidcIdentityProvider` relationship. + +SAML and OIDC providers both carry the secondary kind `GH_IdentityProvider`. +Provider queries run independently, so an unavailable OIDC field or denied +request does not prevent existing SAML collection. An absent OIDC provider is +logged separately from query failures. + +External identities from both enterprise provider types are paginated into the +existing `external_identities` table. Conversion resolves their provider through +the SAML and OIDC metadata tables and emits `GH_HasExternalIdentity` and, when a +linked user exists, `GH_MapsToUser`. These edges remain non-traversable. Nullable +identity attributes and unlinked identities are retained. OIDC identities do not +produce SAML account relationships. If provider lookup is missing or ambiguous, +conversion omits the provider edge while preserving the identity and user mapping; +ambiguous matches are logged. + ### Optional privileged collection The collector runs successfully with the documented read-only permission set. Some higher-fidelity relationships require optional non-read-only permissions because GitHub exposes the supporting read APIs behind privileged permission names: diff --git a/descriptions/edges/GH_HasExternalIdentity.md b/descriptions/edges/GH_HasExternalIdentity.md index 905addf..eab7291 100644 --- a/descriptions/edges/GH_HasExternalIdentity.md +++ b/descriptions/edges/GH_HasExternalIdentity.md @@ -2,19 +2,21 @@ ## General Information -The non-traversable GH_HasExternalIdentity edge represents the relationship between a SAML identity provider and the external identities (SSO users) it manages. This edge links each external identity to the SAML provider that authenticated it. External identities are a key component in cross-platform attack path analysis because they bridge the gap between corporate identity providers and GitHub user accounts via the GH_MapsToUser edge. Enumerating external identities reveals which corporate users have linked GitHub accounts and enables mapping from IdP compromise to GitHub access. +The non-traversable GH_HasExternalIdentity edge represents the relationship between a SAML or OIDC identity provider and the external identities (SSO users) it manages. This edge links each external identity to its identity provider. External identities are a key component in cross-platform attack path analysis because they bridge the gap between corporate identity providers and GitHub user accounts via the GH_MapsToUser edge. Enumerating external identities reveals which corporate users have linked GitHub accounts and enables mapping from IdP compromise to GitHub access. ## Edge Schema | Source | Destination | Traversable | | --- | --- | --- | -| `GH_SamlIdentityProvider` | `GH_ExternalIdentity` | `false` | +| `GH_IdentityProvider` | `GH_ExternalIdentity` | `false` | ## Diagram ```mermaid graph LR - n0["GH_SamlIdentityProvider"] + n0["GH_IdentityProvider"] n1["GH_ExternalIdentity"] n0 -.->|GH_HasExternalIdentity| n1 ``` + +`GH_IdentityProvider` is a shared secondary kind on SAML and OIDC providers. Provider association is resolved from collected provider metadata, without duplicating provider properties on external identity nodes. diff --git a/descriptions/edges/GH_HasOidcIdentityProvider.md b/descriptions/edges/GH_HasOidcIdentityProvider.md new file mode 100644 index 0000000..4fe966c --- /dev/null +++ b/descriptions/edges/GH_HasOidcIdentityProvider.md @@ -0,0 +1,20 @@ +# GH_HasOidcIdentityProvider + +## General Information + +Links a GitHub Enterprise account to its configured OIDC identity provider. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_Enterprise` | `GH_OidcIdentityProvider` | `false` | + +This relationship provides configuration context and does not itself represent an attack path. + +## Diagram + +```mermaid +graph LR + enterprise["GH_Enterprise"] -.->|GH_HasOidcIdentityProvider| provider["GH_OidcIdentityProvider"] +``` diff --git a/descriptions/nodes/GH_ExternalIdentity.md b/descriptions/nodes/GH_ExternalIdentity.md index a270aba..a6a4045 100644 --- a/descriptions/nodes/GH_ExternalIdentity.md +++ b/descriptions/nodes/GH_ExternalIdentity.md @@ -2,7 +2,7 @@ ## General Information -Represents an external identity from a SAML or SCIM identity provider that is linked to a GitHub user. External identities map corporate user accounts (from providers like Okta, Azure AD, etc.) to GitHub user accounts, enabling single sign-on authentication. Each external identity can have both SAML and SCIM identity attributes. +Represents an external identity collected through a SAML or OIDC provider, with a linked GitHub user when available. External identities map corporate user accounts (from providers like Okta, Azure AD, etc.) to GitHub user accounts, enabling single sign-on authentication. SAML attributes, SCIM attributes, and the linked user may be absent. ## Properties diff --git a/descriptions/nodes/GH_IdentityProvider.md b/descriptions/nodes/GH_IdentityProvider.md new file mode 100644 index 0000000..f52a700 --- /dev/null +++ b/descriptions/nodes/GH_IdentityProvider.md @@ -0,0 +1,24 @@ +# GH_IdentityProvider + +## General Information + +A shared, non-display secondary kind on `GH_SamlIdentityProvider` and `GH_OidcIdentityProvider` nodes. Provider-specific kinds retain their display settings and protocol-specific properties. + +Use this kind to query providers across both federation strategies: + +```cypher +MATCH (provider:GH_IdentityProvider) +RETURN provider +``` + +## Properties + +This secondary kind adds no properties. Nodes retain the properties defined by their provider-specific kind. + +## Diagram + +```mermaid +graph LR + provider["GH_IdentityProvider"] -.->|GH_HasExternalIdentity| identity["GH_ExternalIdentity"] + identity -.->|GH_MapsToUser| user["GH_User"] +``` diff --git a/descriptions/nodes/GH_OidcIdentityProvider.md b/descriptions/nodes/GH_OidcIdentityProvider.md new file mode 100644 index 0000000..2e90b4d --- /dev/null +++ b/descriptions/nodes/GH_OidcIdentityProvider.md @@ -0,0 +1,34 @@ +# GH_OidcIdentityProvider + +## General Information + +Represents an OIDC identity provider configured for a GitHub Enterprise account. Collection queries `enterprise.ownerInfo.oidcProvider` using the supplemental classic PAT. The PAT must belong to an enterprise owner and have `read:enterprise` access. + +The node carries `GH_OidcIdentityProvider`, the shared secondary kind `GH_IdentityProvider`, and the source kind `GitHub`. + +## Properties + +| Property | Description | +| --- | --- | +| `node_id` | GitHub's native GraphQL provider ID. | +| `provider_type` | The OIDC provider type returned by GitHub. | +| `tenant_id` | The identity provider's tenant ID. | +| `environmentid` | The GitHub Enterprise node ID. | +| `environment_name` | The enterprise name. | +| `github_deployment_id` | The GitHub deployment identifier. | +| `github_web_origin` | The GitHub web origin. | + +## Relationships + +`GH_Enterprise` connects to this node through the non-traversable `GH_HasOidcIdentityProvider` relationship. Provider collection does not emit SAML service provider or SAML account relationships. + +The provider connects to its `GH_ExternalIdentity` nodes through non-traversable `GH_HasExternalIdentity` edges. Those identities map to linked `GH_User` nodes through `GH_MapsToUser` when available. + +## Diagram + +```mermaid +graph LR + enterprise["GH_Enterprise"] -.->|GH_HasOidcIdentityProvider| provider["GH_OidcIdentityProvider"] + provider -.->|GH_HasExternalIdentity| identity["GH_ExternalIdentity"] + identity -.->|GH_MapsToUser| user["GH_User"] +``` diff --git a/descriptions/nodes/GH_SamlIdentityProvider.md b/descriptions/nodes/GH_SamlIdentityProvider.md index ea79188..f85333a 100644 --- a/descriptions/nodes/GH_SamlIdentityProvider.md +++ b/descriptions/nodes/GH_SamlIdentityProvider.md @@ -37,3 +37,5 @@ graph LR n2 -.->|GH_HasSamlIdentityProvider| n1 n1 -.->|GH_HasExternalIdentity| n3 ``` + +This node also carries the non-display secondary kind `GH_IdentityProvider`, shared with enterprise OIDC providers. diff --git a/extension/schema.json b/extension/schema.json index 71b380e..eeaa118 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -2,7 +2,7 @@ "schema": { "name": "SOGitHub", "display_name": "GitHub Extension (by SpecterOps)", - "version": "v1.4.0", + "version": "v1.4.2", "namespace": "GH" }, "node_kinds": [ @@ -74,7 +74,7 @@ "title": "Identity Provider", "position": 9, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Enterprise {objectid: '%s'})-[:GH_HasSamlIdentityProvider]->(idp:GH_SamlIdentityProvider)\\nOPTIONAL MATCH p1 = (idp)-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p, p1\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider and mapped users for this enterprise in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Enterprise {objectid: '%s'})-[:GH_HasSamlIdentityProvider|GH_HasOidcIdentityProvider]->(idp:GH_IdentityProvider)\\nOPTIONAL MATCH p1 = (idp)-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p, p1\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider and mapped users for this enterprise in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "classic_personal_access_tokens": { @@ -1631,10 +1631,62 @@ } } }, + { + "name": "GH_OidcIdentityProvider", + "display_name": "GitHub OIDC Identity Provider", + "description": "An OIDC identity provider configured for a GitHub Enterprise account", + "is_display_kind": true, + "icon": "id-badge", + "color": "#5A6C8F", + "info": { + "description": { + "title": "Description", + "position": 1, + "markdown": { + "content": "## Overview\n\nRepresents an enterprise OIDC identity provider. The provider_type and tenant_id properties identify the provider and its tenant." + } + }, + "inbound_traversable_relationships": { + "title": "Inbound Traversable GH Relationships", + "position": 2, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE false\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "outbound_traversable_relationships": { + "title": "Outbound Traversable GH Relationships", + "position": 3, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE false\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct outbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "environment_context": { + "title": "Environment Context", + "position": 4, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (environment)-[:GH_HasOidcIdentityProvider]->(selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE environment:GH_Enterprise\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the enterprise using this identity provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "external_identities": { + "title": "External Identities", + "position": 5, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View external identities from this provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "mapped_users": { + "title": "Mapped Users", + "position": 6, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View GitHub users mapped through this identity provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + } + } + }, { "name": "GH_ExternalIdentity", "display_name": "GitHub External Identity", - "description": "An external identity from a SAML/SCIM provider linked to a GitHub user for SSO authentication", + "description": "An external identity from a SAML or OIDC provider with optional SCIM attributes and a linked GitHub user when available", "is_display_kind": true, "icon": "arrows-left-right", "color": "#8A8F98", @@ -1643,7 +1695,7 @@ "title": "Description", "position": 1, "markdown": { - "content": "## Overview\n\nRepresents an external identity from a SAML or SCIM identity provider that is linked to a GitHub user. This node preserves the identity-provider attributes used to understand how a corporate identity maps into GitHub." + "content": "## Overview\n\nRepresents an external identity collected through a SAML or OIDC provider, with a linked GitHub user when available. This node preserves the identity-provider attributes used to understand how a corporate identity maps into GitHub." } }, "inbound_traversable_relationships": { @@ -1664,7 +1716,7 @@ "title": "Identity Provider", "position": 4, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_SamlIdentityProvider)-[:GH_HasExternalIdentity]->(selected:GH_ExternalIdentity {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider containing this external identity in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_IdentityProvider)-[:GH_HasExternalIdentity]->(selected:GH_ExternalIdentity {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider containing this external identity in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "mapped_user": { @@ -1966,6 +2018,12 @@ } } } + }, + { + "name": "GH_IdentityProvider", + "display_name": "GitHub Identity Provider", + "description": "Shared secondary kind for GitHub SAML and OIDC identity providers", + "is_display_kind": false } ], "relationship_kinds": [ @@ -2601,7 +2659,7 @@ }, { "name": "GH_HasExternalIdentity", - "description": "SAML identity provider has this external identity", + "description": "SAML or OIDC identity provider has this external identity", "is_traversable": false }, { @@ -2748,6 +2806,11 @@ "name": "GH_ApprovesDeploymentTo", "description": "User or team is configured as a required reviewer for this environment", "is_traversable": false + }, + { + "name": "GH_HasOidcIdentityProvider", + "description": "Enterprise has this OIDC identity provider configured", + "is_traversable": false } ], "environments": [ diff --git a/src/openhound_github/graphql.py b/src/openhound_github/graphql.py index 87b9578..dc16e4a 100644 --- a/src/openhound_github/graphql.py +++ b/src/openhound_github/graphql.py @@ -131,12 +131,19 @@ } """ -ENTERPRISE_SAML_QUERY = """ -query EnterpriseSAML($slug: String!, $count: Int = 100, $after: String = null) { +def enterprise_external_identity_query(provider_field: str) -> str: + """Build the common paginated identity query for an enterprise provider.""" + if provider_field not in {"samlIdentityProvider", "oidcProvider"}: + raise ValueError(f"Unsupported identity provider: {provider_field}") + return _ENTERPRISE_EXTERNAL_IDENTITY_QUERY.replace("PROVIDER_FIELD", provider_field) + + +_ENTERPRISE_EXTERNAL_IDENTITY_QUERY = """ +query EnterpriseExternalIdentities($slug: String!, $count: Int = 100, $after: String = null) { enterprise(slug: $slug) { slug ownerInfo { - samlIdentityProvider { + PROVIDER_FIELD { externalIdentities(first: $count, after: $after) { totalCount nodes { @@ -179,6 +186,9 @@ } """ +ENTERPRISE_SAML_QUERY = enterprise_external_identity_query("samlIdentityProvider") +ENTERPRISE_OIDC_QUERY = enterprise_external_identity_query("oidcProvider") + ENTERPRISE_SAML_PROVIDER_QUERY = """ query EnterpriseSAMLProvider($slug: String!) { enterprise(slug: $slug) { @@ -199,6 +209,20 @@ } """ +ENTERPRISE_OIDC_PROVIDER_QUERY = """ +query EnterpriseOIDCProvider($slug: String!) { + enterprise(slug: $slug) { + ownerInfo { + oidcProvider { + id + providerType + tenantId + } + } + } +} +""" + TEAMS_QUERY = """ query Teams($login: String!, $count: Int!, $after: String) { organization(login: $login) { diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index c29a3a9..010706c 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -43,6 +43,7 @@ RESTRICTIONS_CAN_PUSH = "GH_RestrictionsCanPush" # Identity and authentication edges +HAS_OIDC_IDENTITY_PROVIDER = "GH_HasOidcIdentityProvider" HAS_EXTERNAL_IDENTITY = "GH_HasExternalIdentity" HAS_SAML_IDENTITY_PROVIDER = "GH_HasSamlIdentityProvider" MAPS_TO_USER = "GH_MapsToUser" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index 2c4b142..dc8c024 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -13,6 +13,8 @@ ENVIRONMENT_BRANCH_POLICY = "GH_EnvironmentBranchPolicy" # Identity nodes +IDENTITY_PROVIDER = "GH_IdentityProvider" +OIDC_IDENTITY_PROVIDER = "GH_OidcIdentityProvider" EXTERNAL_IDENTITY = "GH_ExternalIdentity" SAML_IDENTITY_PROVIDER = "GH_SamlIdentityProvider" ENTERPRISE_MANAGED_USER = "GH_EnterpriseManagedUser" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 45c5a09..850a5cc 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -827,6 +827,28 @@ def idp_for_environment(self, environment_slug: str) -> list: [environment_slug], ) + @lru_cache + def identity_providers_for_environment(self, environment_slug: str) -> list: + """Return provider context without duplicating it on external identity nodes.""" + rows = self._find_all_objects( + f"""SELECT id, issuer, sso_url, environment_node_id, environment_name, + environment_type, 'saml' AS federation_type + FROM {self.schema}.saml_provider WHERE environment_slug = ? + UNION ALL + SELECT id, NULL, NULL, environment_node_id, environment_name, + 'enterprise', 'oidc' + FROM {self.schema}.oidc_provider WHERE environment_slug = ?""", + [environment_slug, environment_slug], + ) + + if len(rows) > 1: + logger.warning( + "Multiple identity providers found for environment '%s'; skipping ambiguous provider association", + environment_slug, + ) + return [] + return rows + @lru_cache def app_node_id(self, app_slug: str, org_login: str | None = None) -> str | None: if org_login is None: diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index 8ecd030..669c5d9 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -72,6 +72,7 @@ def preproc(ctx: PreProcContext): "team_external_groups": "team_external_groups", "team_members": "team_members", "saml_provider": "saml_provider", + "oidc_provider": "oidc_provider", "external_identities": "external_identities", "applications": "applications", "enterprise": "enterprise", diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index a684409..4a7d626 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -26,6 +26,7 @@ from .environment_branch_policy import EnvironmentBranchPolicy from .deploy_key import DeployKey from .external_identity import ExternalIdentity +from .oidc_provider import OidcProvider from .org import Organization from .org_role import OrgRole from .org_role_member import OrgRoleMember @@ -75,6 +76,7 @@ from .workflow_step import WorkflowStep __all__ = [ + "OidcProvider", "Organization", "OrgRole", "OrgRoleMember", diff --git a/src/openhound_github/models/external_identity.py b/src/openhound_github/models/external_identity.py index 4e945f7..3c13208 100644 --- a/src/openhound_github/models/external_identity.py +++ b/src/openhound_github/models/external_identity.py @@ -96,7 +96,7 @@ class User(BaseModel): ), edges=[ EdgeDef( - start=nk.SAML_IDENTITY_PROVIDER, + start=nk.IDENTITY_PROVIDER, end=nk.EXTERNAL_IDENTITY, kind=ek.HAS_EXTERNAL_IDENTITY, description="IdP has external identity", @@ -185,8 +185,8 @@ def as_node(self) -> GHNode: @property def idp(self) -> dict: - ext_idp = self._lookup.idp_for_environment(self.environment_slug) - if not ext_idp: + ext_idp = self._lookup.identity_providers_for_environment(self.environment_slug) + if len(ext_idp) != 1: return { "id": None, "issuer": None, @@ -194,8 +194,12 @@ def idp(self) -> dict: "environment_node_id": None, "environment_name": None, "environment_type": None, + "federation_type": None, } - provider_id, issuer, sso_url, environment_node_id, environment_name, environment_type = ext_idp[0] + ( + provider_id, issuer, sso_url, environment_node_id, + environment_name, environment_type, federation_type, + ) = ext_idp[0] return { "id": provider_id, "issuer": issuer, @@ -203,10 +207,13 @@ def idp(self) -> dict: "environment_node_id": environment_node_id, "environment_name": environment_name, "environment_type": environment_type, + "federation_type": federation_type, } @property def service_provider_node_id(self) -> str | None: + if self.idp.get("federation_type") != "saml": + return None return github_saml_service_provider_id( self.idp.get("environment_type"), self.environment_slug, @@ -263,12 +270,13 @@ def saml_direct_binding_source(self) -> str | None: @property def edges(self): - yield Edge( - kind=ek.HAS_EXTERNAL_IDENTITY, - start=EdgePath(value=self.idp["id"], match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) + if self.idp.get("id"): + yield Edge( + kind=ek.HAS_EXTERNAL_IDENTITY, + start=EdgePath(value=self.idp["id"], match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) has_idp = bool(self.idp.get("id")) service_provider_node_id = self.service_provider_node_id diff --git a/src/openhound_github/models/oidc_provider.py b/src/openhound_github/models/oidc_provider.py new file mode 100644 index 0000000..3d94ff2 --- /dev/null +++ b/src/openhound_github/models/oidc_provider.py @@ -0,0 +1,92 @@ +"""Enterprise OIDC provider metadata collected through GraphQL.""" + +from dataclasses import dataclass +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import ConfigDict, Field + +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 +from .saml_helpers import DEFAULT_GITHUB_DEPLOYMENT_ID, DEFAULT_GITHUB_WEB_ORIGIN + + +@dataclass +class GHOidcProviderProperties(GHNodeProperties): + """OIDC provider metadata and its enterprise collection context.""" + + provider_type: str | None = None + tenant_id: str | None = None + environment_name: str | None = None + github_deployment_id: str | None = None + github_web_origin: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.OIDC_IDENTITY_PROVIDER, + description="OIDC identity provider for a GitHub Enterprise account", + icon="id-badge", + properties=GHOidcProviderProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.OIDC_IDENTITY_PROVIDER, + kind=ek.HAS_OIDC_IDENTITY_PROVIDER, + description="GitHub Enterprise account uses this OIDC identity provider", + traversable=False, + ), + ], +) +class OidcProvider(BaseAsset): + """Convert an enterprise OIDC provider and its containment relationship.""" + + model_config = ConfigDict(populate_by_name=True) + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + id: str + provider_type: str = Field(alias="providerType") + tenant_id: str = Field(alias="tenantId") + environment_node_id: str + environment_name: str + environment_slug: str + github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID + github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + + @property + def node_id(self) -> str: + """Return the provider's native GitHub identifier.""" + return self.id + + @property + def as_node(self) -> GHNode: + """Build the OIDC node with the shared identity-provider kind.""" + return GHNode( + kinds=[nk.OIDC_IDENTITY_PROVIDER, nk.IDENTITY_PROVIDER], + properties=GHOidcProviderProperties( + name=self.node_id, + displayname=self.environment_name, + node_id=self.node_id, + environmentid=self.environment_node_id, + environment_name=self.environment_name, + provider_type=self.provider_type, + tenant_id=self.tenant_id, + github_deployment_id=self.github_deployment_id, + github_web_origin=self.github_web_origin, + ), + ) + + @property + def edges(self): + """Link the enterprise to its provider through a non-traversable edge.""" + yield Edge( + kind=ek.HAS_OIDC_IDENTITY_PROVIDER, + start=EdgePath(value=self.environment_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/saml_provider.py b/src/openhound_github/models/saml_provider.py index d981c01..23e403d 100644 --- a/src/openhound_github/models/saml_provider.py +++ b/src/openhound_github/models/saml_provider.py @@ -101,7 +101,7 @@ def node_id(self) -> str: def as_node(self) -> GHNode: _, foreign_environment_id = detect_foreign_idp(self.issuer, self.sso_url) return GHNode( - kinds=[nk.SAML_IDENTITY_PROVIDER], + kinds=[nk.SAML_IDENTITY_PROVIDER, nk.IDENTITY_PROVIDER], properties=GHSamlProviderProperties( name=self.node_id, displayname=self.environment_name, diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index e636890..6b02355 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -19,8 +19,10 @@ ENTERPRISE_MEMBERS_QUERY, ORGANIZATION_ENTERPRISE_OWNERS_QUERY, ENTERPRISE_SAML_PROVIDER_QUERY, + ENTERPRISE_OIDC_PROVIDER_QUERY, ENTERPRISE_QUERY, ENTERPRISE_SAML_QUERY, + ENTERPRISE_OIDC_QUERY, ) from openhound_github.helpers import ( GraphQLCursorPaginator, @@ -55,6 +57,7 @@ EnterpriseScimOrganization, EnterpriseScimUser, SamlProvider, + OidcProvider, SamlServiceProvider, SamlAssertionConsumerService, SamlIssuer, @@ -1242,6 +1245,64 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): } +@app.transformer( + name="enterprise_oidc_provider", + table_name="oidc_provider", + columns=OidcProvider, + parallelized=True, +) +def enterprise_oidc_provider(enterprise_data: Enterprise, ctx: SourceContext): + """Discover enterprise OIDC independently of the SAML provider query.""" + client, graphql_path = _sso_graphql_client(ctx) + if not client: + logger.info( + "Skipping enterprise_oidc_provider for enterprise '%s': no SSO client configured", + ctx.enterprise_name, + ) + return + try: + response = client.post( + graphql_path, + json={ + "query": ENTERPRISE_OIDC_PROVIDER_QUERY, + "variables": {"slug": ctx.enterprise_name}, + }, + ).json() + if response.get("errors"): + logger.warning( + "Enterprise OIDC provider query failed for '%s': %s", + ctx.enterprise_name, + response["errors"], + ) + return + enterprise_object = (response.get("data") or {}).get("enterprise") + if not enterprise_object: + logger.warning( + "No enterprise object returned while fetching OIDC provider for enterprise '%s'", + ctx.enterprise_name, + ) + return + provider = (enterprise_object.get("ownerInfo") or {}).get("oidcProvider") + if not provider: + logger.info("No enterprise OIDC provider configured for '%s'", ctx.enterprise_name) + return + yield { + **provider, + "environment_node_id": enterprise_data.id, + "environment_name": enterprise_data.name or enterprise_data.slug, + "environment_slug": enterprise_data.slug, + "github_deployment_id": ctx.github_deployment_id, + "github_web_origin": ctx.github_web_origin, + } + except Exception as e: + logger.error( + "Error in resource 'enterprise_oidc_provider' processing enterprise '%s': %s", + ctx.enterprise_name, + e, + extra={"resource": "enterprise_oidc_provider", "phase": "resource_iteration"}, + ) + + @app.transformer( name="enterprise_saml_service_provider", table_name="saml_service_provider", @@ -1302,72 +1363,113 @@ def enterprise_saml_issuer(saml_provider: SamlProvider, ctx: SourceContext): } -@app.transformer( - name="enterprise_external_identity", - table_name="external_identities", - columns=ExternalIdentity, - parallelized=True, -) -def enterprise_external_identity(saml_provider: SamlProvider, ctx: SourceContext): - """Collect enterprise SAML identities from the configured SSO client.""" +def iter_enterprise_external_identities( + ctx: SourceContext, + *, + provider_field: str, + environment_slug: str, + github_deployment_id: str, + resource_name: str, +): + """Yield federation identities only after all pages are collected successfully.""" + query = { + "samlIdentityProvider": ENTERPRISE_SAML_QUERY, + "oidcProvider": ENTERPRISE_OIDC_QUERY, + }[provider_field] client, graphql_path = _sso_graphql_client(ctx) if not client: logger.info( - "Skipping enterprise_external_identity for enterprise '%s': no SSO client configured", + "Skipping %s for enterprise '%s': no SSO client configured", + resource_name, ctx.enterprise_name, ) return - paginator = GraphQLCursorPaginator( - page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", + page_info_path=f"data.enterprise.ownerInfo.{provider_field}.externalIdentities.pageInfo", cursor_variable="after", cursor_field="endCursor", has_next_field="hasNextPage", allow_missing_page_info=True, ) - data = { - "query": ENTERPRISE_SAML_QUERY, - "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, - } - + identities = [] try: - for page_data in client.paginate( + for page in client.paginate( graphql_path, method="POST", - json=data, + json={ + "query": query, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, + }, paginator=paginator, data_selector="data", ): - for enterprise_object in page_data: - es_data = enterprise_object.get("enterprise", {}) - page_provider = (es_data.get("ownerInfo") or {}).get( - "samlIdentityProvider" + for item in page: + enterprise_object = item.get("enterprise") or {} + provider = (enterprise_object.get("ownerInfo") or {}).get( + provider_field ) - if not page_provider: + if not provider: logger.warning( - "No enterprise SAML provider returned while fetching external identities for enterprise '%s'", + "No enterprise %s returned while fetching external identities for enterprise '%s'", + provider_field, ctx.enterprise_name, ) return - for identity in (page_provider.get("externalIdentities") or {}).get( + for identity in (provider.get("externalIdentities") or {}).get( "nodes" ) or []: - yield { - **identity, - "environment_slug": saml_provider.get("environment_slug"), - "github_deployment_id": saml_provider.get( - "github_deployment_id" - ), - } + if identity is not None: + identities.append( + { + **identity, + "environment_slug": environment_slug, + "github_deployment_id": github_deployment_id, + } + ) except Exception as e: logger.error( - f"Error in resource 'enterprise_external_identity' processing enterprise '{ctx.enterprise_name}': {e}", - extra={ - "resource": "enterprise_external_identity", - "phase": "resource_iteration", - }, + "Error in resource '%s' processing enterprise '%s': %s", + resource_name, + ctx.enterprise_name, + e, + extra={"resource": resource_name, "phase": "resource_iteration"}, ) return + yield from identities + + +@app.transformer( + name="enterprise_external_identity", + table_name="external_identities", + columns=ExternalIdentity, + parallelized=True, +) +def enterprise_external_identity(saml_provider: SamlProvider, ctx: SourceContext): + """Preserve the existing SAML resource and output table.""" + yield from iter_enterprise_external_identities( + ctx, + provider_field="samlIdentityProvider", + environment_slug=saml_provider.get("environment_slug"), + github_deployment_id=saml_provider.get("github_deployment_id"), + resource_name="enterprise_external_identity", + ) + + +@app.transformer( + name="enterprise_oidc_external_identity", + table_name="external_identities", + columns=ExternalIdentity, + parallelized=True, +) +def enterprise_oidc_external_identity(oidc_provider: OidcProvider, ctx: SourceContext): + """Collect all OIDC external identities into the shared identity output table.""" + yield from iter_enterprise_external_identities( + ctx, + provider_field="oidcProvider", + environment_slug=oidc_provider.environment_slug, + github_deployment_id=oidc_provider.github_deployment_id, + resource_name="enterprise_oidc_external_identity", + ) def enterprise_resources(ctx: SourceContext): @@ -1403,8 +1505,11 @@ def enterprise_resources(ctx: SourceContext): if ctx.sso_client: saml_resource = enterprise_saml_provider(ctx) + oidc_resource = enterprise_resource | enterprise_oidc_provider(ctx) resources.extend( [ + oidc_resource, + oidc_resource | enterprise_oidc_external_identity(ctx), enterprise_resource | saml_resource, enterprise_resource | saml_resource diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index 3142ee4..76f1238 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -73,6 +73,21 @@ def ensure_optional_input_tables( external_group_name VARCHAR, external_group_updated_at VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.saml_provider ( + id VARCHAR, + issuer VARCHAR, + sso_url VARCHAR, + environment_node_id VARCHAR, + environment_name VARCHAR, + environment_type VARCHAR, + environment_slug VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.oidc_provider ( + id VARCHAR, + environment_node_id VARCHAR, + environment_name VARCHAR, + environment_slug VARCHAR + ); CREATE TABLE IF NOT EXISTS {schema}.external_identities ( id VARCHAR, guid VARCHAR, diff --git a/tests/test_external_identity_federation.py b/tests/test_external_identity_federation.py new file mode 100644 index 0000000..fdf64e8 --- /dev/null +++ b/tests/test_external_identity_federation.py @@ -0,0 +1,319 @@ +import gzip +import json +from types import SimpleNamespace + +import dlt +import duckdb +import pytest +from openhound.core.progress import Progress + +from openhound_github.graphql import ENTERPRISE_OIDC_QUERY, ENTERPRISE_SAML_QUERY +from openhound_github.kinds import edges as ek +from openhound_github.lookup import GithubLookup +from openhound_github.main import preproc +from openhound_github.models import Enterprise, ExternalIdentity +from openhound_github.resources.enterprise import ( + SourceContext, + enterprise_external_identity, + enterprise_oidc_external_identity, + enterprise_oidc_provider, + iter_enterprise_external_identities, +) +from openhound_github.transforms import ensure_optional_input_tables +from test_enterprise_resources import _FakeClient, _FailingPaginateClient + + +def _identity_record(i=1): + """Build an external identity with SCIM attributes and a linked GitHub user.""" + return { + "id": f"EI_{i}", + "guid": f"guid-{i}", + "samlIdentity": None, + "scimIdentity": {"username": f"user{i}@example.test"}, + "user": {"id": f"U_{i}", "login": f"user{i}_acme"}, + } + + +@pytest.mark.parametrize("provider_field", ["samlIdentityProvider", "oidcProvider"]) +def test_shared_identity_collection_reads_all_pages_and_preserves_fields( + provider_field, +): + """Collect every SAML or OIDC identity page using the supplemental PAT.""" + pages = [ + [ + { + "enterprise": { + "ownerInfo": { + provider_field: { + "externalIdentities": { + "nodes": [None, _identity_record(i)] + }, + } + } + } + } + ] + for i in (1, 2) + ] + app = _FakeClient({}) + pat = _FakeClient({}, pages=pages) + ctx = SourceContext(client=app, sso_client=pat, enterprise_name="acme") + if provider_field == "samlIdentityProvider": + records = enterprise_external_identity.__wrapped__( + {"environment_slug": "acme", "github_deployment_id": "deployment-1"}, + ctx, + ) + else: + records = enterprise_oidc_external_identity.__wrapped__( + SimpleNamespace( + environment_slug="acme", github_deployment_id="deployment-1" + ), + ctx, + ) + rows = list(records) + assert [r["id"] for r in rows] == ["EI_1", "EI_2"] + assert rows[0] == { + **_identity_record(), + "environment_slug": "acme", + "github_deployment_id": "deployment-1", + } + assert app.paginate_calls == [] + path, request = pat.paginate_calls[0] + assert path == "/graphql" + assert ( + request["paginator"].page_info_path + == f"data.enterprise.ownerInfo.{provider_field}.externalIdentities.pageInfo" + ) + expected = ( + ENTERPRISE_SAML_QUERY + if provider_field == "samlIdentityProvider" + else ENTERPRISE_OIDC_QUERY + ) + assert request["json"]["query"] == expected + + +def test_oidc_extraction_pipeline_validates_provider_and_shares_identity_table(): + """Validate OIDC records through the DLT pipeline and shared identity table.""" + pat = _FakeClient( + { + "data": { + "enterprise": { + "ownerInfo": { + "oidcProvider": { + "id": "OIDC_1", + "providerType": "AAD", + "tenantId": "tenant-1", + } + } + } + } + }, + pages=[ + [ + { + "enterprise": { + "ownerInfo": { + "oidcProvider": { + "externalIdentities": {"nodes": [_identity_record()]}, + } + } + } + } + ] + ], + ) + ctx = SourceContext(client=None, sso_client=pat, enterprise_name="acme") + source = dlt.resource( + [Enterprise(id="E_1", name="Acme", slug="acme")], name="test_enterprise" + ) + resource = ( + source | enterprise_oidc_provider(ctx) | enterprise_oidc_external_identity(ctx) + ) + rows = list(resource) + assert resource.table_name == "external_identities" + assert rows[0]["environment_slug"] == "acme" + assert ( + ExternalIdentity.model_validate(rows[0]).scim_identity.username + == "user1@example.test" + ) + + +def test_oidc_pagination_failure_is_diagnostic_and_returns(caplog): + """Skip inaccessible OIDC identities while recording a diagnostic.""" + ctx = SourceContext( + client=None, sso_client=_FailingPaginateClient({}), enterprise_name="acme" + ) + rows = list( + enterprise_oidc_external_identity.__wrapped__( + SimpleNamespace(environment_slug="acme", github_deployment_id="github.com"), + ctx, + ) + ) + assert rows == [] + assert "enterprise_oidc_external_identity" in caplog.text + + +@pytest.mark.parametrize("provider_field", ["samlIdentityProvider", "oidcProvider"]) +@pytest.mark.parametrize("failure", ["exception", "missing_provider"]) +def test_late_pagination_failure_discards_buffered_identities( + provider_field, failure, caplog +): + """Keep earlier pages out of the output when a later page cannot be collected.""" + + class LateFailureClient(_FakeClient): + """Return a valid first page before simulating a pagination failure.""" + + def paginate(self, path, **kwargs): + """Yield one identity and then fail or return a missing provider.""" + yield [ + { + "enterprise": { + "ownerInfo": { + provider_field: { + "externalIdentities": {"nodes": [_identity_record()]}, + } + } + } + } + ] + if failure == "exception": + raise ConnectionError("Later page unavailable") + yield ({"enterprise": {"ownerInfo": {provider_field: None}}},) + + ctx = SourceContext( + client=None, sso_client=LateFailureClient({}), enterprise_name="acme" + ) + rows = list( + iter_enterprise_external_identities( + ctx, + provider_field=provider_field, + environment_slug="acme", + github_deployment_id="github.com", + resource_name="identity_probe", + ) + ) + assert rows == [] + if failure == "exception": + error = next(record for record in caplog.records if record.levelname == "ERROR") + assert error.resource == "identity_probe" + assert error.phase == "resource_iteration" + assert "Later page unavailable" in error.message + else: + assert "No enterprise" in caplog.text + + +def _write_records(root, table, rows): + """Write compressed fixture records for preprocessing.""" + directory = root / table + directory.mkdir() + with gzip.open(directory / "rows.jsonl.gz", "wt") as f: + for row in rows: + f.write(json.dumps(row) + "\n") + + +@pytest.mark.parametrize("federation", ["saml", "oidc"]) +def test_preprocessing_and_conversion_resolve_provider_without_identity_properties( + tmp_path, federation +): + """Resolve each federation provider from lookup data during conversion.""" + provider = { + "id": "IDP_1", + "environment_node_id": "E_1", + "environment_name": "Acme", + "environment_slug": "acme", + } + if federation == "saml": + provider.update( + environment_type="enterprise", + issuer="https://idp.example.test", + sso_url="https://idp.example.test/sso", + ) + else: + provider.update(provider_type="AAD", tenant_id="tenant-1") + _write_records(tmp_path, f"{federation}_provider", [provider]) + _write_records( + tmp_path, + "external_identities", + [{**_identity_record(), "environment_slug": "acme"}], + ) + database = tmp_path / "lookup.duckdb" + preproc(tmp_path, database, progress=Progress.log) + connection = duckdb.connect(str(database)) + lookup = GithubLookup(connection) + identity = ExternalIdentity.model_validate( + {**_identity_record(), "environment_slug": "acme"} + ) + identity._lookup = lookup + assert identity.as_node.properties.environmentid == "E_1" + assert identity.as_node.properties.environment_name == "Acme" + assert not hasattr(identity.as_node.properties, "provider_id") + edges = list(identity.edges) + provider_edge = next(e for e in edges if e.kind == ek.HAS_EXTERNAL_IDENTITY) + assert provider_edge.start.value == "IDP_1" + assert provider_edge.end.value == "EI_1" + assert all(e.properties.traversable is False for e in edges) + assert any(e.kind == ek.MAPS_TO_USER and e.end.value == "U_1" for e in edges) + assert (ek.SAML_HAS_ACCOUNT in {e.kind for e in edges}) == (federation == "saml") + connection.close() + + +@pytest.mark.parametrize("user", [None, {"id": "U_1", "login": "user1_acme"}]) +def test_nullable_identity_attributes_and_unlinked_user_are_retained(user): + """Retain identities whose attributes or GitHub user mapping are absent.""" + identity = ExternalIdentity( + id="EI_1", + guid="guid-1", + environment_slug="acme", + samlIdentity=None, + scimIdentity=None, + user=user, + ) + identity._lookup = SimpleNamespace( + identity_providers_for_environment=lambda _: [ + ("OIDC_1", None, None, "E_1", "Acme", "enterprise", "oidc"), + ] + ) + assert identity.as_node.properties.name == "guid-1" + edges = list(identity.edges) + assert edges[0].kind == ek.HAS_EXTERNAL_IDENTITY + assert (ek.MAPS_TO_USER in {e.kind for e in edges}) == bool(user) + assert ek.SAML_HAS_ACCOUNT not in {e.kind for e in edges} + + +def test_oidc_does_not_emit_saml_edge_even_if_saml_attributes_are_present(): + """Prevent OIDC identities from acquiring SAML account relationships.""" + identity = ExternalIdentity.model_validate( + { + **_identity_record(), + "environment_slug": "acme", + "samlIdentity": {"nameId": "name-id"}, + } + ) + identity._lookup = SimpleNamespace( + identity_providers_for_environment=lambda _: [ + ("OIDC_1", None, None, "E_1", "Acme", "enterprise", "oidc"), + ] + ) + assert ek.SAML_HAS_ACCOUNT not in {e.kind for e in identity.edges} + + +def test_missing_or_ambiguous_provider_does_not_emit_invalid_association(caplog): + """Omit unresolved provider relationships while retaining user mappings.""" + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + ensure_optional_input_tables(connection) + connection.execute( + "INSERT INTO github.saml_provider VALUES ('SAML_1', NULL, NULL, 'E_1', 'Acme', 'enterprise', 'acme')" + ) + connection.execute( + "INSERT INTO github.oidc_provider VALUES ('OIDC_1', 'E_1', 'Acme', 'acme')" + ) + lookup = GithubLookup(connection) + for slug in ("missing", "acme"): + identity = ExternalIdentity.model_validate( + {**_identity_record(), "environment_slug": slug} + ) + identity._lookup = lookup + assert [e.kind for e in identity.edges] == [ek.MAPS_TO_USER] + assert "ambiguous provider association" in caplog.text + connection.close() diff --git a/tests/test_oidc_provider.py b/tests/test_oidc_provider.py new file mode 100644 index 0000000..116a1d0 --- /dev/null +++ b/tests/test_oidc_provider.py @@ -0,0 +1,153 @@ +from pathlib import Path +import json + +import dlt +import pytest + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.models import Enterprise, OidcProvider +from openhound_github.resources.enterprise import ( + SourceContext, + enterprise_oidc_provider, + enterprise_saml_provider, + enterprise_resources, +) +from test_enterprise_resources import _FakeClient, _FailingPostClient + + +def _enterprise(): + """Build the enterprise fixture used by provider resource tests.""" + return Enterprise(id="E_1", name="Acme", slug="acme") + + +def _payload(): + """Build the GraphQL response for a configured enterprise OIDC provider.""" + return { + "data": { + "enterprise": { + "ownerInfo": { + "oidcProvider": { + "id": "OIDC_1", + "providerType": "AAD", + "tenantId": "tenant-1", + } + } + } + } + } + + +def test_oidc_provider_uses_pat_graphql_client_and_converts(): + """Collect provider metadata through the PAT and verify its graph representation.""" + app = _FakeClient({}) + pat = _FakeClient({}) + pat_graphql = _FakeClient(_payload()) + ctx = SourceContext( + client=app, + sso_client=pat, + sso_graphql_client=pat_graphql, + enterprise_name="acme", + github_deployment_id="deployment-1", + github_web_origin="https://github.example.test", + ) + source = dlt.resource([_enterprise()], name="test_enterprise") + providers = list(source | enterprise_oidc_provider(ctx)) + assert len(providers) == 1 + provider = providers[0] + assert isinstance(provider, OidcProvider) + node = provider.as_node + assert node.kinds == [nk.OIDC_IDENTITY_PROVIDER, nk.IDENTITY_PROVIDER, "GitHub"] + assert node.properties.environmentid == "E_1" + assert node.properties.tenant_id == "tenant-1" + assert node.properties.provider_type == "AAD" + assert node.properties.github_deployment_id == "deployment-1" + assert node.properties.github_web_origin == "https://github.example.test" + (edge,) = list(provider.edges) + assert edge.kind == ek.HAS_OIDC_IDENTITY_PROVIDER + assert edge.start.value == "E_1" + assert edge.end.value == "OIDC_1" + assert edge.properties.traversable is False + assert pat_graphql.post_calls[0][0] == "" + assert "oidcProvider" in pat_graphql.post_calls[0][1]["query"] + assert app.post_calls == pat.post_calls == [] + + +@pytest.mark.parametrize( + "payload,diagnostic", + [ + ( + {"data": {"enterprise": {"ownerInfo": {"oidcProvider": None}}}}, + "No enterprise OIDC provider configured", + ), + ({"data": {"enterprise": None}}, "No enterprise object returned"), + ({"errors": [{"message": "Forbidden"}]}, "Forbidden"), + ( + {"errors": [{"message": "Field 'oidcProvider' doesn't exist"}]}, + "doesn't exist", + ), + ], +) +def test_absent_or_inaccessible_provider_is_skipped(payload, diagnostic, caplog): + """Skip absent or inaccessible OIDC providers with a diagnostic.""" + ctx = SourceContext( + client=None, sso_client=_FakeClient(payload), enterprise_name="acme" + ) + with caplog.at_level("INFO", logger="openhound_github.resources.enterprise"): + rows = list(enterprise_oidc_provider.__wrapped__(_enterprise(), ctx)) + assert rows == [] + assert diagnostic in caplog.text + + +def test_oidc_network_failure_does_not_prevent_saml_collection(caplog): + """Keep SAML collection available after an OIDC request fails.""" + ctx = SourceContext( + client=None, sso_client=_FailingPostClient({}), enterprise_name="acme" + ) + assert list(enterprise_oidc_provider.__wrapped__(_enterprise(), ctx)) == [] + assert "enterprise_oidc_provider" in caplog.text + saml_client = _FakeClient( + { + "data": { + "enterprise": { + "ownerInfo": { + "samlIdentityProvider": {"id": "SAML_1"}, + } + } + } + } + ) + ctx.sso_client = saml_client + saml = list(enterprise_saml_provider.__wrapped__(_enterprise(), ctx)) + assert saml[0]["id"] == "SAML_1" + assert "oidcProvider" not in saml_client.post_calls[0][1]["query"] + + +def test_oidc_resources_require_pat_and_no_pat_skips_calls(): + """Register and query OIDC resources only when a supplemental PAT exists.""" + app = _FakeClient({}) + ctx = SourceContext(client=app, enterprise_name="acme") + assert list(enterprise_oidc_provider.__wrapped__(_enterprise(), ctx)) == [] + assert app.post_calls == [] + assert "enterprise_oidc_provider" not in {r.name for r in enterprise_resources(ctx)} + ctx.sso_client = _FakeClient({}) + resource = next( + r for r in enterprise_resources(ctx) if r.name == "enterprise_oidc_provider" + ) + assert resource.table_name == "oidc_provider" + + +def test_provider_schema_registers_specific_display_kind_and_secondary_kind(): + """Register OIDC display metadata and the shared identity-provider kind.""" + schema = json.loads( + (Path(__file__).parents[1] / "extension/schema.json").read_text() + ) + kinds = {n["name"]: n for n in schema["node_kinds"]} + assert kinds[nk.IDENTITY_PROVIDER]["is_display_kind"] is False + assert kinds[nk.OIDC_IDENTITY_PROVIDER]["is_display_kind"] is True + edge = next( + e + for e in schema["relationship_kinds"] + if e["name"] == ek.HAS_OIDC_IDENTITY_PROVIDER + ) + assert edge["is_traversable"] is False diff --git a/tests/test_saml_models.py b/tests/test_saml_models.py index c063cbb..6e562c3 100644 --- a/tests/test_saml_models.py +++ b/tests/test_saml_models.py @@ -38,7 +38,7 @@ def _identity_with_lookup(**overrides) -> ExternalIdentity: data.update(overrides) identity = ExternalIdentity.model_validate(data) identity._lookup = SimpleNamespace( - idp_for_environment=lambda _slug: [ + identity_providers_for_environment=lambda _slug: [ ( "IDP_1", "https://issuer.example.com", @@ -46,6 +46,7 @@ def _identity_with_lookup(**overrides) -> ExternalIdentity: "ORG_1", "Acme", "org", + "saml", ) ] ) @@ -233,7 +234,9 @@ def test_saml_provider_replays_snake_case_fields_and_deployment_metadata() -> No assert provider.sso_url == "https://issuer.example.com/sso" assert provider.signature_method == "rsa-sha256" assert provider.idp_certificate == "certificate-data" - assert provider.as_node.kinds == [nk.SAML_IDENTITY_PROVIDER, "GitHub"] + assert provider.as_node.kinds == [ + nk.SAML_IDENTITY_PROVIDER, nk.IDENTITY_PROVIDER, "GitHub" + ] assert provider.as_node.properties.github_deployment_id == "github.example.com" assert provider.as_node.properties.github_web_origin == "https://github.example.com" @@ -273,7 +276,7 @@ def test_saml_account_edge_emits_contract_evidence_and_direct_user_mapping() -> def test_external_identity_does_not_emit_legacy_foreign_user_correlations() -> None: identity = _identity_with_lookup() identity._lookup = SimpleNamespace( - idp_for_environment=lambda _slug: [ + identity_providers_for_environment=lambda _slug: [ ( "IDP_1", "http://www.okta.com/example", @@ -281,6 +284,7 @@ def test_external_identity_does_not_emit_legacy_foreign_user_correlations() -> N "ORG_1", "Acme", "org", + "saml", ) ] ) @@ -296,7 +300,7 @@ def test_external_identity_does_not_emit_legacy_foreign_user_correlations() -> N def test_external_identity_missing_idp_fallback_includes_environment_type() -> None: identity = _identity_with_lookup() - identity._lookup = SimpleNamespace(idp_for_environment=lambda _slug: []) + identity._lookup = SimpleNamespace(identity_providers_for_environment=lambda _slug: []) assert identity.idp == { "id": None, @@ -305,6 +309,7 @@ def test_external_identity_missing_idp_fallback_includes_environment_type() -> N "environment_node_id": None, "environment_name": None, "environment_type": None, + "federation_type": None, } @@ -323,7 +328,7 @@ def test_enterprise_scim_only_identity_emits_managed_user_binding() -> None: scimIdentity={"username": "managed@example.com"}, ) identity._lookup = SimpleNamespace( - idp_for_environment=lambda _slug: [ + identity_providers_for_environment=lambda _slug: [ ( "IDP_1", "https://issuer.example.com", @@ -331,6 +336,7 @@ def test_enterprise_scim_only_identity_emits_managed_user_binding() -> None: "ENT_1", "Acme", "enterprise", + "saml", ) ] )