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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion descriptions/edges/GH_ApprovesDeploymentTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ The non-traversable GH_ApprovesDeploymentTo edge represents that a user or team

This edge is emitted from GH_User or GH_Team nodes to GH_Environment nodes when the environment includes a required reviewer protection rule. Required reviewers act as an approval gate before jobs referencing the environment can continue.

The edge is non-traversable because it records reviewer configuration rather than direct deployment access. When self-review is allowed, the same reviewer may also receive a traversable GH_CanDeployToEnvironment edge because they can satisfy the approval gate themselves. When prevent_self_review is enabled, GH_ApprovesDeploymentTo remains context only because the split-principal approval flow is not currently modeled.
The edge is non-traversable because it records reviewer configuration rather than direct deployment access. When self-review is allowed, the same reviewer may also receive a traversable GH_CanDeployToEnvironment edge only if they can also supply deployable code through GH_CanCreateBranch or GH_CanWriteBranch under the environment's branch policy. When prevent_self_review is enabled, GH_ApprovesDeploymentTo remains context only because the split-principal approval flow is not currently modeled.
6 changes: 4 additions & 2 deletions descriptions/edges/GH_CanDeployToEnvironment.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
## General Information

The traversable GH_CanDeployToEnvironment edge represents the ability for a repository, branch, repository role, or self-approving reviewer to satisfy the modeled deployment constraints for a GitHub Environment.
The traversable GH_CanDeployToEnvironment edge represents the ability for a repository, branch, repository role, or reviewer to satisfy the modeled deployment constraints for a GitHub Environment.

This edge is computed from environment deployment branch policy, branch protection state, required reviewer behavior, and administrator bypass behavior. For environments without required reviewers, unrestricted environments emit repository and branch edges, protected-branch-only environments emit edges only for protected branches unless no branch protection rules exist, and custom branch policies emit edges only for matching branches.

When required reviewers are configured and self-review is allowed, the configured GH_User or GH_Team reviewer receives GH_CanDeployToEnvironment because that reviewer can satisfy the approval gate themselves. When prevent_self_review is enabled, no direct deploy edge is emitted for the reviewer because the required split-principal flow is not currently modeled. GH_ApprovesDeploymentTo remains non-traversable reviewer context in both cases.
When required reviewers are configured and self-review is allowed, a configured GH_User or GH_Team reviewer receives GH_CanDeployToEnvironment only when the same actor can also supply deployable code. For unrestricted environments this means the actor can create a branch in the repository. For protected-branch-only or custom branch policy environments this means the actor can write to an eligible branch under the existing GH_CanWriteBranch rules.

Self-review alone is not sufficient for this edge. GH_ApprovesDeploymentTo remains the non-traversable representation of reviewer authority, while GH_CanDeployToEnvironment represents the combined ability to satisfy both the approval gate and the code-supply path. When prevent_self_review is enabled, no direct deploy edge is emitted for the reviewer because the required split-principal flow is not currently modeled.
2 changes: 1 addition & 1 deletion extension/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@
},
{
"name": "GH_CanDeployToEnvironment",
"description": "[Computed] Repository, branch, repo role, or self-approving reviewer can deploy to this GitHub environment after evaluating deployment branch policy, reviewer gates, and admin bypass behavior",
"description": "[Computed] Repository, branch, repo role, or reviewer can deploy to this GitHub environment after evaluating deployment branch policy, reviewer gates, and admin bypass behavior; reviewer edges require both self-approval and a deployable code path",
"is_traversable": true
},
{
Expand Down
260 changes: 260 additions & 0 deletions src/openhound_github/lookup.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from functools import lru_cache

import duckdb
Expand Down Expand Up @@ -584,6 +585,265 @@ def branches_for_repository(self, repository_node_id: str):
[repository_node_id],
)

@lru_cache
def environment_branch_policy_names(self, environment_node_id: str):
return self._find_all_objects(
f"""
SELECT name
FROM {self.schema}.environment_branch_policies
WHERE environment_node_id = ?
""",
[environment_node_id],
)

@lru_cache
def reviewer_repo_role_assignments(
self, reviewer_node_id: str, reviewer_kind: str, repository_node_id: str
):
reviewer_kind = reviewer_kind.lower()
return self._find_all_objects(
f"""
WITH RECURSIVE
repository_org(org_login) AS (
SELECT org_login
FROM {self.schema}.repositories
WHERE node_id = ?
),
seed_teams(team_id) AS (
SELECT ? WHERE ? = 'team'

UNION

SELECT tm.team_id
FROM {self.schema}.team_members tm
WHERE ? = 'user'
AND tm.id = ?
),
actor_teams(team_id) AS (
SELECT team_id
FROM seed_teams

UNION

SELECT json_extract_string(t.parent_team, '$.id')
FROM {self.schema}.teams t
JOIN actor_teams actor_team ON t.id = actor_team.team_id
WHERE t.parent_team IS NOT NULL
AND json_extract_string(t.parent_team, '$.id') IS NOT NULL
),
direct_repo_roles(
assignment_actor_id,
role_id,
role_name,
base_role,
role_permissions
) AS (
SELECT DISTINCT
rra.node_id,
rr.id,
rra.role_name,
coalesce(rra.base_role, rr.base_role),
rra.role_permissions
FROM {self.schema}.repo_role_assignments rra
LEFT JOIN {self.schema}.repo_roles rr
ON rr.repository_node_id = rra.repo_node_id
AND rr.name = rra.role_name
WHERE rra.repo_node_id = ?
AND (
(lower(rra.assignee_type) = ? AND rra.node_id = ?)
OR (
lower(rra.assignee_type) = 'team'
AND rra.node_id IN (SELECT team_id FROM actor_teams)
)
)
),
actor_org_roles(assignment_actor_id, org_role_name, base_role) AS (
SELECT
u.id,
CASE WHEN u.role = 'ADMIN' THEN 'owners' ELSE 'members' END,
org_role.base_role
FROM {self.schema}.users u
JOIN repository_org repo_org ON repo_org.org_login = u.org_login
JOIN {self.schema}.org_roles org_role
ON org_role.org_login = u.org_login
AND org_role.name = CASE
WHEN u.role = 'ADMIN' THEN 'owners'
ELSE 'members'
END
WHERE ? = 'user'
AND u.id = ?

UNION

SELECT
orm.node_id,
orm.org_role_name,
org_role.base_role
FROM {self.schema}.org_role_members orm
JOIN repository_org repo_org ON repo_org.org_login = orm.org_login
JOIN {self.schema}.org_roles org_role
ON org_role.org_login = orm.org_login
AND org_role.name = orm.org_role_name
WHERE ? = 'user'
AND orm.node_id = ?

UNION

SELECT
ort.node_id,
ort.org_role_name,
org_role.base_role
FROM {self.schema}.org_role_teams ort
JOIN repository_org repo_org ON repo_org.org_login = ort.org_login
JOIN {self.schema}.org_roles org_role
ON org_role.org_login = ort.org_login
AND org_role.name = ort.org_role_name
WHERE ort.node_id IN (SELECT team_id FROM actor_teams)
),
org_repo_roles(
assignment_actor_id,
role_id,
role_name,
base_role,
role_permissions
) AS (
SELECT DISTINCT
actor_org_role.assignment_actor_id,
rr.id,
rr.name,
rr.base_role,
rr.permissions
FROM actor_org_roles actor_org_role
JOIN {self.schema}.repo_roles rr
ON rr.repository_node_id = ?
AND rr.name = actor_org_role.base_role
)
SELECT * FROM direct_repo_roles
UNION
SELECT * FROM org_repo_roles
""",
[
repository_node_id,
reviewer_node_id,
reviewer_kind,
reviewer_kind,
reviewer_node_id,
repository_node_id,
reviewer_kind,
reviewer_node_id,
reviewer_kind,
reviewer_node_id,
reviewer_kind,
reviewer_node_id,
repository_node_id,
],
)

