diff --git a/README.md b/README.md index bfa6c84..2323221 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ A token with enterprise SCIM access is used to collect both `/scim/v2/enterprise `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. +In enterprise app authentication mode, the collector separately retrieves its own enterprise installation for configuration troubleshooting, including granted permissions, timestamps, and suspension status. It also retrieves the parent app's requested permissions and owner metadata using the enterprise installation token. This does not enumerate other enterprise-target app installations or imply organization/repository access. App and installation records use `environment_slug` for the organization login or enterprise slug. Installations also retain `target_type` to distinguish targets; older collected records remain compatible. Existing DLT app schemas are migrated to allow empty legacy `org_login` fields and absent `repository_selection`. A load already stuck in normalization retains its saved schema and needs recovery or restart; do not delete the pipeline or collected output to apply this change. + 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 diff --git a/descriptions/edges/GH_Contains.md b/descriptions/edges/GH_Contains.md index 15e99db..ad186f4 100644 --- a/descriptions/edges/GH_Contains.md +++ b/descriptions/edges/GH_Contains.md @@ -2,12 +2,13 @@ ## General Information -The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, classic personal access tokens, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, scopes, secrets, app installations, fine-grained personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. +The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, classic personal access tokens, the collector app installation, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, scopes, secrets, app installations, fine-grained personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. ## Edge Schema | Source | Destination | Traversable | | --- | --- | --- | +| `GH_Enterprise` | `GH_AppInstallation` | `false` | | `GH_Enterprise` | `GH_ClassicPersonalAccessToken` | `false` | | `GH_Enterprise` | `GH_EnterpriseRole` | `false` | | `GH_Enterprise` | `GH_EnterpriseRunnerGroup` | `false` | @@ -75,6 +76,7 @@ graph LR n29["GH_WorkflowJob"] n30["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 + n0 -.->|GH_Contains| n13 n0 -.->|GH_Contains| n2 n0 -.->|GH_Contains| n3 n0 -.->|GH_Contains| n4 diff --git a/descriptions/nodes/GH_App.md b/descriptions/nodes/GH_App.md index e42229f..a8a5def 100644 --- a/descriptions/nodes/GH_App.md +++ b/descriptions/nodes/GH_App.md @@ -2,9 +2,9 @@ ## General Information -Represents a GitHub App definition — the registered application entity. The app owner holds the private key that can generate installation access tokens for **every** GH_AppInstallation of this app. If the private key is compromised, all installations across all organizations are affected. +Represents a GitHub App definition — the registered application entity. The app owner holds the private key that can generate installation access tokens for **every** GH_AppInstallation of this app. If the private key is compromised, all installations across all organizations and enterprises are affected. -App definitions are retrieved via the public `GET /apps/{app_slug}` endpoint (no authentication required) after discovering unique app slugs from the organization's app installations. +App definitions are retrieved via `GET /apps/{app_slug}` after discovering app slugs from organization installations or the collector app's enterprise installation. Authenticated lookup can also retrieve private/internal apps where GitHub permits access. The `permissions` property records requested app permissions; each installation separately records its granted permissions. ## Properties @@ -12,6 +12,7 @@ App definitions are retrieved via the public `GET /apps/{app_slug}` endpoint (no | --- | --- | --- | | `name` | `string` | The node name used for matching and display. | | `displayname` | `string` | The human-readable display name. | +| `environment_slug` | `string` | The organization login or enterprise slug where the app was collected. | | `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. | | `last_seen` | `datetime` | The timestamp when this node was last observed during collection. | | `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | @@ -24,6 +25,7 @@ App definitions are retrieved via the public `GET /apps/{app_slug}` endpoint (no | `owner_login` | `string` | The login of the user or organization that owns the app. | | `owner_node_id` | `string` | The node_id of the user or organization that owns the app. | | `owner_type` | `string` | The type of the owner (e.g., `User`, `Organization`). | +| `permissions` | `list[string]` | Requested app permissions in `scope:access` form. | | `registration_origin` | `string` | Whether the app is tenant-owned, third-party, or unknown. | | `events` | `list[string]` | JSON string of the default webhook events the app subscribes to. | | `installations_count` | `integer` | The total number of installations of this app across all organizations. | diff --git a/descriptions/nodes/GH_AppInstallation.md b/descriptions/nodes/GH_AppInstallation.md index 73ce0fe..73c88ff 100644 --- a/descriptions/nodes/GH_AppInstallation.md +++ b/descriptions/nodes/GH_AppInstallation.md @@ -2,11 +2,15 @@ ## General Information -Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property. +Represents a GitHub App installed on an organization or enterprise. Organization app installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property. Unlike fine-grained personal access tokens, GitHub does not expose separate organization and repository permission buckets for app installations, so this property remains a single flat permission list. -Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Installations with `repository_selection` set to `all` use one GH_CanAccess edge to the organization's repository/all GH_Scope; GH_ScopedTo edges enumerate the repositories in that reusable scope. For installations with `repository_selection` set to `selected`, the collector uses the enterprise organization-installation repositories API to enumerate the granted repositories when the enterprise installation has `Enterprise organization installations: read` or `Enterprise organization installation repositories: read`. If that permission is unavailable, selected repository edges are skipped. +Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Organization installations with `repository_selection` set to `all` use one GH_CanAccess edge to the organization's repository/all GH_Scope; GH_ScopedTo edges enumerate the repositories in that reusable scope. For installations with `repository_selection` set to `selected`, the collector uses the enterprise organization-installation repositories API to enumerate the granted repositories when the enterprise installation has `Enterprise organization installations: read` or `Enterprise organization installation repositories: read`. If that permission is unavailable, selected repository edges are skipped. + +Enterprise collection includes the authenticating collector app's installation, fetched independently of authentication discovery with `GET /enterprises/{enterprise}/installation`. It records granted enterprise permissions, timestamps, and suspension status, and is contained by `GH_Enterprise`. This is not a complete inventory of apps installed on the enterprise. Enterprise installations do not produce repository access edges. Parent app enrichment failures do not discard the installation. + +`environment_slug` identifies the target account for both installation types. `target_type` distinguishes organization and enterprise targets. Older records using `org_login` or `enterprise_name` remain readable; these fields are no longer used for new installation context. Organization repository access is evaluated only for organization targets. ## Properties @@ -14,6 +18,7 @@ Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Inst | --- | --- | --- | | `name` | `string` | The node name used for matching and display. | | `displayname` | `string` | The human-readable display name. | +| `environment_slug` | `string` | The organization login or enterprise slug of the installation target. | | `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. | | `last_seen` | `datetime` | The timestamp when this node was last observed during collection. | | `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | @@ -25,13 +30,13 @@ Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Inst | `access_tokens_url` | `string` | API URL to create installation access tokens. | | `repositories_url` | `string` | API URL to list repositories accessible to this installation. | | `repository_selection` | `string` | Whether the app has access to `all` repositories or `selected` repositories. | -| `target_type` | `string` | The target type of the installation (e.g., `Organization`). | +| `target_type` | `string` | The target type of the installation (e.g., `Organization` or `Enterprise`). | | `permissions` | `list[string]` | Permissions granted to the installation in `scope:access` form. | | `events` | `string` | JSON string of the webhook events the app subscribes to. | | `created_at` | `datetime` | When the app was installed. | | `updated_at` | `datetime` | When the installation was last updated. | | `suspended_at` | `datetime` | When the installation was suspended, if applicable. | -| `environment_name` | `string` | The name of the environment (GitHub organization) where the app is installed. | +| `environment_name` | `string` | The name of the environment (GitHub organization or enterprise) where the app is installed. | | `query_repositories` | `string` | Query for repositories. | | `query_app` | `string` | Query for app. | @@ -48,4 +53,6 @@ graph LR n1 -.->|GH_CanAccess| n2 n1 -.->|GH_CanAccess| n3 n4 -.->|GH_Contains| n1 + n5["GH_Enterprise"] + n5 -.->|GH_Contains| n1 ``` diff --git a/extension/schema.json b/extension/schema.json index eeaa118..f098a2a 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.2", + "version": "v1.4.3", "namespace": "GH" }, "node_kinds": [ @@ -83,6 +83,13 @@ "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_Contains]->(:GH_ClassicPersonalAccessToken)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View classic personal access tokens in 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 }}" } + }, + "collector_app_installation": { + "title": "Collector App Installation", + "position": 11, + "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_Contains]->(:GH_AppInstallation)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the collector app installation in 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 }}" + } } } }, @@ -1740,7 +1747,7 @@ "title": "Description", "position": 1, "markdown": { - "content": "## Overview\n\nRepresents a GitHub App definition. The app owner controls the private key used to generate installation tokens for every installation of this app, so compromise of the app can affect every organization where it is installed." + "content": "## Overview\n\nRepresents a GitHub App definition. The `permissions` property records requested app permissions. Each installation records its own granted permissions. The app owner controls the private key used to generate installation tokens for its installations on organizations and enterprises.\n\n`environment_slug` records the organization login or enterprise slug for this collection context." } }, "inbound_traversable_relationships": { @@ -1821,7 +1828,7 @@ { "name": "GH_AppInstallation", "display_name": "GitHub App Installation", - "description": "A GitHub App installed on the organization with specific permissions and repository access", + "description": "A GitHub App installed on an organization or enterprise with a specific set of granted permissions", "is_display_kind": true, "icon": "plug", "color": "#A8D8EA", @@ -1830,7 +1837,7 @@ "title": "Description", "position": 1, "markdown": { - "content": "## Overview\n\nRepresents a GitHub App installed on an organization. Installations have a permission set and repository scope, and are linked back to the parent `GH_App` that owns the private key used to mint installation tokens." + "content": "## Overview\n\nRepresents a GitHub App installed on an organization or enterprise. The `permissions` property records permissions granted to this installation; the parent `GH_App` records requested permissions.\n\nEnterprise collection currently includes only the authenticating collector app installation. Enterprise installations do not automatically grant access to organizations or repositories.\n\n`environment_slug` records the organization login or enterprise slug for this collection context." } }, "inbound_traversable_relationships": { @@ -1867,6 +1874,13 @@ "markdown": { "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_AppInstallation {objectid: '%s'})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this app installation 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 }}" } + }, + "enterprise_context": { + "title": "Enterprise Context", + "position": 7, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Enterprise)-[:GH_Contains]->(selected:GH_AppInstallation {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the enterprise containing this app installation 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 }}" + } } } }, diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 850a5cc..d9086f0 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -850,16 +850,11 @@ def identity_providers_for_environment(self, environment_slug: str) -> list: return rows @lru_cache - def app_node_id(self, app_slug: str, org_login: str | None = None) -> str | None: - if org_login is None: - return self._find_single_object( - f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""", - [app_slug], - ) - + def app_node_id(self, app_slug: str) -> str | None: + """Resolve the registration shared by its organization and enterprise installations.""" return self._find_single_object( - f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ? AND org_login = ?""", - [app_slug, org_login], + f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""", + [app_slug], ) @lru_cache diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index 2273c25..fe1036a 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -6,7 +6,7 @@ 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 BaseModel, Field +from pydantic import BaseModel, Field, model_validator from openhound_github.graph import GHNode, GHNodeProperties from openhound_github.kinds import edges as ek @@ -33,13 +33,14 @@ class GHAppInstallationProperties(GHNodeProperties): access_tokens_url: API URL to create installation access tokens. repositories_url: API URL to list repositories accessible to this installation. repository_selection: Whether the app has access to `all` repositories or `selected` repositories. - target_type: The target type of the installation (e.g., `Organization`). + target_type: The target type of the installation (`Organization` or `Enterprise`). permissions: Permissions granted to the installation in `scope:access` form. events: JSON string of the webhook events the app subscribes to. created_at: When the app was installed. updated_at: When the installation was last updated. suspended_at: When the installation was suspended, if applicable. - environment_name: The name of the environment (GitHub organization) where the app is installed. + environment_name: The organization or enterprise where the app is installed. + environment_slug: The organization login or enterprise slug of the installation target. query_repositories: Query for repositories. query_app: Query for app. """ @@ -59,15 +60,18 @@ class GHAppInstallationProperties(GHNodeProperties): updated_at: datetime | None = None suspended_at: datetime | None = None environment_name: str | None = None + environment_slug: str | None = None query_repositories: str | None = None query_app: str | None = None class Account(BaseModel): - login: str + login: str | None = None id: int - node_id: str - type: str + node_id: str | None = None + type: str | None = None + slug: str | None = None + name: str | None = None @app.asset( @@ -78,6 +82,13 @@ class Account(BaseModel): properties=GHAppInstallationProperties, ), edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.APP_INSTALLATION, + kind=ek.CONTAINS, + description="Enterprise contains app installation", + traversable=False, + ), EdgeDef( start=nk.ORGANIZATION, end=nk.APP_INSTALLATION, @@ -100,7 +111,7 @@ class AppInstallation(BaseAsset): dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} id: int - repository_selection: str + repository_selection: str | None = None access_token_url: str | None = None app_id: int target_type: str @@ -119,7 +130,24 @@ class AppInstallation(BaseAsset): access_tokens_url: str | None = None # Additional - org_login: str + org_login: str | None = None + environment_slug: str | None = None + enterprise_name: str | None = None + enterprise_node_id: str | None = None + + @model_validator(mode="before") + @classmethod + def normalize_environment_context(cls, data): + """Read canonical context while accepting legacy collection fields.""" + if isinstance(data, dict): + data = dict(data) + if not data.get("environment_slug"): + data["environment_slug"] = ( + data.get("enterprise_name") + if data.get("target_type") == "Enterprise" + else data.get("org_login") + ) + return data @property def node_id(self): @@ -127,10 +155,21 @@ def node_id(self): @property def org_node_id(self) -> str | None: - return self._lookup.org_id_for_login(self.org_login) + """Resolve the containing organization for organization installations only.""" + if self.target_type != "Organization" or not self.environment_slug: + return None + return self._lookup.org_id_for_login(self.environment_slug) + + @property + def environment_node_id(self) -> str | None: + """Resolve the installation target to its enterprise or organization node.""" + if self.target_type == "Enterprise": + return self.enterprise_node_id + return self.org_node_id @property def as_node(self) -> GHNode: + """Build the installation node with granted permissions and target context.""" slug = self.app_slug or str(self.app_id) return GHNode( kinds=["GH_AppInstallation"], @@ -152,8 +191,9 @@ def as_node(self) -> GHNode: created_at=self.created_at, updated_at=self.updated_at, suspended_at=self.suspended_at, - environment_name=self.org_login, - environmentid=self.org_node_id, + environment_name=self.environment_slug, + environment_slug=self.environment_slug, + environmentid=self.environment_node_id, query_repositories=f"MATCH p=(:GH_AppInstallation {{node_id:'{self.node_id}'}})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository) RETURN p LIMIT 1000", query_app=f"MATCH p=(:GH_App)-[:GH_InstalledAs]->(:GH_AppInstallation {{node_id:'{self.node_id}'}}) RETURN p", ), @@ -161,8 +201,10 @@ def as_node(self) -> GHNode: @property def _app_edges(self): + """Link the registered app to this installation when its slug resolves.""" if self.app_slug: - app_node_id = self._lookup.app_node_id(self.app_slug, self.org_login) + # App slugs identify one registration across all installation targets. + app_node_id = self._lookup.app_node_id(self.app_slug) if app_node_id: yield Edge( kind=ek.INSTALLED_AS, @@ -173,7 +215,12 @@ def _app_edges(self): @property def _can_access_edges(self): - if self.repository_selection == "all": + """Link all-repository organization installations to their repository scope.""" + if ( + self.target_type == "Organization" + and self.environment_slug + and self.repository_selection == "all" + ): yield Edge( kind=ek.CAN_ACCESS, start=EdgePath(value=self.node_id, match_by="id"), @@ -190,12 +237,14 @@ def _can_access_edges(self): @property def edges(self): - yield Edge( - kind=ek.CONTAINS, - start=EdgePath(value=self.org_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) + """Yield target containment, parent app, and repository scope relationships.""" + if self.environment_node_id: + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self.environment_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) yield from self._app_edges yield from self._can_access_edges @@ -216,6 +265,8 @@ class GHAppProperties(GHNodeProperties): owner_node_id: The node_id of the user or organization that owns the app. owner_type: The type of the owner (e.g., `User`, `Organization`). registration_origin: Whether the app is tenant-owned, third-party, or unknown. + permissions: Requested app permissions in `scope:access` form. + environment_slug: The organization login or enterprise slug where the app was collected. events: JSON string of the default webhook events the app subscribes to. installations_count: The total number of installations of this app across all organizations. created_at: When the app was created. @@ -234,6 +285,8 @@ class GHAppProperties(GHNodeProperties): owner_node_id: str | None = None owner_type: str | None = None registration_origin: str | None = None + permissions: list[str] | None = None + environment_slug: str | None = None events: list[str] | None = None installations_count: int | None = None created_at: datetime | None = None @@ -300,17 +353,31 @@ class App(BaseAsset): updated_at: datetime | None = None # Additional - org_login: str + org_login: str | None = None + environment_slug: str | None = None + enterprise_node_id: str | None = None collected_org_node_id: str | None = Field(default=None, alias="org_node_id") + @model_validator(mode="before") + @classmethod + def normalize_environment_context(cls, data): + """Read canonical context while accepting legacy collection fields.""" + if isinstance(data, dict) and not data.get("environment_slug"): + data = {**data, "environment_slug": data.get("org_login")} + return data + @property def org_node_id(self) -> str | None: + """Resolve organization collection context, excluding enterprise context.""" if self.collected_org_node_id: return self.collected_org_node_id - return self._lookup.org_id_for_login(self.org_login) + if self.enterprise_node_id or not self.environment_slug: + return None + return self._lookup.org_id_for_login(self.environment_slug) @property def registration_origin(self) -> str: + """Classify app ownership relative to the collected tenant when known.""" if self.owner is None: return "unknown" @@ -328,9 +395,7 @@ def registration_origin(self) -> str: if enterprise_id is None: return "unknown" return ( - "tenant_owned" - if self.owner.node_id == enterprise_id - else "third_party" + "tenant_owned" if self.owner.node_id == enterprise_id else "third_party" ) return "unknown" @@ -356,6 +421,7 @@ def registration_owner_node_id(self) -> str | None: @property def as_node(self): + """Build the app node with owner metadata and requested permissions.""" aid = self.node_id return GHNode( kinds=[nk.APP], @@ -363,7 +429,8 @@ def as_node(self): name=self.name, displayname=self.name, node_id=aid, - environmentid=self.org_node_id, + environmentid=self.enterprise_node_id or self.org_node_id, + environment_slug=self.environment_slug, id=self.id, client_id=self.client_id, slug=self.slug, @@ -374,7 +441,7 @@ def as_node(self): owner_node_id=self.owner.node_id if self.owner else None, owner_type=self.owner.type if self.owner else None, registration_origin=self.registration_origin, - # permissions=self.permissions, + permissions=normalize_permission_declaration(self.permissions), # installations_count=self.installations_count, events=self.events, created_at=self.created_at, diff --git a/src/openhound_github/resources/app_helpers.py b/src/openhound_github/resources/app_helpers.py new file mode 100644 index 0000000..fac65f4 --- /dev/null +++ b/src/openhound_github/resources/app_helpers.py @@ -0,0 +1,26 @@ +"""Schema compatibility for app resources shared by enterprise and organization collection.""" + +import dlt +from dlt.extract.exceptions import CurrentSourceSchemaNotAvailable + + +def migrate_app_context_schema() -> None: + """Relax legacy organization-only columns without weakening data validation. + + DLT retains schema hints from earlier runs. Its discard-row contract prevents + optional model fields from relaxing existing NOT NULL columns. Update only + these known constraints before any app records reach normalization. + """ + try: + schema = dlt.current.source_schema() + except CurrentSourceSchemaNotAvailable: + # Resource functions can also be exercised directly outside a DLT source. + return + for table, names in ( + ("app_installations", ("org_login", "repository_selection")), + ("applications", ("org_login",)), + ): + columns = schema.tables.get(table, {}).get("columns", {}) + for name in names: + if name in columns: + columns[name]["nullable"] = True diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 6b02355..f2f037b 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -5,7 +5,7 @@ import time from dataclasses import dataclass, field from typing import Any, Callable -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import dlt import requests @@ -34,6 +34,9 @@ is_secondary_rate_limit_response, ) from openhound_github.main import app +from openhound_github.auth import GithubApp +from openhound_github.models.app_installation import App, AppInstallation +from openhound_github.resources.app_helpers import migrate_app_context_schema from openhound_github.models import ( BaseUser, ClassicPersonalAccessToken, @@ -90,6 +93,7 @@ class SourceContext: """Shared context for GitHub API access.""" client: RESTClient + github_app: GithubApp | None = None graphql_client: RESTClient | None = None sso_client: RESTClient | None = None sso_graphql_client: RESTClient | None = None @@ -1472,7 +1476,68 @@ def enterprise_oidc_external_identity(oidc_provider: OidcProvider, ctx: SourceCo ) +@app.transformer( + name="enterprise_app_installation", + table_name="app_installations", + columns=AppInstallation, + parallelized=True, +) +def enterprise_app_installation(enterprise_data: Enterprise, ctx: SourceContext): + """Collect the authenticating app's enterprise installation independently of auth discovery.""" + if ctx.github_app is None: + return + migrate_app_context_schema() + try: + response = ctx.github_app.client.get( + f"/enterprises/{quote(enterprise_data.slug, safe='')}/installation", + headers={ + **ctx.github_app.jwt_headers, + "X-GitHub-Api-Version": "2026-03-10", + }, + timeout=30, + ) + response.raise_for_status() + installation = response.json() + if installation.get("target_type") != "Enterprise": + raise ValueError("Expected an enterprise-target app installation") + yield { + **installation, + "environment_slug": enterprise_data.slug, + "enterprise_node_id": enterprise_data.node_id, + } + except Exception as exc: + logger.warning( + "Skipping enterprise app installation for '%s': %s", + enterprise_data.slug, + exc, + ) + + +@app.transformer( + name="enterprise_applications", + table_name="applications", + columns=App, + parallelized=True, +) +def enterprise_applications(installation: AppInstallation, ctx: SourceContext): + """Enrich the collected installation with its parent app using enterprise auth.""" + if not installation.app_slug or ctx.client is None: + return + migrate_app_context_schema() + try: + response = ctx.client.get(f"/apps/{quote(installation.app_slug, safe='')}") + response.raise_for_status() + yield { + **response.json(), + "environment_slug": installation.environment_slug, + "enterprise_node_id": installation.enterprise_node_id, + } + except Exception as exc: + logger.warning("Skipping app metadata for '%s': %s", installation.app_slug, exc) + + def enterprise_resources(ctx: SourceContext): + """Assemble enterprise resources enabled by the available authentication clients.""" enterprise_resource = enterprise(ctx) organizations_resource = enterprise_organizations(ctx) members_resource = enterprise_members(ctx) @@ -1503,6 +1568,15 @@ def enterprise_resources(ctx: SourceContext): enterprise_resource | enterprise_admins(ctx), ] + if ctx.github_app is not None: + installation_resource = enterprise_resource | enterprise_app_installation(ctx) + resources.extend( + [ + installation_resource, + installation_resource | enterprise_applications(ctx), + ] + ) + if ctx.sso_client: saml_resource = enterprise_saml_provider(ctx) oidc_resource = enterprise_resource | enterprise_oidc_provider(ctx) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index a0e273e..246f56f 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -37,6 +37,7 @@ scim_skip_reason, ) from openhound_github.main import app +from openhound_github.resources.app_helpers import migrate_app_context_schema from openhound_github.models import ( ActionPermission, App, @@ -648,13 +649,14 @@ def app_installations(ctx: SourceContext): Yields: AppInstallation (AppInstallation): App installation record. """ + migrate_app_context_schema() for org in ctx.organizations: org_name = org.org_name client = org.client try: for page in client.paginate(f"/orgs/{org_name}/installations"): for item in page: - yield {**item, "org_login": org_name} + yield {**item, "environment_slug": org_name} except Exception as e: logger.error( f"Error in resource 'app_installations' processing organization '{org_name}': {e}", @@ -671,7 +673,9 @@ def app_installations(ctx: SourceContext): def app_installation_repo_access(app_install: AppInstallation, ctx: SourceContext): """List the repositories granted to a selected organization installation.""" if ( - app_install.repository_selection != "selected" + app_install.target_type != "Organization" + or not app_install.environment_slug + or app_install.repository_selection != "selected" or not ctx.client or not ctx.enterprise_name or ctx.deployment_type == "ghes" @@ -681,7 +685,7 @@ def app_installation_repo_access(app_install: AppInstallation, ctx: SourceContex path = ( f"/enterprises/{ctx.enterprise_name}/apps/organizations/" - f"{app_install.org_login}/installations/{app_install.id}/repositories" + f"{app_install.environment_slug}/installations/{app_install.id}/repositories" ) try: # Read every page before yielding so a failed page cannot create partial access. @@ -711,7 +715,7 @@ def app_installation_repo_access(app_install: AppInstallation, ctx: SourceContex logger.warning( "Skipping repository access for app installation %s in organization '%s': %s", app_install.id, - app_install.org_login, + app_install.environment_slug, exc, ) return @@ -719,7 +723,7 @@ def app_installation_repo_access(app_install: AppInstallation, ctx: SourceContex logger.warning( "Skipping repository access for app installation %s in organization '%s': %s", app_install.id, - app_install.org_login, + app_install.environment_slug, exc, ) return @@ -735,7 +739,7 @@ def app_installation_repo_access(app_install: AppInstallation, ctx: SourceContex "installation_node_id": app_install.node_id, "repository_database_id": repository["id"], "repo_full_name": repository.get("full_name"), - "org_login": app_install.org_login, + "org_login": app_install.environment_slug, } @@ -750,9 +754,12 @@ def applications(app_install: AppInstallation, ctx: SourceContext): Yields: App (App): GitHub App record. """ + if app_install.target_type != "Organization" or not app_install.environment_slug: + return + migrate_app_context_schema() app_slug = app_install.app_slug if app_install.app_slug else app_install.app_id if app_install.id: - client = _client_for_org(ctx, app_install.org_login) + client = _client_for_org(ctx, app_install.environment_slug) app_slug = str(app_slug) if app_slug not in ctx.app_cache: with ctx.cache_lock: @@ -760,7 +767,11 @@ def applications(app_install: AppInstallation, ctx: SourceContext): ctx.app_cache[app_slug] = client.get(f"/apps/{app_slug}").json() app_data = ctx.app_cache[app_slug] if app_data.get("node_id"): - yield {**app_data, "slug": app_slug, "org_login": app_install.org_login} + yield { + **app_data, + "slug": app_slug, + "environment_slug": app_install.environment_slug, + } @app.resource(name="users", columns=User, parallelized=True) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 13382fa..fb92f80 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -159,6 +159,7 @@ class OrgContext: @dataclass class SourceContext: organizations: list[OrgContext] | None = field(default_factory=list) + github_app: GithubApp | None = None client: RESTClient | None = None graphql_client: RESTClient | None = None sso_client: RESTClient | None = None @@ -388,6 +389,7 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: private_key_path=credentials.key_path, api_uri=auth_api_uri, ) + ctx.github_app = github_app_session for installation in github_app_session.installations: if installation.target_type == "Organization": org_installation = GithubInstallation( diff --git a/tests/test_app_installation_repo_access.py b/tests/test_app_installation_repo_access.py index 1ec7cff..09c55eb 100644 --- a/tests/test_app_installation_repo_access.py +++ b/tests/test_app_installation_repo_access.py @@ -17,7 +17,8 @@ def _installation(selection="selected"): return SimpleNamespace( id=42, node_id="GH_AppInstallation_42", - org_login="example-org", + environment_slug="example-org", + target_type="Organization", repository_selection=selection, ) @@ -64,7 +65,9 @@ def test_all_repositories_do_not_need_extra_request(): client=client, enterprise_name="enterprise", deployment_type="ghec" ) - assert list(app_installation_repo_access.__wrapped__(_installation("all"), ctx)) == [] + assert ( + list(app_installation_repo_access.__wrapped__(_installation("all"), ctx)) == [] + ) client.paginate.assert_not_called() diff --git a/tests/test_enterprise_app_installation.py b/tests/test_enterprise_app_installation.py new file mode 100644 index 0000000..7ba0cfc --- /dev/null +++ b/tests/test_enterprise_app_installation.py @@ -0,0 +1,412 @@ +import gzip +import json +from threading import Lock +from types import SimpleNamespace +from unittest.mock import MagicMock + +import dlt +import pytest +import requests +from dlt.destinations import filesystem + +from openhound_github.lookup import GithubLookup +from openhound_github.models.app_installation import App, AppInstallation +from openhound_github.models.enterprise import Enterprise +from openhound_github.resources.enterprise import ( + SourceContext, + enterprise_app_installation, + enterprise_applications, + enterprise_resources, +) +from openhound_github.resources.organization import ( + applications, + app_installations, + app_installation_repo_access, +) + + +def _payload(): + # Enterprise accounts have a slug/name, and may lack login, type and node_id. + """Build an enterprise installation response with granted permissions.""" + return { + "id": 42, + "app_id": 7, + "app_slug": "example-app", + "target_type": "Enterprise", + "account": {"id": 1, "slug": "acme", "name": "Acme"}, + "repository_selection": "all", + "permissions": {"enterprise_people": "read"}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "suspended_at": "2026-01-03T00:00:00Z", + } + + +def _ctx(): + """Build independent app-JWT and enterprise-installation clients.""" + ctx = SourceContext( + client=MagicMock(), github_app=MagicMock(), enterprise_name="acme" + ) + ctx.github_app.jwt_headers = {"Authorization": "Bearer test-jwt"} + ctx.github_app.client.get.return_value.json.return_value = _payload() + ctx.client.get.return_value.json.return_value = { + "id": 7, + "node_id": "APP_7", + "slug": "example-app", + "name": "Example", + "permissions": {"enterprise_people": "read", "contents": "write"}, + "events": [], + "owner": {"id": 1, "node_id": "E_1", "login": "acme", "type": "enterprise"}, + } + return ctx + + +def _collect(ctx): + """Collect the enterprise installation without authentication discovery.""" + return list( + enterprise_app_installation.__wrapped__(Enterprise(id="E_1", slug="acme"), ctx) + ) + + +def test_enterprise_installation_is_collected_independently_with_app_jwt(): + """Fetch installation metadata separately using app JWT authentication.""" + ctx = _ctx() + installation = AppInstallation(**_collect(ctx)[0]) + ctx.github_app.client.get.assert_called_once_with( + "/enterprises/acme/installation", + headers={ + "Authorization": "Bearer test-jwt", + "X-GitHub-Api-Version": "2026-03-10", + }, + timeout=30, + ) + assert installation.account.slug == "acme" + assert installation.enterprise_node_id == "E_1" + assert installation.environment_slug == "acme" + assert installation.org_login is None + assert installation.as_node.properties.environmentid == "E_1" + assert installation.as_node.properties.environment_slug == "acme" + assert installation.as_node.properties.permissions == ["enterprise_people:read"] + assert installation.as_node.properties.suspended_at is not None + # No authentication-discovery payload or org client is used. + ctx.github_app.client.paginate.assert_not_called() + ctx.client.get.assert_not_called() + + +def test_enterprise_installation_links_parent_without_repository_access(): + """Link the installation to its enterprise and app without repository grants.""" + installation = AppInstallation(**_collect(_ctx())[0]) + installation._lookup = MagicMock() + installation._lookup.app_node_id.return_value = "APP_7" + edges = list(installation.edges) + assert [(e.kind, e.start.value, e.end.value) for e in edges] == [ + ("GH_Contains", "E_1", "GH_AppInstallation_42"), + ("GH_InstalledAs", "APP_7", "GH_AppInstallation_42"), + ] + assert edges[0].properties.traversable is False + assert edges[1].properties.traversable is True + installation._lookup.app_node_id.assert_called_once_with("example-app") + + +def test_shared_app_has_distinct_enterprise_and_org_installations(): + """Keep distinct installations linked to the same app registration.""" + enterprise_installation = AppInstallation(**_collect(_ctx())[0]) + org_installation = AppInstallation( + **{ + **_payload(), + "id": 43, + "target_type": "Organization", + "org_login": "org", + } + ) + lookup = MagicMock() + lookup.app_node_id.return_value = "APP_7" + lookup.org_id_for_login.return_value = "O_1" + enterprise_installation._lookup = org_installation._lookup = lookup + enterprise_edges = list(enterprise_installation.edges) + org_edges = list(org_installation.edges) + assert enterprise_installation.node_id != org_installation.node_id + assert enterprise_edges[1].start.value == org_edges[1].start.value == "APP_7" + assert any(e.kind == "GH_CanAccess" for e in org_edges) + assert all(e.kind != "GH_CanAccess" for e in enterprise_edges) + + +def test_enterprise_parent_app_uses_installation_client_and_preserves_requested_permissions(): + """Enrich the parent app with requested permissions using the enterprise token.""" + ctx = _ctx() + installation = AppInstallation(**_collect(ctx)[0]) + parent = App(**list(enterprise_applications.__wrapped__(installation, ctx))[0]) + parent._lookup = MagicMock() + parent._lookup.enterprise_id.return_value = "E_1" + ctx.client.get.assert_called_once_with("/apps/example-app") + assert set(parent.as_node.properties.permissions) == { + "contents:write", + "enterprise_people:read", + } + assert parent.as_node.properties.environmentid == "E_1" + assert parent.edges[0].start.value == "E_1" + + +@pytest.mark.parametrize("status", [403, 404]) +def test_lookup_failure_does_not_break_other_enterprise_collection(status, caplog): + """Skip inaccessible installations with a diagnostic.""" + ctx = _ctx() + response = requests.Response() + response.status_code = status + ctx.github_app.client.get.side_effect = requests.HTTPError(response=response) + assert _collect(ctx) == [] + assert "Skipping enterprise app installation" in caplog.text + + +def test_parent_lookup_failure_preserves_installation(caplog): + """Retain the installation when parent app enrichment fails.""" + ctx = _ctx() + installation = AppInstallation(**_collect(ctx)[0]) + ctx.client.get.side_effect = requests.HTTPError("Unavailable") + assert list(enterprise_applications.__wrapped__(installation, ctx)) == [] + installation._lookup = MagicMock() + installation._lookup.app_node_id.return_value = None + assert [e.kind for e in installation.edges] == ["GH_Contains"] + assert "Skipping app metadata" in caplog.text + + +def test_pat_collection_has_no_app_installation_requests(): + """Avoid app-JWT requests when collecting with a personal access token.""" + ctx = SourceContext(client=MagicMock(), enterprise_name="acme") + assert _collect(ctx) == [] + assert "enterprise_app_installation" not in { + r.name for r in enterprise_resources(ctx) + } + ctx.client.get.assert_not_called() + + +def test_organization_collection_and_enrichment_use_generic_context(): + """Use generic context in organization installation and app resources.""" + client = MagicMock() + client.paginate.return_value = [[{**_payload(), "target_type": "Organization"}]] + client.get.return_value.json.return_value = ( + _ctx().client.get.return_value.json.return_value + ) + ctx = SimpleNamespace( + organizations=[SimpleNamespace(org_name="org", client=client)], + cache_lock=Lock(), + app_cache={}, + ) + row = list(app_installations.__wrapped__(ctx))[0] + assert row["environment_slug"] == "org" + assert "org_login" not in row + installation = AppInstallation(**row) + enriched = list(applications.__wrapped__(installation, ctx))[0] + assert enriched["environment_slug"] == "org" + assert "org_login" not in enriched + client.get.assert_called_once_with("/apps/example-app") + + +@pytest.mark.parametrize("selection", ["all", "selected", None]) +def test_enterprise_slug_is_never_used_for_organization_operations(selection): + """Exclude enterprise installations from organization-specific operations.""" + installation = AppInstallation( + **{ + **_payload(), + "environment_slug": "acme", + "enterprise_node_id": "E_1", + "repository_selection": selection, + } + ) + lookup = MagicMock() + lookup.app_node_id.return_value = "APP_7" + installation._lookup = lookup + ctx = _ctx() + assert list(app_installation_repo_access.__wrapped__(installation, ctx)) == [] + assert list(applications.__wrapped__(installation, ctx)) == [] + assert all(edge.kind != "GH_CanAccess" for edge in installation.edges) + lookup.org_id_for_login.assert_not_called() + ctx.client.get.assert_not_called() + + +def test_legacy_context_fields_are_still_readable(): + """Accept previously collected organization and enterprise context fields.""" + old_org = AppInstallation( + **{**_payload(), "target_type": "Organization", "org_login": "old-org"} + ) + old_enterprise = AppInstallation( + **{ + **_payload(), + "enterprise_name": "old-enterprise", + "enterprise_node_id": "E_1", + } + ) + old_app = App( + **{**_ctx().client.get.return_value.json.return_value, "org_login": "old-org"} + ) + assert old_org.environment_slug == old_app.environment_slug == "old-org" + assert old_enterprise.environment_slug == "old-enterprise" + + +def test_environment_slug_takes_precedence_over_legacy_org_login(): + """Prefer canonical context when both legacy and current fields are present.""" + installation = AppInstallation( + **{ + **_payload(), + "target_type": "Organization", + "environment_slug": "new-org", + "org_login": "old-org", + } + ) + installation._lookup = MagicMock() + installation._lookup.org_id_for_login.return_value = "O_NEW" + assert installation.as_node.properties.environment_slug == "new-org" + installation._lookup.org_id_for_login.assert_called_once_with("new-org") + + +@pytest.mark.parametrize("include_org", [False, True]) +@pytest.mark.parametrize("legacy_schema", [False, True]) +def test_resources_store_enterprise_and_org_records_in_shared_tables( + tmp_path, include_org, legacy_schema +): + """Validate shared tables and migrate legacy nullability constraints.""" + ctx = _ctx() + # Enterprise installations can omit repository_selection altogether. + ctx.github_app.client.get.return_value.json.return_value.pop("repository_selection") + + @dlt.resource(name="enterprise", columns=Enterprise) + def parent(): + """Yield the enterprise that owns the test installation.""" + yield Enterprise(id="E_1", slug="acme") + + @dlt.resource( + name="org_installations", + table_name="app_installations", + columns=AppInstallation, + ) + def org_installations(): + """Yield an organization installation using canonical context.""" + yield { + **_payload(), + "id": 43, + "target_type": "Organization", + "environment_slug": "org", + } + + @dlt.resource(name="org_apps", table_name="applications", columns=App) + def org_apps(): + """Yield parent app metadata in an organization context.""" + yield { + **ctx.client.get.return_value.json.return_value, + "environment_slug": "org", + } + + @dlt.source + def source(): + """Combine enterprise and optional organization collection resources.""" + enterprise = parent() + installation = enterprise | enterprise_app_installation(ctx) + return [ + enterprise, + installation, + installation | enterprise_applications(ctx), + *([org_installations(), org_apps()] if include_org else []), + ] + + pipeline = dlt.pipeline( + pipeline_name="enterprise_app_test", + destination=filesystem(bucket_url=str(tmp_path / "output")), + dataset_name="github_test", + pipelines_dir=str(tmp_path / "pipelines"), + ) + if legacy_schema: + + class LegacyInstallation(AppInstallation): + """Represent the earlier organization-only installation schema.""" + org_login: str + repository_selection: str + + class LegacyApp(App): + """Represent the earlier organization-only app schema.""" + org_login: str + + @dlt.source(name="source") + def legacy(): + """Seed the pipeline with the earlier required organization fields.""" + @dlt.resource(name="app_installations", columns=LegacyInstallation) + def installations(): + """Yield a record conforming to the legacy installation schema.""" + yield {**_payload(), "target_type": "Organization", "org_login": "org"} + + @dlt.resource(name="applications", columns=LegacyApp) + def apps(): + """Yield a record conforming to the legacy app schema.""" + yield { + **ctx.client.get.return_value.json.return_value, + "org_login": "org", + } + + return [installations(), apps()] + + pipeline.run(legacy(), write_disposition="replace", loader_file_format="jsonl") + assert ( + pipeline.default_schema.tables["app_installations"]["columns"]["org_login"][ + "nullable" + ] + is False + ) + + pipeline.run( + source(), + write_disposition="replace", + loader_file_format="jsonl", + schema_contract={ + "tables": "evolve", + "columns": "evolve", + "data_type": "discard_row", + }, + ) + assert ( + pipeline.default_schema.tables["app_installations"]["columns"]["org_login"][ + "nullable" + ] + is True + ) + assert ( + pipeline.default_schema.tables["app_installations"]["columns"][ + "repository_selection" + ]["nullable"] + is True + ) + assert ( + pipeline.default_schema.tables["applications"]["columns"]["org_login"][ + "nullable" + ] + is True + ) + + def stored_rows(table): + """Read normalized records from the filesystem destination.""" + rows = [] + for path in (tmp_path / "output" / "github_test" / table).glob("*.gz"): + with gzip.open(path, "rt") as file: + rows.extend(json.loads(line) for line in file) + return rows + + installations = [AppInstallation(**row) for row in stored_rows("app_installations")] + assert {i.id for i in installations} == ({42, 43} if include_org else {42}) + apps = [App(**row) for row in stored_rows("applications")] + assert len(apps) == (2 if include_org else 1) + assert {a.node_id for a in apps} == {"APP_7"} + + import duckdb + + connection = duckdb.connect() + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.applications (node_id VARCHAR, slug VARCHAR, org_login VARCHAR)" + ) + connection.executemany( + "INSERT INTO github.applications VALUES (?, ?, ?)", + [(a.node_id, a.slug, a.org_login) for a in apps], + ) + lookup = GithubLookup(connection) + for installation in installations: + installation._lookup = lookup + assert list(installation._app_edges)[0].start.value == "APP_7" + connection.close()