open-swe/agent/utils/github_org_membership.py
Johannes du Plessis bed3eefbcb
fix: Lock dashboard login to GitHub org members (#1367)
* Lock dashboard login to GitHub org members

Add an org-membership gate to the dashboard OAuth callback. After
resolving the GitHub login, enforce_org_login_gate(login) checks the
existing ALLOWED_GITHUB_ORGS allowlist before issuing a session.

- Reuses ALLOWED_GITHUB_ORGS (no new config knob) and
  is_user_active_org_member (installation-token check, so no extra
  OAuth scope and private memberships are visible).
- Fail-open when unset/blank so existing deployments keep working;
  fail-closed on API errors.
- Gate runs before the session cookie/token is persisted.

Adds unit tests and documents the behavior in INSTALLATION.md.

* docs: document Organization Members permission required for org login gate
2026-06-01 20:56:30 +00:00

69 lines
2.2 KiB
Python

"""GitHub organization membership checks for webhook gating."""
from __future__ import annotations
import logging
import httpx
from .github_app import get_github_app_installation_token
logger = logging.getLogger(__name__)
INTERNAL_BOT_LOGINS: frozenset[str] = frozenset({"open-swe[bot]", "openswe-dev[bot]"})
async def is_user_active_org_member(username: str, org: str) -> bool:
"""Return True if ``username`` is an *active* member of ``org``.
Uses the GitHub App installation token so that private organization
memberships are visible (the same approach as the reference
``tag-external-contributions.yml`` workflow). On any API error, returns
``False`` — fail-closed for security.
Requires the GitHub App to have the ``Organization -> Members: Read-only``
permission; the ``GET /orgs/{org}/memberships/{username}`` endpoint returns
403 (-> ``False``) without it. See INSTALLATION.md.
"""
if not username or not org:
return False
token = await get_github_app_installation_token()
if not token:
logger.warning(
"GitHub App token unavailable; cannot verify org membership for %s", username
)
return False
url = f"https://api.github.com/orgs/{org}/memberships/{username}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
except Exception:
logger.exception("Error calling GitHub org membership API for %s/%s", org, username)
return False
if response.status_code == 404:
return False
if response.status_code != 200:
logger.warning(
"Unexpected status %s checking %s membership for %s",
response.status_code,
org,
username,
)
return False
try:
state = response.json().get("state")
except ValueError:
logger.warning("Failed to parse org membership response for %s/%s", org, username)
return False
return state == "active"