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:
John Kennedy 2026-06-20 09:44:12 -07:00 • committed by GitHub
parent 855d0fcb42
commit 9949077bc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 30 deletions

View file

@ -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

View file

@ -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()

View file

@ -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"],

View file

@ -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]:

View 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

View file

@ -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