From 8e7bd081ff7782e84a10e6334fe3bc2fa0a9687f Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 6 Nov 2026 22:15:40 -0800 Subject: [PATCH] BED-10486: Classify member-owned GitHub App registrations --- descriptions/nodes/GH_App.md | 11 +++- extension/schema.json | 2 +- .../models/app_installation.py | 13 +++-- tests/test_app_model.py | 53 ++++++++++++++++++- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/descriptions/nodes/GH_App.md b/descriptions/nodes/GH_App.md index a8a5def..ac83b42 100644 --- a/descriptions/nodes/GH_App.md +++ b/descriptions/nodes/GH_App.md @@ -6,6 +6,15 @@ Represents a GitHub App definition — the registered application entity. The ap 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. +`registration_origin` classifies registration ownership using the collected inventory: + +- `tenant_owned`: The owner is a collected organization or the collected enterprise. +- `member_owned`: The owner is a collected organization or enterprise member. The app remains owned by the user's account; membership does not imply tenant control or approval of the app. +- `third_party`: The owner is a user, organization, or enterprise without matching internal ownership or membership evidence. This is the default for recognized owners, including in organization-only collections; incomplete inventory can affect classification. +- `unknown`: Owner metadata is missing or its type is unsupported. + +Organization membership is established from `membersWithRole`, and enterprise membership from the enterprise member inventory. Outside-collaborator status alone does not establish membership. + ## Properties | Property | Type | Description | @@ -26,7 +35,7 @@ App definitions are retrieved via `GET /apps/{app_slug}` after discovering app s | `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. | +| `registration_origin` | `string` | `tenant_owned`, `member_owned`, `third_party`, or `unknown`, based on collected ownership and membership evidence. | | `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. | | `created_at` | `datetime` | When the app was created. | diff --git a/extension/schema.json b/extension/schema.json index f098a2a..2fee25a 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -1747,7 +1747,7 @@ "title": "Description", "position": 1, "markdown": { - "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." + "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.\n\n`registration_origin` is `tenant_owned` when the registration owner is a collected organization or the collected enterprise, and `member_owned` when the owner is a collected organization or enterprise member. Member-owned apps remain under the user's account; membership does not imply tenant control or approval. Recognized owners without matching internal evidence default to `third_party`, including in organization-only collections. This classification depends on collected inventory completeness. Missing owners or unsupported owner types are `unknown`. Outside-collaborator status alone does not establish membership." } }, "inbound_traversable_relationships": { diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index fe1036a..8848533 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -264,7 +264,8 @@ class GHAppProperties(GHNodeProperties): owner_login: The login of the user or organization that owns the app. 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. + registration_origin: Ownership classification: tenant_owned, member_owned, + third_party (no internal ownership evidence), 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. @@ -383,17 +384,19 @@ def registration_origin(self) -> str: owner_type = self.owner.type.casefold() if owner_type == "user": - return "third_party" + return ( + "member_owned" + if self._lookup.is_collected_user_node_id(self.owner.node_id) + else "third_party" + ) if owner_type == "organization": if self._lookup.is_tenant_organization_node_id(self.owner.node_id): return "tenant_owned" - return "third_party" if self._lookup.enterprise_id() else "unknown" + return "third_party" if owner_type == "enterprise": enterprise_id = self._lookup.enterprise_id() - if enterprise_id is None: - return "unknown" return ( "tenant_owned" if self.owner.node_id == enterprise_id else "third_party" ) diff --git a/tests/test_app_model.py b/tests/test_app_model.py index 8f554f2..6150ff6 100644 --- a/tests/test_app_model.py +++ b/tests/test_app_model.py @@ -1,7 +1,9 @@ from unittest.mock import MagicMock +import duckdb import pytest +from openhound_github.lookup import GithubLookup from openhound_github.models.app_installation import App, Owner @@ -51,11 +53,13 @@ def test_app_node_includes_registration_owner_properties() -> None: [ ("Organization", "ORG_NODE_1", True, "ENT_NODE_1", "tenant_owned"), ("Organization", "EXTERNAL_ORG", False, "ENT_NODE_1", "third_party"), - ("Organization", "UNRESOLVED_ORG", False, None, "unknown"), + ("Organization", "ORG_NODE_1", True, None, "tenant_owned"), + ("Organization", "UNRESOLVED_ORG", False, None, "third_party"), ("User", "USER_NODE_1", False, "ENT_NODE_1", "third_party"), + ("User", "USER_NODE_1", False, None, "third_party"), ("Enterprise", "ENT_NODE_1", False, "ENT_NODE_1", "tenant_owned"), ("Enterprise", "EXTERNAL_ENT", False, "ENT_NODE_1", "third_party"), - ("Enterprise", "ENT_NODE_1", False, None, "unknown"), + ("Enterprise", "ENT_NODE_1", False, None, "third_party"), ("Bot", "BOT_NODE_1", False, "ENT_NODE_1", "unknown"), ], ) @@ -69,12 +73,57 @@ def test_app_classifies_registration_origin( app = _app(_owner(owner_type, owner_node_id)) lookup = MagicMock() lookup.is_tenant_organization_node_id.return_value = tenant_org + lookup.is_collected_user_node_id.return_value = False lookup.enterprise_id.return_value = enterprise_id app._lookup = lookup assert app.as_node.properties.registration_origin == expected +@pytest.mark.parametrize( + ("owner_node_id", "expected"), + [ + ("USER_ORG", "member_owned"), + ("USER_ENTERPRISE", "member_owned"), + ("OUTSIDE_COLLABORATOR", "third_party"), + ], +) +def test_user_owned_app_classification_uses_membership_inventory( + owner_node_id: str, expected: str +) -> None: + """Only membership inventory establishes member ownership, not repository access.""" + with duckdb.connect(":memory:") as connection: + connection.execute("CREATE SCHEMA github_test") + connection.execute("CREATE TABLE github_test.users (id VARCHAR)") + connection.execute("CREATE TABLE github_test.enterprise_users (id VARCHAR)") + connection.execute("INSERT INTO github_test.users VALUES ('USER_ORG')") + connection.execute( + "INSERT INTO github_test.enterprise_users VALUES ('USER_ENTERPRISE')" + ) + connection.execute( + "CREATE TABLE github_test.repo_role_assignments (node_id VARCHAR)" + ) + connection.execute( + "INSERT INTO github_test.repo_role_assignments " + "VALUES ('OUTSIDE_COLLABORATOR')" + ) + app = _app(_owner("User", owner_node_id)) + app._lookup = GithubLookup(connection, schema="github_test") + + assert app.as_node.properties.registration_origin == expected + + +def test_org_owned_app_classification_does_not_require_enterprise_lookup() -> None: + """Organization-only classification depends on organization ownership evidence.""" + app = _app(_owner("Organization", "EXTERNAL_ORG")) + lookup = MagicMock() + lookup.is_tenant_organization_node_id.return_value = False + lookup.enterprise_id.side_effect = AssertionError("Unexpected enterprise lookup") + app._lookup = lookup + + assert app.as_node.properties.registration_origin == "third_party" + + def test_app_with_missing_owner_has_unknown_registration_origin() -> None: app = _app(None)