From 3e3ef3e2cacc02bb1ae45909e675e0b12cc73c5c Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 19 Aug 2026 13:35:59 -0700 Subject: [PATCH 1/4] BED-9377: model runner group access paths explicitly Replace the flattened runner access model with explicit runner-group hops. Repository eligibility now terminates at the organization-facing runner group via GH_CanUseRunner, while GH_HasRunner captures direct group-to-runner exposure for organization and enterprise runners. Make GH_InheritedFrom traversable so inherited organization runner groups bridge naturally to enterprise runner groups. Remove GH_GrantsAccessTo and runner-group GH_AssignedTo emissions, update runner and repository panel queries, and refresh schema/docs/tests for the new path shape. --- descriptions/edges/GH_AssignedTo.md | 2 +- descriptions/edges/GH_CanUseRunner.md | 6 +- descriptions/edges/GH_GrantsAccessTo.md | 5 - descriptions/edges/GH_HasRunner.md | 5 + descriptions/edges/GH_InheritedFrom.md | 4 +- descriptions/nodes/GH_EnterpriseRunner.md | 2 +- .../nodes/GH_EnterpriseRunnerGroup.md | 4 +- descriptions/nodes/GH_OrgRunner.md | 2 +- descriptions/nodes/GH_OrgRunnerGroup.md | 2 +- extension/schema.json | 12 +- src/openhound_github/kinds/edges.py | 2 +- src/openhound_github/models/repository.py | 8 +- src/openhound_github/models/runner.py | 174 +++++------------- tests/test_runner_models.py | 106 +++-------- 14 files changed, 106 insertions(+), 228 deletions(-) delete mode 100644 descriptions/edges/GH_GrantsAccessTo.md create mode 100644 descriptions/edges/GH_HasRunner.md diff --git a/descriptions/edges/GH_AssignedTo.md b/descriptions/edges/GH_AssignedTo.md index 2e00c80..58fa76f 100644 --- a/descriptions/edges/GH_AssignedTo.md +++ b/descriptions/edges/GH_AssignedTo.md @@ -1,5 +1,5 @@ ## General Information -The non-traversable GH_AssignedTo edge represents an enterprise-scoped object being assigned to an organization. It is currently used for enterprise teams and enterprise runner groups. For runner groups, this edge indicates that an organization is allowed to inherit and use the enterprise-owned runner group. +The non-traversable GH_AssignedTo edge represents an enterprise-scoped team being assigned to an organization. This edge is not traversable because assignment alone does not directly grant a principal a privilege path. diff --git a/descriptions/edges/GH_CanUseRunner.md b/descriptions/edges/GH_CanUseRunner.md index 24bee42..1e67843 100644 --- a/descriptions/edges/GH_CanUseRunner.md +++ b/descriptions/edges/GH_CanUseRunner.md @@ -1,5 +1,7 @@ ## General Information -The non-traversable GH_CanUseRunner edge represents that a repository can dispatch GitHub Actions jobs to a self-hosted runner. Repository-scoped runners receive this edge directly from their containing repository. Organization and enterprise runners receive composed GH_CanUseRunner edges derived from runner group containment and repository access policy. +The non-traversable GH_CanUseRunner edge represents that a repository is eligible to use a self-hosted runner execution surface based on scope or runner-group repository access policy. -For native organization runner groups, the composition path is `GH_Repository <- GH_GrantsAccessTo - GH_OrgRunnerGroup - GH_Contains -> GH_OrgRunner`. For inherited groups, the path continues through `GH_InheritedFrom` to the enterprise runner group and its contained GH_EnterpriseRunner nodes. +Repository-scoped runners receive this edge directly from their containing repository. Organization and inherited enterprise-backed access instead terminate at the organization-facing GH_OrgRunnerGroup. The graph then continues through GH_HasRunner for native organization runners, or through GH_InheritedFrom and GH_HasRunner for inherited enterprise runners. + +For group-backed runners, this edge currently models repository visibility, selected repository access, and allows_public_repositories only. Workflow restrictions remain properties on the runner group and are not encoded into this edge yet. diff --git a/descriptions/edges/GH_GrantsAccessTo.md b/descriptions/edges/GH_GrantsAccessTo.md deleted file mode 100644 index 3182aa7..0000000 --- a/descriptions/edges/GH_GrantsAccessTo.md +++ /dev/null @@ -1,5 +0,0 @@ -## General Information - -The non-traversable GH_GrantsAccessTo edge represents that a GH_OrgRunnerGroup allows a repository to use the runners exposed by that group. For native groups, those runners are GH_OrgRunner nodes contained by the group. For inherited groups, those runners are GH_EnterpriseRunner nodes reached through GH_InheritedFrom. - -This edge is used as part of the composition path for GH_CanUseRunner and is not traversable on its own because repository eligibility alone is not a privilege escalation path. diff --git a/descriptions/edges/GH_HasRunner.md b/descriptions/edges/GH_HasRunner.md new file mode 100644 index 0000000..fee77cb --- /dev/null +++ b/descriptions/edges/GH_HasRunner.md @@ -0,0 +1,5 @@ +## General Information + +The traversable GH_HasRunner edge represents that a runner group exposes a directly assigned self-hosted runner to repositories or workflows that satisfy the runner group's access policy. + +This edge is distinct from GH_Contains. GH_Contains records structural membership only, while GH_HasRunner represents the runner-group-to-runner capability hop used for attack-path composition. This edge is emitted only for direct organization and enterprise runner group memberships; inherited organization runner group access to enterprise runners is modeled separately. diff --git a/descriptions/edges/GH_InheritedFrom.md b/descriptions/edges/GH_InheritedFrom.md index dc70b3f..de1d214 100644 --- a/descriptions/edges/GH_InheritedFrom.md +++ b/descriptions/edges/GH_InheritedFrom.md @@ -1,5 +1,5 @@ ## General Information -The non-traversable GH_InheritedFrom edge links an inherited GH_OrgRunnerGroup to the GH_EnterpriseRunnerGroup that owns the underlying runner set. This preserves the organization-local view of a runner group while still identifying the enterprise source that provides the runners. +The traversable GH_InheritedFrom edge links an inherited GH_OrgRunnerGroup to the GH_EnterpriseRunnerGroup that owns the underlying runner set. This preserves the organization-local view of a runner group while still identifying the enterprise source that provides the runners. -This edge is not traversable because inheritance describes resource provenance rather than a direct privilege path. +This edge is traversable because an inherited organization runner group is the organization-facing policy boundary for the enterprise runner group. Repository access flows through the organization runner group, then through GH_InheritedFrom to the enterprise group, and finally through GH_HasRunner to directly assigned enterprise runners. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index e42e6c7..67945bd 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and become usable by repositories when an inherited organization runner group grants access to those repositories. +Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use them through an inherited GH_OrgRunnerGroup connected by GH_InheritedFrom. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_EnterpriseRunnerGroup.md b/descriptions/nodes/GH_EnterpriseRunnerGroup.md index b002d5d..9fc3944 100644 --- a/descriptions/nodes/GH_EnterpriseRunnerGroup.md +++ b/descriptions/nodes/GH_EnterpriseRunnerGroup.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner group owned by a GitHub Enterprise account. Enterprise runner groups control which organizations may use a shared set of enterprise runners. Groups with `visibility=all` are assigned to every organization in the enterprise, while groups with `visibility=selected` are assigned only to explicitly selected organizations. +Represents a self-hosted runner group owned by a GitHub Enterprise account. Enterprise runner groups control which organizations may use a shared set of enterprise runners. That organization-level visibility is reflected by which inherited GH_OrgRunnerGroup nodes point back to the enterprise group through GH_InheritedFrom. -Enterprise runner groups contain GH_EnterpriseRunner nodes and may be projected into organizations as inherited GH_OrgRunnerGroup nodes. The GH_InheritedFrom edge links the organization view back to the enterprise-owned group. +Enterprise runner groups contain GH_EnterpriseRunner nodes and emit GH_HasRunner for directly assigned runners to represent the traversable capability hop from the group to the runner. They may be projected into organizations as inherited GH_OrgRunnerGroup nodes. The GH_InheritedFrom edge links the organization view back to the enterprise-owned group. diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index fc4a03c..2861114 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and become usable by repositories through the runner group's repository access policy. +Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use those groups through GH_CanUseRunner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_OrgRunnerGroup.md b/descriptions/nodes/GH_OrgRunnerGroup.md index 07ea2eb..1c79fbb 100644 --- a/descriptions/nodes/GH_OrgRunnerGroup.md +++ b/descriptions/nodes/GH_OrgRunnerGroup.md @@ -2,4 +2,4 @@ Represents a self-hosted runner group visible within a GitHub organization. Organization runner groups may either be native to the organization or inherited from an enterprise runner group. -Native organization runner groups contain GH_OrgRunner nodes directly. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_GrantsAccessTo edges describe which repositories may use the runners exposed by the group. +Native organization runner groups contain GH_OrgRunner nodes directly. Direct memberships also emit GH_HasRunner to represent the traversable capability hop from the group to its runners. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_CanUseRunner edges from repositories describe which repositories may use the group based on repository access policy. diff --git a/extension/schema.json b/extension/schema.json index 9e6bc02..a277bae 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -442,18 +442,18 @@ }, { "name": "GH_AssignedTo", - "description": "Enterprise-scoped team or runner group is assigned to an organization", + "description": "Enterprise-scoped team is assigned to an organization", "is_traversable": false }, { "name": "GH_InheritedFrom", "description": "Organization runner group is inherited from an enterprise runner group", - "is_traversable": false + "is_traversable": true }, { - "name": "GH_GrantsAccessTo", - "description": "Organization runner group grants a repository access to its available runners", - "is_traversable": false + "name": "GH_HasRunner", + "description": "Runner group exposes a directly assigned self-hosted runner to authorized repositories or workflows", + "is_traversable": true }, { "name": "GH_Owns", @@ -967,7 +967,7 @@ }, { "name": "GH_CanUseRunner", - "description": "Repository can dispatch jobs to this self-hosted runner", + "description": "Repository can use this self-hosted runner or organization runner group based on repository access policy", "is_traversable": false }, { diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 14b8ea9..f3e5d18 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -2,7 +2,6 @@ CONTAINS = "GH_Contains" ASSIGNED_TO = "GH_AssignedTo" INHERITED_FROM = "GH_InheritedFrom" -GRANTS_ACCESS_TO = "GH_GrantsAccessTo" # Administrative edges ADMIN_TO = "GH_AdminTo" @@ -12,6 +11,7 @@ HAS_MEMBER = "GH_HasMember" HAS_ROLE = "GH_HasRole" HAS_BASE_ROLE = "GH_HasBaseRole" +HAS_RUNNER = "GH_HasRunner" ADD_MEMBER = "GH_AddMember" MEMBER_OF = "GH_MemberOf" diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 5bea39b..b35f393 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -265,7 +265,13 @@ def as_node(self) -> GHNode: query_roles=f"MATCH p=(:GH_RepoRole)-[*1..]->(:GH_Repository {{node_id: '{rid}'}}) RETURN p", query_teams=f"MATCH p=(:GH_Team)-[:GH_MemberOf|GH_HasRole*1..]->(:GH_RepoRole)-[]->(:GH_Repository {{node_id: '{rid}'}}) RETURN p", query_workflows=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(:GH_WorkflowJob)-[:GH_Contains]->(step:GH_WorkflowStep) OPTIONAL MATCH p1=(step)-[:GH_UsesSecret]->(:GH_Secret) OPTIONAL MATCH p2=(step)-[:GH_UsesVariable]->(:GH_Variable) RETURN p,p1,p2", - query_runners=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_Runner) RETURN p", + query_runners=( + f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_RepoRunner) RETURN p " + "UNION " + f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner) RETURN p " + "UNION " + f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p" + ), query_environments=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Environment) RETURN p", query_secrets=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasSecret]->(:GH_Secret) RETURN p", query_variables=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasVariable]->(:GH_Variable) RETURN p", diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 2e39dd4..7e9a49a 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -44,7 +44,7 @@ class GHRunnerGroupProperties(GHNodeProperties): selected_organizations_url: API URL for organizations assigned to an enterprise group. environment_name: The name of the environment (GitHub organization or enterprise). query_runners: Query for runners. - query_organizations: Query for organizations assigned to an enterprise group. + query_organizations: Query for organizations inheriting an enterprise runner group. query_repositories: Query for repositories. """ @@ -112,7 +112,7 @@ def _runner_group_repository_node_ids( end=nk.ENTERPRISE_RUNNER_GROUP, kind=ek.INHERITED_FROM, description="Organization runner group is inherited from enterprise runner group", - traversable=False, + traversable=True, ), ], ) @@ -144,9 +144,9 @@ def node_id(self) -> str: def as_node(self) -> GHNode: gid = self.node_id query_runners = ( - f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_Contains]->(:GH_EnterpriseRunner) RETURN p" + f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p" if self.inherited - else f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_OrgRunner) RETURN p" + else f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_HasRunner]->(:GH_OrgRunner) RETURN p" ) return GHNode( kinds=[nk.ORG_RUNNER_GROUP, nk.RUNNER_GROUP], @@ -167,7 +167,7 @@ def as_node(self) -> GHNode: environment_name=self.org_login, environmentid=self.org_node_id, query_runners=query_runners, - query_repositories=f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_GrantsAccessTo]->(:GH_Repository) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -193,7 +193,7 @@ def edges(self): value=enterprise_runner_group_node_id, match_by="id", ), - properties=EdgeProperties(traversable=False), + properties=EdgeProperties(traversable=True), ) @@ -212,13 +212,6 @@ def edges(self): description="Enterprise contains enterprise runner group", traversable=False, ), - EdgeDef( - start=nk.ENTERPRISE_RUNNER_GROUP, - end=nk.ORGANIZATION, - kind=ek.ASSIGNED_TO, - description="Enterprise runner group is assigned to organization", - traversable=False, - ), ], ) class EnterpriseRunnerGroup(BaseAsset): @@ -263,9 +256,9 @@ def as_node(self) -> GHNode: selected_organizations_url=self.selected_organizations_url, environment_name=self.enterprise_slug, environmentid=self.enterprise_node_id, - query_runners=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_EnterpriseRunner) RETURN p", - query_organizations=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_AssignedTo]->(:GH_Organization) RETURN p", - query_repositories=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})<-[:GH_InheritedFrom]-(:GH_OrgRunnerGroup)-[:GH_GrantsAccessTo]->(:GH_Repository) RETURN p", + query_runners=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p", + query_organizations=f"MATCH p=(:GH_Organization)-[:GH_Contains]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -277,29 +270,9 @@ def edges(self): end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) - if self.visibility == "all": - for (organization_node_id,) in self._lookup.enterprise_organization_node_ids( - self.enterprise_node_id - ): - yield Edge( - kind=ek.ASSIGNED_TO, - start=EdgePath(value=self.node_id, match_by="id"), - end=EdgePath(value=organization_node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) -@app.asset( - edges=[ - EdgeDef( - start=nk.ENTERPRISE_RUNNER_GROUP, - end=nk.ORGANIZATION, - kind=ek.ASSIGNED_TO, - description="Enterprise runner group is assigned to organization", - traversable=False, - ), - ], -) +@app.asset() class EnterpriseRunnerGroupOrganization(BaseAsset): node_id: str login: str | None = None @@ -317,12 +290,7 @@ def enterprise_runner_group_node_id(self) -> str: @property def edges(self): - yield Edge( - kind=ek.ASSIGNED_TO, - start=EdgePath(value=self.enterprise_runner_group_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) + return [] @dataclass @@ -412,8 +380,8 @@ def as_node(self) -> GHNode: labels=json.dumps(self.labels), environment_name=self.org_login, environmentid=self.org_node_id, - query_group=f"MATCH p=(:GH_OrgRunnerGroup)-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", + query_group=f"MATCH p=(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", ), ) @@ -465,8 +433,8 @@ def as_node(self) -> GHNode: labels=json.dumps(self.labels), environment_name=self.enterprise_slug, environmentid=self.enterprise_node_id, - query_group=f"MATCH p=(:GH_EnterpriseRunnerGroup)-[:GH_Contains]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", + query_group=f"MATCH p=(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", ), ) @@ -477,18 +445,11 @@ def edges(self): @app.asset( edges=[ - EdgeDef( - start=nk.ORG_RUNNER_GROUP, - end=nk.REPOSITORY, - kind=ek.GRANTS_ACCESS_TO, - description="Organization runner group grants repository access to runners", - traversable=False, - ), EdgeDef( start=nk.REPOSITORY, - end=nk.ENTERPRISE_RUNNER, + end=nk.ORG_RUNNER_GROUP, kind=ek.CAN_USE_RUNNER, - description="Repository can dispatch jobs to inherited enterprise runner", + description="Repository is eligible to use organization runner group based on repository access policy", traversable=False, ), EdgeDef( @@ -566,51 +527,16 @@ def _can_create_repository_with_runner_access_query( f"(:GH_OrgRunnerGroup {{node_id:'{self.runner_group_node_id}'}}) RETURN p" ) - def _inherited_can_use_runner_query( - self, repository_node_id: str, runner_node_id: str - ) -> str: - return ( - f"MATCH p=(:GH_Repository {{node_id:'{repository_node_id}'}})" - f"<-[:GH_GrantsAccessTo]-(:GH_OrgRunnerGroup {{node_id:'{self.runner_group_node_id}'}})" - "-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)" - f"-[:GH_Contains]->(:GH_EnterpriseRunner {{node_id:'{runner_node_id}'}}) RETURN p" - ) - @property - def _grants_access_to_edges(self): + def _can_use_runner_edges(self): for (repo_node_id,) in self.repository_node_ids: yield Edge( - kind=ek.GRANTS_ACCESS_TO, - start=EdgePath(value=self.runner_group_node_id, match_by="id"), - end=EdgePath(value=repo_node_id, match_by="id"), + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=repo_node_id, match_by="id"), + end=EdgePath(value=self.runner_group_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) - @property - def _inherited_can_use_runner_edges(self): - if not self.inherited: - return - - runner_node_ids = ( - self._lookup.enterprise_runner_node_ids_for_inherited_org_group( - self.org_node_id, self.runner_group_name - ) - ) - for (repo_node_id,) in self.repository_node_ids: - for (enterprise_runner_node_id,) in runner_node_ids: - yield Edge( - kind=ek.CAN_USE_RUNNER, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=enterprise_runner_node_id, match_by="id"), - properties=GHEdgeProperties( - traversable=False, - composed=True, - query_composition=self._inherited_can_use_runner_query( - repo_node_id, enterprise_runner_node_id - ), - ), - ) - @property def _can_create_repository_with_runner_access_edges(self): edge_kinds = self._repository_creation_edge_kinds @@ -648,8 +574,7 @@ def _can_create_repository_with_runner_access_edges(self): @property def edges(self): - yield from self._grants_access_to_edges - yield from self._inherited_can_use_runner_edges + yield from self._can_use_runner_edges yield from self._can_create_repository_with_runner_access_edges @@ -663,11 +588,11 @@ def edges(self): traversable=False, ), EdgeDef( - start=nk.REPOSITORY, + start=nk.ORG_RUNNER_GROUP, end=nk.ORG_RUNNER, - kind=ek.CAN_USE_RUNNER, - description="Repository can dispatch jobs to runner", - traversable=False, + kind=ek.HAS_RUNNER, + description="Organization runner group exposes organization runner to authorized repositories", + traversable=True, ), ], ) @@ -698,13 +623,6 @@ def _runner_node_id(self): def _runner_group_node_id(self): return runner_group_node_id(self.org_node_id, self.runner_group_id) - def _can_use_runner_query(self, repository_node_id: str) -> str: - return ( - f"MATCH p=(:GH_Repository {{node_id:'{repository_node_id}'}})" - f"<-[:GH_GrantsAccessTo]-(:GH_OrgRunnerGroup {{node_id:'{self._runner_group_node_id}'}})" - f"-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{self._runner_node_id}'}}) RETURN p" - ) - @property def _contains_edge(self): yield Edge( @@ -715,31 +633,18 @@ def _contains_edge(self): ) @property - def _can_use_runner_edges(self): - repo_node_ids = _runner_group_repository_node_ids( - self._lookup, - self.org_login, - self.runner_group_visibility, - self.allows_public_repositories, - self.accessible_repo_node_ids, + def _has_runner_edge(self): + yield Edge( + kind=ek.HAS_RUNNER, + start=EdgePath(value=self._runner_group_node_id, match_by="id"), + end=EdgePath(value=self._runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), ) - for (repo_node_id,) in repo_node_ids: - yield Edge( - kind=ek.CAN_USE_RUNNER, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=self._runner_node_id, match_by="id"), - properties=GHEdgeProperties( - traversable=False, - composed=True, - query_composition=self._can_use_runner_query(repo_node_id), - ), - ) - @property def edges(self): - yield from self._can_use_runner_edges yield from self._contains_edge + yield from self._has_runner_edge @app.asset( @@ -751,6 +656,13 @@ def edges(self): description="Enterprise runner group contains enterprise runner", traversable=False, ), + EdgeDef( + start=nk.ENTERPRISE_RUNNER_GROUP, + end=nk.ENTERPRISE_RUNNER, + kind=ek.HAS_RUNNER, + description="Enterprise runner group exposes enterprise runner to authorized repositories", + traversable=True, + ), ], ) class EnterpriseRunnerGroupMembership(BaseAsset): @@ -781,6 +693,12 @@ def edges(self): end=EdgePath(value=self._runner_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) + yield Edge( + kind=ek.HAS_RUNNER, + start=EdgePath(value=self._runner_group_node_id, match_by="id"), + end=EdgePath(value=self._runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) @app.asset( diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index 3af844c..5ea2c5b 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -43,6 +43,7 @@ def test_inherited_org_runner_group_emits_inherited_from_edge() -> None: assert [edge.kind for edge in edges] == [ek.CONTAINS, ek.INHERITED_FROM] assert edges[1].start.value == "ORG_1_runner_group_1" assert edges[1].end.value == "ENT_1_runner_group_2" + assert edges[1].properties.traversable is True lookup.enterprise_runner_group_node_id_for_inherited_org_group.assert_called_once_with( "ORG_1", "Default" ) @@ -115,7 +116,7 @@ def test_runner_groups_and_runners_use_scope_owner_prefixes_with_generic_suffixe assert repo_runner.as_node.properties.displayname == "repo-runner-1" -def test_enterprise_runner_group_with_all_visibility_assigns_every_enterprise_org() -> None: +def test_enterprise_runner_group_with_all_visibility_emits_only_containment() -> None: group = EnterpriseRunnerGroup( id=2, name="Enterprise Default", @@ -123,19 +124,9 @@ def test_enterprise_runner_group_with_all_visibility_assigns_every_enterprise_or enterprise_node_id="ENT_1", enterprise_slug="acme-enterprise", ) - lookup = MagicMock() - lookup.enterprise_organization_node_ids.return_value = [("ORG_1",), ("ORG_2",)] - group._lookup = lookup - edges = list(group.edges) - assert [edge.kind for edge in edges] == [ - ek.CONTAINS, - ek.ASSIGNED_TO, - ek.ASSIGNED_TO, - ] - assert {edge.end.value for edge in edges[1:]} == {"ORG_1", "ORG_2"} - lookup.enterprise_organization_node_ids.assert_called_once_with("ENT_1") + assert [edge.kind for edge in edges] == [ek.CONTAINS] def test_enterprise_runner_group_with_selected_visibility_does_not_infer_orgs() -> None: @@ -146,13 +137,9 @@ def test_enterprise_runner_group_with_selected_visibility_does_not_infer_orgs() enterprise_node_id="ENT_1", enterprise_slug="acme-enterprise", ) - lookup = MagicMock() - group._lookup = lookup - edges = list(group.edges) assert [edge.kind for edge in edges] == [ek.CONTAINS] - lookup.enterprise_organization_node_ids.assert_not_called() def test_enterprise_runner_group_membership_contains_enterprise_runner() -> None: @@ -163,14 +150,16 @@ def test_enterprise_runner_group_membership_contains_enterprise_runner() -> None enterprise_slug="acme-enterprise", ) - edge = next(iter(membership.edges)) + edges = list(membership.edges) - assert edge.kind == ek.CONTAINS - assert edge.start.value == "ENT_1_runner_group_2" - assert edge.end.value == "ENT_1_runner_9" + assert [edge.kind for edge in edges] == [ek.CONTAINS, ek.HAS_RUNNER] + assert all(edge.start.value == "ENT_1_runner_group_2" for edge in edges) + assert all(edge.end.value == "ENT_1_runner_9" for edge in edges) + assert edges[0].properties.traversable is False + assert edges[1].properties.traversable is True -def test_enterprise_runner_group_organization_assignment_uses_assigned_to() -> None: +def test_enterprise_runner_group_organization_emits_no_graph_edge() -> None: assignment = EnterpriseRunnerGroupOrganization( node_id="ORG_1", login="acme-org", @@ -179,14 +168,10 @@ def test_enterprise_runner_group_organization_assignment_uses_assigned_to() -> N enterprise_slug="acme-enterprise", ) - edge = next(iter(assignment.edges)) + assert list(assignment.edges) == [] - assert edge.kind == ek.ASSIGNED_TO - assert edge.start.value == "ENT_1_runner_group_2" - assert edge.end.value == "ORG_1" - -def test_org_runner_group_access_grants_repositories_and_composes_inherited_runners() -> None: +def test_org_runner_group_access_emits_repository_access_to_inherited_group() -> None: access = OrgRunnerGroupAccess( runner_group_id=1, runner_group_name="Default", @@ -197,27 +182,20 @@ def test_org_runner_group_access_grants_repositories_and_composes_inherited_runn lookup = MagicMock() lookup.org_id_for_login.return_value = "ORG_1" lookup.repository_node_ids_for_org.return_value = [("REPO_1",)] - lookup.enterprise_runner_node_ids_for_inherited_org_group.return_value = [ - ("ENT_1_runner_9",) - ] lookup.members_can_create_repository.return_value = (False, False, False, False) access._lookup = lookup edges = list(access.edges) assert [edge.kind for edge in edges] == [ - ek.GRANTS_ACCESS_TO, ek.CAN_USE_RUNNER, ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, ] - assert edges[0].start.value == "ORG_1_runner_group_1" - assert edges[0].end.value == "REPO_1" - assert edges[1].start.value == "REPO_1" - assert edges[1].end.value == "ENT_1_runner_9" - assert edges[1].properties.composed is True - assert "GH_InheritedFrom" in edges[1].properties.query_composition - assert edges[2].start.value == "ORG_1_owners" - assert edges[2].end.value == "ORG_1_runner_group_1" + assert edges[0].start.value == "REPO_1" + assert edges[0].end.value == "ORG_1_runner_group_1" + assert edges[0].properties.traversable is False + assert edges[1].start.value == "ORG_1_owners" + assert edges[1].end.value == "ORG_1_runner_group_1" def test_org_runner_group_access_all_visibility_excludes_public_repositories_when_disabled() -> None: @@ -232,21 +210,17 @@ def test_org_runner_group_access_all_visibility_excludes_public_repositories_whe lookup = MagicMock() lookup.org_id_for_login.return_value = "ORG_1" lookup.private_repository_node_ids_for_org.return_value = [("REPO_PRIVATE",)] - lookup.enterprise_runner_node_ids_for_inherited_org_group.return_value = [ - ("ENT_1_runner_9",) - ] lookup.members_can_create_repository.return_value = (False, False, False, False) access._lookup = lookup edges = list(access.edges) assert [edge.kind for edge in edges] == [ - ek.GRANTS_ACCESS_TO, ek.CAN_USE_RUNNER, ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, ] - assert edges[0].end.value == "REPO_PRIVATE" - assert edges[1].start.value == "REPO_PRIVATE" + assert edges[0].start.value == "REPO_PRIVATE" + assert edges[0].end.value == "ORG_1_runner_group_1" lookup.private_repository_node_ids_for_org.assert_called_with("acme") lookup.repository_node_ids_for_org.assert_not_called() @@ -267,8 +241,9 @@ def test_org_runner_group_access_selected_visibility_excludes_public_repositorie edges = list(access.edges) - assert [edge.kind for edge in edges] == [ek.GRANTS_ACCESS_TO] - assert edges[0].end.value == "REPO_PRIVATE" + assert [edge.kind for edge in edges] == [ek.CAN_USE_RUNNER] + assert edges[0].start.value == "REPO_PRIVATE" + assert edges[0].end.value == "ORG_1_runner_group_1" def test_org_runner_group_access_all_visibility_emits_latent_access_for_members_with_creation_capability() -> None: @@ -373,7 +348,7 @@ def test_org_runner_group_access_selected_visibility_does_not_emit_latent_access assert list(access.edges) == [] -def test_native_org_runner_group_membership_composes_can_use_runner_through_access() -> None: +def test_native_org_runner_group_membership_emits_structural_and_capability_edges() -> None: membership = OrgRunnerGroupMembership( runner_group_id=1, runner_group_name="Default", @@ -381,40 +356,17 @@ def test_native_org_runner_group_membership_composes_can_use_runner_through_acce runner_group_visibility="all", org_login="acme", ) - lookup = MagicMock() - lookup.org_id_for_login.return_value = "ORG_1" - lookup.repository_node_ids_for_org.return_value = [("REPO_1",)] - membership._lookup = lookup + membership._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") edges = list(membership.edges) - assert [edge.kind for edge in edges] == [ek.CAN_USE_RUNNER, ek.CONTAINS] - assert edges[0].properties.composed is True - assert "GH_GrantsAccessTo" in edges[0].properties.query_composition + assert [edge.kind for edge in edges] == [ek.CONTAINS, ek.HAS_RUNNER] + assert edges[0].start.value == "ORG_1_runner_group_1" + assert edges[0].end.value == "ORG_1_runner_9" + assert edges[0].properties.traversable is False assert edges[1].start.value == "ORG_1_runner_group_1" assert edges[1].end.value == "ORG_1_runner_9" - - -def test_native_org_runner_group_membership_excludes_public_repositories_when_disabled() -> None: - membership = OrgRunnerGroupMembership( - runner_group_id=1, - runner_group_name="Default", - runner_id=9, - runner_group_visibility="all", - allows_public_repositories=False, - org_login="acme", - ) - lookup = MagicMock() - lookup.org_id_for_login.return_value = "ORG_1" - lookup.private_repository_node_ids_for_org.return_value = [("REPO_PRIVATE",)] - membership._lookup = lookup - - edges = list(membership.edges) - - assert [edge.kind for edge in edges] == [ek.CAN_USE_RUNNER, ek.CONTAINS] - assert edges[0].start.value == "REPO_PRIVATE" - lookup.private_repository_node_ids_for_org.assert_called_with("acme") - lookup.repository_node_ids_for_org.assert_not_called() + assert edges[1].properties.traversable is True def test_enterprise_organization_lookup_filters_to_enterprise() -> None: From 919be0ed4c6087286525195b324a42045a9d12a0 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 19 Aug 2026 14:10:13 -0700 Subject: [PATCH 2/4] BED-9377: clarify enterprise runner access path docs Document the full repository-to-org-runner-group-to-enterprise-runner path in the enterprise runner node and GH_HasRunner edge descriptions. --- descriptions/edges/GH_HasRunner.md | 2 +- descriptions/nodes/GH_EnterpriseRunner.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/descriptions/edges/GH_HasRunner.md b/descriptions/edges/GH_HasRunner.md index fee77cb..0cb3e60 100644 --- a/descriptions/edges/GH_HasRunner.md +++ b/descriptions/edges/GH_HasRunner.md @@ -2,4 +2,4 @@ The traversable GH_HasRunner edge represents that a runner group exposes a directly assigned self-hosted runner to repositories or workflows that satisfy the runner group's access policy. -This edge is distinct from GH_Contains. GH_Contains records structural membership only, while GH_HasRunner represents the runner-group-to-runner capability hop used for attack-path composition. This edge is emitted only for direct organization and enterprise runner group memberships; inherited organization runner group access to enterprise runners is modeled separately. +This edge is distinct from GH_Contains. GH_Contains records structural membership only, while GH_HasRunner represents the runner-group-to-runner capability hop used for attack-path composition. This edge is emitted only for direct organization and enterprise runner group memberships; inherited organization runner group access to enterprise runners is modeled as GH_Repository -[:GH_CanUseRunner]-> GH_OrgRunnerGroup -[:GH_InheritedFrom]-> GH_EnterpriseRunnerGroup -[:GH_HasRunner]-> GH_EnterpriseRunner. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index 67945bd..71082b8 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use them through an inherited GH_OrgRunnerGroup connected by GH_InheritedFrom. +Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use them through GH_CanUseRunner to an inherited GH_OrgRunnerGroup, then GH_InheritedFrom to the enterprise group, and finally GH_HasRunner to the runner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. From dfaa023139fe8d44119479436dd74fe9447d73c8 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 19 Aug 2026 15:03:17 -0700 Subject: [PATCH 3/4] BED-9377: model actionable runner group access paths Split runner group repository policy from executable runner access by introducing the non-traversable GH_IsEligibleFor edge and reserving GH_CanUseRunner for repositories and branches that can actually dispatch workflows. Emit traversable GH_CanUseRunner edges only when Actions is enabled for the repository and the relevant organization and inherited enterprise runner groups are not restricted to selected workflows. Add GH_HasRunner and traversable GH_InheritedFrom composition support so runner-group paths continue through to the underlying runner. Make GH_CanCreateRepositoryWithRunnerAccess traversable only when a newly created repository would immediately be usable: the repository visibility is in scope, organization Actions are enabled for all repositories, and the runner-group workflow policy is unrestricted. Update schema metadata, descriptions, lookups, collection fields, and focused tests to document and validate the new model. --- .../GH_CanCreateRepositoryWithRunnerAccess.md | 8 +- descriptions/edges/GH_CanUseRunner.md | 6 +- descriptions/edges/GH_HasRunner.md | 2 +- descriptions/edges/GH_IsEligibleFor.md | 5 + descriptions/nodes/GH_EnterpriseRunner.md | 2 +- descriptions/nodes/GH_OrgRunner.md | 2 +- descriptions/nodes/GH_OrgRunnerGroup.md | 2 +- extension/schema.json | 11 +- src/openhound_github/kinds/edges.py | 1 + src/openhound_github/lookup.py | 51 +++++ src/openhound_github/models/runner.py | 125 ++++++++++-- .../resources/organization.py | 1 + tests/test_runner_models.py | 191 ++++++++++++++++-- tests/test_runner_resources.py | 1 + 14 files changed, 365 insertions(+), 43 deletions(-) create mode 100644 descriptions/edges/GH_IsEligibleFor.md diff --git a/descriptions/edges/GH_CanCreateRepositoryWithRunnerAccess.md b/descriptions/edges/GH_CanCreateRepositoryWithRunnerAccess.md index 9378f62..3293226 100644 --- a/descriptions/edges/GH_CanCreateRepositoryWithRunnerAccess.md +++ b/descriptions/edges/GH_CanCreateRepositoryWithRunnerAccess.md @@ -1,9 +1,9 @@ ## General Information -The non-traversable GH_CanCreateRepositoryWithRunnerAccess edge is a computed edge indicating that a GH_OrgRole can create a repository whose visibility automatically places it in scope for a GH_OrgRunnerGroup. +The traversable GH_CanCreateRepositoryWithRunnerAccess edge is a computed edge indicating that a GH_OrgRole can create a repository that will immediately be able to dispatch workflows to a GH_OrgRunnerGroup. -This edge is emitted only for runner groups with `visibility=all` or `visibility=private`. Groups with `visibility=selected` require explicit repository assignment, so creating a repository does not automatically grant access to the group. +This edge is emitted only for runner groups with `visibility=all` or `visibility=private`. Groups with `visibility=selected` require explicit repository assignment, so creating a repository does not automatically grant access to the group. For `visibility=all`, public repository creation is included only when `allows_public_repositories=true`; otherwise the composition is limited to private and internal repository creation. -The computation follows repository-creation capability edges from the org role to the organization and then GH_Contains to the runner group. For `visibility=all`, public repository creation is included only when `allows_public_repositories=true`; otherwise the composition is limited to private and internal repository creation. Each edge includes a `query_composition` Cypher query showing the underlying graph evidence. +The collector emits this edge only when new repositories in the organization have GitHub Actions enabled by default (`actions_enabled_repositories=all`), the organization-facing runner group has `restricted_to_workflows=false`, and inherited enterprise-backed access also has `restricted_to_workflows=false` on the source GH_EnterpriseRunnerGroup. -This edge represents latent repository eligibility subject to runner-group workflow policy. It is non-traversable because creating an eligible repository does not by itself prove that arbitrary jobs can dispatch to the group's runners. +The computation follows repository-creation capability edges from the org role to the organization and then GH_Contains to the runner group. Each edge includes a `query_composition` Cypher query showing the repository-creation path and the Actions and runner-group policy predicates that make the path immediately usable. diff --git a/descriptions/edges/GH_CanUseRunner.md b/descriptions/edges/GH_CanUseRunner.md index 1e67843..601483a 100644 --- a/descriptions/edges/GH_CanUseRunner.md +++ b/descriptions/edges/GH_CanUseRunner.md @@ -1,7 +1,7 @@ ## General Information -The non-traversable GH_CanUseRunner edge represents that a repository is eligible to use a self-hosted runner execution surface based on scope or runner-group repository access policy. +For runner-group-backed access, the traversable GH_CanUseRunner edge is a computed edge representing that a repository or branch can dispatch workflows to a self-hosted runner execution surface under the modeled runner-group controls. -Repository-scoped runners receive this edge directly from their containing repository. Organization and inherited enterprise-backed access instead terminate at the organization-facing GH_OrgRunnerGroup. The graph then continues through GH_HasRunner for native organization runners, or through GH_InheritedFrom and GH_HasRunner for inherited enterprise runners. +The collector derives this edge from GH_IsEligibleFor rather than directly from repository visibility. It emits GH_CanUseRunner only when the repository is within the runner group's repository-access scope, GitHub Actions is enabled for the repository, and `restricted_to_workflows=false` on the organization-facing runner group. Inherited enterprise-backed access also requires `restricted_to_workflows=false` on the source GH_EnterpriseRunnerGroup. Every collected branch in a repository that satisfies those conditions receives the same edge so branch write paths can reach the execution surface. -For group-backed runners, this edge currently models repository visibility, selected repository access, and allows_public_repositories only. Workflow restrictions remain properties on the runner group and are not encoded into this edge yet. +Organization and inherited enterprise-backed access terminate at the organization-facing GH_OrgRunnerGroup, then continue through GH_HasRunner for native organization runners or through GH_InheritedFrom and GH_HasRunner for inherited enterprise runners. Repository-scoped runners currently receive GH_CanUseRunner directly from their containing repository and are not part of this runner-group traversability change. diff --git a/descriptions/edges/GH_HasRunner.md b/descriptions/edges/GH_HasRunner.md index 0cb3e60..393056a 100644 --- a/descriptions/edges/GH_HasRunner.md +++ b/descriptions/edges/GH_HasRunner.md @@ -2,4 +2,4 @@ The traversable GH_HasRunner edge represents that a runner group exposes a directly assigned self-hosted runner to repositories or workflows that satisfy the runner group's access policy. -This edge is distinct from GH_Contains. GH_Contains records structural membership only, while GH_HasRunner represents the runner-group-to-runner capability hop used for attack-path composition. This edge is emitted only for direct organization and enterprise runner group memberships; inherited organization runner group access to enterprise runners is modeled as GH_Repository -[:GH_CanUseRunner]-> GH_OrgRunnerGroup -[:GH_InheritedFrom]-> GH_EnterpriseRunnerGroup -[:GH_HasRunner]-> GH_EnterpriseRunner. +This edge is distinct from GH_Contains. GH_Contains records structural membership only, while GH_HasRunner represents the runner-group-to-runner capability hop used for attack-path composition. This edge is emitted only for direct organization and enterprise runner group memberships; inherited organization runner group access to enterprise runners is modeled as GH_Repository or GH_Branch -[:GH_CanUseRunner]-> GH_OrgRunnerGroup -[:GH_InheritedFrom]-> GH_EnterpriseRunnerGroup -[:GH_HasRunner]-> GH_EnterpriseRunner. diff --git a/descriptions/edges/GH_IsEligibleFor.md b/descriptions/edges/GH_IsEligibleFor.md new file mode 100644 index 0000000..9b0a24f --- /dev/null +++ b/descriptions/edges/GH_IsEligibleFor.md @@ -0,0 +1,5 @@ +## General Information + +The non-traversable GH_IsEligibleFor edge represents that a repository is within the repository-access scope of an organization runner group. + +For runner groups, this edge evaluates the group's `visibility`, selected repository assignments, and `allows_public_repositories` setting. It does not prove that workflows in the repository can dispatch to the group's runners, because GitHub Actions may be disabled for the repository or the runner group may be restricted to selected workflows. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index 71082b8..6de69ff 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use them through GH_CanUseRunner to an inherited GH_OrgRunnerGroup, then GH_InheritedFrom to the enterprise group, and finally GH_HasRunner to the runner. +Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible for the organization-facing runner group through GH_IsEligibleFor. Repositories and branches that can dispatch workflows then reach the runner through GH_CanUseRunner to an inherited GH_OrgRunnerGroup, GH_InheritedFrom to the enterprise group, and finally GH_HasRunner to the runner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index 2861114..2cf50c3 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -1,5 +1,5 @@ ## Description -Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible to use those groups through GH_CanUseRunner. +Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible for those groups through GH_IsEligibleFor, while repositories and branches that can dispatch workflows to them are linked through GH_CanUseRunner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_OrgRunnerGroup.md b/descriptions/nodes/GH_OrgRunnerGroup.md index 1c79fbb..5f96afd 100644 --- a/descriptions/nodes/GH_OrgRunnerGroup.md +++ b/descriptions/nodes/GH_OrgRunnerGroup.md @@ -2,4 +2,4 @@ Represents a self-hosted runner group visible within a GitHub organization. Organization runner groups may either be native to the organization or inherited from an enterprise runner group. -Native organization runner groups contain GH_OrgRunner nodes directly. Direct memberships also emit GH_HasRunner to represent the traversable capability hop from the group to its runners. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_CanUseRunner edges from repositories describe which repositories may use the group based on repository access policy. +Native organization runner groups contain GH_OrgRunner nodes directly. Direct memberships also emit GH_HasRunner to represent the traversable capability hop from the group to its runners. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_IsEligibleFor edges from repositories describe repository access policy scope, while GH_CanUseRunner edges identify repositories and branches that can dispatch workflows to the group under the collected Actions and workflow-restriction settings. diff --git a/extension/schema.json b/extension/schema.json index a277bae..add2562 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -967,13 +967,18 @@ }, { "name": "GH_CanUseRunner", - "description": "Repository can use this self-hosted runner or organization runner group based on repository access policy", + "description": "Repository or branch can dispatch workflows to this self-hosted runner execution surface", + "is_traversable": true + }, + { + "name": "GH_IsEligibleFor", + "description": "Repository is within the repository-access scope of this runner group", "is_traversable": false }, { "name": "GH_CanCreateRepositoryWithRunnerAccess", - "description": "Org role can create a repository that inherits access to this runner group", - "is_traversable": false + "description": "Org role can create a repository that can dispatch workflows to this runner group", + "is_traversable": true }, { "name": "GH_CanWriteBranch", diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index f3e5d18..99c3d8f 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -18,6 +18,7 @@ # Access and capability edges CAN_ACCESS = "GH_CanAccess" CAN_USE_RUNNER = "GH_CanUseRunner" +IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" CAN_CREATE_ENVIRONMENT = "GH_CanCreateEnvironment" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index dfbe0a6..0b9c851 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -109,6 +109,30 @@ def enterprise_runner_group_node_id_for_inherited_org_group( enterprise_node_id, runner_group_id = identity return runner_group_node_id(enterprise_node_id, runner_group_id) + @lru_cache + def enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( + self, org_node_id: str, group_name: str + ) -> bool | None: + identity = self._enterprise_runner_group_identity_for_inherited_org_group( + org_node_id, group_name + ) + if not identity: + return None + + enterprise_node_id, runner_group_id = identity + row = self._find_single_row( + f""" + SELECT restricted_to_workflows + FROM {self.schema}.enterprise_runner_groups + WHERE enterprise_node_id = ? + AND id = ? + """, + [enterprise_node_id, runner_group_id], + ) + if row is None or row[0] is None: + return None + return bool(row[0]) + @lru_cache def enterprise_runner_node_ids_for_inherited_org_group( self, org_node_id: str, group_name: str @@ -195,6 +219,33 @@ def private_repository_node_ids_for_org(self, org_login: str): [org_login], ) + @lru_cache + def actions_enabled_repository_node_ids_for_org(self, org_login: str): + return self._find_all_objects( + f"""SELECT node_id FROM {self.schema}.repositories WHERE org_login = ? AND actions_enabled = true""", + [org_login], + ) + + @lru_cache + def actions_enabled_repositories_for_org(self, org_login: str) -> str | None: + return self._find_single_object( + f"""SELECT actions_enabled_repositories FROM {self.schema}.organizations WHERE login = ?""", + [org_login], + ) + + @lru_cache + def branch_node_ids_for_org(self, org_login: str): + return self._find_all_objects( + f""" + SELECT b.repository_node_id, b.id + FROM {self.schema}.branches b + JOIN {self.schema}.repositories r + ON r.node_id = b.repository_node_id + WHERE r.org_login = ? + """, + [org_login], + ) + @lru_cache def repository_branch_ruleset_count(self, repository_node_id: str) -> int | None: row = self._find_single_row( diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 7e9a49a..9efe4f7 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -167,7 +167,7 @@ def as_node(self) -> GHNode: environment_name=self.org_login, environmentid=self.org_node_id, query_runners=query_runners, - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor]->(:GH_OrgRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -258,7 +258,7 @@ def as_node(self) -> GHNode: environmentid=self.enterprise_node_id, query_runners=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p", query_organizations=f"MATCH p=(:GH_Organization)-[:GH_Contains]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -448,16 +448,30 @@ def edges(self): EdgeDef( start=nk.REPOSITORY, end=nk.ORG_RUNNER_GROUP, - kind=ek.CAN_USE_RUNNER, - description="Repository is eligible to use organization runner group based on repository access policy", + kind=ek.IS_ELIGIBLE_FOR, + description="Repository is eligible for organization runner group based on repository access policy", traversable=False, ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.ORG_RUNNER_GROUP, + kind=ek.CAN_USE_RUNNER, + description="Repository can dispatch workflows to organization runner group", + traversable=True, + ), + EdgeDef( + start=nk.BRANCH, + end=nk.ORG_RUNNER_GROUP, + kind=ek.CAN_USE_RUNNER, + description="Branch can dispatch workflows to organization runner group", + traversable=True, + ), EdgeDef( start=nk.ORG_ROLE, end=nk.ORG_RUNNER_GROUP, kind=ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, - description="Org role can create a repository that inherits access to this runner group", - traversable=False, + description="Org role can create a repository that can dispatch workflows to this runner group", + traversable=True, ), ], ) @@ -468,6 +482,7 @@ class OrgRunnerGroupAccess(BaseAsset): runner_group_name: str runner_group_visibility: str | None = None allows_public_repositories: bool | None = None + restricted_to_workflows: bool | None = None inherited: bool | None = None accessible_repo_node_ids: list[str] = Field(default_factory=list) @@ -520,27 +535,110 @@ def _can_create_repository_with_runner_access_query( self, role_node_id: str, edge_kinds: tuple[str, ...] ) -> str: creation_edges = "|".join(edge_kinds) + inherited_path = ( + "-[:GH_InheritedFrom]->(enterprise_group:GH_EnterpriseRunnerGroup)" + if self.inherited + else "" + ) + conditions = [ + "org.actions_enabled_repositories = 'all'", + "coalesce(group.restricted_to_workflows, true) = false", + ] + if self.inherited: + conditions.append( + "coalesce(enterprise_group.restricted_to_workflows, true) = false" + ) return ( f"MATCH p=(:GH_OrgRole {{node_id:'{role_node_id}'}})" f"-[:{creation_edges}]->" - "(:GH_Organization)-[:GH_Contains]->" - f"(:GH_OrgRunnerGroup {{node_id:'{self.runner_group_node_id}'}}) RETURN p" + "(org:GH_Organization)-[:GH_Contains]->" + f"(group:GH_OrgRunnerGroup {{node_id:'{self.runner_group_node_id}'}})" + f"{inherited_path} " + f"WHERE {' AND '.join(conditions)} RETURN p" ) @property - def _can_use_runner_edges(self): + def _workflow_policy_allows_repository_dispatch(self) -> bool: + if self.restricted_to_workflows is not False: + return False + + if not self.inherited: + return True + + return ( + self._lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( + self.org_node_id, self.runner_group_name + ) + is False + ) + + @property + def _can_use_runner_repository_node_ids(self) -> list[str]: + if not self._workflow_policy_allows_repository_dispatch: + return [] + + actions_enabled_repository_node_ids = { + repository_node_id + for (repository_node_id,) in self._lookup.actions_enabled_repository_node_ids_for_org( + self.org_login + ) + } + return [ + repository_node_id + for (repository_node_id,) in self.repository_node_ids + if repository_node_id in actions_enabled_repository_node_ids + ] + + @property + def _new_repositories_can_dispatch_workflows(self) -> bool: + return ( + self._workflow_policy_allows_repository_dispatch + and self._lookup.actions_enabled_repositories_for_org(self.org_login) + == "all" + ) + + @property + def _is_eligible_for_edges(self): for (repo_node_id,) in self.repository_node_ids: yield Edge( - kind=ek.CAN_USE_RUNNER, + kind=ek.IS_ELIGIBLE_FOR, start=EdgePath(value=repo_node_id, match_by="id"), end=EdgePath(value=self.runner_group_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) + @property + def _can_use_runner_edges(self): + repository_node_ids = self._can_use_runner_repository_node_ids + if not repository_node_ids: + return + + repository_node_id_set = set(repository_node_ids) + for repository_node_id in repository_node_ids: + yield Edge( + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=repository_node_id, match_by="id"), + end=EdgePath(value=self.runner_group_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) + + for repository_node_id, branch_node_id in self._lookup.branch_node_ids_for_org( + self.org_login + ): + if repository_node_id not in repository_node_id_set: + continue + + yield Edge( + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=branch_node_id, match_by="id"), + end=EdgePath(value=self.runner_group_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) + @property def _can_create_repository_with_runner_access_edges(self): edge_kinds = self._repository_creation_edge_kinds - if not edge_kinds: + if not edge_kinds or not self._new_repositories_can_dispatch_workflows: return owners_role_id = f"{self.org_node_id}_owners" @@ -549,7 +647,7 @@ def _can_create_repository_with_runner_access_edges(self): start=EdgePath(value=owners_role_id, match_by="id"), end=EdgePath(value=self.runner_group_node_id, match_by="id"), properties=GHEdgeProperties( - traversable=False, + traversable=True, composed=True, query_composition=self._can_create_repository_with_runner_access_query( owners_role_id, edge_kinds @@ -564,7 +662,7 @@ def _can_create_repository_with_runner_access_edges(self): start=EdgePath(value=members_role_id, match_by="id"), end=EdgePath(value=self.runner_group_node_id, match_by="id"), properties=GHEdgeProperties( - traversable=False, + traversable=True, composed=True, query_composition=self._can_create_repository_with_runner_access_query( members_role_id, edge_kinds @@ -574,6 +672,7 @@ def _can_create_repository_with_runner_access_edges(self): @property def edges(self): + yield from self._is_eligible_for_edges yield from self._can_use_runner_edges yield from self._can_create_repository_with_runner_access_edges diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 51435fa..f5cb09b 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -1210,6 +1210,7 @@ def org_runner_group_access(group: OrgRunnerGroup, ctx: SourceContext): "runner_group_name": group.name, "runner_group_visibility": group.visibility, "allows_public_repositories": group.allows_public_repositories, + "restricted_to_workflows": getattr(group, "restricted_to_workflows", None), "inherited": group.inherited, "accessible_repo_node_ids": _selected_runner_group_repo_node_ids( group_row, client, org_name diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index 5ea2c5b..74dbd0c 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -187,15 +187,10 @@ def test_org_runner_group_access_emits_repository_access_to_inherited_group() -> edges = list(access.edges) - assert [edge.kind for edge in edges] == [ - ek.CAN_USE_RUNNER, - ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, - ] + assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] assert edges[0].start.value == "REPO_1" assert edges[0].end.value == "ORG_1_runner_group_1" assert edges[0].properties.traversable is False - assert edges[1].start.value == "ORG_1_owners" - assert edges[1].end.value == "ORG_1_runner_group_1" def test_org_runner_group_access_all_visibility_excludes_public_repositories_when_disabled() -> None: @@ -215,10 +210,7 @@ def test_org_runner_group_access_all_visibility_excludes_public_repositories_whe edges = list(access.edges) - assert [edge.kind for edge in edges] == [ - ek.CAN_USE_RUNNER, - ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, - ] + assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] assert edges[0].start.value == "REPO_PRIVATE" assert edges[0].end.value == "ORG_1_runner_group_1" lookup.private_repository_node_ids_for_org.assert_called_with("acme") @@ -241,22 +233,107 @@ def test_org_runner_group_access_selected_visibility_excludes_public_repositorie edges = list(access.edges) - assert [edge.kind for edge in edges] == [ek.CAN_USE_RUNNER] + assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] + lookup.branch_node_ids_for_org.assert_not_called() assert edges[0].start.value == "REPO_PRIVATE" assert edges[0].end.value == "ORG_1_runner_group_1" -def test_org_runner_group_access_all_visibility_emits_latent_access_for_members_with_creation_capability() -> None: +def test_org_runner_group_access_emits_traversable_use_edges_for_unrestricted_group() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Selected", + runner_group_visibility="selected", + restricted_to_workflows=False, + accessible_repo_node_ids=["REPO_1"], + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.actions_enabled_repository_node_ids_for_org.return_value = [("REPO_1",)] + lookup.branch_node_ids_for_org.return_value = [ + ("REPO_1", "BRANCH_1"), + ("REPO_2", "BRANCH_2"), + ] + access._lookup = lookup + + edges = list(access.edges) + + assert [edge.kind for edge in edges] == [ + ek.IS_ELIGIBLE_FOR, + ek.CAN_USE_RUNNER, + ek.CAN_USE_RUNNER, + ] + assert [(edge.start.value, edge.end.value) for edge in edges] == [ + ("REPO_1", "ORG_1_runner_group_1"), + ("REPO_1", "ORG_1_runner_group_1"), + ("BRANCH_1", "ORG_1_runner_group_1"), + ] + assert edges[0].properties.traversable is False + assert all(edge.properties.traversable is True for edge in edges[1:]) + + +def test_org_runner_group_access_does_not_emit_use_edges_when_actions_disabled() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Selected", + runner_group_visibility="selected", + restricted_to_workflows=False, + accessible_repo_node_ids=["REPO_1"], + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.actions_enabled_repository_node_ids_for_org.return_value = [] + lookup.branch_node_ids_for_org.return_value = [("REPO_1", "BRANCH_1")] + access._lookup = lookup + + edges = list(access.edges) + + assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] + lookup.branch_node_ids_for_org.assert_not_called() + + +def test_inherited_org_runner_group_access_requires_enterprise_workflow_policy_to_be_open() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Selected", + runner_group_visibility="selected", + restricted_to_workflows=False, + inherited=True, + accessible_repo_node_ids=["REPO_1"], + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group.return_value = ( + True + ) + access._lookup = lookup + + edges = list(access.edges) + + assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group.assert_called_once_with( + "ORG_1", "Selected" + ) + lookup.actions_enabled_repository_node_ids_for_org.assert_not_called() + lookup.branch_node_ids_for_org.assert_not_called() + + +def test_org_runner_group_access_all_visibility_emits_traversable_create_access_for_members_with_creation_capability() -> None: access = OrgRunnerGroupAccess( runner_group_id=1, runner_group_name="Default", runner_group_visibility="all", allows_public_repositories=True, + restricted_to_workflows=False, org_login="acme", ) lookup = MagicMock() lookup.org_id_for_login.return_value = "ORG_1" lookup.repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "all" lookup.members_can_create_repository.return_value = (True, False, False, False) access._lookup = lookup @@ -268,12 +345,15 @@ def test_org_runner_group_access_all_visibility_emits_latent_access_for_members_ ] assert [edge.start.value for edge in edges] == ["ORG_1_owners", "ORG_1_members"] assert {edge.end.value for edge in edges} == {"ORG_1_runner_group_1"} + assert all(edge.properties.traversable is True for edge in edges) query = edges[1].properties.query_composition assert "GH_CanCreateRepositories" in query assert "GH_CanCreatePublicRepositories" in query assert "GH_CanCreateInternalRepositories" in query assert "GH_CanCreatePrivateRepositories" in query assert "GH_Contains" in query + assert "org.actions_enabled_repositories = 'all'" in query + assert "coalesce(group.restricted_to_workflows, true) = false" in query def test_org_runner_group_access_without_public_access_requires_private_or_internal_creation() -> None: @@ -282,11 +362,13 @@ def test_org_runner_group_access_without_public_access_requires_private_or_inter runner_group_name="Default", runner_group_visibility="all", allows_public_repositories=False, + restricted_to_workflows=False, org_login="acme", ) lookup = MagicMock() lookup.org_id_for_login.return_value = "ORG_1" lookup.private_repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "all" lookup.members_can_create_repository.return_value = (True, True, False, False) access._lookup = lookup @@ -296,6 +378,7 @@ def test_org_runner_group_access_without_public_access_requires_private_or_inter ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS ] assert edges[0].start.value == "ORG_1_owners" + assert edges[0].properties.traversable is True query = edges[0].properties.query_composition assert "GH_CanCreateInternalRepositories" in query assert "GH_CanCreatePrivateRepositories" in query @@ -309,11 +392,13 @@ def test_org_runner_group_access_private_visibility_requires_private_or_internal runner_group_name="Private", runner_group_visibility="private", allows_public_repositories=True, + restricted_to_workflows=False, org_login="acme", ) lookup = MagicMock() lookup.org_id_for_login.return_value = "ORG_1" lookup.private_repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "all" lookup.members_can_create_repository.return_value = (False, False, True, False) access._lookup = lookup @@ -324,6 +409,7 @@ def test_org_runner_group_access_private_visibility_requires_private_or_internal ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, ] assert [edge.start.value for edge in edges] == ["ORG_1_owners", "ORG_1_members"] + assert all(edge.properties.traversable is True for edge in edges) query = edges[1].properties.query_composition assert "GH_CanCreateInternalRepositories" in query assert "GH_CanCreatePrivateRepositories" in query @@ -348,6 +434,56 @@ def test_org_runner_group_access_selected_visibility_does_not_emit_latent_access assert list(access.edges) == [] +def test_org_runner_group_access_does_not_emit_create_access_when_new_repositories_do_not_have_actions_enabled() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Default", + runner_group_visibility="all", + allows_public_repositories=True, + restricted_to_workflows=False, + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "selected" + lookup.members_can_create_repository.return_value = (True, True, True, True) + access._lookup = lookup + + assert list(access.edges) == [] + + +def test_inherited_org_runner_group_create_access_requires_enterprise_workflow_policy_to_be_open() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Default", + runner_group_visibility="all", + allows_public_repositories=True, + restricted_to_workflows=False, + inherited=True, + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "all" + lookup.members_can_create_repository.return_value = (False, False, False, False) + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group.return_value = ( + False + ) + access._lookup = lookup + + edges = list(access.edges) + + assert [edge.kind for edge in edges] == [ + ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS + ] + assert edges[0].properties.traversable is True + query = edges[0].properties.query_composition + assert "GH_InheritedFrom" in query + assert "coalesce(enterprise_group.restricted_to_workflows, true) = false" in query + + def test_native_org_runner_group_membership_emits_structural_and_capability_edges() -> None: membership = OrgRunnerGroupMembership( runner_group_id=1, @@ -385,6 +521,17 @@ def test_enterprise_organization_lookup_filters_to_enterprise() -> None: ] +def test_actions_enabled_repositories_lookup_returns_org_policy() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.organizations (login VARCHAR, actions_enabled_repositories VARCHAR)" + ) + connection.execute("INSERT INTO github.organizations VALUES ('acme', 'all')") + + assert GithubLookup(connection).actions_enabled_repositories_for_org("acme") == "all" + + def test_inherited_org_runner_group_lookup_resolves_all_and_selected_assignments() -> None: connection = duckdb.connect(":memory:") connection.execute("CREATE SCHEMA github") @@ -392,7 +539,7 @@ def test_inherited_org_runner_group_lookup_resolves_all_and_selected_assignments "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" ) connection.execute( - "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, enterprise_node_id VARCHAR)" + "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, restricted_to_workflows BOOLEAN, enterprise_node_id VARCHAR)" ) connection.execute( "CREATE TABLE github.enterprise_runner_group_organizations (node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" @@ -404,7 +551,7 @@ def test_inherited_org_runner_group_lookup_resolves_all_and_selected_assignments "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1')" ) connection.execute( - "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', 'ENT_1'), (2, 'Selected', 'selected', 'ENT_1')" + "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', false, 'ENT_1'), (2, 'Selected', 'selected', true, 'ENT_1')" ) connection.execute( "INSERT INTO github.enterprise_runner_group_organizations VALUES ('ORG_1', 2, 'ENT_1')" @@ -427,6 +574,18 @@ def test_inherited_org_runner_group_lookup_resolves_all_and_selected_assignments ) == "ENT_1_runner_group_2" ) + assert ( + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( + "ORG_1", "Default" + ) + is False + ) + assert ( + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( + "ORG_1", "Selected" + ) + is True + ) assert lookup.enterprise_runner_node_ids_for_inherited_org_group( "ORG_1", "Default" ) == [("ENT_1_runner_9",)] @@ -442,7 +601,7 @@ def test_inherited_org_runner_group_lookup_skips_missing_and_ambiguous_matches() "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" ) connection.execute( - "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, enterprise_node_id VARCHAR)" + "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, restricted_to_workflows BOOLEAN, enterprise_node_id VARCHAR)" ) connection.execute( "CREATE TABLE github.enterprise_runner_group_organizations (node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" @@ -451,7 +610,7 @@ def test_inherited_org_runner_group_lookup_skips_missing_and_ambiguous_matches() "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1'), ('ORG_1', 'ENT_2')" ) connection.execute( - "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', 'ENT_1'), (2, 'Default', 'all', 'ENT_2')" + "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', false, 'ENT_1'), (2, 'Default', 'all', false, 'ENT_2')" ) lookup = GithubLookup(connection) diff --git a/tests/test_runner_resources.py b/tests/test_runner_resources.py index 4921b8f..f829795 100644 --- a/tests/test_runner_resources.py +++ b/tests/test_runner_resources.py @@ -58,6 +58,7 @@ def test_org_runner_group_access_collects_selected_repository_policy() -> None: "runner_group_name": "Default", "runner_group_visibility": "selected", "allows_public_repositories": False, + "restricted_to_workflows": None, "inherited": True, "accessible_repo_node_ids": ["REPO_1", "REPO_2"], "org_login": "acme", From 672df5da710c5a5b4d8ef8f2c87fcbf78a9aa92b Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 19 Aug 2026 15:12:02 -0700 Subject: [PATCH 4/4] BED-9377: add runner access policy regression coverage Add negative coverage for inherited runner groups whose enterprise workflow policy remains restricted, ensuring GH_CanCreateRepositoryWithRunnerAccess is not emitted in that case. Update the runner-group resource fixture to assert restricted_to_workflows is forwarded from collected group data instead of only exercising the compatibility fallback. --- tests/test_runner_models.py | 24 ++++++++++++++++++++++++ tests/test_runner_resources.py | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index 74dbd0c..2418bc4 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -484,6 +484,30 @@ def test_inherited_org_runner_group_create_access_requires_enterprise_workflow_p assert "coalesce(enterprise_group.restricted_to_workflows, true) = false" in query +def test_inherited_org_runner_group_create_access_is_not_emitted_when_enterprise_workflow_policy_is_restricted() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Default", + runner_group_visibility="all", + allows_public_repositories=True, + restricted_to_workflows=False, + inherited=True, + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.repository_node_ids_for_org.return_value = [] + lookup.actions_enabled_repositories_for_org.return_value = "all" + lookup.members_can_create_repository.return_value = (False, False, False, False) + lookup.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group.return_value = ( + True + ) + access._lookup = lookup + + assert list(access.edges) == [] + lookup.actions_enabled_repositories_for_org.assert_not_called() + + def test_native_org_runner_group_membership_emits_structural_and_capability_edges() -> None: membership = OrgRunnerGroupMembership( runner_group_id=1, diff --git a/tests/test_runner_resources.py b/tests/test_runner_resources.py index f829795..5aa888e 100644 --- a/tests/test_runner_resources.py +++ b/tests/test_runner_resources.py @@ -46,6 +46,7 @@ def test_org_runner_group_access_collects_selected_repository_policy() -> None: name="Default", visibility="selected", allows_public_repositories=False, + restricted_to_workflows=True, inherited=True, org_login="acme", ) @@ -58,7 +59,7 @@ def test_org_runner_group_access_collects_selected_repository_policy() -> None: "runner_group_name": "Default", "runner_group_visibility": "selected", "allows_public_repositories": False, - "restricted_to_workflows": None, + "restricted_to_workflows": True, "inherited": True, "accessible_repo_node_ids": ["REPO_1", "REPO_2"], "org_login": "acme",