mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
fix: allow GitHub logins for dashboard admins (#1582)
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
855d0fcb42
commit
9949077bc7
6 changed files with 93 additions and 30 deletions
|
|
@ -471,9 +471,9 @@ DASHBOARD_JWT_SECRET="" # Generate with: openssl rand -hex 32
|
|||
# Required whenever the frontend and API are on different origins — including local
|
||||
# dev (UI :3000 -> API :2024 is cross-origin). CORS is only enabled when this is set.
|
||||
DASHBOARD_ALLOWED_ORIGINS="http://localhost:3000" # prod: your frontend origin(s)
|
||||
# Comma-separated email allowlist for admin dashboard endpoints (matched against the
|
||||
# logged-in user's GitHub email). Empty => nobody is an admin.
|
||||
CONFIGURED_ADMINS="" # e.g. "alice@my-org.com,bob@my-org.com"
|
||||
# Comma-separated GitHub login or email allowlist for admin dashboard endpoints.
|
||||
# Empty => nobody is an admin.
|
||||
CONFIGURED_ADMINS="" # e.g. "alice,bob@my-org.com"
|
||||
# URL of the LangGraph server the FastAPI side calls to trigger/stream runs.
|
||||
# Defaults to http://localhost:2024 locally; set to your deployment URL in prod.
|
||||
LANGGRAPH_URL="http://localhost:2024"
|
||||
|
|
@ -587,7 +587,7 @@ The dashboard needs `VITE_DASHBOARD_API_BASE_URL` in `ui/.env` pointing at the b
|
|||
|
||||
The client calls `${VITE_DASHBOARD_API_BASE_URL}/dashboard/api/*` with `credentials: "include"`, so the backend's `osw_session` cookie rides along. Because the UI (`:3000`) and API (`:2024`) are different origins, the backend needs **CORS** enabled for the UI origin — set `DASHBOARD_ALLOWED_ORIGINS="http://localhost:3000"` (CORS is off unless this is set). Keep `DASHBOARD_API_BASE_URL` on an `http://` URL locally so the cookie uses `SameSite=Lax` rather than `Secure`.
|
||||
|
||||
For the dashboard login to succeed, you need (from steps 3c / 6): `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `DASHBOARD_JWT_SECRET`, `DASHBOARD_API_BASE_URL`, `DASHBOARD_BASE_URL`, and `DASHBOARD_ALLOWED_ORIGINS`. To reach the admin pages (user mappings, etc.), add your GitHub email to `CONFIGURED_ADMINS`.
|
||||
For the dashboard login to succeed, you need (from steps 3c / 6): `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `DASHBOARD_JWT_SECRET`, `DASHBOARD_API_BASE_URL`, `DASHBOARD_BASE_URL`, and `DASHBOARD_ALLOWED_ORIGINS`. To reach the admin pages (user mappings, etc.), add your GitHub login or email to `CONFIGURED_ADMINS`.
|
||||
|
||||
Other UI scripts: `bun run build`, `bun run typecheck`, `bun run lint`, `bun run test`.
|
||||
|
||||
|
|
@ -621,7 +621,7 @@ Other UI scripts: `bun run build`, `bun run typecheck`, `bun run lint`, `bun run
|
|||
|
||||
1. With the backend (step 7) and UI (step 8) both running, open `http://localhost:3000`
|
||||
2. Click **Sign in with GitHub** — you'll be sent through the GitHub OAuth flow and back to the dashboard
|
||||
3. You should land logged-in and be able to see your profile/settings. If your email is in `CONFIGURED_ADMINS`, the **Admin** pages (e.g. User mappings) are available.
|
||||
3. You should land logged-in and be able to see your profile/settings. If your GitHub login or email is in `CONFIGURED_ADMINS`, the **Admin** pages (e.g. User mappings) are available.
|
||||
|
||||
## 10. Production deployment
|
||||
|
||||
|
|
@ -674,7 +674,7 @@ Alternatively, you can run the dashboard as a direct cross-origin client: set `V
|
|||
- OAuth `redirect_uri` mismatch: the GitHub App must list `<DASHBOARD_API_BASE_URL>/dashboard/api/auth/callback` as a callback URL (step 3b). Locally that's `http://localhost:2024/dashboard/api/auth/callback`.
|
||||
- Login redirects but the session doesn't stick: this is almost always a cookie problem. Locally, keep `DASHBOARD_API_BASE_URL` on `http://` (so cookies are `SameSite=Lax`); in prod use `https://` for both API and frontend and add the frontend origin to `DASHBOARD_ALLOWED_ORIGINS`.
|
||||
- Login rejected with an org error: `ALLOWED_GITHUB_ORGS` gates dashboard login (and requires the App's Organization → Members: Read-only permission). See step 5.
|
||||
- Admin pages 403: add your GitHub email to `CONFIGURED_ADMINS`.
|
||||
- Admin pages 403: add your GitHub login or email to `CONFIGURED_ADMINS`.
|
||||
|
||||
### Dashboard UI can't reach the backend
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,37 @@
|
|||
"""Admin email gate driven by the CONFIGURED_ADMINS env var."""
|
||||
"""Admin gate driven by the CONFIGURED_ADMINS env var."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _admin_emails() -> frozenset[str]:
|
||||
def _configured_admins() -> frozenset[str]:
|
||||
raw = os.environ.get("CONFIGURED_ADMINS", "")
|
||||
return frozenset(e.strip().lower() for e in raw.split(",") if e.strip())
|
||||
|
||||
|
||||
def is_admin(email: str | None) -> bool:
|
||||
if not email:
|
||||
return False
|
||||
return email.strip().lower() in _admin_emails()
|
||||
return frozenset(entry.strip().lower() for entry in raw.split(",") if entry.strip())
|
||||
|
||||
|
||||
def _observability_emails() -> frozenset[str]:
|
||||
raw = os.environ.get("OBSERVABILITY_AUTHORIZED_EMAILS", "")
|
||||
return frozenset(e.strip().lower() for e in raw.split(",") if e.strip())
|
||||
return frozenset(entry.strip().lower() for entry in raw.split(",") if entry.strip())
|
||||
|
||||
|
||||
def is_observability_authorized(email: str | None) -> bool:
|
||||
"""Whether ``email`` may use the team observability tools.
|
||||
def _admin_identities(email: str | None, login: str | None) -> frozenset[str]:
|
||||
return frozenset(
|
||||
value.strip().lower()
|
||||
for value in (email, login)
|
||||
if isinstance(value, str) and value.strip()
|
||||
)
|
||||
|
||||
Admins always qualify; additional non-admin emails can be allow-listed via
|
||||
``OBSERVABILITY_AUTHORIZED_EMAILS``.
|
||||
"""
|
||||
|
||||
def is_admin(email: str | None, *, login: str | None = None) -> bool:
|
||||
return bool(_admin_identities(email, login) & _configured_admins())
|
||||
|
||||
|
||||
def is_observability_authorized(email: str | None, *, login: str | None = None) -> bool:
|
||||
"""Whether a user may use the team observability tools."""
|
||||
identities = _admin_identities(email, login)
|
||||
if identities & _configured_admins():
|
||||
return True
|
||||
if not email:
|
||||
return False
|
||||
normalized = email.strip().lower()
|
||||
return normalized in _admin_emails() or normalized in _observability_emails()
|
||||
return email.strip().lower() in _observability_emails()
|
||||
|
|
|
|||
|
|
@ -166,8 +166,12 @@ _GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
|
|||
_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404})
|
||||
|
||||
|
||||
def _session_is_admin(session: dict[str, Any]) -> bool:
|
||||
return is_admin(session.get("email"), login=session.get("sub"))
|
||||
|
||||
|
||||
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
|
||||
if not is_admin(session.get("email")):
|
||||
if not _session_is_admin(session):
|
||||
raise HTTPException(403, "admin only")
|
||||
return session
|
||||
|
||||
|
|
@ -362,7 +366,7 @@ async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|||
"login": session["sub"],
|
||||
"email": session.get("email"),
|
||||
"avatar_url": session.get("avatar_url"),
|
||||
"is_admin": is_admin(session.get("email")),
|
||||
"is_admin": _session_is_admin(session),
|
||||
"slack_oauth_enabled": slack_oauth_configured(),
|
||||
}
|
||||
|
||||
|
|
@ -1179,7 +1183,7 @@ async def api_list_threads(
|
|||
all: bool = False,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> list[dict[str, Any]]:
|
||||
if all and not is_admin(session.get("email")):
|
||||
if all and not _session_is_admin(session):
|
||||
raise HTTPException(403, "admin only")
|
||||
return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all)
|
||||
|
||||
|
|
@ -1191,7 +1195,7 @@ async def api_list_threads_sidebar(
|
|||
all: bool = False,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
if all and not is_admin(session.get("email")):
|
||||
if all and not _session_is_admin(session):
|
||||
raise HTTPException(403, "admin only")
|
||||
return await list_dashboard_threads_sidebar(
|
||||
session["sub"],
|
||||
|
|
@ -1214,7 +1218,7 @@ async def api_list_threads_page(
|
|||
q: str | None = None,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
if all and not is_admin(session.get("email")):
|
||||
if all and not _session_is_admin(session):
|
||||
raise HTTPException(403, "admin only")
|
||||
return await list_dashboard_threads_page(
|
||||
session["sub"],
|
||||
|
|
|
|||
|
|
@ -501,13 +501,17 @@ async def _observability_authorized(config: RunnableConfig, profile_login: str |
|
|||
"""
|
||||
configurable = (config or {}).get("configurable") or {}
|
||||
slack_thread = configurable.get("slack_thread") or {}
|
||||
config_login = configurable.get("github_login")
|
||||
candidate_login = profile_login or (config_login if isinstance(config_login, str) else None)
|
||||
candidate_emails = [
|
||||
configurable.get("user_email"),
|
||||
slack_thread.get("triggering_user_email"),
|
||||
]
|
||||
if any(is_observability_authorized(email) for email in candidate_emails):
|
||||
if any(is_observability_authorized(email, login=candidate_login) for email in candidate_emails):
|
||||
return True
|
||||
return is_observability_authorized(await email_for_login(profile_login))
|
||||
return is_observability_authorized(
|
||||
await email_for_login(candidate_login), login=candidate_login
|
||||
)
|
||||
|
||||
|
||||
async def _load_observability_tools(authorized: bool) -> list[Any]:
|
||||
|
|
|
|||
39
tests/test_dashboard_admin.py
Normal file
39
tests/test_dashboard_admin.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.dashboard.admin import is_admin, is_observability_authorized
|
||||
|
||||
|
||||
def test_is_admin_accepts_email_or_github_login(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CONFIGURED_ADMINS", "Alice, bob@langchain.dev")
|
||||
|
||||
assert is_admin("bob@langchain.dev", login="not-bob") is True
|
||||
assert is_admin("other@langchain.dev", login="alice") is True
|
||||
assert is_admin("other@langchain.dev", login="ALICE") is True
|
||||
assert is_admin("other@langchain.dev", login="mallory") is False
|
||||
|
||||
|
||||
def test_is_admin_rejects_blank_identities(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CONFIGURED_ADMINS", "alice")
|
||||
|
||||
assert is_admin(None, login=None) is False
|
||||
assert is_admin("", login=" ") is False
|
||||
|
||||
|
||||
def test_observability_authorized_treats_admin_login_as_admin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CONFIGURED_ADMINS", "alice")
|
||||
monkeypatch.delenv("OBSERVABILITY_AUTHORIZED_EMAILS", raising=False)
|
||||
|
||||
assert is_observability_authorized(None, login="alice") is True
|
||||
assert is_observability_authorized("other@langchain.dev", login="mallory") is False
|
||||
|
||||
|
||||
def test_observability_allowlist_still_uses_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CONFIGURED_ADMINS", "")
|
||||
monkeypatch.setenv("OBSERVABILITY_AUTHORIZED_EMAILS", "trusted@langchain.dev")
|
||||
|
||||
assert is_observability_authorized("trusted@langchain.dev", login="mallory") is True
|
||||
assert is_observability_authorized("other@langchain.dev", login="trusted") is False
|
||||
|
|
@ -154,3 +154,15 @@ async def test_observability_authorized_resolves_login_email(
|
|||
|
||||
config = {"configurable": {"github_login": "dev"}}
|
||||
assert await server._observability_authorized(config, "dev") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observability_authorized_accepts_admin_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CONFIGURED_ADMINS", "dev")
|
||||
monkeypatch.delenv("OBSERVABILITY_AUTHORIZED_EMAILS", raising=False)
|
||||
monkeypatch.setattr(server, "email_for_login", AsyncMock(return_value=None))
|
||||
|
||||
config = {"configurable": {"github_login": "dev"}}
|
||||
assert await server._observability_authorized(config, "dev") is True
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue