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
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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