mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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
This commit is contained in:
parent
4a55145bb1
commit
bed3eefbcb
5 changed files with 127 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
tests/test_dashboard_org_login_gate.py
Normal file
79
tests/test_dashboard_org_login_gate.py
Normal file
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue