From ecdfc5529af1286a78575a063d022e4bbff86597 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Mon, 7 Sep 2026 11:27:58 -0700 Subject: [PATCH 1/7] BED-9678: model workflow job environment OIDC capability --- .../edges/GH_CanRequestOIDCTokenFor.md | 7 + descriptions/nodes/GH_Environment.md | 2 + descriptions/nodes/GH_WorkflowJob.md | 2 +- extension/schema.json | 5 + src/openhound_github/kinds/edges.py | 1 + src/openhound_github/models/workflow_job.py | 35 +++++ tests/test_workflow_interception_path.py | 31 ++++- tests/test_workflow_model.py | 123 ++++++++++++++++++ 8 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 descriptions/edges/GH_CanRequestOIDCTokenFor.md diff --git a/descriptions/edges/GH_CanRequestOIDCTokenFor.md b/descriptions/edges/GH_CanRequestOIDCTokenFor.md new file mode 100644 index 0000000..c06a219 --- /dev/null +++ b/descriptions/edges/GH_CanRequestOIDCTokenFor.md @@ -0,0 +1,7 @@ +## General Information + +The traversable GH_CanRequestOIDCTokenFor edge represents that a GitHub Actions workflow job execution context can request a GitHub-signed OIDC token containing claims for its associated GitHub Environment. + +This edge is derived from the existing GH_DeploysTo relationship and the job's calculated `effective_github_token_permissions`. The collector emits it only when the job targets a statically resolved environment and its effective permissions include `id-token:write`. + +This is a capability edge, not evidence that the workflow has historically requested a token or contains an explicit OIDC-related step. Code executing in a job with `id-token:write` can request the token directly. diff --git a/descriptions/nodes/GH_Environment.md b/descriptions/nodes/GH_Environment.md index bdd3d45..bc4e4ef 100644 --- a/descriptions/nodes/GH_Environment.md +++ b/descriptions/nodes/GH_Environment.md @@ -3,3 +3,5 @@ Represents a GitHub Actions deployment environment configured on a repository. Environments can have protection rules including required reviewers, wait timers, administrator bypass behavior, and deployment branch policies. Repositories always contain their environments. When custom branch policies are configured, the environment also contains one or more GH_EnvironmentBranchPolicy nodes that describe which branches are allowed to deploy. Environment-scoped secrets and variables are modeled as child nodes of the environment and become available to workflow jobs that reference it. + +GH_CanRequestOIDCTokenFor edges from GH_WorkflowJob nodes identify jobs whose execution context can request a GitHub-signed OIDC token for this environment because their effective `GITHUB_TOKEN` permissions include `id-token:write`. diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index 95bf400..6d0a8ef 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -6,4 +6,4 @@ When the job has a statically resolvable self-hosted `runs-on` selector, GH_Runs When present, `job_permissions` captures the job-level `permissions` declaration from the workflow YAML. `effective_github_token_permissions` captures the calculated static `GITHUB_TOKEN` permissions after applying the repository default, workflow-level declaration, and job-level declaration. -GH_CanAccessSecret edges identify secrets statically referenced by the job's modeled steps or job-level `env` block that the job execution context can access. GH_CanInterceptJob edges from GH_Runner nodes not explicitly marked ephemeral identify jobs whose future execution context may be exposed if that runner is controlled. +GH_CanAccessSecret edges identify secrets statically referenced by the job's modeled steps or job-level `env` block that the job execution context can access. GH_CanInterceptJob edges from GH_Runner nodes not explicitly marked ephemeral identify jobs whose future execution context may be exposed if that runner is controlled. When a job targets an environment and its effective permissions include `id-token:write`, GH_CanRequestOIDCTokenFor identifies the environment OIDC context that code executing in the job can request. diff --git a/extension/schema.json b/extension/schema.json index fa63a17..55c181b 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -1055,6 +1055,11 @@ "description": "[Computed] Workflow job execution context can access a statically referenced secret — GH_WorkflowJob → GH_RepoSecret / GH_OrgSecret / GH_EnvironmentSecret", "is_traversable": true }, + { + "name": "GH_CanRequestOIDCTokenFor", + "description": "[Computed] Workflow job execution context can request a GitHub OIDC token for its deployment environment — GH_WorkflowJob → GH_Environment", + "is_traversable": true + }, { "name": "GH_UsesVariable", "description": "[Workflow] Job or step references a variable by name — GH_WorkflowJob / GH_WorkflowStep → GH_RepoVariable / GH_OrgVariable / GH_EnvironmentVariable (scope match)", diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index da2a377..986f513 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -21,6 +21,7 @@ RUNS_ON = "GH_RunsOn" CAN_INTERCEPT_JOB = "GH_CanInterceptJob" CAN_ACCESS_SECRET = "GH_CanAccessSecret" +CAN_REQUEST_OIDC_TOKEN_FOR = "GH_CanRequestOIDCTokenFor" IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 4b48ec6..f5a8b2d 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -198,6 +198,13 @@ class GHWorkflowJobProperties(GHNodeProperties): description="Workflow job execution context can access environment secret", traversable=True, ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ENVIRONMENT, + kind=ek.CAN_REQUEST_OIDC_TOKEN_FOR, + description="Workflow job execution context can request an OIDC token for environment", + traversable=True, + ), ], ) class WorkflowJob(BaseAsset): @@ -435,6 +442,33 @@ def _environment_edges(self): properties=EdgeProperties(traversable=False), ) + def _can_request_oidc_token_for_query(self) -> str: + return ( + f"MATCH p=(job:GH_WorkflowJob {{node_id:'{self.node_id}'}})" + "-[:GH_DeploysTo]->(:GH_Environment) " + "WHERE 'id-token:write' IN job.effective_github_token_permissions " + "RETURN p" + ) + + @property + def _can_request_oidc_token_for_edges(self): + if "id-token:write" not in ( + self.calculated_effective_github_token_permissions or [] + ): + return + + for environment_edge in self._environment_edges: + yield Edge( + kind=ek.CAN_REQUEST_OIDC_TOKEN_FOR, + start=environment_edge.start, + end=environment_edge.end, + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=self._can_request_oidc_token_for_query(), + ), + ) + @property def _calls_workflows_edge(self): if self.uses_reusable and self.uses_reusable.startswith("./.github/workflows/"): @@ -573,3 +607,4 @@ def edges(self): yield from self._runs_on_edges yield from self._can_intercept_job_edges yield from self._can_access_secret_edges + yield from self._can_request_oidc_token_for_edges diff --git a/tests/test_workflow_interception_path.py b/tests/test_workflow_interception_path.py index 1637ed3..2b39115 100644 --- a/tests/test_workflow_interception_path.py +++ b/tests/test_workflow_interception_path.py @@ -81,6 +81,10 @@ def _cross_org_enterprise_runner_lookup() -> GithubLookup: "CREATE TABLE github.environment_secrets " "(name VARCHAR, repository_node_id VARCHAR, environment_name VARCHAR)" ) + connection.execute( + "CREATE TABLE github.environments " + "(name VARCHAR, repository_node_id VARCHAR)" + ) connection.execute( "INSERT INTO github.organizations VALUES " "('attacker', 'ORG_A'), ('victim', 'ORG_B')" @@ -122,6 +126,9 @@ def _cross_org_enterprise_runner_lookup() -> GithubLookup: connection.execute( "INSERT INTO github.repository_secrets VALUES ('DEPLOY_TOKEN', 'REPO_B')" ) + connection.execute( + "INSERT INTO github.environments VALUES ('prod', 'REPO_B')" + ) return GithubLookup(connection) @@ -171,6 +178,8 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: repository_node_id="REPO_B", org_login="victim", runs_on={"group": "enterprise-prod", "labels": ["self-hosted", "linux"]}, + environment="prod", + permissions={"id-token": "write"}, ) victim_job._lookup = lookup victim_step = WorkflowStep( @@ -212,11 +221,21 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: contains_step = _find_edge(job_edges + step_edges, ek.CONTAINS, "JOB_B", "STEP_B") uses_secret = _find_edge(step_edges, ek.USES_SECRET, "STEP_B") can_access_secret = _find_edge(job_edges, ek.CAN_ACCESS_SECRET, "JOB_B") + can_request_oidc_token = _find_edge( + job_edges, ek.CAN_REQUEST_OIDC_TOKEN_FOR, "JOB_B" + ) assert [ edge.properties.traversable - for edge in [can_use, inherited_from, has_runner, can_intercept, can_access_secret] - ] == [True, True, True, True, True] + for edge in [ + can_use, + inherited_from, + has_runner, + can_intercept, + can_access_secret, + can_request_oidc_token, + ] + ] == [True, True, True, True, True, True] assert runs_on.properties.traversable is False assert contains_step.properties.traversable is False assert uses_secret.properties.traversable is False @@ -229,6 +248,14 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: "name": "DEPLOY_TOKEN", "repository_id": "REPO_B", } + assert can_request_oidc_token.end.kind == nk.ENVIRONMENT + assert { + matcher.key: matcher.value + for matcher in can_request_oidc_token.end.property_matchers + } == { + "name": "PROD", + "repository_id": "REPO_B", + } def test_inherited_runner_lookup_survives_upgraded_enterprise_runner_group_stub() -> None: diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index 775d324..0af7754 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -476,6 +476,128 @@ def test_workflow_job_emits_can_access_secret_edges_for_step_references() -> Non assert "GH_UsesSecret" in edges[0].properties.query_composition +def test_workflow_job_emits_can_request_oidc_token_for_environment_without_oidc_step() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ) + lookup = _org_reference_lookup() + lookup.environment.return_value = ("prod",) + job._lookup = lookup + + edges = list(job._can_request_oidc_token_for_edges) + + assert len(edges) == 1 + assert edges[0].kind == ek.CAN_REQUEST_OIDC_TOKEN_FOR + assert edges[0].start.value == "JOB_1" + assert edges[0].end.kind == nk.ENVIRONMENT + assert _matcher_values(edges[0]) == { + "repository_id": "REPO_1", + "name": "PROD", + } + assert edges[0].properties.traversable is True + assert edges[0].properties.composed is True + assert edges[0].properties.query_composition == ( + "MATCH p=(job:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_DeploysTo]->(:GH_Environment) " + "WHERE 'id-token:write' IN job.effective_github_token_permissions " + "RETURN p" + ) + + +def test_workflow_job_can_request_oidc_token_for_requires_permission_and_environment() -> None: + no_permission = WorkflowJob( + node_id="JOB_NO_PERMISSION", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"contents": "read"}, + ) + no_environment = WorkflowJob( + node_id="JOB_NO_ENVIRONMENT", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + permissions={"id-token": "write"}, + ) + lookup = _org_reference_lookup() + lookup.environment.return_value = ("prod",) + no_permission._lookup = lookup + no_environment._lookup = lookup + + assert list(no_permission._can_request_oidc_token_for_edges) == [] + assert list(no_environment._can_request_oidc_token_for_edges) == [] + + +def test_workflow_job_can_request_oidc_token_for_edges_are_per_job_and_idempotent() -> None: + jobs = [ + WorkflowJob( + node_id="JOB_1", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ), + WorkflowJob( + node_id="JOB_2", + name="publish", + job_key="publish", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ), + WorkflowJob( + node_id="JOB_3", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"contents": "read"}, + ), + ] + lookup = _org_reference_lookup() + lookup.environment.return_value = ("prod",) + for job in jobs: + job._lookup = lookup + + edges = [ + edge + for job in jobs + for edge in job.edges + if edge.kind == ek.CAN_REQUEST_OIDC_TOKEN_FOR + ] + + assert [(edge.start.value, _matcher_values(edge)) for edge in edges] == [ + ("JOB_1", {"repository_id": "REPO_1", "name": "PROD"}), + ("JOB_2", {"repository_id": "REPO_1", "name": "PROD"}), + ] + assert len(list(jobs[0]._can_request_oidc_token_for_edges)) == 1 + + def test_workflow_job_can_access_secret_edges_deduplicate_step_references() -> None: job = WorkflowJob( node_id="JOB_1", @@ -592,6 +714,7 @@ def _org_reference_lookup() -> MagicMock: lookup = MagicMock() lookup.org_id_for_login.return_value = ORG_NODE_ID lookup.repository_workflow_permissions.return_value = None + lookup.environment.return_value = None lookup.workflow_step_secret_reference_names.return_value = [] lookup.repo_secret.return_value = None lookup.org_secret.return_value = ("DEPLOY_TOKEN",) From d995ad7b39f30e65b87cc8d4bcff19bb19c4f9b7 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Mon, 7 Sep 2026 11:43:59 -0700 Subject: [PATCH 2/7] BED-9678: address workflow OIDC review feedback --- src/openhound_github/kinds/edges.py | 4 ++-- src/openhound_github/lookup.py | 23 +++++++++++++++++---- src/openhound_github/models/workflow_job.py | 7 +++++-- tests/test_repository_rulesets.py | 3 ++- tests/test_runner_models.py | 6 +++--- tests/test_workflow_interception_path.py | 10 ++++++++- tests/test_workflow_model.py | 18 ++++++++++------ tests/test_workflow_resources.py | 12 ++++++++++- 8 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 986f513..beeb949 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -20,8 +20,8 @@ CAN_USE_RUNNER = "GH_CanUseRunner" RUNS_ON = "GH_RunsOn" CAN_INTERCEPT_JOB = "GH_CanInterceptJob" -CAN_ACCESS_SECRET = "GH_CanAccessSecret" -CAN_REQUEST_OIDC_TOKEN_FOR = "GH_CanRequestOIDCTokenFor" +CAN_ACCESS_SECRET = "GH_CanAccessSecret" # noqa: S105 - OpenGraph edge kind +CAN_REQUEST_OIDC_TOKEN_FOR = "GH_CanRequestOIDCTokenFor" # noqa: S105 - OpenGraph edge kind IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 68beac1..7574be4 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -243,18 +243,21 @@ def _workflow_job_runner_matches( repository_visibility, actions_enabled = repository required_labels = {str(label).casefold() for label in labels} - matching_runners: list[tuple[str, bool | None]] = [] + matching_runners: list[tuple[int, str, bool | None]] = [] seen_runner_node_ids: set[str] = set() def add_matching_runner( - node_id: str, raw_labels: Any, ephemeral: bool | None + source_order: int, + node_id: str, + raw_labels: Any, + ephemeral: bool | None, ) -> None: if node_id in seen_runner_node_ids: return if not required_labels.issubset(self._runner_label_names(raw_labels)): return seen_runner_node_ids.add(node_id) - matching_runners.append((node_id, ephemeral)) + matching_runners.append((source_order, node_id, ephemeral)) if group_name is None: for runner_id, raw_labels, ephemeral in self._find_all_objects( @@ -266,6 +269,7 @@ def add_matching_runner( [repository_node_id], ): add_matching_runner( + 0, runner_node_id(repository_node_id, int(runner_id)), raw_labels, ephemeral, @@ -345,6 +349,7 @@ def add_matching_runner( [enterprise_node_id, enterprise_runner_group_id], ): add_matching_runner( + 2, runner_node_id(enterprise_node_id, int(runner_id)), raw_labels, ephemeral, @@ -364,12 +369,19 @@ def add_matching_runner( [org_login, runner_group_id], ): add_matching_runner( + 1, runner_node_id(self.org_id_for_login(org_login), int(runner_id)), raw_labels, ephemeral, ) - return matching_runners + return [ + (node_id, ephemeral) + for _source_order, node_id, ephemeral in sorted( + matching_runners, + key=lambda runner: (runner[0], runner[1]), + ) + ] @lru_cache def workflow_job_runner_node_ids( @@ -594,6 +606,9 @@ def repository_workflow_permissions( repository_can_approve_pull_request_reviews FROM {self.schema}.workflows WHERE repository_node_id = ? + ORDER BY + repository_default_workflow_permissions IS NULL, + repository_can_approve_pull_request_reviews IS NULL LIMIT 1 """, [repository_node_id], diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index f5a8b2d..2b86a44 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -426,7 +426,10 @@ def _dependency_edges(self): @property def _environment_edges(self): if self.environment and not TEMPLATE_RE.search(self.environment): - if self._lookup.environment(self.environment, self.repository_node_id): + persisted_environment_name = self._lookup.environment( + self.environment, self.repository_node_id + ) + if persisted_environment_name: yield Edge( kind=ek.DEPLOYS_TO, start=EdgePath(value=self.node_id, match_by="id"), @@ -436,7 +439,7 @@ def _environment_edges(self): PropertyMatch( key="repository_id", value=self.repository_node_id ), - PropertyMatch(key="name", value=self.environment.upper()), + PropertyMatch(key="name", value=persisted_environment_name), ], ), properties=EdgeProperties(traversable=False), diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index 17f00ad..9b6c09a 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -443,7 +443,8 @@ def test_repository_workflow_permissions_lookup_returns_collected_policy() -> No "CREATE TABLE github.workflows (repository_node_id VARCHAR, repository_default_workflow_permissions VARCHAR, repository_can_approve_pull_request_reviews BOOLEAN)" ) connection.execute( - "INSERT INTO github.workflows VALUES ('R_1', 'read', false), ('R_2', NULL, NULL)" + "INSERT INTO github.workflows VALUES " + "('R_1', NULL, NULL), ('R_1', 'read', false), ('R_2', NULL, NULL)" ) lookup = GithubLookup(connection) diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index f203329..f859437 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -65,9 +65,9 @@ def _workflow_runner_lookup() -> GithubLookup: ) connection.execute( """INSERT INTO github.org_runners VALUES - (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'acme'), (12, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"ARM64"}]', true, 'acme'), - (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', NULL, 'acme')""" + (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', NULL, 'acme'), + (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'acme')""" ) connection.execute( """INSERT INTO github.org_runner_group_access VALUES @@ -77,7 +77,7 @@ def _workflow_runner_lookup() -> GithubLookup: (4, 'enterprise-prod', 'selected', true, false, true, '["REPO_1"]', 'acme')""" ) connection.execute( - "INSERT INTO github.org_runner_group_memberships VALUES (1, 11, 'acme'), (1, 12, 'acme'), (1, 13, 'acme'), (2, 11, 'acme'), (2, 12, 'acme'), (3, 11, 'acme')" + "INSERT INTO github.org_runner_group_memberships VALUES (1, 13, 'acme'), (1, 12, 'acme'), (1, 11, 'acme'), (2, 12, 'acme'), (2, 11, 'acme'), (3, 11, 'acme')" ) connection.execute( "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1')" diff --git a/tests/test_workflow_interception_path.py b/tests/test_workflow_interception_path.py index 2b39115..0e3fbde 100644 --- a/tests/test_workflow_interception_path.py +++ b/tests/test_workflow_interception_path.py @@ -218,6 +218,7 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: can_intercept = _find_edge( job_edges, ek.CAN_INTERCEPT_JOB, "ENT_1_runner_31", "JOB_B" ) + deploys_to = _find_edge(job_edges, ek.DEPLOYS_TO, "JOB_B") contains_step = _find_edge(job_edges + step_edges, ek.CONTAINS, "JOB_B", "STEP_B") uses_secret = _find_edge(step_edges, ek.USES_SECRET, "STEP_B") can_access_secret = _find_edge(job_edges, ek.CAN_ACCESS_SECRET, "JOB_B") @@ -237,6 +238,7 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: ] ] == [True, True, True, True, True, True] assert runs_on.properties.traversable is False + assert deploys_to.properties.traversable is False assert contains_step.properties.traversable is False assert uses_secret.properties.traversable is False assert uses_secret.end.kind == nk.REPO_SECRET @@ -253,7 +255,13 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: matcher.key: matcher.value for matcher in can_request_oidc_token.end.property_matchers } == { - "name": "PROD", + "name": "prod", + "repository_id": "REPO_B", + } + assert { + matcher.key: matcher.value for matcher in deploys_to.end.property_matchers + } == { + "name": "prod", "repository_id": "REPO_B", } diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index 0af7754..e4105aa 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -489,18 +489,24 @@ def test_workflow_job_emits_can_request_oidc_token_for_environment_without_oidc_ permissions={"id-token": "write"}, ) lookup = _org_reference_lookup() - lookup.environment.return_value = ("prod",) + lookup.environment.return_value = "prod" job._lookup = lookup + environment_edges = list(job._environment_edges) edges = list(job._can_request_oidc_token_for_edges) + assert len(environment_edges) == 1 + assert _matcher_values(environment_edges[0]) == { + "repository_id": "REPO_1", + "name": "prod", + } assert len(edges) == 1 assert edges[0].kind == ek.CAN_REQUEST_OIDC_TOKEN_FOR assert edges[0].start.value == "JOB_1" assert edges[0].end.kind == nk.ENVIRONMENT assert _matcher_values(edges[0]) == { "repository_id": "REPO_1", - "name": "PROD", + "name": "prod", } assert edges[0].properties.traversable is True assert edges[0].properties.composed is True @@ -535,7 +541,7 @@ def test_workflow_job_can_request_oidc_token_for_requires_permission_and_environ permissions={"id-token": "write"}, ) lookup = _org_reference_lookup() - lookup.environment.return_value = ("prod",) + lookup.environment.return_value = "prod" no_permission._lookup = lookup no_environment._lookup = lookup @@ -580,7 +586,7 @@ def test_workflow_job_can_request_oidc_token_for_edges_are_per_job_and_idempoten ), ] lookup = _org_reference_lookup() - lookup.environment.return_value = ("prod",) + lookup.environment.return_value = "prod" for job in jobs: job._lookup = lookup @@ -592,8 +598,8 @@ def test_workflow_job_can_request_oidc_token_for_edges_are_per_job_and_idempoten ] assert [(edge.start.value, _matcher_values(edge)) for edge in edges] == [ - ("JOB_1", {"repository_id": "REPO_1", "name": "PROD"}), - ("JOB_2", {"repository_id": "REPO_1", "name": "PROD"}), + ("JOB_1", {"repository_id": "REPO_1", "name": "prod"}), + ("JOB_2", {"repository_id": "REPO_1", "name": "prod"}), ] assert len(list(jobs[0]._can_request_oidc_token_for_edges)) == 1 diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index 72e6c74..4d1a0a4 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -64,8 +64,18 @@ def _workflow_row(workflow_id: int, state: str = "active") -> dict: } +def _workflow_transformer_generator(): + """Return the raw workflow transformer generator for dlt 1.26.0 tests. + + dlt does not expose a public accessor for the wrapped generator. The + private pipe access stays isolated here so tests can preserve deferred() + invocation behavior without spreading that dependency. + """ + return inspect.unwrap(workflows._pipe.gen) + + def _collect_workflows(repo, ctx) -> list[dict]: - generator = inspect.unwrap(workflows._pipe.gen) + generator = _workflow_transformer_generator() return [deferred() for deferred in generator(repo, ctx)] From d2bcb853205608d9aa3699e6a51a617eee4b51c9 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Mon, 7 Sep 2026 20:52:15 -0700 Subject: [PATCH 3/7] BED-9679: normalize credential permission metadata --- descriptions/nodes/GH_AppInstallation.md | 4 +- descriptions/nodes/GH_PersonalAccessToken.md | 4 +- .../nodes/GH_PersonalAccessTokenRequest.md | 4 +- .../t0-app-installations-all-repos.json | 2 +- .../t0-apps-all-repos.json | 2 +- .../t0-pats-all-repos.json | 2 +- .../models/app_installation.py | 7 +- src/openhound_github/models/permissions.py | 18 +++ .../models/personal_access_token.py | 22 ++-- .../models/personal_access_token_request.py | 14 ++- src/openhound_github/models/workflow.py | 17 +-- src/openhound_github/models/workflow_job.py | 2 +- tests/test_credential_permission_models.py | 112 ++++++++++++++++++ 13 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 src/openhound_github/models/permissions.py create mode 100644 tests/test_credential_permission_models.py diff --git a/descriptions/nodes/GH_AppInstallation.md b/descriptions/nodes/GH_AppInstallation.md index cc6bd09..ca2870a 100644 --- a/descriptions/nodes/GH_AppInstallation.md +++ b/descriptions/nodes/GH_AppInstallation.md @@ -1,5 +1,7 @@ ## Description -Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the app are captured as a JSON string in the properties. +Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property. + +Unlike fine-grained personal access tokens, GitHub does not expose separate organization and repository permission buckets for app installations, so this property remains a single flat permission list. Each installation is linked to its parent GH_App via a GH_InstalledAs edge. For installations with `repository_selection` set to `all`, GH_CanAccess edges are created to every repository in the organization. For installations with `repository_selection` set to `selected`, repository-level edges cannot be enumerated with a PAT (requires app installation token authentication). diff --git a/descriptions/nodes/GH_PersonalAccessToken.md b/descriptions/nodes/GH_PersonalAccessToken.md index fc88170..93ec580 100644 --- a/descriptions/nodes/GH_PersonalAccessToken.md +++ b/descriptions/nodes/GH_PersonalAccessToken.md @@ -1,3 +1,5 @@ ## Description -Represents a fine-grained personal access token that has been granted access to organization resources. PATs are linked to their owning user, the organization, and the repositories they can access. The permissions granted to the token are captured as a JSON string in the properties. +Represents a fine-grained personal access token that has been granted access to organization resources. PATs are linked to their owning user, the organization, and the repositories they can access. + +The granted permissions are stored separately as `organization_permissions` and `repository_permissions`. Each property is a list of `scope:access` values such as `members:read` or `contents:write`, matching the permission format used on GH_WorkflowJob nodes. diff --git a/descriptions/nodes/GH_PersonalAccessTokenRequest.md b/descriptions/nodes/GH_PersonalAccessTokenRequest.md index 4078e6f..0bc4805 100644 --- a/descriptions/nodes/GH_PersonalAccessTokenRequest.md +++ b/descriptions/nodes/GH_PersonalAccessTokenRequest.md @@ -1,3 +1,5 @@ ## Description -Represents a pending request from an organization member to access organization resources with a fine-grained personal access token. PAT requests are linked to their owning user and the organization. The requested permissions are captured as a JSON string in the properties. +Represents a pending request from an organization member to access organization resources with a fine-grained personal access token. PAT requests are linked to their owning user and the organization. + +The requested permissions are stored separately as `organization_permissions` and `repository_permissions`. Each property is a list of `scope:access` values such as `members:read` or `contents:write`, matching the permission format used on GH_WorkflowJob nodes. diff --git a/extension/privilege_zone_rules/t0-app-installations-all-repos.json b/extension/privilege_zone_rules/t0-app-installations-all-repos.json index 0ef7811..416d252 100644 --- a/extension/privilege_zone_rules/t0-app-installations-all-repos.json +++ b/extension/privilege_zone_rules/t0-app-installations-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero App Installations (All Repositories)", "description": "GitHub App installations scoped to all repositories in the organization that have at least one write permission. A compromised app credential grants write access to every repository. Installations with only read permissions are excluded — they pose a data exfiltration risk but do not grant control over the organization.", - "cypher": "MATCH (n:GH_AppInstallation {repository_selection:'all'})\nWHERE n.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN n.permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/privilege_zone_rules/t0-apps-all-repos.json b/extension/privilege_zone_rules/t0-apps-all-repos.json index 040e7e8..19f6bac 100644 --- a/extension/privilege_zone_rules/t0-apps-all-repos.json +++ b/extension/privilege_zone_rules/t0-apps-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero Apps (All-Repository Installations)", "description": "GitHub App definitions whose installations have write access to all repositories. The app owner controls the private key that can generate tokens for any installation. Compromise of the app's private key grants write access to every repository in organizations where it is installed. Apps whose installations have only read permissions are excluded.", - "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation {repository_selection:'all'})\nWHERE i.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN i.permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/privilege_zone_rules/t0-pats-all-repos.json b/extension/privilege_zone_rules/t0-pats-all-repos.json index 7b3deca..b3bcfa8 100644 --- a/extension/privilege_zone_rules/t0-pats-all-repos.json +++ b/extension/privilege_zone_rules/t0-pats-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero PATs (All Repositories)", "description": "Fine-grained personal access tokens scoped to all repositories in the organization that have at least one write permission. A single compromised token grants write access to every repository. PATs with only read permissions are excluded — they pose a data exfiltration risk but do not grant control over the organization.", - "cypher": "MATCH (n:GH_PersonalAccessToken {repository_selection:'all'})\nWHERE n.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_PersonalAccessToken {repository_selection:'all'})\nWHERE ANY(permission IN n.repository_permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index c8344cc..1ce5c55 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -12,6 +12,7 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.models.permissions import normalize_permission_declaration @dataclass @@ -28,7 +29,7 @@ class GHAppInstallationProperties(GHNodeProperties): repositories_url: API URL to list repositories accessible to this installation. repository_selection: Whether the app has access to `all` repositories or `selected` repositories. target_type: The target type of the installation (e.g., `Organization`). - permissions: JSON string of the permissions granted to the app (e.g., `{"contents": "read", "metadata": "read"}`). + permissions: Permissions granted to the installation in `scope:access` form. events: JSON string of the webhook events the app subscribes to. created_at: When the app was installed. updated_at: When the installation was last updated. @@ -47,7 +48,7 @@ class GHAppInstallationProperties(GHNodeProperties): repositories_url: str | None = None repository_selection: str | None = None target_type: str | None = None - permissions: str | None = None + permissions: list[str] | None = None events: str | None = None created_at: datetime | None = None updated_at: datetime | None = None @@ -141,7 +142,7 @@ def as_node(self) -> GHNode: repositories_url=self.repositories_url, repository_selection=self.repository_selection, target_type=self.target_type, - permissions=json.dumps(self.permissions) if self.permissions else None, + permissions=normalize_permission_declaration(self.permissions), events=json.dumps(self.events) if self.events else None, created_at=self.created_at, updated_at=self.updated_at, diff --git a/src/openhound_github/models/permissions.py b/src/openhound_github/models/permissions.py new file mode 100644 index 0000000..b41a416 --- /dev/null +++ b/src/openhound_github/models/permissions.py @@ -0,0 +1,18 @@ +from typing import Any + + +def normalize_permission_declaration(value: Any) -> list[str] | None: + """Normalize GitHub permission payloads into query-friendly scope:access values.""" + if value is None: + return None + + if isinstance(value, str): + return [value] + + if isinstance(value, list): + return [str(item) for item in value] + + if isinstance(value, dict): + return [f"{key!s}:{item!s}" for key, item in value.items()] + + return [str(value)] diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index 5d2e7b5..ab0f35b 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import ClassVar -from dlt.common import json from dlt.common.libs.pydantic import DltConfig from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -12,6 +11,7 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.models.permissions import normalize_permission_declaration class Permissions(BaseModel): @@ -35,8 +35,8 @@ class GHPersonalAccessTokenProperties(GHNodeProperties): owner_id: The GitHub ID of the token owner. owner_node_id: The GraphQL node ID of the token owner. token_expires_at: The ISO 8601 timestamp of when the token expires. - organization_permissions: JSON string of the PAT's organization-scoped permissions. - repository_permissions: JSON string of the PAT's repository-scoped permissions. + organization_permissions: Organization-scoped permissions in `scope:access` form. + repository_permissions: Repository-scoped permissions in `scope:access` form. token_last_used_at: The ISO 8601 timestamp of when the token was last used. access_granted_at: The ISO 8601 timestamp of when the token was granted to the organization. | token_name: The user-assigned display name of the token. @@ -55,8 +55,8 @@ class GHPersonalAccessTokenProperties(GHNodeProperties): token_expires_at: datetime | None = None token_last_used_at: datetime | None = None access_granted_at: datetime | None = None - organization_permissions: str | None = None - repository_permissions: str | None = None + organization_permissions: list[str] | None = None + repository_permissions: list[str] | None = None token_name: str | None = None owner_login: str | None = None repository_selection: str | None = None @@ -144,15 +144,11 @@ def as_node(self) -> GHNode: environment_name=self.org_login, token_expires_at=self.token_expires_at, owner_id=self.owner.id if self.owner else None, - organization_permissions=( - json.dumps(self.permissions.organization) - if self.permissions and self.permissions.organization - else None + organization_permissions=normalize_permission_declaration( + self.permissions.organization if self.permissions else None ), - repository_permissions=( - json.dumps(self.permissions.repository) - if self.permissions and self.permissions.repository - else None + repository_permissions=normalize_permission_declaration( + self.permissions.repository if self.permissions else None ), token_last_used_at=self.token_last_used_at, query_organization_permissions=f"MATCH p=(:GH_PersonalAccessToken {{node_id:'{pid}'}})-[:GH_CanAccess]->(:GH_Organization) RETURN p", diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index cfbc634..5e76c5e 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -9,6 +9,7 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.models.permissions import normalize_permission_declaration class Owner(BaseModel): @@ -29,19 +30,23 @@ class GHPersonalAccessTokenRequestProperties(GHNodeProperties): repository_selection: Whether the request targets `all`, `subset`, or `none` of the organization's repositories. reason: The rationale provided by the requester for the access request. org_name: The org name property. + organization_permissions: Requested organization-scoped permissions in `scope:access` form. + repository_permissions: Requested repository-scoped permissions in `scope:access` form. query_organization_permissions: Query for organization permissions. query_user: Query for user. query_repositories: Query for repositories. """ # TODO: Check for the following fields - # owner_id, owner_node_id, toke_id, token_expires_at, token_last_used_at, permissions, and environment_name + # owner_id, owner_node_id, toke_id, token_expires_at, token_last_used_at, and environment_name token_name: str | None = None owner_login: str | None = None repository_selection: str | None = None reason: str | None = None org_name: str | None = None + organization_permissions: list[str] | None = None + repository_permissions: list[str] | None = None query_organization_permissions: str | None = None query_user: str | None = None query_repositories: str | None = None @@ -102,6 +107,7 @@ def node_id(self) -> str: @property def as_node(self) -> GHNode: rid = self.node_id + permissions = self.permissions or {} return GHNode( kinds=[nk.PERSONAL_ACCESS_TOKEN_REQUEST], properties=GHPersonalAccessTokenRequestProperties( @@ -114,6 +120,12 @@ def as_node(self) -> GHNode: repository_selection=self.repository_selection, reason=self.reason, org_name=self.org_login, + organization_permissions=normalize_permission_declaration( + permissions.get("organization") + ), + repository_permissions=normalize_permission_declaration( + permissions.get("repository") + ), query_organization_permissions=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Organization) RETURN p", query_user=f"MATCH p=(:GH_User)-[:GH_HasPersonalAccessTokenRequest]->(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index 4c9c7e6..d8051b1 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -24,6 +24,7 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.models.permissions import normalize_permission_declaration class GithubActionsLoader(yaml.SafeLoader): @@ -124,22 +125,6 @@ class RunsOnSelector(BaseModel): WRITE_ONLY_GITHUB_TOKEN_PERMISSION_SCOPES = {"id-token"} -def normalize_permission_declaration(value: Any) -> list[str] | None: - if value is None: - return None - - if isinstance(value, str): - return [value] - - if isinstance(value, list): - return [str(item) for item in value] - - if isinstance(value, dict): - return [f"{key!s}:{item!s}" for key, item in value.items()] - - return [str(value)] - - def _empty_github_token_permissions() -> dict[str, str]: return {scope: "none" for scope in GITHUB_TOKEN_PERMISSION_SCOPES} diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 2b86a44..db6f325 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -21,8 +21,8 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.models.permissions import normalize_permission_declaration from openhound_github.models.workflow import ( - normalize_permission_declaration, parse_runs_on_selector, resolve_effective_github_token_permissions, ) diff --git a/tests/test_credential_permission_models.py b/tests/test_credential_permission_models.py new file mode 100644 index 0000000..6cdf39b --- /dev/null +++ b/tests/test_credential_permission_models.py @@ -0,0 +1,112 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from openhound_github.models.app_installation import AppInstallation +from openhound_github.models.personal_access_token import ( + Owner as PersonalAccessTokenOwner, + Permissions, + PersonalAccessToken, +) +from openhound_github.models.personal_access_token_request import ( + Owner as PersonalAccessTokenRequestOwner, + PersonalAccessTokenRequest, +) + + +def _lookup() -> MagicMock: + lookup = MagicMock() + lookup.org_id_for_login.return_value = "O_1" + return lookup + + +def test_personal_access_token_permissions_are_normalized_by_scope() -> None: + token = PersonalAccessToken( + id=1, + owner=PersonalAccessTokenOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + ), + permissions=Permissions( + organization={"members": "read"}, + repository={"contents": "write", "metadata": "read"}, + ), + token_id=1, + token_name="ci-token", + token_expired=False, + org_login="acme", + ) + token._lookup = _lookup() + + properties = token.as_node.properties + + assert properties.organization_permissions == ["members:read"] + assert properties.repository_permissions == ["contents:write", "metadata:read"] + + +def test_personal_access_token_request_permissions_are_normalized_by_scope() -> None: + request = PersonalAccessTokenRequest( + id=1, + owner=PersonalAccessTokenRequestOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + site_admin=False, + ), + token_name="requested-token", + token_expired=False, + permissions={ + "organization": {"members": "read"}, + "repository": {"contents": "write", "metadata": "read"}, + }, + org_login="acme", + ) + request._lookup = _lookup() + + properties = request.as_node.properties + + assert properties.organization_permissions == ["members:read"] + assert properties.repository_permissions == ["contents:write", "metadata:read"] + + +def test_personal_access_token_request_preserves_missing_permission_scope() -> None: + request = PersonalAccessTokenRequest( + id=1, + owner=PersonalAccessTokenRequestOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + site_admin=False, + ), + token_name="requested-token", + token_expired=False, + permissions={"repository": {"contents": "read"}}, + org_login="acme", + ) + request._lookup = _lookup() + + properties = request.as_node.properties + + assert properties.organization_permissions is None + assert properties.repository_permissions == ["contents:read"] + + +def test_app_installation_permissions_use_normalized_permission_shape() -> None: + installation = AppInstallation( + id=1, + repository_selection="all", + app_id=42, + target_type="Organization", + permissions={"contents": "write", "metadata": "read"}, + created_at=datetime(2026, 1, 1), + org_login="acme", + ) + installation._lookup = _lookup() + + assert installation.as_node.properties.permissions == [ + "contents:write", + "metadata:read", + ] From 159a2117990088b6fa7cc3f1b9fad5fd9348837b Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Mon, 7 Sep 2026 22:55:15 -0700 Subject: [PATCH 4/7] BED-9680: optimize environment collection --- descriptions/nodes/GH_Repository.md | 2 + src/openhound_github/graphql.py | 4 ++ src/openhound_github/lookup.py | 19 ++++++++ src/openhound_github/models/repository.py | 10 +++++ .../resources/organization.py | 17 ++++--- src/openhound_github/transforms.py | 8 +++- tests/test_environment_resources.py | 36 +++++++++++++++ tests/test_repository_rulesets.py | 45 ++++++++++++++++++- 8 files changed, 133 insertions(+), 8 deletions(-) diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index ed131e7..ed7b9c1 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -3,3 +3,5 @@ Represents a GitHub repository within the organization. Repository nodes capture metadata about the repo including visibility, Actions enablement status, and security configuration. Repository role nodes (GH_RepoRole) are created alongside each repository to represent the permission levels available. For repositories with active workflows, the collector records the applicable default workflow permissions and whether workflows may approve pull request reviews. These properties preserve the repository-level policy input later used to derive effective GITHUB_TOKEN permissions for GH_WorkflowJob nodes. + +The `branch_count` and `environment_count` properties preserve GitHub-reported totals from the repository GraphQL response. These values can be compared to collected GH_Branch and GH_Environment children to identify incomplete collection before relying on branch- or environment-dependent analysis. diff --git a/src/openhound_github/graphql.py b/src/openhound_github/graphql.py index cbd1d02..cd6ec6c 100644 --- a/src/openhound_github/graphql.py +++ b/src/openhound_github/graphql.py @@ -272,6 +272,7 @@ totalCount } refs(first: 100, refPrefix: "refs/heads/") { + totalCount nodes { id name @@ -280,6 +281,9 @@ } pageInfo { endCursor hasNextPage } } + environments(first: 1) { + totalCount + } } pageInfo { endCursor hasNextPage } } diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 7574be4..2a88a09 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -595,6 +595,25 @@ def repository_branch_ruleset_count(self, repository_node_id: str) -> int | None return None return int(row[0]) + @lru_cache + def repository_graphql_counts( + self, repository_node_id: str + ) -> tuple[int | None, int | None]: + row = self._find_single_row( + f""" + SELECT branch_count, environment_count + FROM {self.schema}.repositories_graphql + WHERE id = ? + """, + [repository_node_id], + ) + if row is None: + return None, None + return ( + None if row[0] is None else int(row[0]), + None if row[1] is None else int(row[1]), + ) + @lru_cache def repository_workflow_permissions( self, repository_node_id: str diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 1e57b8c..9f74fc8 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -46,6 +46,8 @@ class GHRepositoryProperties(GHNodeProperties): secret_scanning: Status of secret scanning (e.g., `enabled`, `disabled`). branch_ruleset_count: Number of branch-targeted rulesets that apply to this repository. has_branch_rulesets: Whether at least one branch-targeted ruleset applies to this repository. + branch_count: Number of branch refs reported by GitHub for this repository. + environment_count: Number of deployment environments reported by GitHub for this repository. query_branches: Query for branches. query_protected_branches: Query for protected branches. query_branch_protection_rules: Query for branch protection rules. @@ -94,6 +96,8 @@ class GHRepositoryProperties(GHNodeProperties): secret_scanning: str | None = None branch_ruleset_count: int | None = None has_branch_rulesets: bool | None = None + branch_count: int | None = None + environment_count: int | None = None query_branches: str | None = None query_protected_branches: str | None = None query_branch_protection_rules: str | None = None @@ -149,6 +153,7 @@ class Branch(BaseModel): class Ref(BaseModel): page_info: PageInfo = Field(alias="pageInfo") nodes: list[Branch] + total_count: int | None = Field(alias="totalCount", default=None) class RepositoryQL(BaseModel): @@ -158,6 +163,8 @@ class RepositoryQL(BaseModel): name: str refs: Ref branch_ruleset_count: int | None = None + branch_count: int | None = None + environment_count: int | None = None # Additional org_login: str @@ -230,6 +237,7 @@ def owner_name(self) -> str: def as_node(self) -> GHNode: rid = self.node_id branch_ruleset_count = self._lookup.repository_branch_ruleset_count(rid) + branch_count, environment_count = self._lookup.repository_graphql_counts(rid) workflow_permissions = self._lookup.repository_workflow_permissions(rid) default_workflow_permissions, can_approve_pull_request_reviews = ( workflow_permissions if workflow_permissions else (None, None) @@ -272,6 +280,8 @@ def as_node(self) -> GHNode: if branch_ruleset_count is not None else None ), + branch_count=branch_count, + environment_count=environment_count, # secret_scanning=self.secret_scanning, query_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Branch) RETURN p", query_protected_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Branch)<-[:GH_ProtectedBy]-(:GH_BranchProtectionRule) RETURN p", diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index cd9694c..a1da89f 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -1207,10 +1207,14 @@ def repositories_graphql(ctx: SourceContext): for repo in repos_page["nodes"]: repo_record = {**repo} branch_rulesets = repo_record.pop("branchRulesets", None) or {} + environments = repo_record.pop("environments", None) or {} + refs = repo_record.get("refs") or {} emitted_repositories += 1 yield { **repo_record, "branch_ruleset_count": branch_rulesets.get("totalCount"), + "branch_count": refs.get("totalCount"), + "environment_count": environments.get("totalCount"), "org_login": org_name, } @@ -1430,20 +1434,23 @@ def workflow_steps(workflow: Workflow): @app.transformer(name="environments", columns=Environment, parallelized=True) -def environments(repo: Repository, ctx: SourceContext): +def environments(repo: RepositoryQL, ctx: SourceContext): """Fetch deployment environments for a repository. Args: - repo (Repository): The repository to fetch environments for. + repo (RepositoryQL): Repository metadata with the GitHub-reported environment count. ctx (SourceContext): The shared context containing the REST client and organization name. Yields: Environment (Environment): Deployment environment record. """ - full_name = repo.full_name + if repo.environment_count == 0: + return + + full_name = f"{repo.org_login}/{repo.name}" repo_name = repo.name - repo_node_id = repo.node_id + repo_node_id = repo.id client = _client_for_org(ctx, repo.org_login) for page in client.paginate( f"/repos/{full_name}/environments", @@ -2317,12 +2324,12 @@ def organization_resources(ctx: SourceContext): repo_roles_base = RepositoryRoleCache(ctx) repos_resource = repositories(ctx) workflows_resource = repos_resource | workflows(ctx) - environments_resource = repos_resource | environments(ctx) personal_access_tokens_resource = personal_access_tokens(ctx) teams_resource = teams(ctx) team_external_groups_resource = team_external_groups(ctx) repositories_graphql_resource = repositories_graphql(ctx) + environments_resource = repositories_graphql_resource | environments(ctx) app_installs_resource = app_installations(ctx) runner_groups_resource = runner_groups(ctx) runner_group_access_resource = runner_groups_resource | org_runner_group_access(ctx) diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index b34cc05..5de5ef7 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -20,7 +20,9 @@ def ensure_optional_input_tables( ); CREATE TABLE IF NOT EXISTS {schema}.repositories_graphql ( id VARCHAR, - branch_ruleset_count BIGINT + branch_ruleset_count BIGINT, + branch_count BIGINT, + environment_count BIGINT ); CREATE TABLE IF NOT EXISTS {schema}.branch_protection_rules ( id VARCHAR, @@ -198,6 +200,10 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS id VARCHAR; ALTER TABLE {schema}.repositories_graphql ADD COLUMN IF NOT EXISTS branch_ruleset_count BIGINT; + ALTER TABLE {schema}.repositories_graphql + ADD COLUMN IF NOT EXISTS branch_count BIGINT; + ALTER TABLE {schema}.repositories_graphql + ADD COLUMN IF NOT EXISTS environment_count BIGINT; ALTER TABLE {schema}.branch_protection_rules ADD COLUMN IF NOT EXISTS id VARCHAR; diff --git a/tests/test_environment_resources.py b/tests/test_environment_resources.py index 65b30a9..efe56a5 100644 --- a/tests/test_environment_resources.py +++ b/tests/test_environment_resources.py @@ -5,6 +5,7 @@ from openhound_github.resources.organization import ( OrgContext, SourceContext, + environments, environment_branch_policies, environment_secrets, environment_variables, @@ -41,6 +42,41 @@ def _environment(name: str) -> SimpleNamespace: ) +def _repository(environment_count: int | None) -> SimpleNamespace: + return SimpleNamespace( + id="REPO_1", + name="repo", + org_login="acme", + environment_count=environment_count, + ) + + +@pytest.mark.parametrize("environment_count", [1, None]) +def test_environments_queries_when_repository_may_have_environments( + environment_count: int | None, +) -> None: + client = _FakeClient() + + rows = list(environments.__wrapped__(_repository(environment_count), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [ + ( + "/repos/acme/repo/environments", + {"params": {"per_page": 100}, "data_selector": "environments"}, + ) + ] + + +def test_environments_skips_query_when_repository_has_no_environments() -> None: + client = _FakeClient() + + rows = list(environments.__wrapped__(_repository(0), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [] + + @pytest.mark.parametrize( ("transformer", "suffix"), [ diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index 9b6c09a..eaace88 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -38,6 +38,8 @@ def _repository_page_data( repository_name: str, *, branch_ruleset_count: int | None = None, + branch_count: int | None = None, + environment_count: int | None = None, repository_end_cursor: str | None = None, repositories_has_next_page: bool = False, ) -> dict: @@ -54,12 +56,14 @@ def _repository_page_data( "name": repository_name, "branchRulesets": {"totalCount": branch_ruleset_count}, "refs": { + "totalCount": branch_count, "nodes": [], "pageInfo": { "endCursor": None, "hasNextPage": False, }, }, + "environments": {"totalCount": environment_count}, } ] } @@ -122,8 +126,18 @@ def _make_repository() -> Repository: ) -def test_repositories_graphql_flattens_branch_ruleset_count() -> None: - client = _FakeClient() +def test_repositories_graphql_flattens_repository_counts() -> None: + client = _FakeClient( + _graphql_response( + _repository_page_data( + "R_1", + "repo", + branch_ruleset_count=2, + branch_count=7, + environment_count=3, + ) + ) + ) ctx = SourceContext( client=client, organizations=[OrgContext(client=client, org_name="org")], @@ -136,10 +150,13 @@ def test_repositories_graphql_flattens_branch_ruleset_count() -> None: "id": "R_1", "name": "repo", "refs": { + "totalCount": 7, "nodes": [], "pageInfo": {"endCursor": None, "hasNextPage": False}, }, "branch_ruleset_count": 2, + "branch_count": 7, + "environment_count": 3, "org_login": "org", } ] @@ -390,6 +407,7 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: lookup = MagicMock() lookup.org_id_for_login.return_value = "O_1" lookup.repository_branch_ruleset_count.return_value = 2 + lookup.repository_graphql_counts.return_value = (7, 3) lookup.repository_workflow_permissions.return_value = ("read", False) repo._lookup = lookup @@ -397,10 +415,13 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: assert node.properties.branch_ruleset_count == 2 assert node.properties.has_branch_rulesets is True + assert node.properties.branch_count == 7 + assert node.properties.environment_count == 3 assert node.properties.default_workflow_permissions == "read" assert node.properties.can_approve_pull_request_reviews is False assert node.properties.size == 0 lookup.repository_branch_ruleset_count.assert_called_once_with("R_1") + lookup.repository_graphql_counts.assert_called_once_with("R_1") lookup.repository_workflow_permissions.assert_called_once_with("R_1") @@ -409,6 +430,7 @@ def test_repository_node_preserves_unknown_branch_ruleset_presence() -> None: lookup = MagicMock() lookup.org_id_for_login.return_value = "O_1" lookup.repository_branch_ruleset_count.return_value = None + lookup.repository_graphql_counts.return_value = (None, None) lookup.repository_workflow_permissions.return_value = None repo._lookup = lookup @@ -416,6 +438,8 @@ def test_repository_node_preserves_unknown_branch_ruleset_presence() -> None: assert node.properties.branch_ruleset_count is None assert node.properties.has_branch_rulesets is None + assert node.properties.branch_count is None + assert node.properties.environment_count is None assert node.properties.default_workflow_permissions is None assert node.properties.can_approve_pull_request_reviews is None @@ -436,6 +460,23 @@ def test_repository_branch_ruleset_count_lookup_returns_int() -> None: assert lookup.repository_branch_ruleset_count("R_2") is None +def test_repository_graphql_counts_lookup_returns_ints() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.repositories_graphql (id VARCHAR, branch_count BIGINT, environment_count BIGINT)" + ) + connection.execute( + "INSERT INTO github.repositories_graphql VALUES ('R_1', 7, 3), ('R_2', NULL, NULL)" + ) + + lookup = GithubLookup(connection) + + assert lookup.repository_graphql_counts("R_1") == (7, 3) + assert lookup.repository_graphql_counts("R_2") == (None, None) + assert lookup.repository_graphql_counts("R_3") == (None, None) + + def test_repository_workflow_permissions_lookup_returns_collected_policy() -> None: connection = duckdb.connect(":memory:") connection.execute("CREATE SCHEMA github") From 8ae249a84f7da78bb93adb0c7d1ebd627f713872 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 11:09:49 -0700 Subject: [PATCH 5/7] BED-9678: cover workflow permission cache isolation --- tests/test_workflow_resources.py | 62 ++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index 4d1a0a4..bbbf4be 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -13,8 +13,16 @@ def json(self) -> dict: class _FakeClient: - def __init__(self, workflow_pages: list[list[dict]]): + def __init__( + self, + workflow_pages: list[list[dict]], + *, + default_workflow_permissions: str = "read", + can_approve_pull_request_reviews: bool = False, + ): self.workflow_pages = workflow_pages + self.default_workflow_permissions = default_workflow_permissions + self.can_approve_pull_request_reviews = can_approve_pull_request_reviews self.get_calls: list[tuple[str, dict]] = [] self.paginate_calls: list[tuple[str, dict]] = [] @@ -27,8 +35,8 @@ def get(self, path: str, **kwargs): if path.endswith("/actions/permissions/workflow"): return _FakeResponse( { - "default_workflow_permissions": "read", - "can_approve_pull_request_reviews": False, + "default_workflow_permissions": self.default_workflow_permissions, + "can_approve_pull_request_reviews": self.can_approve_pull_request_reviews, } ) return _FakeResponse({"content": "am9iczoge30="}) @@ -44,6 +52,16 @@ def _repo() -> SimpleNamespace: ) +def _repo_for_org(org_login: str, node_id: str) -> SimpleNamespace: + return SimpleNamespace( + full_name=f"{org_login}/repo", + name="repo", + node_id=node_id, + org_login=org_login, + default_branch="main", + ) + + def _ctx(client: _FakeClient) -> SourceContext: return SourceContext( client=client, @@ -106,3 +124,41 @@ def test_workflows_cache_repository_permissions_for_active_workflows() -> None: assert [ path for path, _kwargs in client.get_calls if path.endswith("/permissions/workflow") ] == ["/repos/acme/repo/actions/permissions/workflow"] + + +def test_workflows_cache_repository_permissions_by_organization_and_repository() -> None: + acme_client = _FakeClient( + [[_workflow_row(1)]], + default_workflow_permissions="read", + can_approve_pull_request_reviews=False, + ) + other_client = _FakeClient( + [[_workflow_row(2)]], + default_workflow_permissions="write", + can_approve_pull_request_reviews=True, + ) + ctx = SourceContext( + client=acme_client, + organizations=[ + OrgContext(client=acme_client, org_name="acme"), + OrgContext(client=other_client, org_name="other"), + ], + ) + + acme_rows = _collect_workflows(_repo_for_org("acme", "REPO_1"), ctx) + other_rows = _collect_workflows(_repo_for_org("other", "REPO_2"), ctx) + + assert acme_rows[0]["repository_default_workflow_permissions"] == "read" + assert acme_rows[0]["repository_can_approve_pull_request_reviews"] is False + assert other_rows[0]["repository_default_workflow_permissions"] == "write" + assert other_rows[0]["repository_can_approve_pull_request_reviews"] is True + assert ctx.repository_workflow_permissions_cache == { + "acme/repo": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "other/repo": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + } From 41ee6c66977f8eabf4c5c85a53ba12349cc2a286 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 11:16:07 -0700 Subject: [PATCH 6/7] BED-9678: cover repository-specific workflow cache keys --- tests/test_workflow_resources.py | 55 ++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index bbbf4be..fc178c3 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -19,10 +19,12 @@ def __init__( *, default_workflow_permissions: str = "read", can_approve_pull_request_reviews: bool = False, + workflow_permission_responses: dict[str, dict] | None = None, ): self.workflow_pages = workflow_pages self.default_workflow_permissions = default_workflow_permissions self.can_approve_pull_request_reviews = can_approve_pull_request_reviews + self.workflow_permission_responses = workflow_permission_responses or {} self.get_calls: list[tuple[str, dict]] = [] self.paginate_calls: list[tuple[str, dict]] = [] @@ -33,6 +35,8 @@ def paginate(self, path: str, **kwargs): def get(self, path: str, **kwargs): self.get_calls.append((path, kwargs)) if path.endswith("/actions/permissions/workflow"): + if path in self.workflow_permission_responses: + return _FakeResponse(self.workflow_permission_responses[path]) return _FakeResponse( { "default_workflow_permissions": self.default_workflow_permissions, @@ -52,10 +56,12 @@ def _repo() -> SimpleNamespace: ) -def _repo_for_org(org_login: str, node_id: str) -> SimpleNamespace: +def _repo_for_org( + org_login: str, node_id: str, repository_name: str = "repo" +) -> SimpleNamespace: return SimpleNamespace( - full_name=f"{org_login}/repo", - name="repo", + full_name=f"{org_login}/{repository_name}", + name=repository_name, node_id=node_id, org_login=org_login, default_branch="main", @@ -162,3 +168,46 @@ def test_workflows_cache_repository_permissions_by_organization_and_repository() "can_approve_pull_request_reviews": True, }, } + + +def test_workflows_cache_repository_permissions_by_repository_within_organization() -> None: + client = _FakeClient( + [[_workflow_row(1)]], + workflow_permission_responses={ + "/repos/acme/repo/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "/repos/acme/other-repo/actions/permissions/workflow": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + }, + ) + ctx = _ctx(client) + + repo_rows = _collect_workflows(_repo_for_org("acme", "REPO_1"), ctx) + other_repo_rows = _collect_workflows( + _repo_for_org("acme", "REPO_2", repository_name="other-repo"), ctx + ) + + assert repo_rows[0]["repository_default_workflow_permissions"] == "read" + assert repo_rows[0]["repository_can_approve_pull_request_reviews"] is False + assert other_repo_rows[0]["repository_default_workflow_permissions"] == "write" + assert other_repo_rows[0]["repository_can_approve_pull_request_reviews"] is True + assert [ + path for path, _kwargs in client.get_calls if path.endswith("/permissions/workflow") + ] == [ + "/repos/acme/repo/actions/permissions/workflow", + "/repos/acme/other-repo/actions/permissions/workflow", + ] + assert ctx.repository_workflow_permissions_cache == { + "acme/repo": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "acme/other-repo": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + } From c07ce937d387344e1ef7c62a1b2380c433edc8cb Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 12:55:50 -0700 Subject: [PATCH 7/7] BED-9679: address workflow permission review feedback --- src/openhound_github/lookup.py | 15 +++++++--- src/openhound_github/models/workflow_job.py | 7 ++++- .../resources/organization.py | 14 +++++++-- tests/test_runner_models.py | 15 ++++++++++ tests/test_workflow_model.py | 29 +++++++++++++++++++ tests/test_workflow_resources.py | 23 +++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 7574be4..389b378 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -278,6 +278,16 @@ def add_matching_runner( if actions_enabled is not True: return [] + org_node_id = self.org_id_for_login(org_login) + if not org_node_id: + return [ + (node_id, ephemeral) + for _source_order, node_id, ephemeral in sorted( + matching_runners, + key=lambda runner: (runner[0], runner[1]), + ) + ] + for ( runner_group_id, runner_group_name, @@ -320,9 +330,6 @@ def add_matching_runner( continue if inherited: - org_node_id = self.org_id_for_login(org_login) - if not org_node_id: - continue if ( self.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( org_node_id, runner_group_name @@ -370,7 +377,7 @@ def add_matching_runner( ): add_matching_runner( 1, - runner_node_id(self.org_id_for_login(org_login), int(runner_id)), + runner_node_id(org_node_id, int(runner_id)), raw_labels, ephemeral, ) diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 213fb98..e07c4f3 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -34,6 +34,10 @@ TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") +def _escape_cypher_string(value: Any) -> str: + return str(value).replace("\\", "\\\\").replace("'", "\\'") + + @dataclass class GHWorkflowJobProperties(GHNodeProperties): """Workflow job-specific properties. @@ -542,7 +546,8 @@ def _can_access_secret_query( source: str, ) -> str: properties = ", ".join( - f"{matcher.key}:'{matcher.value}'" for matcher in property_matchers + f"{matcher.key}:'{_escape_cypher_string(matcher.value)}'" + for matcher in property_matchers ) if source == "job": return ( diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index eaf4a4d..8e150b4 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -240,9 +240,17 @@ def _repository_workflow_permissions( if cache_key not in ctx.repository_workflow_permissions_cache: with ctx.cache_lock: if cache_key not in ctx.repository_workflow_permissions_cache: - ctx.repository_workflow_permissions_cache[cache_key] = client.get( - f"/repos/{repository_full_name}/actions/permissions/workflow" - ).json() + try: + ctx.repository_workflow_permissions_cache[cache_key] = client.get( + f"/repos/{repository_full_name}/actions/permissions/workflow" + ).json() + except Exception as e: + logger.warning( + "Unable to fetch workflow permissions for repository '%s': %s", + repository_full_name, + e, + ) + ctx.repository_workflow_permissions_cache[cache_key] = {} return ctx.repository_workflow_permissions_cache[cache_key] diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index f859437..92c6bdb 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -176,6 +176,21 @@ def test_workflow_job_runner_lookup_returns_no_runners_when_actions_disabled() - ) +def test_workflow_job_runner_lookup_skips_org_runners_when_org_id_is_missing() -> None: + lookup = _workflow_runner_lookup() + lookup.client.execute("DELETE FROM github.organizations WHERE login = 'acme'") + + assert lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", None, ("self-hosted", "linux", "x64") + ) == ["REPO_1_runner_21"] + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "Default", ("self-hosted", "linux", "x64") + ) + == [] + ) + + def test_org_runner_group_keeps_generic_runner_group_label() -> None: group = OrgRunnerGroup(id=1, name="Default", org_login="acme") group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index e4105aa..d7282ce 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -476,6 +476,35 @@ def test_workflow_job_emits_can_access_secret_edges_for_step_references() -> Non assert "GH_UsesSecret" in edges[0].properties.query_composition +def test_workflow_job_can_access_secret_query_escapes_environment_matcher_values() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment=r"prod\east's", + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = ["ENV_TOKEN"] + lookup.org_secret.return_value = None + lookup.environment_secret_for_environment.return_value = ("ENV_TOKEN",) + job._lookup = lookup + + edge = next(job._can_access_secret_edges) + + assert edge.end.kind == nk.ENVIRONMENT_SECRET + assert edge.properties.query_composition == ( + "MATCH p=(:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_Contains]->(:GH_WorkflowStep)" + "-[:GH_UsesSecret]->(:GH_EnvironmentSecret " + "{name:'ENV_TOKEN', deployment_environment_name:'prod\\\\east\\'s', " + "repository_id:'REPO_1'}) RETURN p" + ) + + def test_workflow_job_emits_can_request_oidc_token_for_environment_without_oidc_step() -> None: job = WorkflowJob( node_id="JOB_1", diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index fc178c3..550f3e0 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -20,11 +20,13 @@ def __init__( default_workflow_permissions: str = "read", can_approve_pull_request_reviews: bool = False, workflow_permission_responses: dict[str, dict] | None = None, + workflow_permission_errors: set[str] | None = None, ): self.workflow_pages = workflow_pages self.default_workflow_permissions = default_workflow_permissions self.can_approve_pull_request_reviews = can_approve_pull_request_reviews self.workflow_permission_responses = workflow_permission_responses or {} + self.workflow_permission_errors = workflow_permission_errors or set() self.get_calls: list[tuple[str, dict]] = [] self.paginate_calls: list[tuple[str, dict]] = [] @@ -35,6 +37,8 @@ def paginate(self, path: str, **kwargs): def get(self, path: str, **kwargs): self.get_calls.append((path, kwargs)) if path.endswith("/actions/permissions/workflow"): + if path in self.workflow_permission_errors: + raise RuntimeError("workflow permissions unavailable") if path in self.workflow_permission_responses: return _FakeResponse(self.workflow_permission_responses[path]) return _FakeResponse( @@ -211,3 +215,22 @@ def test_workflows_cache_repository_permissions_by_repository_within_organizatio "can_approve_pull_request_reviews": True, }, } + + +def test_workflows_continue_when_repository_permission_lookup_fails() -> None: + permission_path = "/repos/acme/repo/actions/permissions/workflow" + client = _FakeClient( + [[_workflow_row(1)]], + workflow_permission_errors={permission_path}, + ) + ctx = _ctx(client) + + rows = _collect_workflows(_repo(), ctx) + _collect_workflows(_repo(), ctx) + + assert rows[0]["repository_default_workflow_permissions"] is None + assert rows[0]["repository_can_approve_pull_request_reviews"] is None + assert ctx.repository_workflow_permissions_cache == {"acme/repo": {}} + assert [ + path for path, _kwargs in client.get_calls if path == permission_path + ] == [permission_path]