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:
Johannes du Plessis 2026-06-01 13:56:30 -07:00 • committed by GitHub
parent 4a55145bb1
commit bed3eefbcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 0 deletions

View file

@ -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 - Pull requests: Read & write
- Issues: Read & write - Issues: Read & write
- Metadata: Read-only - 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: 4. Under **Subscribe to events**, enable:
- `Issue comment` - `Issue comment`
- `Pull request review` - `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. 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) ### Linear (optional)
Open SWE listens for Linear comments that mention `@openswe`. 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) === # === Repo Allowlist (optional) ===
# Comma-separated list of GitHub orgs the agent is allowed to operate on. # 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. # Leave empty to allow all orgs.
ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org" 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. # Comma-separated list of specific owner/repo pairs the agent is allowed to operate on.

View file

@ -16,6 +16,8 @@ import httpx
import jwt import jwt
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from agent.utils.github_org_membership import is_user_active_org_member
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
COOKIE_NAME = "osw_session" COOKIE_NAME = "osw_session"
@ -80,6 +82,37 @@ def sanitize_redirect_to(redirect_to: str | None) -> str:
return fallback 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: def issue_session(*, login: str, email: str | None, avatar_url: str | None) -> str:
now = int(time.time()) now = int(time.time())
payload = { payload = {

View file

@ -24,6 +24,7 @@ from .oauth import (
STATE_COOKIE_NAME, STATE_COOKIE_NAME,
STATE_TTL_SECONDS, STATE_TTL_SECONDS,
decode_state, decode_state,
enforce_org_login_gate,
exchange_code, exchange_code,
fetch_github_user, fetch_github_user,
hash_state_nonce, hash_state_nonce,
@ -188,6 +189,8 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp
if not login: if not login:
raise HTTPException(400, "could not resolve GitHub 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) 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")) session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))

View file

@ -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 memberships are visible (the same approach as the reference
``tag-external-contributions.yml`` workflow). On any API error, returns ``tag-external-contributions.yml`` workflow). On any API error, returns
``False`` — fail-closed for security. ``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: if not username or not org:
return False return False

View 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"}