diff --git a/INSTALLATION.md b/INSTALLATION.md index 3b3bc079..9b4ed879 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -69,6 +69,8 @@ Write this down. You'll use it in the callback URL below and again in step 4 whe - Pull requests: Read & write - Issues: Read & write - Metadata: Read-only + - **Organization permissions** (required only if you plan to set `ALLOWED_GITHUB_ORGS` — see step 5 / Security): + - Members: Read-only — used to verify org membership for the dashboard-login gate via `GET /orgs/{org}/memberships/{username}`. Without this permission that call returns 403, the check fails closed, and **every** dashboard login is rejected. 4. Under **Subscribe to events**, enable: - `Issue comment` - `Pull request review` @@ -225,6 +227,10 @@ ALLOWED_GITHUB_REPOS="some-user/their-repo,another-org/specific-repo" A GitHub or Linear webhook is accepted if the resolved repo's org is in `ALLOWED_GITHUB_ORGS` **or** the `owner/repo` is in `ALLOWED_GITHUB_REPOS`. If both are empty, all repos are allowed. Slack mentions are not rejected from regex-inferred repository text; repository access is bounded by the GitHub App installation permissions. +`ALLOWED_GITHUB_ORGS` also gates **dashboard login**: when set, only GitHub accounts that are active members of one of the listed organizations can complete the OAuth login and receive a session. Membership is verified server-side with the GitHub App installation token (so private memberships are visible and no extra OAuth scope is required), and the check fails closed on any API error. When `ALLOWED_GITHUB_ORGS` is empty, dashboard login is open to any GitHub account (the prior behavior). + +> **Required GitHub App permission**: the membership check calls `GET /orgs/{org}/memberships/{username}`, which requires the GitHub App's **Organization → Members: Read-only** permission (see step 3b). If you set `ALLOWED_GITHUB_ORGS` without granting that permission, the call returns 403, the check fails closed, and **every** dashboard login is rejected. After changing an installed app's permissions, GitHub requires you to **approve the new permission** on each installation before it takes effect. + ### Linear (optional) Open SWE listens for Linear comments that mention `@openswe`. @@ -385,6 +391,8 @@ GITHUB_OAUTH_PROVIDER_ID="" # The provider ID from steps 3a / 4b # === Repo Allowlist (optional) === # Comma-separated list of GitHub orgs the agent is allowed to operate on. +# Also gates dashboard login to members of these orgs (requires the GitHub App's +# Organization -> Members: Read-only permission; without it, all dashboard logins are rejected). # Leave empty to allow all orgs. ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org" # Comma-separated list of specific owner/repo pairs the agent is allowed to operate on. diff --git a/agent/dashboard/oauth.py b/agent/dashboard/oauth.py index 97cea7a3..7fc370cd 100644 --- a/agent/dashboard/oauth.py +++ b/agent/dashboard/oauth.py @@ -16,6 +16,8 @@ import httpx import jwt from fastapi import HTTPException, Request +from agent.utils.github_org_membership import is_user_active_org_member + logger = logging.getLogger(__name__) COOKIE_NAME = "osw_session" @@ -80,6 +82,37 @@ def sanitize_redirect_to(redirect_to: str | None) -> str: return fallback +def _allowed_login_orgs() -> frozenset[str]: + """Orgs whose members may log in to the dashboard. + + Reuses the webhook-side ``ALLOWED_GITHUB_ORGS`` allowlist so deployments + configure a single org gate. When empty the dashboard login gate is + disabled (fail-open) to preserve existing deployments. + """ + return frozenset( + org.strip().lower() + for org in os.environ.get("ALLOWED_GITHUB_ORGS", "").split(",") + if org.strip() + ) + + +async def enforce_org_login_gate(login: str) -> None: + """Reject dashboard login for users outside the allowed GitHub org(s). + + No-op when ``ALLOWED_GITHUB_ORGS`` is unset. Otherwise the user must be an + active member of at least one configured org; membership is checked with + the GitHub App installation token (fail-closed on any API error). + """ + orgs = _allowed_login_orgs() + if not orgs: + return + for org in orgs: + if await is_user_active_org_member(login, org): + return + logger.warning("Rejected dashboard login for %r — not in allowed org(s)", login) + raise HTTPException(403, "your GitHub account is not a member of an authorized organization") + + def issue_session(*, login: str, email: str | None, avatar_url: str | None) -> str: now = int(time.time()) payload = { diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 899dcb73..6f06df2e 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -24,6 +24,7 @@ from .oauth import ( STATE_COOKIE_NAME, STATE_TTL_SECONDS, decode_state, + enforce_org_login_gate, exchange_code, fetch_github_user, hash_state_nonce, @@ -188,6 +189,8 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp if not login: raise HTTPException(400, "could not resolve GitHub login") + await enforce_org_login_gate(login) + await upsert_access_token_from_github_response(login, email or "", token_data) session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url")) diff --git a/agent/utils/github_org_membership.py b/agent/utils/github_org_membership.py index 42ad8fee..5c972264 100644 --- a/agent/utils/github_org_membership.py +++ b/agent/utils/github_org_membership.py @@ -20,6 +20,10 @@ async def is_user_active_org_member(username: str, org: str) -> bool: 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 diff --git a/tests/test_dashboard_org_login_gate.py b/tests/test_dashboard_org_login_gate.py new file mode 100644 index 00000000..4283e466 --- /dev/null +++ b/tests/test_dashboard_org_login_gate.py @@ -0,0 +1,79 @@ +"""Tests for the dashboard GitHub-org login gate (ALLOWED_GITHUB_ORGS).""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from agent.dashboard import oauth + + +def _stub_membership(monkeypatch, members: dict[str, set[str]]) -> dict[str, list[tuple[str, str]]]: + """Stub is_user_active_org_member; ``members`` maps org -> set of logins.""" + seen: dict[str, list[tuple[str, str]]] = {"calls": []} + + async def fake_is_user_active_org_member(username: str, org: str) -> bool: + seen["calls"].append((username, org)) + return username in members.get(org, set()) + + monkeypatch.setattr(oauth, "is_user_active_org_member", fake_is_user_active_org_member) + return seen + + +@pytest.mark.asyncio +async def test_gate_noop_when_unset(monkeypatch) -> None: + monkeypatch.delenv("ALLOWED_GITHUB_ORGS", raising=False) + seen = _stub_membership(monkeypatch, {}) + + await oauth.enforce_org_login_gate("anyone") + + assert seen["calls"] == [] + + +@pytest.mark.asyncio +async def test_gate_noop_when_blank(monkeypatch) -> None: + monkeypatch.setenv("ALLOWED_GITHUB_ORGS", " , ") + seen = _stub_membership(monkeypatch, {}) + + await oauth.enforce_org_login_gate("anyone") + + assert seen["calls"] == [] + + +@pytest.mark.asyncio +async def test_gate_allows_member(monkeypatch) -> None: + monkeypatch.setenv("ALLOWED_GITHUB_ORGS", "langchain-ai") + _stub_membership(monkeypatch, {"langchain-ai": {"insider"}}) + + await oauth.enforce_org_login_gate("insider") + + +@pytest.mark.asyncio +async def test_gate_rejects_non_member(monkeypatch) -> None: + monkeypatch.setenv("ALLOWED_GITHUB_ORGS", "langchain-ai") + _stub_membership(monkeypatch, {"langchain-ai": {"insider"}}) + + with pytest.raises(HTTPException) as exc: + await oauth.enforce_org_login_gate("stranger") + + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_gate_allows_member_of_any_configured_org(monkeypatch) -> None: + monkeypatch.setenv("ALLOWED_GITHUB_ORGS", "langchain-ai, anthropics") + _stub_membership(monkeypatch, {"anthropics": {"insider"}}) + + await oauth.enforce_org_login_gate("insider") + + +@pytest.mark.asyncio +async def test_gate_rejects_when_member_of_no_configured_org(monkeypatch) -> None: + monkeypatch.setenv("ALLOWED_GITHUB_ORGS", "langchain-ai,anthropics") + seen = _stub_membership(monkeypatch, {"other-org": {"stranger"}}) + + with pytest.raises(HTTPException) as exc: + await oauth.enforce_org_login_gate("stranger") + + assert exc.value.status_code == 403 + assert {org for _, org in seen["calls"]} == {"langchain-ai", "anthropics"}