Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion descriptions/nodes/GH_App.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion extension/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 8 additions & 5 deletions src/openhound_github/models/app_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
)
Expand Down
53 changes: 51 additions & 2 deletions tests/test_app_model.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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"),
],
)
Expand All @@ -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)

Expand Down
Loading