@staticmethod
def _role_permissions(raw_permissions) -> set[str]:
if raw_permissions is None:
return set()
if isinstance(raw_permissions, str):
try:
raw_permissions = json.loads(raw_permissions)
except json.JSONDecodeError:
return set()
if not isinstance(raw_permissions, list):
return set()
return {str(permission) for permission in raw_permissions}

@lru_cache
def reviewer_deployment_path(
self,
reviewer_node_id: str,
reviewer_kind: str,
repository_node_id: str,
eligible_branch_ids: tuple[str, ...],
allow_create_branch: bool,
) -> tuple[str, str | None] | None:
if not self.repository_default_branch_collected(repository_node_id):
return None

eligible_branches = set(eligible_branch_ids)
write_roles = {"write", "maintain", "admin"}
bypass_roles = {"maintain"}

for (
assignment_actor_id,
role_id,
role_name,
base_role,
raw_permissions,
) in self.reviewer_repo_role_assignments(
reviewer_node_id, reviewer_kind, repository_node_id
):
permissions = self._role_permissions(raw_permissions)
has_write_access = role_name in write_roles or base_role in write_roles
if not has_write_access:
continue

if (
allow_create_branch
and role_id is not None
and self.role_can_create_branch(role_id, repository_node_id)
):
return ("create_branch", None)

writable_branches = {
branch_id
for (branch_id,) in self.unprotected_branches(repository_node_id)
}

has_push_protected_branch = (
("push_protected_branch" in permissions and base_role in write_roles)
or role_name in bypass_roles
or base_role in bypass_roles
)
has_bypass_branch_protection = (
"bypass_branch_protection" in permissions and base_role in write_roles
)

if role_name == "admin" or base_role == "admin":
writable_branches.update(
branch_id
for (branch_id,) in self._write_admin_bypass(repository_node_id)
)
if has_push_protected_branch:
writable_branches.update(
branch_id
for (branch_id,) in self._write_push_restricted_branch_bypass(
repository_node_id
)
)
if has_bypass_branch_protection:
writable_branches.update(
branch_id
for (branch_id,) in self._write_branch_protection_bypass(
repository_node_id
)
)
if has_push_protected_branch and has_bypass_branch_protection:
writable_branches.update(
branch_id
for (branch_id,) in self._write_combined_bypass(repository_node_id)
)

writable_branches.update(
branch_id
for (branch_id,) in self.actor_gate_bypass(
assignment_actor_id,
repository_node_id,
has_bypass_branch_protection,
has_push_protected_branch,
)
)

for branch_id in eligible_branch_ids:
if branch_id in writable_branches and branch_id in eligible_branches:
return ("write_branch", branch_id)

return None

@lru_cache
def members_can_fork_private_repositories(self, org_login: str):
return self._find_all_objects(
Expand Down
6 changes: 6 additions & 0 deletions src/openhound_github/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def preproc(ctx: PreProcContext):
"repo_role_assignments": "repo_role_assignments",
"branches": "branches",
"repo_roles": "repo_roles",
"users": "users",
"teams": "teams",
"team_members": "team_members",
"saml_provider": "saml_provider",
"applications": "applications",
"enterprise": "enterprise",
Expand All @@ -67,8 +70,11 @@ def preproc(ctx: PreProcContext):
"enterprise_runner_group_organizations": "enterprise_runner_group_organizations",
"enterprise_runner_group_memberships": "enterprise_runner_group_memberships",
"org_roles": "org_roles",
"org_role_members": "org_role_members",
"org_role_teams": "org_role_teams",
"projected_enterprise_teams": "projected_enterprise_teams",
"environments": "environments",
"environment_branch_policies": "environment_branch_policies",
"environment_secrets": "environment_secrets",
"environment_variables": "environment_variables",
"organization_secrets": "organization_secrets",
Expand Down
Loading
Loading