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_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 24bee42..601483a 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. +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. -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. +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. + +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_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..393056a --- /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 as GH_Repository or GH_Branch -[:GH_CanUseRunner]-> GH_OrgRunnerGroup -[:GH_InheritedFrom]-> GH_EnterpriseRunnerGroup -[:GH_HasRunner]-> GH_EnterpriseRunner. 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/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 e42e6c7..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 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 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_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..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 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 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 07ea2eb..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. 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_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 9e6bc02..add2562 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,13 +967,18 @@ }, { "name": "GH_CanUseRunner", - "description": "Repository can dispatch jobs to this self-hosted runner", + "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 14b8ea9..99c3d8f 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,12 +11,14 @@ 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" # 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/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..9efe4f7 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_IsEligibleFor]->(: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_IsEligibleFor]->(: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", ), ) @@ -478,25 +446,32 @@ 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", + start=nk.REPOSITORY, + end=nk.ORG_RUNNER_GROUP, + 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.ENTERPRISE_RUNNER, + end=nk.ORG_RUNNER_GROUP, kind=ek.CAN_USE_RUNNER, - description="Repository can dispatch jobs to inherited enterprise runner", - traversable=False, + 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, ), ], ) @@ -507,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) @@ -559,62 +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" ) - def _inherited_can_use_runner_query( - self, repository_node_id: str, runner_node_id: str - ) -> str: + @property + 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 ( - 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" + self._workflow_policy_allows_repository_dispatch + and self._lookup.actions_enabled_repositories_for_org(self.org_login) + == "all" ) @property - def _grants_access_to_edges(self): + def _is_eligible_for_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.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 _inherited_can_use_runner_edges(self): - if not self.inherited: + def _can_use_runner_edges(self): + repository_node_ids = self._can_use_runner_repository_node_ids + if not repository_node_ids: return - runner_node_ids = ( - self._lookup.enterprise_runner_node_ids_for_inherited_org_group( - self.org_node_id, self.runner_group_name + 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), ) - ) - 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 - 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" @@ -623,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 @@ -638,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 @@ -648,8 +672,8 @@ 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._is_eligible_for_edges + yield from self._can_use_runner_edges yield from self._can_create_repository_with_runner_access_edges @@ -663,11 +687,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 +722,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 +732,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 +755,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 +792,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/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 3af844c..2418bc4 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,15 @@ 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 [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 def test_org_runner_group_access_all_visibility_excludes_public_repositories_when_disabled() -> None: @@ -232,21 +205,14 @@ 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 [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") lookup.repository_node_ids_for_org.assert_not_called() @@ -267,21 +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.GRANTS_ACCESS_TO] - assert edges[0].end.value == "REPO_PRIVATE" + 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 @@ -293,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: @@ -307,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 @@ -321,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 @@ -334,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 @@ -349,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 @@ -373,48 +434,99 @@ 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: - membership = OrgRunnerGroupMembership( +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_id=9, 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 = [("REPO_1",)] - membership._lookup = lookup + 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 - edges = list(membership.edges) + assert list(access.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 edges[1].start.value == "ORG_1_runner_group_1" - assert edges[1].end.value == "ORG_1_runner_9" +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) -def test_native_org_runner_group_membership_excludes_public_repositories_when_disabled() -> None: - membership = OrgRunnerGroupMembership( + 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_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_id=9, runner_group_visibility="all", - allows_public_repositories=False, + 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.private_repository_node_ids_for_org.return_value = [("REPO_PRIVATE",)] - membership._lookup = lookup + 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, + runner_group_name="Default", + runner_id=9, + runner_group_visibility="all", + org_login="acme", + ) + 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].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 [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" + assert edges[1].properties.traversable is True def test_enterprise_organization_lookup_filters_to_enterprise() -> None: @@ -433,6 +545,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") @@ -440,7 +563,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)" @@ -452,7 +575,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')" @@ -475,6 +598,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",)] @@ -490,7 +625,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)" @@ -499,7 +634,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..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,6 +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": True, "inherited": True, "accessible_repo_node_ids": ["REPO_1", "REPO_2"], "org_login": "acme",