diff --git a/INSTALLATION.md b/INSTALLATION.md index ec91bff1..f0a23418 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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/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 diff --git a/agent/dashboard/admin.py b/agent/dashboard/admin.py index a823ca64..7b224cc2 100644 --- a/agent/dashboard/admin.py +++ b/agent/dashboard/admin.py @@ -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() diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 1ee9366f..f022017a 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -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"], diff --git a/agent/server.py b/agent/server.py index 4840c136..f52deebe 100644 --- a/agent/server.py +++ b/agent/server.py @@ -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]: diff --git a/tests/test_dashboard_admin.py b/tests/test_dashboard_admin.py new file mode 100644 index 00000000..b5338574 --- /dev/null +++ b/tests/test_dashboard_admin.py @@ -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 diff --git a/tests/test_observability_tools.py b/tests/test_observability_tools.py index 548abcd7..e0f724e2 100644 --- a/tests/test_observability_tools.py +++ b/tests/test_observability_tools.py @@ -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