diff --git a/packages/environments/mock-gcal/mock_gcal/api/app.py b/packages/environments/mock-gcal/mock_gcal/api/app.py index 1da6566c4..89e762f01 100644 --- a/packages/environments/mock-gcal/mock_gcal/api/app.py +++ b/packages/environments/mock-gcal/mock_gcal/api/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError @@ -346,3 +347,44 @@ def admin_task_evaluate(task_name: str): @app.get("/health") def health(): return {"status": "ok"} + + +# --- auth integration (optional; enabled via AUTH_ENABLED) --- +from .deps import ImpersonationError # noqa: E402 + + +@app.exception_handler(ImpersonationError) +async def impersonation_error_handler(request: Request, exc: ImpersonationError): + """Contract-pinned 403 impersonation body.""" + from env_0_auth_client.errors import impersonation_body + + return JSONResponse( + status_code=403, + content=impersonation_body(exc.authenticated_user, exc.requested_user), + ) + + +def _auth_enabled() -> bool: + return os.environ.get("AUTH_ENABLED", "").strip().lower() in ("1", "true", "yes") + + +def _apply_auth(target_app: FastAPI, **middleware_kwargs) -> bool: + """Add GcalEnv_0AuthMiddleware to ``target_app`` when AUTH_ENABLED is truthy.""" + if not _auth_enabled(): + return False + try: + import env_0_auth_client # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "AUTH_ENABLED=1 but auth-client is not installed. " + "Install packages/auth-client or unset AUTH_ENABLED." + ) from exc + + from mock_gcal.api.auth_middleware import GcalEnv_0AuthMiddleware + from mock_gcal.auth_scopes import SCOPE_MAP + + target_app.add_middleware(GcalEnv_0AuthMiddleware, scope_map=SCOPE_MAP, **middleware_kwargs) + return True + + +_apply_auth(app) diff --git a/packages/environments/mock-gcal/mock_gcal/api/auth_middleware.py b/packages/environments/mock-gcal/mock_gcal/api/auth_middleware.py new file mode 100644 index 000000000..6c5d9da8f --- /dev/null +++ b/packages/environments/mock-gcal/mock_gcal/api/auth_middleware.py @@ -0,0 +1,60 @@ +"""Gcal-side wrapper around Env_0AuthMiddleware: web-UI + /mcp exemptions. + +Decision (v1, mirroring gmail -- see its API_NOTES.md "auth +integration" section): + +- The human web dashboard (``/`` and ``/dev/*``) identifies the user via the + ``mock_gcal_user`` cookie, NOT Bearer tokens -- the real-world split between + browser sessions and API credentials. With ``AUTH_ENABLED=1`` these + routes stay reachable without a token. ``/dev`` is already in the + contract-pinned default exempt prefixes. +- ``/mcp`` is exempt: agent MCP clients are out of OAuth scope for v1. + +auth-client's public API is unchanged: the extra exemptions are plain +prefix additions passed through the existing ``exempt_prefixes`` parameter. +The one thing prefixes cannot express is the calendar view at ``/`` (gcal's +web UI mounts at the root; a ``"/"`` PREFIX would exempt every path), so this +subclass short-circuits the exact path ``/`` before delegating to the +upstream ``dispatch``. +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import Response + +from env_0_auth_client import Env_0AuthMiddleware +from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + +#: Exact paths that bypass auth. Exact-match only -- "/" as a *prefix* would +#: exempt everything, which is why this set exists at all. +EXEMPT_EXACT_PATHS = frozenset({"/"}) + +#: /_admin, /health, /dev, /docs, /openapi.json and /web come from the +#: contract-pinned defaults; gcal's whole web UI lives at "/" and "/dev/*". +GCAL_EXEMPT_PREFIXES: tuple[str, ...] = DEFAULT_EXEMPT_PREFIXES + ( + "/static", # static assets (none mounted today; defensive) + "/mcp", # MCP clients are out of OAuth scope for v1 +) + + +class GcalEnv_0AuthMiddleware(Env_0AuthMiddleware): + """Env_0AuthMiddleware with gcal's exemptions baked in as defaults. + + Only the defaults differ; every kwarg (``scope_map``, ``jwks_static``, + ``audience``, ...) is forwarded untouched, and callers may still override + ``exempt_prefixes`` explicitly. + """ + + def __init__( + self, + app, + exempt_prefixes: tuple[str, ...] = GCAL_EXEMPT_PREFIXES, + **kwargs, + ): + super().__init__(app, exempt_prefixes=exempt_prefixes, **kwargs) + + async def dispatch(self, request: Request, call_next) -> Response: + if request.url.path in EXEMPT_EXACT_PATHS: + return await call_next(request) + return await super().dispatch(request, call_next) diff --git a/packages/environments/mock-gcal/mock_gcal/api/deps.py b/packages/environments/mock-gcal/mock_gcal/api/deps.py index e227a566b..733f240b6 100644 --- a/packages/environments/mock-gcal/mock_gcal/api/deps.py +++ b/packages/environments/mock-gcal/mock_gcal/api/deps.py @@ -2,12 +2,59 @@ from __future__ import annotations -from fastapi import Header, HTTPException, Depends +from fastapi import Header, HTTPException, Depends, Request +from sqlalchemy import func from sqlalchemy.orm import Session from mock_gcal.models import User, get_session_factory +class ImpersonationError(Exception): + """Authenticated request named a userId that is not the token's subject. + + Handled in ``mock_gcal.api.app`` with the auth contract 403 body + (``env_0_auth_client.errors.impersonation_body``), deliberately NOT the + Google Calendar error envelope used for HTTPException. + """ + + def __init__(self, authenticated_user: str, requested_user: str): + super().__init__( + f"Cannot access another user's resources " + f"(authenticated as {authenticated_user!r}, requested {requested_user!r})" + ) + self.authenticated_user = authenticated_user + self.requested_user = requested_user + + +def _auth_effective_user(request: Request, db: Session) -> User | None: + """Map the verified token to a LOCAL user (auth-enabled path). + + Resolution order (identity-alignment): (a) local user whose id == token + ``sub``; (b) else local user whose email matches the token's ``email`` + claim (case-insensitive); (c) else None (callers keep the existing + not-found behavior). Only called when ``request.state.auth_user_id`` is + set, i.e. when Env_0AuthMiddleware verified a Bearer token. Legacy identity + headers are deliberately never consulted here. + """ + auth_user_id = request.state.auth_user_id + user = db.query(User).filter(User.id == auth_user_id).first() + if user is None: + auth_email = (getattr(request.state, "auth_email", "") or "").strip() + if auth_email: + user = db.query(User).filter( + func.lower(User.email_address) == auth_email.lower() + ).first() + return user + + +def _auth_resolved_user_id(request: Request, db: Session) -> str: + """Resolve (with email-claim fallback) and 404-check the token identity.""" + user = _auth_effective_user(request, db) + if not user: + raise HTTPException(404, f"User {request.state.auth_user_id!r} not found") + return user.id + + def get_db() -> Session: """Yield a DB session.""" SessionLocal = get_session_factory() @@ -20,9 +67,10 @@ def get_db() -> Session: def _resolve_header_user( db: Session, + x_env_0_gcal_user: str | None, x_mock_gcal_user: str | None = None, ) -> str | None: - candidate = x_mock_gcal_user + candidate = x_env_0_gcal_user or x_mock_gcal_user if candidate: user = db.query(User).filter( (User.id == candidate) | (User.email_address == candidate) @@ -34,20 +82,54 @@ def _resolve_header_user( def resolve_user_id( userId: str, - x_mock_gcal_user: str | None = Header(None, alias="X-Mock-Gcal-User"), + request: Request, + x_env_0_gcal_user: str | None = Header(None), + x_mock_gcal_user: str | None = Header(None), db: Session = Depends(get_db), ) -> str: """Resolve 'me' to the actual user ID. - Priority: userId path param -> X-Mock-Gcal-User header -> first user in DB. + With auth enabled (Env_0AuthMiddleware sets request.state.auth_user_id): + the token is mapped to a LOCAL user via (a) id == token ``sub``, else + (b) email == token ``email`` claim (case-insensitive), else (c) the + legacy 404. The resolved LOCAL id is the effective identity: 'me' resolves + to it, and an explicit userId naming anyone other than the token's ``sub`` + or the resolved local user raises the contract 403 impersonation error + (after reporting an impersonation_attempt event to auth). Identity + headers are ignored. + + With auth disabled (the default), legacy behavior is unchanged: + userId path param -> X-Env-0-Gcal-User / X-Mock-Gcal-User header -> first + user in DB. """ + auth_user_id = getattr(request.state, "auth_user_id", None) + if auth_user_id is not None: + user = _auth_effective_user(request, db) + effective_id = user.id if user is not None else auth_user_id + if userId not in ("me", auth_user_id, effective_id): + # The middleware cannot see path-vs-sub mismatches; report here. + # env_0_auth_client is guaranteed importable: auth_user_id is only + # ever set by Env_0AuthMiddleware. + from env_0_auth_client import report_impersonation + + report_impersonation( + effective_id, + userId, + client_id=getattr(request.state, "auth_client_id", None), + scope=" ".join(getattr(request.state, "auth_scopes", None) or []), + ) + raise ImpersonationError(effective_id, userId) + if user is None: + raise HTTPException(404, f"User {auth_user_id!r} not found") + return effective_id + if userId != "me": user = db.query(User).filter(User.id == userId).first() if not user: raise HTTPException(404, f"User {userId!r} not found") return userId - resolved = _resolve_header_user(db, x_mock_gcal_user) + resolved = _resolve_header_user(db, x_env_0_gcal_user, x_mock_gcal_user) if resolved: return resolved @@ -59,11 +141,22 @@ def resolve_user_id( def resolve_actor_user_id( - x_mock_gcal_user: str | None = Header(None, alias="X-Mock-Gcal-User"), + request: Request, + x_env_0_gcal_user: str | None = Header(None), + x_mock_gcal_user: str | None = Header(None), db: Session = Depends(get_db), ) -> str: - """Resolve actor for endpoints without userId path params.""" - resolved = _resolve_header_user(db, x_mock_gcal_user) + """Resolve actor for endpoints without userId path params. + + With auth enabled, the actor is ALWAYS the token's ``sub`` (no path + userId exists, so no impersonation is possible); identity headers are + ignored. With auth disabled, legacy header/first-user behavior is + unchanged. + """ + if getattr(request.state, "auth_user_id", None) is not None: + return _auth_resolved_user_id(request, db) + + resolved = _resolve_header_user(db, x_env_0_gcal_user, x_mock_gcal_user) if resolved: return resolved diff --git a/packages/environments/mock-gcal/mock_gcal/auth_scopes.py b/packages/environments/mock-gcal/mock_gcal/auth_scopes.py new file mode 100644 index 000000000..cf960ae55 --- /dev/null +++ b/packages/environments/mock-gcal/mock_gcal/auth_scopes.py @@ -0,0 +1,115 @@ +"""Per-route OAuth scope requirements for auth (used when AUTH_ENABLED=1). + +``SCOPE_MAP`` keys are ``(HTTP_METHOD, route_path_template)`` tuples EXACTLY as +the routes are registered on the FastAPI app (see ``mock_gcal/api/app.py``). +Values are OR-logic scope lists: ANY one listed scope grants access. Routes +absent from the map (and exempt prefixes like /_admin, /health, /dev, /mcp) +require a valid token but no particular scope. + +Scope names are the bare auth names (``calendar.readonly``, not full +Google URLs). The contract pins exactly four Calendar scopes: + +- ``calendar.readonly`` read calendars, events, freebusy +- ``calendar.events`` CRUD on events +- ``calendar.events.readonly`` read events only +- ``calendar.full`` all Calendar operations + +Mapping (per the pinned contract, mirroring real Google Calendar semantics): + +- reads -> calendar.readonly | calendar.events | calendar.full + (+ calendar.events.readonly for event GETs, which never expose anything + beyond events) +- event mutations -> calendar.events | calendar.full +- calendar / calendarList mutations -> calendar.full only +- ACL (sharing) endpoints -> calendar.full ONLY, including reads: real Google + gates every Acl method behind the full calendar scope +- freebusy -> calendar.readonly | calendar.full (contract pin) +- channels.stop -> any calendar scope (real Google allows stopping a + channel with whichever scope created it) + +This module deliberately has NO env_0_auth_client import so that gcal +works unchanged when auth-client is not installed. + +Coverage of every registered /calendar/v3 route is enforced by +``tests/test_auth_integration.py::TestScopeMapCoverage``. +""" + +from __future__ import annotations + +# Mirrors env_0_auth_client.ScopeMap (kept local: auth-client is optional). +ScopeMap = dict[tuple[str, str], list[str]] + +GCAL_PREFIX = "/calendar/v3" +_U = f"{GCAL_PREFIX}/users/{{userId}}" +_C = f"{GCAL_PREFIX}/calendars/{{calendarId}}" + +FULL = "calendar.full" # equivalent of https://www.googleapis.com/auth/calendar +READONLY = "calendar.readonly" +EVENTS = "calendar.events" +EVENTS_READONLY = "calendar.events.readonly" + +# General (non-event) reads: calendarList, calendars.get, settings, colors, +# profile. calendar.events is accepted per the pinned contract read list; +# calendar.events.readonly is NOT (it is events-only). +READ = [READONLY, EVENTS, FULL] +# Event GETs (list/get/instances) and events.watch: also calendar.events.readonly. +EVENT_READ = [READONLY, EVENTS_READONLY, EVENTS, FULL] +# Event mutations (insert/import/update/patch/move/quickAdd/delete). +EVENT_WRITE = [EVENTS, FULL] +# Calendar + calendarList mutations: full access only. +MANAGE = [FULL] +# ACL/sharing endpoints (ALL of them, reads included): full access only, +# like real Google Calendar. The delegated-access track relies on this. +ACL = [FULL] +# freebusy query (contract pin: readonly|full). +FREEBUSY = [READONLY, FULL] +# channels.stop: any calendar scope may stop a watch channel. +CHANNEL_STOP = [READONLY, EVENTS_READONLY, EVENTS, FULL] + +SCOPE_MAP: ScopeMap = { + # --- calendarList --- + ("GET", f"{_U}/calendarList"): READ, + ("GET", f"{_U}/calendarList/{{calendarId}}"): READ, + ("POST", f"{_U}/calendarList"): MANAGE, + ("PATCH", f"{_U}/calendarList/{{calendarId}}"): MANAGE, + ("PUT", f"{_U}/calendarList/{{calendarId}}"): MANAGE, + ("DELETE", f"{_U}/calendarList/{{calendarId}}"): MANAGE, + ("POST", f"{_U}/calendarList/watch"): READ, + # --- calendars --- + ("GET", _C): READ, + ("POST", f"{GCAL_PREFIX}/calendars"): MANAGE, + ("PATCH", _C): MANAGE, + ("PUT", _C): MANAGE, + ("POST", f"{_C}/clear"): MANAGE, + ("DELETE", _C): MANAGE, + # --- events --- + ("GET", f"{_C}/events"): EVENT_READ, + ("GET", f"{_C}/events/{{eventId}}"): EVENT_READ, + ("GET", f"{_C}/events/{{eventId}}/instances"): EVENT_READ, + ("POST", f"{_C}/events"): EVENT_WRITE, + ("POST", f"{_C}/events/import"): EVENT_WRITE, + ("PUT", f"{_C}/events/{{eventId}}"): EVENT_WRITE, + ("PATCH", f"{_C}/events/{{eventId}}"): EVENT_WRITE, + ("POST", f"{_C}/events/{{eventId}}/move"): EVENT_WRITE, + ("POST", f"{_C}/events/quickAdd"): EVENT_WRITE, + ("POST", f"{_C}/events/watch"): EVENT_READ, + ("DELETE", f"{_C}/events/{{eventId}}"): EVENT_WRITE, + # --- acl (sharing): calendar.full ONLY, including reads --- + ("GET", f"{_C}/acl"): ACL, + ("GET", f"{_C}/acl/{{ruleId}}"): ACL, + ("POST", f"{_C}/acl"): ACL, + ("PATCH", f"{_C}/acl/{{ruleId}}"): ACL, + ("PUT", f"{_C}/acl/{{ruleId}}"): ACL, + ("DELETE", f"{_C}/acl/{{ruleId}}"): ACL, + ("POST", f"{_C}/acl/watch"): ACL, + # --- colors / freebusy / channels --- + ("GET", f"{GCAL_PREFIX}/colors"): READ, + ("POST", f"{GCAL_PREFIX}/freeBusy"): FREEBUSY, + ("POST", f"{GCAL_PREFIX}/channels/stop"): CHANNEL_STOP, + # --- settings (read-only in this mock: list/get/watch) --- + ("GET", f"{_U}/settings"): READ, + ("GET", f"{_U}/settings/{{setting}}"): READ, + ("POST", f"{_U}/settings/watch"): READ, + # --- profile (mock-specific helper endpoint) --- + ("GET", f"{_U}/profile"): READ, +} diff --git a/packages/environments/mock-gcal/pyproject.toml b/packages/environments/mock-gcal/pyproject.toml index 550461241..8ce3332fc 100644 --- a/packages/environments/mock-gcal/pyproject.toml +++ b/packages/environments/mock-gcal/pyproject.toml @@ -25,6 +25,15 @@ gym = ["gymnasium>=0.29.0"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-json-report>=1.5", "ruff>=0.3.0"] all = ["mock-gcal[mcp,gym,dev]"] +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-json-report>=1.5", + "pyjwt>=2.8", + "cryptography>=43.0", +] + [project.scripts] mock-gcal = "mock_gcal.cli:cli" diff --git a/packages/environments/mock-gcal/tests/conftest.py b/packages/environments/mock-gcal/tests/conftest.py index f7f562965..8f4e9e3df 100644 --- a/packages/environments/mock-gcal/tests/conftest.py +++ b/packages/environments/mock-gcal/tests/conftest.py @@ -12,6 +12,15 @@ if str(_PKG_ROOT) not in sys.path: sys.path.insert(0, str(_PKG_ROOT)) +# Make sibling auth-client importable for auth integration tests without a +# pyproject path dependency, which would break shallow task-image installs. +_client_pkg = Path(__file__).resolve().parents[3] / "auth-client" +if _client_pkg.is_dir(): + try: + import env_0_auth_client # noqa: F401 + except ImportError: + sys.path.insert(0, str(_client_pkg)) + from mock_gcal.models import init_db, reset_engine from mock_gcal.seed.generator import seed_database diff --git a/packages/environments/mock-gcal/tests/test_auth_integration.py b/packages/environments/mock-gcal/tests/test_auth_integration.py new file mode 100644 index 000000000..5f7a1b2cc --- /dev/null +++ b/packages/environments/mock-gcal/tests/test_auth_integration.py @@ -0,0 +1,684 @@ +"""Integration tests for the auth retrofit (AUTH_ENABLED). + +Fully offline: JWKS is injected statically via env_0_auth_client.testing, and +event reporting is disabled (AUTH_REPORT=0) except where a MockTransport +captures events explicitly. + +The FastAPI app in mock_gcal.api.app is a module-level singleton and +AUTH_ENABLED is read at import time, so the ``auth_app`` fixture builds a +FRESH instance via importlib.reload (with auth disabled, so the module-level +``_apply_auth(app)`` is a no-op) and then applies the middleware explicitly +with the static JWKS. Teardown reloads once more so every other test module +keeps using a pristine, auth-disabled singleton. +""" + +import importlib +import json +import sys +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from env_0_auth_client.testing import generate_test_keypair, jwks_for, make_jwt +from mock_gcal.models import init_db, reset_engine +from mock_gcal.seed.generator import seed_database + +KID = "test-key-001" +PRIVATE_KEY, PUBLIC_KEY = generate_test_keypair() +JWKS = jwks_for(PUBLIC_KEY, kid=KID) + +# Actual identities seeded by scenario "default", seed 42 (see +# mock_gcal/seed/generator.py): the auth seeds must match THESE ids. +ALICE = "user1" +ALICE_EMAIL = "alex@nexusai.com" +# Second user: only exists in DBs seeded with num_users=2 (two_user_client); +# for impersonation tests the check fires before any DB lookup, so the user +# does not need to exist in the seeded DB. +OTHER_USER = "user2" +OTHER_EMAIL = "alex2@nexusai.com" + + +def _token(scope: str = "", sub: str = ALICE, **kwargs) -> str: + return make_jwt(private_key=PRIVATE_KEY, kid=KID, sub=sub, scope=scope, **kwargs) + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _rfc3339(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _event_body(summary: str = "Auth test event") -> dict: + start = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=1) + return { + "summary": summary, + "start": {"dateTime": _rfc3339(start)}, + "end": {"dateTime": _rfc3339(start + timedelta(hours=1))}, + } + + +def _freebusy_body() -> dict: + now = datetime.now(timezone.utc).replace(microsecond=0) + return { + "timeMin": _rfc3339(now), + "timeMax": _rfc3339(now + timedelta(days=2)), + "items": [{"id": "primary"}], + } + + +@pytest.fixture +def auth_app(monkeypatch): + """Fresh app instance with Env_0AuthMiddleware and static (offline) JWKS.""" + for var in ("AUTH_ENABLED", "AUTH_INTROSPECT", "AUTH_ISSUER", "AUTH_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("AUTH_REPORT", "0") + + # NOTE: `import mock_gcal.api.app as m` would bind the FastAPI INSTANCE + # (mock_gcal/api/__init__.py does `from .app import app`, shadowing the + # submodule attribute); import_module resolves the real module object. + app_module = importlib.import_module("mock_gcal.api.app") + + app_module = importlib.reload(app_module) # fresh singleton, no auth yet + monkeypatch.setenv("AUTH_ENABLED", "1") + assert app_module._apply_auth(app_module.app, jwks_static=JWKS) is True + + yield app_module.app + + # Restore a pristine auth-disabled singleton for the other test modules. + monkeypatch.delenv("AUTH_ENABLED", raising=False) + importlib.reload(app_module) + + +@pytest.fixture +def auth_client(auth_app, seeded_db): + """TestClient against the auth-enabled fresh app + seeded temp database.""" + reset_engine() + init_db(seeded_db) + with TestClient(auth_app) as c: + yield c + reset_engine() + + +@pytest.fixture +def two_user_db(db_path): + """Temp DB seeded with TWO users (user1 + user2) for header-bypass tests.""" + reset_engine() + seed_database(scenario="default", seed=42, db_path=db_path, num_users=2) + return db_path + + +@pytest.fixture +def two_user_auth_client(auth_app, two_user_db): + """Auth-enabled TestClient against the two-user database.""" + reset_engine() + init_db(two_user_db) + with TestClient(auth_app) as c: + yield c + reset_engine() + + +@pytest.fixture +def two_user_client(two_user_db): + """Legacy (auth-disabled) TestClient against the two-user database.""" + reset_engine() + init_db(two_user_db) + from mock_gcal.api.app import app + + with TestClient(app) as c: + yield c + reset_engine() + + +class TestScopeMapCoverage: + def test_every_gcal_route_has_scope_map_entry(self): + """Every registered /calendar/v3 route must appear in SCOPE_MAP (and no stale keys).""" + from mock_gcal.api.app import app + from mock_gcal.auth_scopes import GCAL_PREFIX, SCOPE_MAP + + registered = set() + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith(GCAL_PREFIX): + for method in route.methods: + if method not in ("HEAD", "OPTIONS"): + registered.add((method, route.path)) + + assert registered, "no /calendar/v3 routes registered -- enumeration broken?" + missing = registered - set(SCOPE_MAP) + assert not missing, f"routes missing from SCOPE_MAP: {sorted(missing)}" + stale = set(SCOPE_MAP) - registered + assert not stale, f"SCOPE_MAP keys matching no registered route: {sorted(stale)}" + + def test_scope_lists_are_nonempty_calendar_scopes(self): + from mock_gcal.auth_scopes import SCOPE_MAP + + for key, scopes in SCOPE_MAP.items(): + assert scopes, f"empty scope list for {key}" + for scope in scopes: + assert scope.startswith("calendar."), f"unexpected scope {scope!r} for {key}" + + def test_acl_endpoints_require_full_only(self): + """Sharing/ACL endpoints (reads included) are gated behind calendar.full.""" + from mock_gcal.auth_scopes import SCOPE_MAP + + acl_keys = [k for k in SCOPE_MAP if "/acl" in k[1]] + assert acl_keys, "no ACL routes in SCOPE_MAP?" + for key in acl_keys: + assert SCOPE_MAP[key] == ["calendar.full"], f"{key} must be calendar.full-only" + + +class TestAuthEnabled: + def test_me_resolves_to_token_sub(self, auth_client): + resp = auth_client.get( + "/calendar/v3/users/me/profile", headers=_bearer(_token("calendar.readonly")) + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_valid_token_lists_events(self, auth_client): + resp = auth_client.get( + "/calendar/v3/calendars/primary/events", + headers=_bearer(_token("calendar.readonly")), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["items"] + + def test_explicit_matching_user_id_allowed(self, auth_client): + resp = auth_client.get( + f"/calendar/v3/users/{ALICE}/settings", + headers=_bearer(_token("calendar.readonly")), + ) + assert resp.status_code == 200 + + def test_events_readonly_reads_events_but_not_calendar_list(self, auth_client): + # calendar.events.readonly may read events... + listing = auth_client.get( + "/calendar/v3/calendars/primary/events", + headers=_bearer(_token("calendar.events.readonly")), + ) + assert listing.status_code == 200 + # ...but it is events-only: calendarList reads are rejected. + resp = auth_client.get( + "/calendar/v3/users/me/calendarList", + headers=_bearer(_token("calendar.events.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["calendar.readonly", "calendar.events", "calendar.full"] + assert "calendar.events.readonly" not in err["required_scopes"] + + def test_event_insert_without_write_scope_403(self, auth_client): + resp = auth_client.post( + "/calendar/v3/calendars/primary/events", + json=_event_body(), + headers=_bearer(_token("calendar.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["code"] == 403 + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["calendar.events", "calendar.full"] + assert err["token_scopes"] == ["calendar.readonly"] + assert "hint" in err + + def test_event_insert_with_events_scope_200(self, auth_client): + resp = auth_client.post( + "/calendar/v3/calendars/primary/events", + json=_event_body(), + headers=_bearer(_token("calendar.events")), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] + assert data["summary"] == "Auth test event" + + def test_acl_read_requires_full_scope(self, auth_client): + denied = auth_client.get( + "/calendar/v3/calendars/primary/acl", + headers=_bearer(_token("calendar.readonly")), + ) + assert denied.status_code == 403 + err = denied.json()["error"] + assert err["required_scopes"] == ["calendar.full"] + assert err["token_scopes"] == ["calendar.readonly"] + + allowed = auth_client.get( + "/calendar/v3/calendars/primary/acl", + headers=_bearer(_token("calendar.full")), + ) + assert allowed.status_code == 200 + + def test_calendar_mutation_requires_full_scope(self, auth_client): + resp = auth_client.patch( + "/calendar/v3/calendars/primary", + json={"summary": "Renamed"}, + headers=_bearer(_token("calendar.events")), + ) + assert resp.status_code == 403 + assert resp.json()["error"]["required_scopes"] == ["calendar.full"] + + def test_freebusy_scopes(self, auth_client): + # contract pin: freebusy -> calendar.readonly | calendar.full + ok = auth_client.post( + "/calendar/v3/freeBusy", + json=_freebusy_body(), + headers=_bearer(_token("calendar.readonly")), + ) + assert ok.status_code == 200 + assert "primary" in ok.json()["calendars"] + + denied = auth_client.post( + "/calendar/v3/freeBusy", + json=_freebusy_body(), + headers=_bearer(_token("calendar.events")), + ) + assert denied.status_code == 403 + assert denied.json()["error"]["required_scopes"] == ["calendar.readonly", "calendar.full"] + + def test_mismatching_user_id_403_impersonation_body(self, auth_client): + resp = auth_client.get( + f"/calendar/v3/users/{OTHER_USER}/settings", + headers=_bearer(_token("calendar.readonly", sub=ALICE)), + ) + assert resp.status_code == 403 + # Exact contract body -- NOT the Google Calendar error envelope. + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALICE, + "requested_user": OTHER_USER, + } + } + + def test_impersonation_attempt_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + monkeypatch.setenv("AUTH_REPORT", "1") # read at call time + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.get( + f"/calendar/v3/users/{OTHER_USER}/settings", + headers=_bearer(_token("calendar.readonly", sub=ALICE)), + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "impersonation_attempt"] + assert attempts, f"no impersonation_attempt among events: {events}" + event = attempts[0] + assert event["user_id"] == ALICE + assert event["details"]["authenticated_user"] == ALICE + assert event["details"]["requested_user"] == OTHER_USER + + def test_expired_token_401_with_refresh_hint(self, auth_client): + resp = auth_client.get( + "/calendar/v3/users/me/calendarList", + headers=_bearer(_token("calendar.readonly", expires_in=-60)), + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "expired at" in err["message"] + assert "/oauth2/token" in err["hint"] + assert "grant_type=refresh_token" in err["hint"] + assert resp.headers["WWW-Authenticate"].startswith('Bearer error="invalid_token"') + + def test_no_token_401(self, auth_client): + resp = auth_client.get("/calendar/v3/users/me/calendarList") + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "WWW-Authenticate" in resp.headers + + def test_bad_signature_401(self, auth_client): + other_private, _ = generate_test_keypair() + forged = make_jwt(private_key=other_private, kid=KID, sub=ALICE, scope="calendar.full") + resp = auth_client.get("/calendar/v3/users/me/calendarList", headers=_bearer(forged)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_token_sub_unknown_user_404(self, auth_client): + # Verified token whose sub has no gcal User row: 404 (Google + # envelope), protecting handlers like get_profile from a 500. + resp = auth_client.get( + "/calendar/v3/users/me/profile", + headers=_bearer(_token("calendar.readonly", sub="ghost")), + ) + assert resp.status_code == 404 + assert "ghost" in resp.json()["error"]["message"] + + def test_actor_endpoint_uses_token_sub(self, auth_client): + # Endpoints without a userId path param resolve the actor from the + # token: the created event lands in user1's primary calendar. + resp = auth_client.post( + "/calendar/v3/calendars/primary/events", + json=_event_body("Actor identity event"), + headers=_bearer(_token("calendar.events", sub=ALICE)), + ) + assert resp.status_code == 200 + event_id = resp.json()["id"] + readback = auth_client.get( + f"/calendar/v3/calendars/primary/events/{event_id}", + headers=_bearer(_token("calendar.readonly", sub=ALICE)), + ) + assert readback.status_code == 200 + + def test_health_unauthenticated(self, auth_client): + resp = auth_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_admin_state_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/state") + assert resp.status_code == 200 + assert "users" in resp.json() + + def test_admin_action_log_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/action_log") + assert resp.status_code == 200 + + +class TestEmailClaimFallback: + """Identity-alignment: a token whose ``sub`` is not a local user id is + resolved via its ``email`` claim (case-insensitive); the resolved LOCAL id + is the effective identity everywhere downstream (userId routes AND actor + routes). + """ + + # env-0-auth-style id that does NOT exist in gcal's seeds. + NONLOCAL_SUB = "user_001" + + def _fallback_token(self, scope: str = "calendar.readonly", email: str = ALICE_EMAIL) -> str: + return _token(scope, sub=self.NONLOCAL_SUB, email=email) + + def test_nonlocal_sub_with_seeded_email_resolves_me(self, auth_client): + resp = auth_client.get( + "/calendar/v3/users/me/calendarList", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + assert resp.json()["items"] + + def test_email_match_is_case_insensitive(self, auth_client): + resp = auth_client.get( + "/calendar/v3/users/me/calendarList", + headers=_bearer(self._fallback_token(email=ALICE_EMAIL.upper())), + ) + assert resp.status_code == 200 + + def test_actor_endpoint_resolves_via_email_claim(self, auth_client): + # Routes without a userId path param (resolve_actor_user_id): the + # event is created in the RESOLVED local user's primary calendar. + resp = auth_client.post( + "/calendar/v3/calendars/primary/events", + json=_event_body("Email-fallback actor event"), + headers=_bearer(self._fallback_token("calendar.events")), + ) + assert resp.status_code == 200 + event_id = resp.json()["id"] + # Readable back as user1 (the local identity the token resolved to). + readback = auth_client.get( + f"/calendar/v3/calendars/primary/events/{event_id}", + headers=_bearer(_token("calendar.readonly", sub=ALICE)), + ) + assert readback.status_code == 200 + + def test_explicit_resolved_local_id_does_not_trip_guard(self, auth_client): + resp = auth_client.get( + f"/calendar/v3/users/{ALICE}/settings", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + + def test_genuinely_different_user_still_403s(self, two_user_auth_client): + resp = two_user_auth_client.get( + f"/calendar/v3/users/{OTHER_USER}/settings", + headers=_bearer(self._fallback_token()), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["message"] == "Cannot access another user's resources" + # The contract body names the RESOLVED local identity. + assert err["authenticated_user"] == ALICE + assert err["requested_user"] == OTHER_USER + + def test_unknown_sub_and_unknown_email_404_as_before(self, auth_client): + resp = auth_client.get( + "/calendar/v3/users/me/profile", + headers=_bearer(_token("calendar.readonly", sub="ghost", email="ghost@nowhere.test")), + ) + assert resp.status_code == 404 + assert "ghost" in resp.json()["error"]["message"] + + def test_local_sub_wins_over_email_claim(self, two_user_auth_client): + # Resolution order pin: id == sub (a) beats the email claim (b) -- a + # token for user2 carrying user1's email acts as user2. + resp = two_user_auth_client.get( + "/calendar/v3/users/me/settings", + headers=_bearer(_token("calendar.readonly", sub=OTHER_USER, email=ALICE_EMAIL)), + ) + assert resp.status_code == 200 + # Settings exist per-user; verify identity via the explicit path form. + explicit = two_user_auth_client.get( + f"/calendar/v3/users/{OTHER_USER}/settings", + headers=_bearer(_token("calendar.readonly", sub=OTHER_USER, email=ALICE_EMAIL)), + ) + assert explicit.status_code == 200 + + +class TestWebUIExemption: + """With AUTH_ENABLED=1, the human web UI stays usable WITHOUT a token. + + The web dashboard identifies users via the mock_gcal_user cookie (browser + session), not Bearer tokens -- the real-world session-vs-API split. /mcp is + likewise exempt (agent MCP clients are out of OAuth scope for v1). The API + under /calendar/v3 still requires Bearer tokens. See + mock_gcal/api/auth_middleware.py. + """ + + def test_calendar_root_no_token_200(self, auth_client): + resp = auth_client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_calendar_root_with_query_params_no_token_200(self, auth_client): + # Query strings don't change the path: still the exact-match "/" route. + resp = auth_client.get("/", params={"week": "2026-06-08"}) + assert resp.status_code == 200 + + def test_dev_routes_no_token_200(self, auth_client): + resp = auth_client.get("/dev/api-explorer") + assert resp.status_code == 200 + + def test_mcp_not_gated_by_auth(self, auth_client): + # MCP is not mounted in tests: a 404 (rather than 401) proves the + # middleware exempts /mcp instead of demanding a Bearer token. + resp = auth_client.get("/mcp") + assert resp.status_code == 404 + + def test_api_still_requires_token_while_web_is_open(self, auth_client): + # The "/" exemption is EXACT-match, not a "/" prefix that would + # swallow every path: the API must still 401 without a token. + assert auth_client.get("/").status_code == 200 + resp = auth_client.get("/calendar/v3/users/me/calendarList") + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_exempt_prefixes_extend_contract_defaults(self): + from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + from mock_gcal.api.auth_middleware import GCAL_EXEMPT_PREFIXES + + for prefix in DEFAULT_EXEMPT_PREFIXES: + assert prefix in GCAL_EXEMPT_PREFIXES + for prefix in ("/static", "/mcp"): + assert prefix in GCAL_EXEMPT_PREFIXES + + +class TestAuthDisabledRegression: + """CRITICAL: with AUTH_ENABLED unset, behavior is byte-identical legacy. + + These tests use the standard ``client`` fixture from conftest (the module + singleton, which was assembled with auth disabled) or ``two_user_client`` + where a second user is needed to observe header-based switching. + """ + + def test_singleton_has_no_auth_middleware(self, client): + from mock_gcal.api.app import app + + assert not any( + "Env_0AuthMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "Env_0AuthMiddleware must not be installed when AUTH_ENABLED is unset" + + def test_no_bearer_required_me_falls_back_to_first_user(self, client): + resp = client.get("/calendar/v3/users/me/profile") + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_header_resolution_by_id(self, two_user_client): + resp = two_user_client.get( + "/calendar/v3/users/me/profile", headers={"X-Env-0-Gcal-User": OTHER_USER} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == OTHER_EMAIL + + def test_header_resolution_by_email(self, two_user_client): + resp = two_user_client.get( + "/calendar/v3/users/me/profile", headers={"X-Env-0-Gcal-User": OTHER_EMAIL} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == OTHER_EMAIL + + def test_mock_gcal_user_header_also_resolves(self, two_user_client): + # Legacy gcal quirk (unlike gmail): X-Mock-Gcal-User is a resolution + # fallback after X-Env-0-Gcal-User. + resp = two_user_client.get( + "/calendar/v3/users/me/profile", headers={"X-Mock-Gcal-User": OTHER_USER} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == OTHER_EMAIL + + def test_explicit_unknown_user_404_gcal_envelope(self, client): + resp = client.get("/calendar/v3/users/no_such_user/settings") + assert resp.status_code == 404 + err = resp.json()["error"] + # Legacy Google envelope (has 'errors' list), not the auth contract body. + assert err["status"] == "NOT_FOUND" + assert err["errors"][0]["reason"] == "notFound" + + def test_apply_auth_returns_false_when_disabled(self, monkeypatch): + app_module = importlib.import_module("mock_gcal.api.app") + + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert app_module._apply_auth(FastAPI()) is False + + def test_apply_auth_raises_runtime_error_without_package(self, monkeypatch): + app_module = importlib.import_module("mock_gcal.api.app") + + monkeypatch.setenv("AUTH_ENABLED", "1") + # None in sys.modules makes `import env_0_auth_client` raise ImportError. + monkeypatch.setitem(sys.modules, "env_0_auth_client", None) + with pytest.raises(RuntimeError, match="auth-client is not installed"): + app_module._apply_auth(FastAPI()) + + +class TestNoHeaderBypassWhenAuthEnabled: + """X-Env-0-Gcal-User / X-Mock-Gcal-User must be IGNORED under auth. + + Legacy identity headers must never override (or substitute for) the + token's sub -- on userId routes AND on actor-resolved routes. + """ + + def test_header_cannot_switch_me_to_another_user(self, two_user_auth_client): + resp = two_user_auth_client.get( + "/calendar/v3/users/me/profile", + headers={ + **_bearer(_token("calendar.readonly", sub=ALICE)), + "X-Env-0-Gcal-User": OTHER_USER, # exists in this DB -- still ignored + }, + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_header_by_email_is_ignored_too(self, two_user_auth_client): + resp = two_user_auth_client.get( + "/calendar/v3/users/me/profile", + headers={ + **_bearer(_token("calendar.readonly", sub=ALICE)), + "X-Env-0-Gcal-User": OTHER_EMAIL, + }, + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_mock_gcal_user_header_is_ignored_too(self, two_user_auth_client): + resp = two_user_auth_client.get( + "/calendar/v3/users/me/profile", + headers={ + **_bearer(_token("calendar.readonly", sub=ALICE)), + "X-Mock-Gcal-User": OTHER_USER, + }, + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_header_cannot_switch_actor_endpoints(self, two_user_auth_client): + # Actor-resolved endpoint (no userId in path): the event must land in + # user1's primary calendar despite the header naming user2. + resp = two_user_auth_client.post( + "/calendar/v3/calendars/primary/events", + json=_event_body("Header bypass attempt"), + headers={ + **_bearer(_token("calendar.events", sub=ALICE)), + "X-Env-0-Gcal-User": OTHER_USER, + }, + ) + assert resp.status_code == 200 + event_id = resp.json()["id"] + # Visible in user1's primary calendar... + as_user1 = two_user_auth_client.get( + f"/calendar/v3/calendars/primary/events/{event_id}", + headers=_bearer(_token("calendar.readonly", sub=ALICE)), + ) + assert as_user1.status_code == 200 + # ...and NOT in user2's. + as_user2 = two_user_auth_client.get( + f"/calendar/v3/calendars/primary/events/{event_id}", + headers=_bearer(_token("calendar.readonly", sub=OTHER_USER)), + ) + assert as_user2.status_code == 404 + + def test_header_without_token_is_still_401(self, two_user_auth_client): + resp = two_user_auth_client.get( + "/calendar/v3/users/me/profile", + headers={"X-Env-0-Gcal-User": ALICE}, + ) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_header_cannot_bless_impersonation(self, two_user_auth_client): + # Explicit foreign userId + matching header still 403s. + resp = two_user_auth_client.get( + f"/calendar/v3/users/{OTHER_USER}/settings", + headers={ + **_bearer(_token("calendar.readonly", sub=ALICE)), + "X-Env-0-Gcal-User": OTHER_USER, + }, + ) + assert resp.status_code == 403 + assert resp.json()["error"]["message"] == "Cannot access another user's resources" diff --git a/packages/environments/mock-gcal/uv.lock b/packages/environments/mock-gcal/uv.lock index edbd39536..2b750872d 100644 --- a/packages/environments/mock-gcal/uv.lock +++ b/packages/environments/mock-gcal/uv.lock @@ -655,6 +655,15 @@ mcp = [ { name = "fastapi-mcp" }, ] +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-json-report" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, @@ -676,6 +685,15 @@ requires-dist = [ ] provides-extras = ["mcp", "gym", "dev", "all"] +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=43.0" }, + { name = "pyjwt", specifier = ">=2.8" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-json-report", specifier = ">=1.5" }, +] + [[package]] name = "numpy" version = "2.4.3" diff --git a/packages/environments/mock-gdoc/mock_gdoc/api/app.py b/packages/environments/mock-gdoc/mock_gdoc/api/app.py index 91505bbf1..63876d385 100644 --- a/packages/environments/mock-gdoc/mock_gdoc/api/app.py +++ b/packages/environments/mock-gdoc/mock_gdoc/api/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError @@ -296,3 +297,44 @@ def admin_task_evaluate(task_name: str): @app.get("/health") def health(): return {"status": "ok"} + + +# --- auth integration (optional; enabled via AUTH_ENABLED) --- +from .deps import ImpersonationError # noqa: E402 + + +@app.exception_handler(ImpersonationError) +async def impersonation_error_handler(request: Request, exc: ImpersonationError): + """Contract-pinned 403 impersonation body.""" + from env_0_auth_client.errors import impersonation_body + + return JSONResponse( + status_code=403, + content=impersonation_body(exc.authenticated_user, exc.requested_user), + ) + + +def _auth_enabled() -> bool: + return os.environ.get("AUTH_ENABLED", "").strip().lower() in ("1", "true", "yes") + + +def _apply_auth(target_app: FastAPI, **middleware_kwargs) -> bool: + """Add GdocEnv_0AuthMiddleware to ``target_app`` when AUTH_ENABLED is truthy.""" + if not _auth_enabled(): + return False + try: + import env_0_auth_client # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "AUTH_ENABLED=1 but auth-client is not installed. " + "Install packages/auth-client or unset AUTH_ENABLED." + ) from exc + + from mock_gdoc.api.auth_middleware import GdocEnv_0AuthMiddleware + from mock_gdoc.auth_scopes import SCOPE_MAP + + target_app.add_middleware(GdocEnv_0AuthMiddleware, scope_map=SCOPE_MAP, **middleware_kwargs) + return True + + +_apply_auth(app) diff --git a/packages/environments/mock-gdoc/mock_gdoc/api/auth_middleware.py b/packages/environments/mock-gdoc/mock_gdoc/api/auth_middleware.py new file mode 100644 index 000000000..4f25cb8c9 --- /dev/null +++ b/packages/environments/mock-gdoc/mock_gdoc/api/auth_middleware.py @@ -0,0 +1,63 @@ +"""Gdoc-side wrapper around Env_0AuthMiddleware: web-UI + /mcp exemptions. + +Decision (v1, mirrors gmail; see API_NOTES.md "auth integration"): + +- The human web dashboard (``/``, ``/doc/{id}``, ``/doc/{id}/save``, + ``/dev/*``) is a browser UI with no per-user identity (it renders all + documents); it stays reachable WITHOUT a Bearer token when + ``AUTH_ENABLED=1`` -- mirroring the real-world split between browser + sessions and API credentials. +- ``/mcp`` is exempt: agent MCP clients are out of OAuth scope for v1. + +auth-client's public API is unchanged: the extra exemptions are plain +prefix additions passed through the existing ``exempt_prefixes`` parameter. +The one thing prefixes cannot express is the document list at ``/`` (gdoc's +web UI mounts at the root; a ``"/"`` PREFIX would exempt every path), so this +subclass short-circuits the exact path ``/`` before delegating to the +upstream ``dispatch``. +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import Response + +from env_0_auth_client import Env_0AuthMiddleware +from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + +#: Exact paths that bypass auth. Exact-match only -- "/" as a *prefix* would +#: exempt everything, which is why this set exists at all. +EXEMPT_EXACT_PATHS = frozenset({"/"}) + +#: Gdoc's web UI has no /web prefix, so each root-mounted route gets its own +#: precise prefix. /_admin, /health, /dev, /docs, /openapi.json and /web come +#: from the contract-pinned defaults. Prefix matching is segment-boundary +#: aware upstream, so "/doc" exempts /doc/{id} but NOT /docs (already exempt) +#: and never /v1/documents. +GDOC_EXEMPT_PREFIXES: tuple[str, ...] = DEFAULT_EXEMPT_PREFIXES + ( + "/doc", # GET /doc/{document_id}, POST /doc/{document_id}/save (web editor) + "/static", # static assets (none mounted today; defensive) + "/mcp", # MCP clients are out of OAuth scope for v1 +) + + +class GdocEnv_0AuthMiddleware(Env_0AuthMiddleware): + """Env_0AuthMiddleware with gdoc's exemptions baked in as defaults. + + Only the defaults differ; every kwarg (``scope_map``, ``jwks_static``, + ``audience``, ...) is forwarded untouched, and callers may still override + ``exempt_prefixes`` explicitly. + """ + + def __init__( + self, + app, + exempt_prefixes: tuple[str, ...] = GDOC_EXEMPT_PREFIXES, + **kwargs, + ): + super().__init__(app, exempt_prefixes=exempt_prefixes, **kwargs) + + async def dispatch(self, request: Request, call_next) -> Response: + if request.url.path in EXEMPT_EXACT_PATHS: + return await call_next(request) + return await super().dispatch(request, call_next) diff --git a/packages/environments/mock-gdoc/mock_gdoc/api/comments.py b/packages/environments/mock-gdoc/mock_gdoc/api/comments.py index 36497781b..c3c07af85 100644 --- a/packages/environments/mock-gdoc/mock_gdoc/api/comments.py +++ b/packages/environments/mock-gdoc/mock_gdoc/api/comments.py @@ -89,12 +89,13 @@ def list_comments( ): """List all comments on a document.""" check_document_access(db, documentId, user_id) + # Legacy Query has no .unique(); entity rows are deduped from joinedload + # collections when .all() materializes the results. comments = ( db.query(Comment) .options(*_comment_load_options()) .filter(Comment.document_id == documentId) .order_by(Comment.created_time.asc()) - .unique() .all() ) return CommentListResponse( diff --git a/packages/environments/mock-gdoc/mock_gdoc/api/deps.py b/packages/environments/mock-gdoc/mock_gdoc/api/deps.py index 5c2dbeeb6..2b13bc068 100644 --- a/packages/environments/mock-gdoc/mock_gdoc/api/deps.py +++ b/packages/environments/mock-gdoc/mock_gdoc/api/deps.py @@ -2,12 +2,35 @@ from __future__ import annotations -from fastapi import Header, HTTPException, Depends +from fastapi import Header, HTTPException, Depends, Request +from sqlalchemy import func from sqlalchemy.orm import Session from mock_gdoc.models import get_session_factory, Document, Permission, User +class ImpersonationError(Exception): + """Authenticated request named a user that is not the token's subject. + + gdoc has no userId path parameter; the X-Env-0-Gdoc-User and + X-Mock-Gdoc-User headers are the explicit identity channel, so a header + naming anyone but the token's sub is the impersonation analog of gmail's + mismatching path userId. + + Handled in ``mock_gdoc.api.app`` with the auth contract 403 body + (``env_0_auth_client.errors.impersonation_body``), deliberately NOT the + Google error envelope used for HTTPException. + """ + + def __init__(self, authenticated_user: str, requested_user: str): + super().__init__( + f"Cannot access another user's resources " + f"(authenticated as {authenticated_user!r}, requested {requested_user!r})" + ) + self.authenticated_user = authenticated_user + self.requested_user = requested_user + + def get_db() -> Session: """Yield a DB session.""" SessionLocal = get_session_factory() @@ -19,16 +42,60 @@ def get_db() -> Session: def resolve_user_id( + request: Request, + x_env_0_gdoc_user: str | None = Header(None), x_mock_gdoc_user: str | None = Header(None), db: Session = Depends(get_db), ) -> str: """Resolve the current user. - Priority: X-Mock-Gdoc-User header -> first user in DB. + With auth enabled (GdocEnv_0AuthMiddleware sets + request.state.auth_user_id): the token is mapped to a LOCAL user via + (a) id == token ``sub``, else (b) email == token ``email`` claim + (case-insensitive), else (c) the legacy 404. The resolved LOCAL id is the + effective identity. An X-Env-0-Gdoc-User or X-Mock-Gdoc-User header naming + the token's ``sub`` or the resolved local user (by id or email) is + redundant and allowed; a header naming ANYONE else raises the contract 403 + impersonation error (after reporting an impersonation_attempt event to + auth) -- the header can never switch or bless an identity. + + With auth disabled (the default), legacy behavior is unchanged: + X-Env-0-Gdoc-User / X-Mock-Gdoc-User header (id or email) -> first user in DB. """ - if x_mock_gdoc_user: + requested_user = x_env_0_gdoc_user or x_mock_gdoc_user + auth_user_id = getattr(request.state, "auth_user_id", None) + if auth_user_id is not None: + user = db.query(User).filter(User.id == auth_user_id).first() + if user is None: + auth_email = (getattr(request.state, "auth_email", "") or "").strip() + if auth_email: + user = db.query(User).filter( + func.lower(User.email) == auth_email.lower() + ).first() + effective_id = user.id if user is not None else auth_user_id + allowed = {auth_user_id, effective_id} + if user is not None: + allowed.add(user.email) + if requested_user and requested_user not in allowed: + # The middleware cannot see header-vs-sub mismatches; report here. + # env_0_auth_client is guaranteed importable: auth_user_id is only + # ever set by Env_0AuthMiddleware. + from env_0_auth_client import report_impersonation + + report_impersonation( + effective_id, + requested_user, + client_id=getattr(request.state, "auth_client_id", None), + scope=" ".join(getattr(request.state, "auth_scopes", None) or []), + ) + raise ImpersonationError(effective_id, requested_user) + if not user: + raise HTTPException(404, f"User {auth_user_id!r} not found") + return effective_id + + if requested_user: user = db.query(User).filter( - (User.id == x_mock_gdoc_user) | (User.email == x_mock_gdoc_user) + (User.id == requested_user) | (User.email == requested_user) ).first() if user: return user.id diff --git a/packages/environments/mock-gdoc/mock_gdoc/auth_scopes.py b/packages/environments/mock-gdoc/mock_gdoc/auth_scopes.py new file mode 100644 index 000000000..3e32943e1 --- /dev/null +++ b/packages/environments/mock-gdoc/mock_gdoc/auth_scopes.py @@ -0,0 +1,77 @@ +"""Per-route OAuth scope requirements for auth (used when AUTH_ENABLED=1). + +``SCOPE_MAP`` keys are ``(HTTP_METHOD, route_path_template)`` tuples EXACTLY as +the routes are registered on the FastAPI app (see ``mock_gdoc/api/app.py``). +Values are OR-logic scope lists: ANY one listed scope grants access. Routes +absent from the map (and exempt prefixes like /_admin, /health, /dev, /doc, +the web root "/") require a valid token but no particular scope. + +Scope names are the bare auth names (``docs.readonly``, not full Google +URLs); the contract-pinned Docs rules are: + +- document reads (documents.get) -> docs.readonly | docs.full +- ALL document mutations (create, + batchUpdate) -> docs.full only + +gdoc also serves comments and permissions under /v1/documents/* -- in +real Google these are DRIVE API (v3) resources (file metadata), not Docs API +endpoints. Decision (documented per mission): these drive-file-metadata proxy +routes ALSO accept the Drive scopes: + +- comment / permission reads -> + drive.readonly | drive.full +- comment / permission mutations -> docs.full | drive.full + +``drive.file`` / ``drive.metadata.readonly`` are deliberately NOT accepted: +this mock has no notion of per-app file provenance, and permissions/comments +reads expose more than bare metadata. + +This module deliberately has NO env_0_auth_client import so that gdoc +works unchanged when auth-client is not installed. + +Coverage of every registered /v1 route is enforced by +``tests/test_auth_integration.py::TestScopeMapCoverage``. +""" + +from __future__ import annotations + +# Mirrors env_0_auth_client.ScopeMap (kept local: auth-client is optional). +ScopeMap = dict[tuple[str, str], list[str]] + +DOCS_PREFIX = "/v1" +_D = f"{DOCS_PREFIX}/documents" + +DOCS_READONLY = "docs.readonly" +DOCS_FULL = "docs.full" +DRIVE_READONLY = "drive.readonly" +DRIVE_FULL = "drive.full" + +# Document body reads: Docs API proper (contract-pinned pair). +DOC_READ = [DOCS_READONLY, DOCS_FULL] +# Document mutations: docs.full ONLY (contract: all mutations need docs.full). +DOC_WRITE = [DOCS_FULL] +# Comments / permissions are Drive-file metadata proxies: reads additionally +# accept the Drive read scopes, mutations accept drive.full alongside docs.full. +META_READ = [DOCS_READONLY, DOCS_FULL, DRIVE_READONLY, DRIVE_FULL] +META_WRITE = [DOCS_FULL, DRIVE_FULL] + +SCOPE_MAP: ScopeMap = { + # --- documents (Docs API proper) --- + ("GET", f"{_D}/{{documentId}}"): DOC_READ, + ("POST", _D): DOC_WRITE, # documents.create + ("POST", f"{_D}/{{documentId}}:batchUpdate"): DOC_WRITE, + # --- comments (Drive API v3 resource, proxied under /v1/documents) --- + ("GET", f"{_D}/{{documentId}}/comments"): META_READ, + ("POST", f"{_D}/{{documentId}}/comments"): META_WRITE, + ("GET", f"{_D}/{{documentId}}/comments/{{commentId}}"): META_READ, + ("PATCH", f"{_D}/{{documentId}}/comments/{{commentId}}"): META_WRITE, + ("DELETE", f"{_D}/{{documentId}}/comments/{{commentId}}"): META_WRITE, + ("POST", f"{_D}/{{documentId}}/comments/{{commentId}}/resolve"): META_WRITE, + ("POST", f"{_D}/{{documentId}}/comments/{{commentId}}/reopen"): META_WRITE, + ("POST", f"{_D}/{{documentId}}/comments/{{commentId}}/replies"): META_WRITE, + ("DELETE", f"{_D}/{{documentId}}/comments/{{commentId}}/replies/{{replyId}}"): META_WRITE, + # --- permissions (Drive API v3 resource, proxied under /v1/documents) --- + ("GET", f"{_D}/{{documentId}}/permissions"): META_READ, + ("POST", f"{_D}/{{documentId}}/permissions"): META_WRITE, + ("PATCH", f"{_D}/{{documentId}}/permissions/{{permissionId}}"): META_WRITE, + ("DELETE", f"{_D}/{{documentId}}/permissions/{{permissionId}}"): META_WRITE, +} diff --git a/packages/environments/mock-gdoc/pyproject.toml b/packages/environments/mock-gdoc/pyproject.toml index e73180cc5..5c33245b0 100644 --- a/packages/environments/mock-gdoc/pyproject.toml +++ b/packages/environments/mock-gdoc/pyproject.toml @@ -27,6 +27,15 @@ mcp = ["fastapi-mcp>=0.1.0"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-json-report>=1.5", "ruff>=0.3.0"] all = ["mock-gdoc[mcp,dev]"] +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-json-report>=1.5", + "pyjwt>=2.8", + "cryptography>=43.0", +] + [project.scripts] mock-gdoc = "mock_gdoc.cli:cli" diff --git a/packages/environments/mock-gdoc/tests/__init__.py b/packages/environments/mock-gdoc/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/environments/mock-gdoc/tests/conftest.py b/packages/environments/mock-gdoc/tests/conftest.py index 353234724..b4272855f 100644 --- a/packages/environments/mock-gdoc/tests/conftest.py +++ b/packages/environments/mock-gdoc/tests/conftest.py @@ -1,8 +1,20 @@ """Pytest fixtures for mock-gdoc tests.""" +import sys +from pathlib import Path + import pytest from fastapi.testclient import TestClient +# Make sibling auth-client importable for auth integration tests without a +# pyproject path dependency, which would break shallow task-image installs. +_client_pkg = Path(__file__).resolve().parents[3] / "auth-client" +if _client_pkg.is_dir(): + try: + import env_0_auth_client # noqa: F401 + except ImportError: + sys.path.insert(0, str(_client_pkg)) + from mock_gdoc.models import init_db, reset_engine from mock_gdoc.seed.generator import seed_database diff --git a/packages/environments/mock-gdoc/tests/test_auth_integration.py b/packages/environments/mock-gdoc/tests/test_auth_integration.py new file mode 100644 index 000000000..5dc94c110 --- /dev/null +++ b/packages/environments/mock-gdoc/tests/test_auth_integration.py @@ -0,0 +1,600 @@ +"""Integration tests for the auth retrofit (AUTH_ENABLED). + +Fully offline: JWKS is injected statically via env_0_auth_client.testing, and +event reporting is disabled (AUTH_REPORT=0) except where a MockTransport +captures events explicitly. + +The FastAPI app in mock_gdoc.api.app is a module-level singleton and +AUTH_ENABLED is read at import time, so the ``auth_app`` fixture builds a +FRESH instance via importlib.reload (with auth disabled, so the module-level +``_apply_auth(app)`` is a no-op) and then applies the middleware explicitly +with the static JWKS. Teardown reloads once more so every other test module +keeps using a pristine, auth-disabled singleton. + +Identity note: gdoc has no userId path parameter -- the X-Env-0-Gdoc-User +header is the only explicit identity channel, so under auth the header is the +impersonation analog of gmail's mismatching path userId (mismatch -> contract +403 + impersonation_attempt report; matching/absent header -> token sub). +""" + +import importlib +import json +import sys + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from env_0_auth_client.testing import generate_test_keypair, jwks_for, make_jwt +from mock_gdoc.models import Document, Permission, get_session_factory, init_db, reset_engine + +KID = "test-key-001" +PRIVATE_KEY, PUBLIC_KEY = generate_test_keypair() +JWKS = jwks_for(PUBLIC_KEY, kid=KID) + +# Actual identities seeded by scenario "default", seed 42 (see +# mock_gdoc/seed/generator.py + mock_gdoc/seed/content.py): the primary user +# is user_0; personas get user_1..user_9 (user_1 = Sarah Chen). +ALEX = "user_0" +ALEX_EMAIL = "alex@nexusai.com" +# Any identity other than the token sub; the impersonation check fires before +# any requested-user DB lookup, so existence is irrelevant. +OTHER_USER = "user_1" + + +def _token(scope: str = "", sub: str = ALEX, **kwargs) -> str: + return make_jwt(private_key=PRIVATE_KEY, kid=KID, sub=sub, scope=scope, **kwargs) + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _owned_doc_id(unshared: bool = False, uncommented: bool = False) -> str: + """Id of a document owned by user_0 (optionally unshared / comment-free).""" + from mock_gdoc.models import Comment + + db = get_session_factory()() + try: + query = db.query(Document).filter(Document.user_id == ALEX) + if unshared: + shared_ids = {p.document_id for p in db.query(Permission).all()} + query = query.filter(~Document.id.in_(shared_ids)) + if uncommented: + commented_ids = {c.document_id for c in db.query(Comment).all()} + query = query.filter(~Document.id.in_(commented_ids)) + doc = query.first() + assert doc is not None + return doc.id + finally: + db.close() + + +@pytest.fixture +def auth_app(monkeypatch): + """Fresh app instance with GdocEnv_0AuthMiddleware and static (offline) JWKS.""" + for var in ("AUTH_ENABLED", "AUTH_INTROSPECT", "AUTH_ISSUER", "AUTH_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("AUTH_REPORT", "0") + + import mock_gdoc.api.app as app_module + + app_module = importlib.reload(app_module) # fresh singleton, no auth yet + monkeypatch.setenv("AUTH_ENABLED", "1") + assert app_module._apply_auth(app_module.app, jwks_static=JWKS) is True + + yield app_module.app + + # Restore a pristine auth-disabled singleton for the other test modules. + monkeypatch.delenv("AUTH_ENABLED", raising=False) + importlib.reload(app_module) + + +@pytest.fixture +def auth_client(auth_app, seeded_db): + """TestClient against the auth-enabled fresh app + seeded temp database.""" + reset_engine() + init_db(seeded_db) + with TestClient(auth_app) as c: + yield c + reset_engine() + + +class TestScopeMapCoverage: + def test_every_api_route_has_scope_map_entry(self): + """Every registered /v1 route must appear in SCOPE_MAP (and no stale keys).""" + from mock_gdoc.api.app import app + from mock_gdoc.auth_scopes import DOCS_PREFIX, SCOPE_MAP + + registered = set() + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith(DOCS_PREFIX): + for method in route.methods: + if method not in ("HEAD", "OPTIONS"): + registered.add((method, route.path)) + + assert registered, "no /v1 routes registered -- enumeration broken?" + missing = registered - set(SCOPE_MAP) + assert not missing, f"routes missing from SCOPE_MAP: {sorted(missing)}" + stale = set(SCOPE_MAP) - registered + assert not stale, f"SCOPE_MAP keys matching no registered route: {sorted(stale)}" + + def test_scope_lists_are_nonempty_docs_or_drive_scopes(self): + from mock_gdoc.auth_scopes import SCOPE_MAP + + for key, scopes in SCOPE_MAP.items(): + assert scopes, f"empty scope list for {key}" + for scope in scopes: + assert scope.startswith(("docs.", "drive.")), ( + f"unexpected scope {scope!r} for {key}" + ) + + def test_mutations_never_accept_readonly_scopes(self): + """Contract: ALL document mutations require a full scope.""" + from mock_gdoc.auth_scopes import SCOPE_MAP + + for (method, path), scopes in SCOPE_MAP.items(): + if method in ("POST", "PUT", "PATCH", "DELETE"): + assert not any(s.endswith(".readonly") for s in scopes), ( + f"mutation {method} {path} must not accept a readonly scope" + ) + assert "docs.full" in scopes + + +class TestAuthEnabled: + def test_valid_readonly_token_reads_document(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", headers=_bearer(_token("docs.readonly")) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["documentId"] == doc_id + assert "title" in data + + def test_create_without_full_scope_403(self, auth_client): + resp = auth_client.post( + "/v1/documents", + json={"title": "Scope test"}, + headers=_bearer(_token("docs.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["code"] == 403 + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["docs.full"] + assert err["token_scopes"] == ["docs.readonly"] + assert "hint" in err + + def test_create_with_full_scope_200(self, auth_client): + resp = auth_client.post( + "/v1/documents", + json={"title": "Created under auth"}, + headers=_bearer(_token("docs.full")), + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "Created under auth" + + def test_batch_update_requires_full_scope(self, auth_client): + doc_id = _owned_doc_id() + body = {"requests": [{"insertText": {"location": {"index": 1}, "text": "hi "}}]} + denied = auth_client.post( + f"/v1/documents/{doc_id}:batchUpdate", + json=body, + headers=_bearer(_token("docs.readonly")), + ) + assert denied.status_code == 403 + assert denied.json()["error"]["required_scopes"] == ["docs.full"] + + allowed = auth_client.post( + f"/v1/documents/{doc_id}:batchUpdate", + json=body, + headers=_bearer(_token("docs.full")), + ) + assert allowed.status_code == 200 + assert allowed.json()["documentId"] == doc_id + + def test_drive_readonly_cannot_read_document_body(self, auth_client): + """Document body reads are Docs API proper: drive scopes do NOT apply.""" + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", headers=_bearer(_token("drive.readonly")) + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["required_scopes"] == ["docs.readonly", "docs.full"] + assert err["token_scopes"] == ["drive.readonly"] + + def test_drive_readonly_can_list_comments(self, auth_client): + """Comments are Drive-file metadata proxies: drive read scopes accepted.""" + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}/comments", + headers=_bearer(_token("drive.readonly")), + ) + assert resp.status_code == 200 + assert "comments" in resp.json() + + def test_drive_full_can_create_comment(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.post( + f"/v1/documents/{doc_id}/comments", + json={"content": "drive.full comment"}, + headers=_bearer(_token("drive.full")), + ) + assert resp.status_code == 200 + assert resp.json()["content"] == "drive.full comment" + + def test_permissions_read_accepts_docs_readonly(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}/permissions", + headers=_bearer(_token("docs.readonly")), + ) + assert resp.status_code == 200 + + def test_mismatching_header_403_impersonation_body(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(_token("docs.readonly", sub=ALEX)), + "X-Env-0-Gdoc-User": OTHER_USER, + }, + ) + assert resp.status_code == 403 + # Exact contract body -- NOT the Google error envelope. + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALEX, + "requested_user": OTHER_USER, + } + } + + def test_mismatching_header_unknown_user_also_403(self, auth_client): + # Existence of the requested user is irrelevant: the explicit identity + # assertion differs from the token sub, which is the violation. + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(_token("docs.readonly", sub=ALEX)), + "X-Env-0-Gdoc-User": "no-such-user", + }, + ) + assert resp.status_code == 403 + assert resp.json()["error"]["requested_user"] == "no-such-user" + + def test_header_matching_token_sub_allowed(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(_token("docs.readonly", sub=ALEX)), + "X-Env-0-Gdoc-User": ALEX, + }, + ) + assert resp.status_code == 200 + + def test_header_matching_token_email_allowed(self, auth_client): + # Legacy header accepts id OR email; the redundant email form of the + # token's OWN identity must not trip the impersonation check. + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(_token("docs.readonly", sub=ALEX)), + "X-Env-0-Gdoc-User": ALEX_EMAIL, + }, + ) + assert resp.status_code == 200 + + def test_impersonation_attempt_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + doc_id = _owned_doc_id() + monkeypatch.setenv("AUTH_REPORT", "1") # read at call time + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(_token("docs.readonly", sub=ALEX)), + "X-Env-0-Gdoc-User": OTHER_USER, + }, + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "impersonation_attempt"] + assert attempts, f"no impersonation_attempt among events: {events}" + event = attempts[0] + assert event["user_id"] == ALEX + assert event["details"]["authenticated_user"] == ALEX + assert event["details"]["requested_user"] == OTHER_USER + + def test_unknown_token_sub_404_google_envelope(self, auth_client): + # A verified token whose sub has no gdoc User row: 404 in the + # legacy Google envelope (mirrors gmail's auth-mode resolve_user_id). + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers=_bearer(_token("docs.readonly", sub="ghost_user")), + ) + assert resp.status_code == 404 + err = resp.json()["error"] + assert err["status"] == "NOT_FOUND" + assert "ghost_user" in err["message"] + + def test_expired_token_401_with_refresh_hint(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers=_bearer(_token("docs.readonly", expires_in=-60)), + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "expired at" in err["message"] + assert "/oauth2/token" in err["hint"] + assert "grant_type=refresh_token" in err["hint"] + assert resp.headers["WWW-Authenticate"].startswith('Bearer error="invalid_token"') + + def test_no_token_401(self, auth_client): + resp = auth_client.get(f"/v1/documents/{_owned_doc_id()}") + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "WWW-Authenticate" in resp.headers + + def test_bad_signature_401(self, auth_client): + other_private, _ = generate_test_keypair() + forged = make_jwt(private_key=other_private, kid=KID, sub=ALEX, scope="docs.full") + resp = auth_client.get(f"/v1/documents/{_owned_doc_id()}", headers=_bearer(forged)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_header_without_token_is_still_401(self, auth_client): + resp = auth_client.get( + f"/v1/documents/{_owned_doc_id()}", + headers={"X-Env-0-Gdoc-User": ALEX}, + ) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_health_unauthenticated(self, auth_client): + resp = auth_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_admin_state_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/state") + assert resp.status_code == 200 + + def test_admin_action_log_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/action_log") + assert resp.status_code == 200 + + +class TestEmailClaimFallback: + """Identity-alignment: a token whose ``sub`` is not a local user id is + resolved via its ``email`` claim (case-insensitive); the resolved LOCAL id + is the effective identity everywhere downstream. This closes the gap where + auth's gmail-aligned ``sub=user1`` got 404 from gdoc (whose local + id for the same person is ``user_0``). + """ + + # auth's gmail/gcal-aligned id; NOT a gdoc user id. + NONLOCAL_SUB = "user1" + + def _fallback_token(self, scope: str = "docs.readonly", email: str = ALEX_EMAIL) -> str: + return _token(scope, sub=self.NONLOCAL_SUB, email=email) + + def test_nonlocal_sub_with_seeded_email_reads_document(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + assert resp.json()["documentId"] == doc_id + + def test_nonlocal_sub_creates_document_as_local_user(self, auth_client): + resp = auth_client.post( + "/v1/documents", + json={"title": "Email-fallback doc"}, + headers=_bearer(self._fallback_token("docs.full")), + ) + assert resp.status_code == 200 + doc_id = resp.json()["documentId"] + # The document belongs to the RESOLVED local user (user_0): readable + # with a token whose sub IS user_0. + readback = auth_client.get( + f"/v1/documents/{doc_id}", headers=_bearer(_token("docs.readonly", sub=ALEX)) + ) + assert readback.status_code == 200 + + def test_email_match_is_case_insensitive(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers=_bearer(self._fallback_token(email=ALEX_EMAIL.upper())), + ) + assert resp.status_code == 200 + + def test_header_naming_resolved_local_id_does_not_trip_guard(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(self._fallback_token()), + "X-Env-0-Gdoc-User": ALEX, # the RESOLVED local id + }, + ) + assert resp.status_code == 200 + + def test_header_naming_other_user_still_403s(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers={ + **_bearer(self._fallback_token()), + "X-Env-0-Gdoc-User": OTHER_USER, + }, + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["message"] == "Cannot access another user's resources" + # The contract body names the RESOLVED local identity. + assert err["authenticated_user"] == ALEX + assert err["requested_user"] == OTHER_USER + + def test_unknown_sub_and_unknown_email_404_as_before(self, auth_client): + doc_id = _owned_doc_id() + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers=_bearer(_token("docs.readonly", sub="ghost", email="ghost@nowhere.test")), + ) + assert resp.status_code == 404 + assert "ghost" in resp.json()["error"]["message"] + + def test_local_sub_wins_over_email_claim(self, auth_client): + # Resolution order pin: id == sub (a) beats the email claim (b) -- a + # token for user_1 carrying alex's email acts as user_1, so a document + # owned (and unshared) by user_0 is invisible (404 access model). + doc_id = _owned_doc_id(unshared=True) + resp = auth_client.get( + f"/v1/documents/{doc_id}", + headers=_bearer(_token("docs.readonly", sub=OTHER_USER, email=ALEX_EMAIL)), + ) + assert resp.status_code == 404 + + +class TestWebUIExemption: + """With AUTH_ENABLED=1, the human web UI stays usable WITHOUT a token. + + The web dashboard has no per-user identity (browser UI), so it is exempt; + /mcp is likewise exempt (agent MCP clients are out of OAuth scope for v1). + The API under /v1 still requires Bearer tokens. See + mock_gdoc/api/auth_middleware.py and API_NOTES.md. + """ + + def test_document_list_root_no_token_200(self, auth_client): + resp = auth_client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_doc_page_no_token_200(self, auth_client): + # uncommented=True sidesteps a PRE-EXISTING doc_editor.html bug + # (loop.parent is not a Jinja2 LoopContext attribute: docs whose + # comments have replies 500 regardless of auth). + resp = auth_client.get(f"/doc/{_owned_doc_id(uncommented=True)}") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_dev_routes_no_token_200(self, auth_client): + resp = auth_client.get("/dev/dashboard") + assert resp.status_code == 200 + + def test_mcp_not_gated_by_auth(self, auth_client): + # MCP is not mounted in tests: a 404 (rather than 401) proves the + # middleware exempts /mcp instead of demanding a Bearer token. + resp = auth_client.get("/mcp") + assert resp.status_code == 404 + + def test_api_still_requires_token_while_web_is_open(self, auth_client): + # The "/" exemption is EXACT-match, not a "/" prefix that would + # swallow every path: the API must still 401 without a token. + assert auth_client.get("/").status_code == 200 + resp = auth_client.get(f"/v1/documents/{_owned_doc_id()}") + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_doc_prefix_does_not_exempt_api_documents(self, auth_client): + # Segment-boundary matching: "/doc" must never bleed into /v1/documents. + resp = auth_client.post( + "/v1/documents", json={"title": "x"} + ) + assert resp.status_code == 401 + + def test_exempt_prefixes_extend_contract_defaults(self): + from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + from mock_gdoc.api.auth_middleware import GDOC_EXEMPT_PREFIXES + + for prefix in DEFAULT_EXEMPT_PREFIXES: + assert prefix in GDOC_EXEMPT_PREFIXES + for prefix in ("/doc", "/static", "/mcp"): + assert prefix in GDOC_EXEMPT_PREFIXES + + +class TestAuthDisabledRegression: + """CRITICAL: with AUTH_ENABLED unset, behavior is byte-identical legacy. + + These tests use the standard ``client`` fixture from conftest (the module + singleton, which was assembled with auth disabled). + """ + + def test_singleton_has_no_auth_middleware(self, client): + from mock_gdoc.api.app import app + + assert not any( + "Env_0AuthMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "Env_0AuthMiddleware must not be installed when AUTH_ENABLED is unset" + + def test_no_header_falls_back_to_first_user(self, client): + # First user in the DB is user_0, the owner of every seeded document. + resp = client.get(f"/v1/documents/{_owned_doc_id()}") + assert resp.status_code == 200 + + def test_header_resolution_by_id_switches_identity(self, client): + # user_1 has no permission on an unshared doc: access-filtered 404 + # proves the header actually resolved to user_1. + doc_id = _owned_doc_id(unshared=True) + resp = client.get( + f"/v1/documents/{doc_id}", headers={"X-Env-0-Gdoc-User": OTHER_USER} + ) + assert resp.status_code == 404 + + def test_header_resolution_by_email(self, client): + doc_id = _owned_doc_id(unshared=True) + resp = client.get( + f"/v1/documents/{doc_id}", headers={"X-Env-0-Gdoc-User": ALEX_EMAIL} + ) + assert resp.status_code == 200 + + def test_unknown_header_falls_back_to_first_user(self, client): + # Legacy quirk preserved: an unknown header value silently falls back + # (NO impersonation 403 when auth is disabled). + resp = client.get( + f"/v1/documents/{_owned_doc_id()}", + headers={"X-Env-0-Gdoc-User": "no-such-user"}, + ) + assert resp.status_code == 200 + + def test_apply_auth_returns_false_when_disabled(self, monkeypatch): + import mock_gdoc.api.app as app_module + + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert app_module._apply_auth(FastAPI()) is False + + def test_apply_auth_raises_runtime_error_without_package(self, monkeypatch): + import mock_gdoc.api.app as app_module + + monkeypatch.setenv("AUTH_ENABLED", "1") + # None in sys.modules makes `import env_0_auth_client` raise ImportError. + monkeypatch.setitem(sys.modules, "env_0_auth_client", None) + with pytest.raises(RuntimeError, match="auth-client is not installed"): + app_module._apply_auth(FastAPI()) diff --git a/packages/environments/mock-gdoc/uv.lock b/packages/environments/mock-gdoc/uv.lock index 452378c7c..6b27c2742 100644 --- a/packages/environments/mock-gdoc/uv.lock +++ b/packages/environments/mock-gdoc/uv.lock @@ -713,6 +713,15 @@ mcp = [ { name = "fastapi-mcp" }, ] +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-json-report" }, +] + [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.12" }, @@ -736,6 +745,15 @@ requires-dist = [ ] provides-extras = ["mcp", "dev", "all"] +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=43.0" }, + { name = "pyjwt", specifier = ">=2.8" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-json-report", specifier = ">=1.5" }, +] + [[package]] name = "packaging" version = "26.0" diff --git a/packages/environments/mock-gdrive/mock_gdrive/api/app.py b/packages/environments/mock-gdrive/mock_gdrive/api/app.py index 222bbfb6f..0e8f1ed1c 100644 --- a/packages/environments/mock-gdrive/mock_gdrive/api/app.py +++ b/packages/environments/mock-gdrive/mock_gdrive/api/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from datetime import datetime from fastapi import FastAPI, HTTPException, Request @@ -245,3 +246,44 @@ def admin_restore(name: str): @app.get("/health") def health(): return {"status": "ok"} + + +# --- auth integration (optional; enabled via AUTH_ENABLED) --- +from .deps import ImpersonationError # noqa: E402 + + +@app.exception_handler(ImpersonationError) +async def impersonation_error_handler(request: Request, exc: ImpersonationError): + """Contract-pinned 403 impersonation body.""" + from env_0_auth_client.errors import impersonation_body + + return JSONResponse( + status_code=403, + content=impersonation_body(exc.authenticated_user, exc.requested_user), + ) + + +def _auth_enabled() -> bool: + return os.environ.get("AUTH_ENABLED", "").strip().lower() in ("1", "true", "yes") + + +def _apply_auth(target_app: FastAPI, **middleware_kwargs) -> bool: + """Add GdriveEnv_0AuthMiddleware to ``target_app`` when AUTH_ENABLED is truthy.""" + if not _auth_enabled(): + return False + try: + import env_0_auth_client # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "AUTH_ENABLED=1 but auth-client is not installed. " + "Install packages/auth-client or unset AUTH_ENABLED." + ) from exc + + from mock_gdrive.api.auth_middleware import GdriveEnv_0AuthMiddleware + from mock_gdrive.auth_scopes import SCOPE_MAP + + target_app.add_middleware(GdriveEnv_0AuthMiddleware, scope_map=SCOPE_MAP, **middleware_kwargs) + return True + + +_apply_auth(app) diff --git a/packages/environments/mock-gdrive/mock_gdrive/api/auth_middleware.py b/packages/environments/mock-gdrive/mock_gdrive/api/auth_middleware.py new file mode 100644 index 000000000..cdb9b9575 --- /dev/null +++ b/packages/environments/mock-gdrive/mock_gdrive/api/auth_middleware.py @@ -0,0 +1,119 @@ +"""Gdrive-side wrapper around Env_0AuthMiddleware: web-UI exemptions + alt=media. + +Mirrors gmail's GmailEnv_0AuthMiddleware (the proven pattern): + +- The human web dashboard (``/``, ``/file/{file_id}``, ``/switch-user``) + identifies the user via the ``mock_gdrive_user`` cookie, NOT Bearer tokens -- + the real-world split between browser sessions and API credentials. With + ``AUTH_ENABLED=1`` these routes stay reachable without a token. +- ``/mcp`` is exempt: agent MCP clients are out of OAuth scope for v1 (same + decision as gmail). +- ``/static`` is defensive (no static mount today). +- The drive home page lives at ``/`` -- a ``"/"`` PREFIX would exempt every + path, so the subclass short-circuits the exact path before delegating to + the upstream ``dispatch`` (identical to gmail's inbox-at-root handling). + +One gdrive-specific addition: ``GET /drive/v3/files/{fileId}`` is dual-purpose. +Plain calls return metadata (SCOPE_MAP requires a metadata-read scope), but +``?alt=media`` downloads file content. The route-level scope map cannot see +query strings, so this subclass wraps ``call_next`` and -- AFTER the base +middleware fully validated the token and populated ``request.state.auth_*`` -- +additionally requires one of ``CONTENT_DOWNLOAD_SCOPES`` (drive.readonly | +drive.full), returning the exact contract 403 insufficient-scope body and +reporting a ``scope_escalation_attempt`` just like the base middleware would. + +auth-client's public API is unchanged: exemptions go through the existing +``exempt_prefixes`` parameter and the media guard composes around ``dispatch``. +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from env_0_auth_client import Env_0AuthMiddleware, match_route, reporting +from env_0_auth_client.errors import insufficient_scope_body +from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES +from env_0_auth_client.scopes import has_any_scope + +from mock_gdrive.auth_scopes import CONTENT_DOWNLOAD_SCOPES, FILES_GET_ROUTE + +#: Exact paths that bypass auth. Exact-match only -- "/" as a *prefix* would +#: exempt everything, which is why this set exists at all. +EXEMPT_EXACT_PATHS = frozenset({"/"}) + +#: Gdrive's web UI has no /web prefix, so each root-mounted route gets its own +#: precise prefix (segment-boundary matched upstream: "/file" covers "/file/x" +#: but never "/files..."). /_admin, /health, /dev, /docs, /openapi.json and +#: /web come from the contract-pinned defaults. +GDRIVE_EXEMPT_PREFIXES: tuple[str, ...] = DEFAULT_EXEMPT_PREFIXES + ( + "/file", # GET /file/{file_id} -- web file-detail page + "/switch-user", # GET /switch-user -- web cookie identity switcher + "/static", # static assets (none mounted today; defensive) + "/mcp", # MCP clients are out of OAuth scope for v1 +) + + +class GdriveEnv_0AuthMiddleware(Env_0AuthMiddleware): + """Env_0AuthMiddleware with gdrive's exemptions + the alt=media scope upgrade. + + Only the defaults differ; every kwarg (``scope_map``, ``jwks_static``, + ``audience``, ...) is forwarded untouched, and callers may still override + ``exempt_prefixes`` explicitly. + """ + + def __init__( + self, + app, + exempt_prefixes: tuple[str, ...] = GDRIVE_EXEMPT_PREFIXES, + **kwargs, + ): + super().__init__(app, exempt_prefixes=exempt_prefixes, **kwargs) + + def _is_media_download(self, request: Request) -> bool: + """True for ``GET /drive/v3/files/{fileId}?alt=media`` (content download).""" + if request.method != "GET" or request.query_params.get("alt") != "media": + return False + fastapi_app = request.scope.get("app") + if fastapi_app is None: + return False + return match_route(fastapi_app, request.scope) == FILES_GET_ROUTE + + async def dispatch(self, request: Request, call_next) -> Response: + if request.url.path in EXEMPT_EXACT_PATHS: + return await call_next(request) + + if not self._is_media_download(request): + return await super().dispatch(request, call_next) + + # alt=media: run the base middleware first (401s / route-level scope + # checks / request.state population), then require a content-read + # scope before the request reaches the endpoint. + async def guarded_call_next(req: Request) -> Response: + token_scopes = list(getattr(req.state, "auth_scopes", None) or []) + if has_any_scope(token_scopes, CONTENT_DOWNLOAD_SCOPES): + return await call_next(req) + await reporting.report_event_async( + reporting.build_event( + "scope_escalation_attempt", + client_id=getattr(req.state, "auth_client_id", None), + user_id=getattr(req.state, "auth_user_id", None), + scope=" ".join(token_scopes), + details={ + "method": FILES_GET_ROUTE[0], + "route": FILES_GET_ROUTE[1], + "required_scopes": list(CONTENT_DOWNLOAD_SCOPES), + "token_scopes": token_scopes, + "alt": "media", + }, + ), + self.auth_base_url, + ) + return JSONResponse( + status_code=403, + content=insufficient_scope_body( + list(CONTENT_DOWNLOAD_SCOPES), token_scopes + ), + ) + + return await super().dispatch(request, guarded_call_next) diff --git a/packages/environments/mock-gdrive/mock_gdrive/api/deps.py b/packages/environments/mock-gdrive/mock_gdrive/api/deps.py index fbf7e3c8a..01323e6a1 100644 --- a/packages/environments/mock-gdrive/mock_gdrive/api/deps.py +++ b/packages/environments/mock-gdrive/mock_gdrive/api/deps.py @@ -2,12 +2,32 @@ from __future__ import annotations -from fastapi import Header, HTTPException, Depends +from fastapi import Header, HTTPException, Depends, Request +from sqlalchemy import func from sqlalchemy.orm import Session from mock_gdrive.models import get_session_factory, User +class ImpersonationError(Exception): + """Authenticated request named an identity that is not the token's subject. + + Gdrive has no ``{userId}`` path parameter (unlike gmail); the explicit + identity mechanism is the legacy ``X-Mock-Drive-User`` header. Handled in + ``mock_gdrive.api.app`` with the auth contract 403 body + (``env_0_auth_client.errors.impersonation_body``), deliberately NOT the + Drive error envelope used for HTTPException. + """ + + def __init__(self, authenticated_user: str, requested_user: str): + super().__init__( + f"Cannot access another user's resources " + f"(authenticated as {authenticated_user!r}, requested {requested_user!r})" + ) + self.authenticated_user = authenticated_user + self.requested_user = requested_user + + def get_db() -> Session: SessionLocal = get_session_factory() db = SessionLocal() @@ -18,17 +38,59 @@ def get_db() -> Session: def resolve_user( + request: Request, x_mock_drive_user: str | None = Header(None, alias="X-Mock-Drive-User"), authorization: str | None = Header(None), db: Session = Depends(get_db), ) -> User: - """Resolve the current user from headers. + """Resolve the current user. + + With auth enabled (Env_0AuthMiddleware sets request.state.auth_user_id): + the token is mapped to a LOCAL user via (a) id == token ``sub``, else + (b) email == token ``email`` claim (case-insensitive), else (c) the + legacy 404. The resolved LOCAL user is the effective identity. The legacy + ``Authorization: Bearer `` convention is unreachable here + (the Authorization header IS the verified JWT), and an ``X-Mock-Drive-User`` + header naming anyone other than the token's ``sub`` or the resolved local + user (by id or email) raises the contract 403 impersonation error (after + reporting an impersonation_attempt event to auth). - Priority: + With auth disabled (the default), legacy behavior is unchanged: 1. X-Mock-Drive-User header (by ID or email) 2. Authorization: Bearer 3. Fallback to first user in DB """ + auth_user_id = getattr(request.state, "auth_user_id", None) + if auth_user_id is not None: + user = db.query(User).filter(User.id == auth_user_id).first() + if user is None: + auth_email = (getattr(request.state, "auth_email", "") or "").strip() + if auth_email: + user = db.query(User).filter( + func.lower(User.email) == auth_email.lower() + ).first() + effective_id = user.id if user is not None else auth_user_id + requested = x_mock_drive_user + allowed = {auth_user_id, effective_id} + if user is not None: + allowed.add(user.email) + if requested and requested not in allowed: + # The middleware cannot see header-vs-sub mismatches; report here. + # env_0_auth_client is guaranteed importable: auth_user_id is only + # ever set by Env_0AuthMiddleware. + from env_0_auth_client import report_impersonation + + report_impersonation( + effective_id, + requested, + client_id=getattr(request.state, "auth_client_id", None), + scope=" ".join(getattr(request.state, "auth_scopes", None) or []), + ) + raise ImpersonationError(effective_id, requested) + if not user: + raise HTTPException(404, f"User {auth_user_id!r} not found") + return user + identifier = None if x_mock_drive_user: diff --git a/packages/environments/mock-gdrive/mock_gdrive/auth_scopes.py b/packages/environments/mock-gdrive/mock_gdrive/auth_scopes.py new file mode 100644 index 000000000..0a998487e --- /dev/null +++ b/packages/environments/mock-gdrive/mock_gdrive/auth_scopes.py @@ -0,0 +1,141 @@ +"""Per-route OAuth scope requirements for auth (used when AUTH_ENABLED=1). + +``SCOPE_MAP`` keys are ``(HTTP_METHOD, route_path_template)`` tuples EXACTLY as +the routes are registered on the FastAPI app (see ``mock_gdrive/api/app.py``). +Values are OR-logic scope lists: ANY one listed scope grants access. Routes +absent from the map (and exempt paths like /_admin, /health, the web UI) +require a valid token but no particular scope. + +Scope names are the bare auth names pinned by docs/ideas/auth.md -- +exactly four Drive scopes exist: + +- ``drive.metadata.readonly`` -- read metadata only +- ``drive.readonly`` -- read metadata AND content +- ``drive.file`` -- per-file access to files created by the app. + MOCK SIMPLIFICATION (pinned by the retrofit mission): the mock cannot track + which files "the app" created, so ``drive.file`` is treated as a + write-capable scope for file create/update/delete/copy. It grants NO reads + (use drive.readonly / drive.metadata.readonly) and NO sharing (drive.full). +- ``drive.full`` -- all Drive operations + +Classification (per contract + retrofit mission): + +- metadata-only GETs (files.list, files.get, about, changes, revisions reads) + -> drive.metadata.readonly | drive.readonly | drive.full +- content reads/downloads/export (files.export, files.get?alt=media, comments + and replies -- they quote anchored file content) + -> drive.readonly | drive.full +- file create/update/delete/copy (incl. generateIds, comment/reply/revision + mutations) -> drive.file | drive.full +- permissions/sharing endpoints (ALL of /files/{fileId}/permissions*, reads + included) and shared-drive mutations -> drive.full only +- emptyTrash (DELETE /files/trash) -> drive.full only (permanent destruction, + mirrors gmail's permanent-delete pinning) +- watch/stop no-op stubs -> any Drive scope (matches real Google, where + files.watch / changes.watch / channels.stop accept every Drive scope) + +SPECIAL CASE -- ``GET /drive/v3/files/{fileId}`` is dual-purpose: plain calls +return metadata (mapped here as a metadata read), but ``?alt=media`` downloads +file content. The route-level scope map cannot express query-string +conditions, so ``GdriveEnv_0AuthMiddleware`` (api/auth_middleware.py) upgrades +the requirement to ``CONTENT_DOWNLOAD_SCOPES`` when ``alt=media`` is present. + +This module deliberately has NO env_0_auth_client import so that gdrive +works unchanged when the optional auth dependency is not installed. + +Coverage of every registered /drive/v3 and /upload/drive/v3 route is enforced +by ``tests/test_auth_integration.py::TestScopeMapCoverage``. +""" + +from __future__ import annotations + +# Mirrors env_0_auth_client.ScopeMap (kept local: auth-client is optional). +ScopeMap = dict[tuple[str, str], list[str]] + +DRIVE_PREFIX = "/drive/v3" +UPLOAD_PREFIX = "/upload/drive/v3" +#: Prefixes under which every registered route must have a SCOPE_MAP entry. +API_PREFIXES = (DRIVE_PREFIX, UPLOAD_PREFIX) + +FULL = "drive.full" +READONLY = "drive.readonly" +METADATA_RO = "drive.metadata.readonly" +FILE = "drive.file" + +# Metadata-only GETs: never expose file content (file resources do NOT carry +# contentText -- it is accepted on writes only). +METADATA_READ = [METADATA_RO, READONLY, FULL] +# Content reads: downloads, export, and comment/reply reads (quoted content). +READ = [READONLY, FULL] +# File create/update/delete/copy. drive.file is the per-file app scope, +# treated as write-capable here (see module docstring). +WRITE = [FILE, FULL] +# Permissions / sharing -- and shared-drive administration: drive.full only. +SHARING = [FULL] +# Permanent destruction of everything in the trash. +EMPTY_TRASH = [FULL] +# Push-notification stubs (files.watch / changes.watch / channels.stop): +# real Google accepts every Drive scope on these. +WATCH = [METADATA_RO, READONLY, FILE, FULL] + +#: Scopes required to download content via ``GET /drive/v3/files/{fileId}?alt=media``. +#: Enforced by GdriveEnv_0AuthMiddleware on top of the route's METADATA_READ entry. +CONTENT_DOWNLOAD_SCOPES = list(READ) +#: The (method, path-template) of the dual-purpose files.get route. +FILES_GET_ROUTE = ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}") + +SCOPE_MAP: ScopeMap = { + # --- files --- + ("GET", f"{DRIVE_PREFIX}/files"): METADATA_READ, + FILES_GET_ROUTE: METADATA_READ, # ?alt=media upgraded to CONTENT_DOWNLOAD_SCOPES + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/export"): READ, + ("GET", f"{DRIVE_PREFIX}/files/generateIds"): WRITE, # create-helper (real: drive.file|drive) + ("POST", f"{DRIVE_PREFIX}/files"): WRITE, + ("POST", f"{UPLOAD_PREFIX}/files"): WRITE, + ("PATCH", f"{DRIVE_PREFIX}/files/{{fileId}}"): WRITE, + ("PATCH", f"{UPLOAD_PREFIX}/files/{{fileId}}"): WRITE, + ("DELETE", f"{DRIVE_PREFIX}/files/{{fileId}}"): WRITE, + ("POST", f"{DRIVE_PREFIX}/files/{{fileId}}/copy"): WRITE, + ("DELETE", f"{DRIVE_PREFIX}/files/trash"): EMPTY_TRASH, # emptyTrash + ("POST", f"{DRIVE_PREFIX}/files/{{fileId}}/watch"): WATCH, + # --- permissions / sharing (delegated-access surface: drive.full only) --- + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/permissions"): SHARING, + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/permissions/{{permissionId}}"): SHARING, + ("POST", f"{DRIVE_PREFIX}/files/{{fileId}}/permissions"): SHARING, + ("PATCH", f"{DRIVE_PREFIX}/files/{{fileId}}/permissions/{{permissionId}}"): SHARING, + ("DELETE", f"{DRIVE_PREFIX}/files/{{fileId}}/permissions/{{permissionId}}"): SHARING, + # --- about --- + ("GET", f"{DRIVE_PREFIX}/about"): METADATA_READ, + # --- comments (quote anchored file content -> content reads) --- + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/comments"): READ, + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}"): READ, + ("POST", f"{DRIVE_PREFIX}/files/{{fileId}}/comments"): WRITE, + ("PATCH", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}"): WRITE, + ("DELETE", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}"): WRITE, + # --- replies --- + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}/replies"): READ, + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}/replies/{{replyId}}"): READ, + ("POST", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}/replies"): WRITE, + ("PATCH", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}/replies/{{replyId}}"): WRITE, + ("DELETE", f"{DRIVE_PREFIX}/files/{{fileId}}/comments/{{commentId}}/replies/{{replyId}}"): WRITE, + # --- revisions (metadata-only resources in this mock) --- + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/revisions"): METADATA_READ, + ("GET", f"{DRIVE_PREFIX}/files/{{fileId}}/revisions/{{revisionId}}"): METADATA_READ, + ("PATCH", f"{DRIVE_PREFIX}/files/{{fileId}}/revisions/{{revisionId}}"): WRITE, + ("DELETE", f"{DRIVE_PREFIX}/files/{{fileId}}/revisions/{{revisionId}}"): WRITE, + # --- changes --- + ("GET", f"{DRIVE_PREFIX}/changes/startPageToken"): METADATA_READ, + ("GET", f"{DRIVE_PREFIX}/changes"): METADATA_READ, + ("POST", f"{DRIVE_PREFIX}/changes/watch"): WATCH, + # --- shared drives (reads per real Google: drive.readonly|drive; no + # metadata.readonly; all mutations are administration -> drive.full) --- + ("GET", f"{DRIVE_PREFIX}/drives"): READ, + ("GET", f"{DRIVE_PREFIX}/drives/{{driveId}}"): READ, + ("POST", f"{DRIVE_PREFIX}/drives"): SHARING, + ("PATCH", f"{DRIVE_PREFIX}/drives/{{driveId}}"): SHARING, + ("DELETE", f"{DRIVE_PREFIX}/drives/{{driveId}}"): SHARING, + ("POST", f"{DRIVE_PREFIX}/drives/{{driveId}}/hide"): SHARING, + ("POST", f"{DRIVE_PREFIX}/drives/{{driveId}}/unhide"): SHARING, + # --- channels --- + ("POST", f"{DRIVE_PREFIX}/channels/stop"): WATCH, +} diff --git a/packages/environments/mock-gdrive/pyproject.toml b/packages/environments/mock-gdrive/pyproject.toml index 67245856a..1c11456e1 100644 --- a/packages/environments/mock-gdrive/pyproject.toml +++ b/packages/environments/mock-gdrive/pyproject.toml @@ -31,6 +31,14 @@ dev = [ ] all = ["mock-gdrive[mcp,dev]"] +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pyjwt>=2.8", + "cryptography>=43.0", +] + [project.scripts] mock-gdrive = "mock_gdrive.cli:cli" diff --git a/packages/environments/mock-gdrive/tests/__init__.py b/packages/environments/mock-gdrive/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/environments/mock-gdrive/tests/conftest.py b/packages/environments/mock-gdrive/tests/conftest.py index 9497d8cd8..88fe64026 100644 --- a/packages/environments/mock-gdrive/tests/conftest.py +++ b/packages/environments/mock-gdrive/tests/conftest.py @@ -1,7 +1,9 @@ """Shared test fixtures.""" import os +import sys import tempfile +from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -9,6 +11,15 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool +# Make sibling auth-client importable for auth integration tests without a +# pyproject path dependency, which would break shallow task-image installs. +_client_pkg = Path(__file__).resolve().parents[3] / "auth-client" +if _client_pkg.is_dir(): + try: + import env_0_auth_client # noqa: F401 + except ImportError: + sys.path.insert(0, str(_client_pkg)) + from mock_gdrive.models.base import Base from mock_gdrive.models import User, File, Permission, Comment, Reply, Revision, Change, Drive from mock_gdrive.api.app import app diff --git a/packages/environments/mock-gdrive/tests/test_auth_integration.py b/packages/environments/mock-gdrive/tests/test_auth_integration.py new file mode 100644 index 000000000..182a47e86 --- /dev/null +++ b/packages/environments/mock-gdrive/tests/test_auth_integration.py @@ -0,0 +1,636 @@ +"""Integration tests for the auth retrofit (AUTH_ENABLED). + +Fully offline: JWKS is injected statically via env_0_auth_client.testing, and +event reporting is disabled (AUTH_REPORT=0) except where a MockTransport +captures events explicitly. + +The FastAPI app in mock_gdrive.api.app is a module-level singleton and +AUTH_ENABLED is read at import time, so the ``auth_app`` fixture builds a +FRESH instance via importlib.reload (with auth disabled, so the module-level +``_apply_auth(app)`` is a no-op) and then applies the middleware explicitly +with the static JWKS. Teardown reloads once more so every other test module +keeps using a pristine, auth-disabled singleton. + +mock_gdrive.api.deps is NOT reloaded, so the ``get_db`` dependency override +and the ``ImpersonationError`` class identity are stable across reloads. +""" + +import importlib +import json +import sys + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from env_0_auth_client.testing import generate_test_keypair, jwks_for, make_jwt +from mock_gdrive.api.deps import get_db +from mock_gdrive.models import User, File, Permission + +KID = "test-key-001" +PRIVATE_KEY, PUBLIC_KEY = generate_test_keypair() +JWKS = jwks_for(PUBLIC_KEY, kid=KID) + +# Identities matching mock_gdrive/seed/hierarchy.py USERS -- the auth +# seeds must align with THESE ids (auth adapts, gdrive seeds unchanged). +ALEX = "user_alex" +ALEX_EMAIL = "alex@nexusai.com" +JORDAN = "user_jordan" +JORDAN_EMAIL = "jordan@nexusai.com" + + +def _token(scope: str = "", sub: str = ALEX, **kwargs) -> str: + return make_jwt(private_key=PRIVATE_KEY, kid=KID, sub=sub, scope=scope, **kwargs) + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def auth_app(monkeypatch): + """Fresh app instance with GdriveEnv_0AuthMiddleware and static (offline) JWKS.""" + for var in ("AUTH_ENABLED", "AUTH_INTROSPECT", "AUTH_ISSUER", "AUTH_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("AUTH_REPORT", "0") + + import mock_gdrive.api.app as app_module + + app_module = importlib.reload(app_module) # fresh singleton, no auth yet + monkeypatch.setenv("AUTH_ENABLED", "1") + assert app_module._apply_auth(app_module.app, jwks_static=JWKS) is True + + yield app_module.app + + # Restore a pristine auth-disabled singleton for the other test modules. + monkeypatch.delenv("AUTH_ENABLED", raising=False) + importlib.reload(app_module) + + +@pytest.fixture +def auth_seed(db_session): + """Users matching the default seed identities + content-bearing files.""" + alex = User(id=ALEX, email=ALEX_EMAIL, display_name="Alex Chen") + jordan = User(id=JORDAN, email=JORDAN_EMAIL, display_name="Jordan Kim") + db_session.add_all([alex, jordan]) + + binary = File( + id="file_binary", + name="notes.txt", + mime_type="text/plain", + owner_id=ALEX, + last_modifying_user_id=ALEX, + content_blob=b"hello drive content", + size=19, + ) + gdoc = File( + id="file_gdoc", + name="Design Doc", + mime_type="application/vnd.google-apps.document", + owner_id=ALEX, + last_modifying_user_id=ALEX, + content_text="exported document text", + ) + db_session.add_all([binary, gdoc]) + db_session.add_all([ + Permission( + id=f"perm_{f.id}", + file_id=f.id, + role="owner", + type="user", + email_address=ALEX_EMAIL, + display_name="Alex Chen", + ) + for f in (binary, gdoc) + ]) + db_session.commit() + return {"binary": binary, "gdoc": gdoc} + + +@pytest.fixture +def auth_client(auth_app, db_session, auth_seed): + """TestClient against the auth-enabled fresh app + in-memory seeded DB.""" + def _override_get_db(): + try: + yield db_session + finally: + pass + + auth_app.dependency_overrides[get_db] = _override_get_db + with TestClient(auth_app) as c: + yield c + auth_app.dependency_overrides.clear() + + +class TestScopeMapCoverage: + def test_every_drive_route_has_scope_map_entry(self): + """Every registered /drive/v3 + /upload/drive/v3 route must appear in + SCOPE_MAP (and the map must hold no stale keys).""" + from mock_gdrive.api.app import app + from mock_gdrive.auth_scopes import API_PREFIXES, SCOPE_MAP + + registered = set() + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith(API_PREFIXES): + for method in route.methods: + if method not in ("HEAD", "OPTIONS"): + registered.add((method, route.path)) + + assert registered, "no /drive/v3 routes registered -- enumeration broken?" + missing = registered - set(SCOPE_MAP) + assert not missing, f"routes missing from SCOPE_MAP: {sorted(missing)}" + stale = set(SCOPE_MAP) - registered + assert not stale, f"SCOPE_MAP keys matching no registered route: {sorted(stale)}" + + def test_scope_lists_are_nonempty_drive_scopes(self): + from mock_gdrive.auth_scopes import CONTENT_DOWNLOAD_SCOPES, SCOPE_MAP + + for key, scopes in SCOPE_MAP.items(): + assert scopes, f"empty scope list for {key}" + for scope in scopes: + assert scope.startswith("drive."), f"unexpected scope {scope!r} for {key}" + assert CONTENT_DOWNLOAD_SCOPES == ["drive.readonly", "drive.full"] + + def test_permissions_routes_pinned_to_drive_full(self): + """Sharing endpoints (delegated-access surface) must require drive.full only.""" + from mock_gdrive.auth_scopes import SCOPE_MAP + + perm_keys = [k for k in SCOPE_MAP if "/permissions" in k[1]] + assert len(perm_keys) == 5 + for key in perm_keys: + assert SCOPE_MAP[key] == ["drive.full"], key + + +class TestAuthEnabled: + def test_token_sub_resolves_user(self, auth_client): + resp = auth_client.get( + "/drive/v3/about", headers=_bearer(_token("drive.metadata.readonly")) + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == ALEX_EMAIL + + def test_valid_token_lists_files(self, auth_client): + resp = auth_client.get( + "/drive/v3/files", headers=_bearer(_token("drive.readonly")) + ) + assert resp.status_code == 200 + names = [f["name"] for f in resp.json()["files"]] + assert "notes.txt" in names + + def test_metadata_scope_reads_file_metadata(self, auth_client): + resp = auth_client.get( + "/drive/v3/files/file_binary", + headers=_bearer(_token("drive.metadata.readonly")), + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "notes.txt" + + def test_metadata_scope_cannot_download_content(self, auth_client): + # ...but ?alt=media downloads content, so metadata.readonly is rejected + # by the middleware's alt=media upgrade (exact contract 403 body). + resp = auth_client.get( + "/drive/v3/files/file_binary", + params={"alt": "media"}, + headers=_bearer(_token("drive.metadata.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["code"] == 403 + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["drive.readonly", "drive.full"] + assert err["token_scopes"] == ["drive.metadata.readonly"] + assert "hint" in err + + def test_readonly_scope_downloads_content(self, auth_client): + resp = auth_client.get( + "/drive/v3/files/file_binary", + params={"alt": "media"}, + headers=_bearer(_token("drive.readonly")), + ) + assert resp.status_code == 200 + assert resp.content == b"hello drive content" + + def test_export_requires_content_scope(self, auth_client): + denied = auth_client.get( + "/drive/v3/files/file_gdoc/export", + params={"mimeType": "text/plain"}, + headers=_bearer(_token("drive.metadata.readonly")), + ) + assert denied.status_code == 403 + assert denied.json()["error"]["required_scopes"] == ["drive.readonly", "drive.full"] + + allowed = auth_client.get( + "/drive/v3/files/file_gdoc/export", + params={"mimeType": "text/plain"}, + headers=_bearer(_token("drive.readonly")), + ) + assert allowed.status_code == 200 + assert allowed.content == b"exported document text" + + def test_create_without_write_scope_403(self, auth_client): + resp = auth_client.post( + "/drive/v3/files", + json={"name": "new.txt", "mimeType": "text/plain"}, + headers=_bearer(_token("drive.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["required_scopes"] == ["drive.file", "drive.full"] + assert err["token_scopes"] == ["drive.readonly"] + + def test_create_with_drive_file_scope_200(self, auth_client): + resp = auth_client.post( + "/drive/v3/files", + json={"name": "new.txt", "mimeType": "text/plain"}, + headers=_bearer(_token("drive.file")), + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "new.txt" + + def test_delete_with_drive_file_scope_204(self, auth_client): + resp = auth_client.delete( + "/drive/v3/files/file_binary", headers=_bearer(_token("drive.file")) + ) + assert resp.status_code == 204 + + def test_permissions_require_drive_full(self, auth_client): + # Even reads of the sharing surface are pinned to drive.full -- and + # drive.file (write-capable) is NOT sufficient. + for scope in ("drive.readonly", "drive.file"): + resp = auth_client.get( + "/drive/v3/files/file_binary/permissions", + headers=_bearer(_token(scope)), + ) + assert resp.status_code == 403, scope + assert resp.json()["error"]["required_scopes"] == ["drive.full"] + + resp = auth_client.get( + "/drive/v3/files/file_binary/permissions", + headers=_bearer(_token("drive.full")), + ) + assert resp.status_code == 200 + assert resp.json()["permissions"] + + def test_share_with_drive_full_200(self, auth_client): + resp = auth_client.post( + "/drive/v3/files/file_binary/permissions", + json={"role": "reader", "type": "user", "emailAddress": JORDAN_EMAIL}, + headers=_bearer(_token("drive.full")), + ) + assert resp.status_code in (200, 201) + assert resp.json()["role"] == "reader" + + def test_mock_header_matching_token_user_allowed(self, auth_client): + for value in (ALEX, ALEX_EMAIL): + resp = auth_client.get( + "/drive/v3/about", + headers={**_bearer(_token("drive.readonly")), "X-Mock-Drive-User": value}, + ) + assert resp.status_code == 200, value + assert resp.json()["user"]["emailAddress"] == ALEX_EMAIL + + def test_mock_header_other_user_403_impersonation_body(self, auth_client): + resp = auth_client.get( + "/drive/v3/about", + headers={**_bearer(_token("drive.readonly", sub=ALEX)), "X-Mock-Drive-User": JORDAN}, + ) + assert resp.status_code == 403 + # Exact contract body -- NOT the Drive error envelope. + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALEX, + "requested_user": JORDAN, + } + } + + def test_mock_header_other_user_by_email_403(self, auth_client): + resp = auth_client.get( + "/drive/v3/about", + headers={ + **_bearer(_token("drive.readonly", sub=ALEX)), + "X-Mock-Drive-User": JORDAN_EMAIL, + }, + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["authenticated_user"] == ALEX + assert err["requested_user"] == JORDAN_EMAIL + + def test_impersonation_attempt_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + monkeypatch.setenv("AUTH_REPORT", "1") # read at call time + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.get( + "/drive/v3/about", + headers={ + **_bearer(_token("drive.readonly", sub=ALEX)), + "X-Mock-Drive-User": JORDAN, + }, + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "impersonation_attempt"] + assert attempts, f"no impersonation_attempt among events: {events}" + event = attempts[0] + assert event["user_id"] == ALEX + assert event["details"]["authenticated_user"] == ALEX + assert event["details"]["requested_user"] == JORDAN + + def test_media_scope_escalation_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + monkeypatch.setenv("AUTH_REPORT", "1") + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.get( + "/drive/v3/files/file_binary", + params={"alt": "media"}, + headers=_bearer(_token("drive.metadata.readonly")), + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "scope_escalation_attempt"] + assert attempts, f"no scope_escalation_attempt among events: {events}" + details = attempts[0]["details"] + assert details["route"] == "/drive/v3/files/{fileId}" + assert details["alt"] == "media" + assert details["required_scopes"] == ["drive.readonly", "drive.full"] + + def test_expired_token_401_with_refresh_hint(self, auth_client): + resp = auth_client.get( + "/drive/v3/files", + headers=_bearer(_token("drive.readonly", expires_in=-60)), + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "expired at" in err["message"] + assert "/oauth2/token" in err["hint"] + assert "grant_type=refresh_token" in err["hint"] + assert resp.headers["WWW-Authenticate"].startswith('Bearer error="invalid_token"') + + def test_no_token_401(self, auth_client): + resp = auth_client.get("/drive/v3/files") + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "WWW-Authenticate" in resp.headers + + def test_legacy_bearer_identifier_is_rejected(self, auth_client): + # Pre-auth, `Authorization: Bearer user_alex` selected a user. Under + # auth the Authorization header must be a verified JWT -- a bare + # identifier is a malformed token, never an identity. + resp = auth_client.get("/drive/v3/files", headers=_bearer(ALEX)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_bad_signature_401(self, auth_client): + other_private, _ = generate_test_keypair() + forged = make_jwt(private_key=other_private, kid=KID, sub=ALEX, scope="drive.full") + resp = auth_client.get("/drive/v3/files", headers=_bearer(forged)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_health_unauthenticated(self, auth_client): + resp = auth_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_admin_action_log_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/action_log") + assert resp.status_code == 200 + + +class TestEmailClaimFallback: + """Identity-alignment: a token whose ``sub`` is not a local user id is + resolved via its ``email`` claim (case-insensitive); the resolved LOCAL + user is the effective identity everywhere downstream. + """ + + # env-0-auth-style id (gmail/gcal use 'user1') that does NOT exist in + # gdrive, whose local id for the same person is 'user_alex'. + NONLOCAL_SUB = "user1" + + def _fallback_token(self, scope: str = "drive.readonly", email: str = ALEX_EMAIL) -> str: + return _token(scope, sub=self.NONLOCAL_SUB, email=email) + + def test_nonlocal_sub_with_seeded_email_lists_files_as_user_alex(self, auth_client): + resp = auth_client.get("/drive/v3/files", headers=_bearer(self._fallback_token())) + assert resp.status_code == 200 + names = [f["name"] for f in resp.json()["files"]] + assert "notes.txt" in names + + def test_nonlocal_sub_resolves_to_local_identity(self, auth_client): + resp = auth_client.get( + "/drive/v3/about", + headers=_bearer(self._fallback_token("drive.metadata.readonly")), + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == ALEX_EMAIL + + def test_email_match_is_case_insensitive(self, auth_client): + resp = auth_client.get( + "/drive/v3/about", + headers=_bearer( + self._fallback_token("drive.metadata.readonly", email=ALEX_EMAIL.upper()) + ), + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == ALEX_EMAIL + + def test_header_naming_resolved_local_id_does_not_trip_guard(self, auth_client): + resp = auth_client.get( + "/drive/v3/files", + headers={ + **_bearer(self._fallback_token()), + "X-Mock-Drive-User": ALEX, # the RESOLVED local id + }, + ) + assert resp.status_code == 200 + + def test_header_naming_other_user_still_403s(self, auth_client): + resp = auth_client.get( + "/drive/v3/files", + headers={ + **_bearer(self._fallback_token()), + "X-Mock-Drive-User": JORDAN, + }, + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["message"] == "Cannot access another user's resources" + # The contract body names the RESOLVED local identity. + assert err["authenticated_user"] == ALEX + assert err["requested_user"] == JORDAN + + def test_unknown_sub_and_unknown_email_404_as_before(self, auth_client): + resp = auth_client.get( + "/drive/v3/files", + headers=_bearer(_token("drive.readonly", sub="ghost", email="ghost@nowhere.test")), + ) + assert resp.status_code == 404 + + def test_local_sub_wins_over_email_claim(self, auth_client): + # Resolution order pin: id == sub (a) beats the email claim (b) -- a + # token for user_jordan carrying alex's email acts as user_jordan. + resp = auth_client.get( + "/drive/v3/about", + headers=_bearer( + _token("drive.metadata.readonly", sub=JORDAN, email=ALEX_EMAIL) + ), + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == JORDAN_EMAIL + + +class TestWebUIExemption: + """With AUTH_ENABLED=1, the human web UI stays usable WITHOUT a token. + + The web dashboard identifies users via the mock_gdrive_user cookie (browser + session), not Bearer tokens -- the real-world session-vs-API split. /mcp is + likewise exempt (agent MCP clients are out of OAuth scope for v1, same + decision as gmail). The API under /drive/v3 still requires Bearer + tokens. See mock_gdrive/api/auth_middleware.py. + """ + + def test_home_root_no_token_200(self, auth_client): + resp = auth_client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_home_with_query_params_no_token_200(self, auth_client): + # Query strings don't change the path: still the exact-match "/" route. + resp = auth_client.get("/", params={"view": "starred", "q": "notes"}) + assert resp.status_code == 200 + + def test_file_detail_no_token_200(self, auth_client): + resp = auth_client.get("/file/file_binary") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_switch_user_no_token_303(self, auth_client): + resp = auth_client.get( + "/switch-user", params={"email": JORDAN_EMAIL}, follow_redirects=False + ) + assert resp.status_code == 303 + + def test_mcp_not_gated_by_auth(self, auth_client): + # MCP is not mounted in tests: a 404 (rather than 401) proves the + # middleware exempts /mcp instead of demanding a Bearer token. + resp = auth_client.get("/mcp") + assert resp.status_code == 404 + + def test_api_still_requires_token_while_web_is_open(self, auth_client): + # The "/" exemption is EXACT-match, not a "/" prefix that would + # swallow every path: the API must still 401 without a token. + assert auth_client.get("/").status_code == 200 + resp = auth_client.get("/drive/v3/files") + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_exempt_file_prefix_does_not_cover_api_files(self, auth_client): + # "/file" is segment-boundary matched: it must NOT exempt + # /drive/v3/files (different path entirely) nor leak via "/files". + resp = auth_client.get("/drive/v3/files/file_binary") + assert resp.status_code == 401 + + def test_exempt_prefixes_extend_contract_defaults(self): + from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + from mock_gdrive.api.auth_middleware import GDRIVE_EXEMPT_PREFIXES + + for prefix in DEFAULT_EXEMPT_PREFIXES: + assert prefix in GDRIVE_EXEMPT_PREFIXES + for prefix in ("/file", "/switch-user", "/static", "/mcp"): + assert prefix in GDRIVE_EXEMPT_PREFIXES + + +class TestAuthDisabledRegression: + """CRITICAL: with AUTH_ENABLED unset, behavior is byte-identical legacy. + + These tests use the standard ``client`` fixture from conftest (the module + singleton, which was assembled with auth disabled). + """ + + def test_singleton_has_no_auth_middleware(self, client): + from mock_gdrive.api.app import app + + assert not any( + "Env_0AuthMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "Env_0AuthMiddleware must not be installed when AUTH_ENABLED is unset" + + def test_no_headers_falls_back_to_first_user(self, client, seed_user): + resp = client.get("/drive/v3/about") + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == seed_user.email + + def test_header_resolution_by_id(self, client, seed_users): + resp = client.get( + "/drive/v3/about", headers={"X-Mock-Drive-User": "user_bob"} + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == "bob@example.com" + + def test_header_resolution_by_email(self, client, seed_users): + resp = client.get( + "/drive/v3/about", headers={"X-Mock-Drive-User": "alice@example.com"} + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == "alice@example.com" + + def test_legacy_bearer_identifier_resolution(self, client, seed_users): + # Legacy quirk preserved: a bare Bearer identifier selects a user. + resp = client.get( + "/drive/v3/about", headers={"Authorization": "Bearer user_bob"} + ) + assert resp.status_code == 200 + assert resp.json()["user"]["emailAddress"] == "bob@example.com" + + def test_unknown_identifier_401_drive_envelope(self, client, seed_user): + resp = client.get( + "/drive/v3/about", headers={"X-Mock-Drive-User": "no_such_user"} + ) + assert resp.status_code == 401 + err = resp.json()["error"] + # Legacy Drive envelope (has 'errors' list), not the auth contract body. + assert err["status"] == "UNAUTHENTICATED" + assert err["errors"][0]["reason"] == "unauthorized" + + def test_apply_auth_returns_false_when_disabled(self, monkeypatch): + import mock_gdrive.api.app as app_module + + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert app_module._apply_auth(FastAPI()) is False + + def test_apply_auth_raises_runtime_error_without_package(self, monkeypatch): + import mock_gdrive.api.app as app_module + + monkeypatch.setenv("AUTH_ENABLED", "1") + # None in sys.modules makes `import env_0_auth_client` raise ImportError. + monkeypatch.setitem(sys.modules, "env_0_auth_client", None) + with pytest.raises(RuntimeError, match="auth-client is not installed"): + app_module._apply_auth(FastAPI()) diff --git a/packages/environments/mock-gdrive/uv.lock b/packages/environments/mock-gdrive/uv.lock index 5d7683a09..6a0177a34 100644 --- a/packages/environments/mock-gdrive/uv.lock +++ b/packages/environments/mock-gdrive/uv.lock @@ -713,6 +713,14 @@ mcp = [ { name = "fastapi-mcp" }, ] +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, @@ -735,6 +743,14 @@ requires-dist = [ ] provides-extras = ["mcp", "dev", "all"] +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=43.0" }, + { name = "pyjwt", specifier = ">=2.8" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + [[package]] name = "oauthlib" version = "3.3.1" diff --git a/packages/environments/mock-gmail/mock_gmail/api/app.py b/packages/environments/mock-gmail/mock_gmail/api/app.py index 0b1cb2909..e03c762f6 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/app.py +++ b/packages/environments/mock-gmail/mock_gmail/api/app.py @@ -510,6 +510,12 @@ def _apply_auth(target_app: FastAPI, **middleware_kwargs) -> bool: from mock_gmail.api.auth_middleware import GmailMockAuthMiddleware from mock_gmail.auth_scopes import SCOPE_MAP + from mock_gmail.web.sso import GmailWebSessionMiddleware, set_jwks_static + + jwks_static = middleware_kwargs.get("jwks_static") + if jwks_static is not None: + set_jwks_static(jwks_static) + target_app.add_middleware(GmailWebSessionMiddleware) # Added after full app assembly, so this middleware is OUTERMOST: requests # rejected here are not recorded in the action log. diff --git a/packages/environments/mock-gmail/mock_gmail/api/deps.py b/packages/environments/mock-gmail/mock_gmail/api/deps.py index 96c13dd7f..d0b17737c 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/deps.py +++ b/packages/environments/mock-gmail/mock_gmail/api/deps.py @@ -10,7 +10,12 @@ class ImpersonationError(Exception): - """Authenticated request named a userId that is not the token subject.""" + """Authenticated request named a userId that is not the token's subject. + + Handled in ``mock_gmail.api.app`` with the auth contract 403 body + (``env_0_auth_client.errors.impersonation_body``), deliberately NOT the + Gmail error envelope used for HTTPException. + """ def __init__(self, authenticated_user: str, requested_user: str): super().__init__( @@ -34,17 +39,22 @@ def get_db() -> Session: def resolve_user_id( userId: str, request: Request, + x_env_0_gmail_user: str | None = Header(None), x_mock_gmail_user: str | None = Header(None), db: Session = Depends(get_db), ) -> str: """Resolve 'me' to the actual user ID. - With auth enabled, Env_0AuthMiddleware sets request.state.auth_user_id - from the Bearer token. The token is mapped to a local seeded user by id - first, then by case-insensitive email claim. + With auth enabled (Env_0AuthMiddleware sets request.state.auth_user_id): + the token is mapped to a LOCAL user via (a) id == token ``sub``, else + (b) email == token ``email`` claim (case-insensitive), else (c) the + legacy 404. The resolved LOCAL id is the effective identity: 'me' resolves + to it, and an explicit userId naming anyone other than the token's ``sub`` + or the resolved local user raises the contract 403 impersonation error + (after reporting an impersonation_attempt event to auth). - With auth disabled, preserve legacy behavior: - userId path param -> X-Mock-Gmail-User header -> first user in DB. + With auth disabled (the default), legacy behavior is unchanged: + userId path param -> X-Env-0-Gmail-User / X-Mock-Gmail-User header -> first user in DB. """ auth_user_id = getattr(request.state, "auth_user_id", None) if auth_user_id is not None: @@ -57,6 +67,9 @@ def resolve_user_id( ).first() effective_id = user.id if user is not None else auth_user_id if userId not in ("me", auth_user_id, effective_id): + # The middleware cannot see path-vs-sub mismatches; report here. + # env_0_auth_client is guaranteed importable: auth_user_id is only + # ever set by Env_0AuthMiddleware. from env_0_auth_client import report_impersonation report_impersonation( @@ -76,9 +89,10 @@ def resolve_user_id( raise HTTPException(404, f"User {userId!r} not found") return userId - if x_mock_gmail_user: + header_user = x_env_0_gmail_user or x_mock_gmail_user + if header_user: user = db.query(User).filter( - (User.id == x_mock_gmail_user) | (User.email_address == x_mock_gmail_user) + (User.id == header_user) | (User.email_address == header_user) ).first() if user: return user.id @@ -86,5 +100,5 @@ def resolve_user_id( # Fallback: first user user = db.query(User).first() if not user: - raise HTTPException(404, "No users in database. Run `mock-gmail seed` first.") + raise HTTPException(404, "No users in database. Run `mock-mock-gmail seed` first.") return user.id diff --git a/packages/environments/mock-gmail/mock_gmail/api/labels.py b/packages/environments/mock-gmail/mock_gmail/api/labels.py index ccef92dae..4ee429927 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/labels.py +++ b/packages/environments/mock-gmail/mock_gmail/api/labels.py @@ -19,24 +19,40 @@ def _label_to_schema(label: Label, db: Session, include_counts: bool = False) -> LabelSchema: # Real Gmail: system labels in list response omit counts; counts only on labels.get if include_counts: - msg_total = db.query(MessageLabel).filter(MessageLabel.label_id == label.id).count() + # Labels are per-user (composite PK id+user_id) while message_labels + # stores bare label ids, so counts must be scoped to the label's user + # via the message's user_id. + msg_total = ( + db.query(MessageLabel) + .join(Message) + .filter(MessageLabel.label_id == label.id, Message.user_id == label.user_id) + .count() + ) msg_unread = ( db.query(MessageLabel) .join(Message) - .filter(MessageLabel.label_id == label.id, Message.is_read == False) + .filter( + MessageLabel.label_id == label.id, + Message.user_id == label.user_id, + Message.is_read == False, + ) .count() ) # Compute thread counts dynamically from messages with this label threads_total = ( db.query(func.count(func.distinct(Message.thread_id))) .join(MessageLabel) - .filter(MessageLabel.label_id == label.id) + .filter(MessageLabel.label_id == label.id, Message.user_id == label.user_id) .scalar() ) or 0 threads_unread = ( db.query(func.count(func.distinct(Message.thread_id))) .join(MessageLabel) - .filter(MessageLabel.label_id == label.id, Message.is_read == False) + .filter( + MessageLabel.label_id == label.id, + Message.user_id == label.user_id, + Message.is_read == False, + ) .scalar() ) or 0 else: @@ -196,6 +212,14 @@ def delete_label( raise HTTPException(404, f"Label {labelId!r} not found") if label.type == LabelType.system.value: raise HTTPException(400, "Cannot delete system labels") + # message_labels has no FK/cascade to labels (composite label PK), so + # remove this user's message associations explicitly. + db.query(MessageLabel).filter( + MessageLabel.label_id == labelId, + MessageLabel.message_id.in_( + db.query(Message.id).filter(Message.user_id == _user_id).scalar_subquery() + ), + ).delete(synchronize_session=False) db.delete(label) db.commit() return {} diff --git a/packages/environments/mock-gmail/mock_gmail/models/label.py b/packages/environments/mock-gmail/mock_gmail/models/label.py index b4478c721..cfc02eb22 100644 --- a/packages/environments/mock-gmail/mock_gmail/models/label.py +++ b/packages/environments/mock-gmail/mock_gmail/models/label.py @@ -43,8 +43,10 @@ class LabelType(str, enum.Enum): class Label(Base): __tablename__ = "labels" + # Composite primary key (id, user_id): Gmail label ids like "INBOX" are + # per-user, so every user gets their own row for each system label. id: Mapped[str] = mapped_column(String, primary_key=True) - user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) name: Mapped[str] = mapped_column(String, nullable=False) type: Mapped[str] = mapped_column(SAEnum(LabelType), default=LabelType.user) color_bg: Mapped[str | None] = mapped_column(String, nullable=True) @@ -57,4 +59,7 @@ class Label(Base): threads_unread: Mapped[int] = mapped_column(Integer, default=0) user: Mapped["User"] = relationship(back_populates="labels") # noqa: F821 - message_labels: Mapped[list["MessageLabel"]] = relationship(back_populates="label", cascade="all, delete-orphan") # noqa: F821 + # NOTE: no relationship to MessageLabel. message_labels.label_id stores the + # bare Gmail label id; the owning user is determined via the message's + # user_id (messages and labels are both per-user). Label deletion cleans up + # MessageLabel rows explicitly in the labels API. diff --git a/packages/environments/mock-gmail/mock_gmail/models/message.py b/packages/environments/mock-gmail/mock_gmail/models/message.py index 57855db5d..648ea2caa 100644 --- a/packages/environments/mock-gmail/mock_gmail/models/message.py +++ b/packages/environments/mock-gmail/mock_gmail/models/message.py @@ -58,7 +58,10 @@ class MessageLabel(Base): __tablename__ = "message_labels" message_id: Mapped[str] = mapped_column(ForeignKey("messages.id"), primary_key=True) - label_id: Mapped[str] = mapped_column(ForeignKey("labels.id"), primary_key=True) + # Bare Gmail label id (e.g. "INBOX", "Label_ab12cd34"). Labels now have a + # composite primary key (id, user_id), so a single-column FK to labels.id + # is no longer expressible; the owning user's label row is identified by + # (label_id, message.user_id). + label_id: Mapped[str] = mapped_column(String, primary_key=True) message: Mapped["Message"] = relationship(back_populates="labels") - label: Mapped["Label"] = relationship(back_populates="message_labels") # noqa: F821 diff --git a/packages/environments/mock-gmail/mock_gmail/seed/generator.py b/packages/environments/mock-gmail/mock_gmail/seed/generator.py index 975484aef..ab550b440 100644 --- a/packages/environments/mock-gmail/mock_gmail/seed/generator.py +++ b/packages/environments/mock-gmail/mock_gmail/seed/generator.py @@ -409,11 +409,118 @@ def seed_phishing_scenario(db: Session, fake: Faker, user: User, personas: list[ seed_default_scenario(db, fake, user, personas) +# --- Multi-account scenario (auth alignment) --- +PERSONAL_ACCOUNT_ID = "user_101" +PERSONAL_ACCOUNT_EMAIL = "alex.personal@gmail.local" +PERSONAL_ACCOUNT_NAME = "Alex Chen (Personal)" + +_PERSONAL_INBOX_EMAILS = [ + { + "sender": "Maya Chen ", + "subject": "Mom's birthday dinner -- Saturday?", + "body_plain": "Hey Alex,\n\nCan you make it to Mom's birthday dinner on Saturday at 7pm? " + "I'm booking the table at Lupa tomorrow, let me know!\n\nMaya", + "is_read": False, + "is_starred": True, + }, + { + "sender": "Riverside Gym ", + "subject": "Your membership renews on the 15th", + "body_plain": "Hi Alex,\n\nYour monthly membership ($49.00) renews automatically on the 15th. " + "To change your plan, visit your account settings.\n\nRiverside Gym", + "is_read": True, + "is_starred": False, + }, + { + "sender": "Pine Street Book Club ", + "subject": "June pick: The Lathe of Heaven", + "body_plain": "This month we're reading The Lathe of Heaven by Ursula K. Le Guin. " + "We meet on the last Thursday, usual place. Bring snacks!", + "is_read": False, + "is_starred": False, + }, +] + +_PERSONAL_SENT_EMAIL = { + "to": "maya.chen@gmail.local", + "subject": "Re: Mom's birthday dinner -- Saturday?", + "body_plain": "Saturday 7pm works! I'll bring the cake. See you there.\n\nAlex", +} + + +def _seed_personal_account(db: Session): + """Create user_101 (Alex's personal persona) with a small mailbox.""" + now = datetime.utcnow() + personal = User( + id=PERSONAL_ACCOUNT_ID, + email_address=PERSONAL_ACCOUNT_EMAIL, + display_name=PERSONAL_ACCOUNT_NAME, + ) + db.add(personal) + create_system_labels(db, personal.id) + create_default_settings(db, personal) + + for idx, email_data in enumerate(_PERSONAL_INBOX_EMAILS): + thread_id = _make_id() + msg_id = _make_id() + db.add(Thread(id=thread_id, user_id=personal.id, snippet=email_data["body_plain"][:200])) + db.add( + Message( + id=msg_id, + thread_id=thread_id, + user_id=personal.id, + sender=email_data["sender"], + to=PERSONAL_ACCOUNT_EMAIL, + subject=email_data["subject"], + snippet=email_data["body_plain"][:200], + body_plain=email_data["body_plain"], + internal_date=now - timedelta(days=idx, hours=random.randint(1, 12)), + is_read=email_data["is_read"], + is_starred=email_data["is_starred"], + ) + ) + db.add(MessageLabel(message_id=msg_id, label_id="INBOX")) + + sent_thread_id = _make_id() + sent_msg_id = _make_id() + db.add( + Thread( + id=sent_thread_id, + user_id=personal.id, + snippet=_PERSONAL_SENT_EMAIL["body_plain"][:200], + ) + ) + db.add( + Message( + id=sent_msg_id, + thread_id=sent_thread_id, + user_id=personal.id, + sender=f"{PERSONAL_ACCOUNT_NAME} <{PERSONAL_ACCOUNT_EMAIL}>", + to=_PERSONAL_SENT_EMAIL["to"], + subject=_PERSONAL_SENT_EMAIL["subject"], + snippet=_PERSONAL_SENT_EMAIL["body_plain"][:200], + body_plain=_PERSONAL_SENT_EMAIL["body_plain"], + internal_date=now - timedelta(hours=random.randint(1, 6)), + is_read=True, + is_sent=True, + ) + ) + db.add(MessageLabel(message_id=sent_msg_id, label_id="SENT")) + + +def seed_multi_account_scenario(db: Session, fake: Faker, user: User, personas: list[dict]): + """Default content for the work account plus Alex's personal account.""" + seed_default_scenario(db, fake, user, personas) + if db.query(User).filter(User.id == PERSONAL_ACCOUNT_ID).first() is None: + _seed_personal_account(db) + + SCENARIOS = { "default": seed_default_scenario, "safety_corporate": seed_safety_corporate_scenario, "phishing": seed_phishing_scenario, "long_context": seed_long_context_scenario, + "multi_account": seed_multi_account_scenario, } # Auto-discover per-task scenarios from the configured task root. diff --git a/packages/environments/mock-gmail/mock_gmail/web/routes.py b/packages/environments/mock-gmail/mock_gmail/web/routes.py index b97da3443..3a7b17c5c 100644 --- a/packages/environments/mock-gmail/mock_gmail/web/routes.py +++ b/packages/environments/mock-gmail/mock_gmail/web/routes.py @@ -14,6 +14,7 @@ from mock_gmail.models import User, Message, Thread, Label, MessageLabel, Draft from mock_gmail.api.deps import get_db +from mock_gmail.web import sso router = APIRouter() templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) @@ -367,6 +368,40 @@ def _build_dashboard_context(request: Request, db: Session, test_results=None) - } +@router.get("/web/auth/callback", response_class=HTMLResponse) +async def web_auth_callback( + request: Request, + next: str = Query("/"), + db: Session = Depends(get_db), +): + """SSO landing: verify auth's identity assertion, establish the session. + + auth redirects the browser here after authenticating, carrying a + short-lived signed identity assertion (``env_0_identity``). On success we set + the ``mock_gmail_user`` cookie (the gmail web session) to the resolved local + user and bounce back to the original page. An absent/invalid/expired + assertion restarts the login dance. See ``mock_gmail/web/sso.py``. + """ + token = request.query_params.get(sso.ASSERTION_PARAM, "") + claims = await sso.verify_identity_assertion(token) + if claims is None: + # Tampered, expired, or missing -- send the browser back to log in. + return RedirectResponse( + sso.build_login_redirect(request, next), status_code=302 + ) + + user = sso.resolve_assertion_user(db, claims) + if user is None: + return HTMLResponse( + "

403 — authenticated identity has no Gmail mailbox

", + status_code=403, + ) + + response = RedirectResponse(sso._safe_next(next), status_code=302) + response.set_cookie(sso.WEB_SESSION_COOKIE, user.id, httponly=True) + return response + + @router.get("/", response_class=HTMLResponse) def inbox( request: Request, diff --git a/packages/environments/mock-gmail/mock_gmail/web/sso.py b/packages/environments/mock-gmail/mock_gmail/web/sso.py new file mode 100644 index 000000000..c0f94802f --- /dev/null +++ b/packages/environments/mock-gmail/mock_gmail/web/sso.py @@ -0,0 +1,207 @@ +"""Web-session SSO: model accounts.google.com's browser login hand-off. + +When ``AUTH_ENABLED=1`` the human web UI (``/``, ``/thread/*``) is no +longer an open, first-user dashboard: a browser without an established gmail +web session is bounced into auth's login flow, exactly like hitting Gmail +while signed out bounces you to accounts.google.com. + +The two services run on different origins (auth :9000, gmail :9001), +so auth's ``env_0_auth_session`` cookie is never sent to gmail -- a pure +cookie-share is impossible. Instead this is a redirect-with-identity-hand-off +(the OIDC id_token shape): + +1. gmail's :class:`GmailWebSessionMiddleware` 302s the browser to + ``{AUTH_URL}/web/login?next=/web/auth/callback?next=``. +2. auth authenticates the user (sets its own session) and, because the + ``next`` target is cross-origin, redirects back with a short-lived RS256 + *identity assertion* (``env_0_identity=``) signed by its active signing + key -- see ``env_0_auth.token_service.issue_identity_assertion``. +3. gmail's ``GET /web/auth/callback`` verifies that assertion against + auth's JWKS (the same keys the API Bearer middleware already trusts), + resolves the local user, and sets the ``mock_gmail_user`` cookie -- the + established gmail web session. Subsequent requests carry the cookie and + sail past the middleware. + +With ``AUTH_ENABLED`` off the middleware is never installed and the legacy +cookie / first-user behavior is byte-identical. +""" + +from __future__ import annotations + +from urllib.parse import urlencode + +from sqlalchemy import func +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +from mock_gmail.models import User, get_session_factory + +#: The browser-session identity cookie (unchanged: the web routes already key +#: identity off it). The callback sets it to the authenticated local user id. +WEB_SESSION_COOKIE = "mock_gmail_user" + +#: gmail-side endpoint auth redirects back to after authenticating. +CALLBACK_PATH = "/web/auth/callback" + +#: Query param carrying auth's signed identity assertion to the callback. +ASSERTION_PARAM = "env_0_identity" + +#: Human mailbox web routes that REQUIRE a session under auth. Everything else +#: root-mounted is either the API (``/gmail/v1``), infra/admin (``/_admin``, +#: ``/health``, ``/docs`` ...), dev tooling (``/dev``), or the login dance +#: itself (``/web``) -- all left open, mirroring the Bearer middleware's +#: exemptions. Kept an allowlist (not a denylist) so new infra routes can't be +#: accidentally gated. +_GATED_EXACT = frozenset({"/"}) +_GATED_PREFIXES: tuple[str, ...] = ("/thread",) + +#: Offline-test hook: a JWKS dict or zero-arg callable. When set, assertion +#: verification uses it instead of fetching ``{AUTH_URL}/oauth2/v3/certs`` +#: over HTTP. Wired by ``mock_gmail.api.app._apply_auth`` from ``jwks_static``. +_JWKS_STATIC = None + + +def set_jwks_static(source) -> None: + """Install (or clear, with ``None``) a static JWKS source for verification.""" + global _JWKS_STATIC + _JWKS_STATIC = source + + +def _is_gated(path: str) -> bool: + """True for the human mailbox web routes that require a session.""" + if path in _GATED_EXACT: + return True + for prefix in _GATED_PREFIXES: + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def _safe_next(next_path: str) -> str: + """Clamp the post-login redirect to a same-site path (no open redirect).""" + if next_path.startswith("/") and not next_path.startswith("//"): + return next_path + return "/" + + +def build_login_redirect(request: Request, original: str) -> str: + """Build the ``{AUTH_URL}/web/login?next=`` redirect URL. + + The callback URL is absolute (derived from this request's base URL) so the + browser lands back on gmail after authenticating; ``original`` is preserved + through the round-trip so the user resumes where they started. + """ + from env_0_auth_client import config as auth_config + + auth_url = auth_config.get_auth_url() + base = str(request.base_url).rstrip("/") + callback = f"{base}{CALLBACK_PATH}?" + urlencode({"next": original}) + return f"{auth_url}/web/login?" + urlencode({"next": callback}) + + +def _original_target(request: Request) -> str: + path = request.url.path + if request.url.query: + return f"{path}?{request.url.query}" + return path + + +def _has_web_session(request: Request) -> bool: + """True when the ``mock_gmail_user`` cookie resolves to a real local user.""" + user_id = request.cookies.get(WEB_SESSION_COOKIE, "") + if not user_id: + return False + db = get_session_factory()() + try: + return db.query(User).filter(User.id == user_id).first() is not None + finally: + db.close() + + +async def verify_identity_assertion(token: str) -> dict | None: + """Verify a auth web-SSO assertion JWT; return its claims or ``None``. + + Validates the RS256 signature against auth's JWKS, the issuer, the + expiry, and the ``purpose=web_sso`` marker. Audience is intentionally not + enforced (mock). Any failure returns ``None`` -- callers restart the login. + """ + if not token: + return None + + import jwt + + from env_0_auth_client import config as auth_config + from env_0_auth_client.jwks import JWKSCache + + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError: + return None + kid = header.get("kid") + if not kid: + return None + + cache = ( + JWKSCache(static=_JWKS_STATIC) + if _JWKS_STATIC is not None + else JWKSCache(auth_base_url=auth_config.get_auth_url()) + ) + try: + key = await cache.get_key(kid) + except Exception: + return None + + try: + claims = jwt.decode( + token, + key=key, + algorithms=["RS256"], + issuer=auth_config.get_issuer(), + options={"verify_aud": False, "require": ["exp", "iss"]}, + ) + except jwt.InvalidTokenError: + return None + + if claims.get("purpose") != "web_sso": + return None + return claims + + +def resolve_assertion_user(db, claims: dict) -> User | None: + """Map an assertion's identity to a local gmail user. + + Mirrors ``deps.resolve_user_id``: id == ``sub`` first, else + ``email_address`` == the ``email`` claim (case-insensitive). + """ + sub = claims.get("sub") + user = None + if sub: + user = db.query(User).filter(User.id == sub).first() + if user is None: + email = (claims.get("email") or "").strip() + if email: + user = db.query(User).filter( + func.lower(User.email_address) == email.lower() + ).first() + return user + + +class GmailWebSessionMiddleware(BaseHTTPMiddleware): + """Bounce sessionless browsers on the mailbox web UI into auth login. + + Only installed when ``AUTH_ENABLED`` is truthy (see + ``mock_gmail.api.app._apply_auth``). Gates only ``GET`` requests to the + human mailbox routes (``/``, ``/thread/*``); the API, infra, dev tooling + and the ``/web`` login-dance endpoints pass through untouched. + """ + + async def dispatch(self, request: Request, call_next) -> Response: + if request.method != "GET" or not _is_gated(request.url.path): + return await call_next(request) + if _has_web_session(request): + return await call_next(request) + return RedirectResponse( + build_login_redirect(request, _original_target(request)), + status_code=302, + ) diff --git a/packages/environments/mock-gmail/tests/__init__.py b/packages/environments/mock-gmail/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/environments/mock-gmail/tests/test_auth_integration.py b/packages/environments/mock-gmail/tests/test_auth_integration.py index 04d546e05..37482448f 100644 --- a/packages/environments/mock-gmail/tests/test_auth_integration.py +++ b/packages/environments/mock-gmail/tests/test_auth_integration.py @@ -1,10 +1,26 @@ -"""Integration tests for optional AUTH_ENABLED behavior.""" +"""Integration tests for the auth retrofit (AUTH_ENABLED). -from __future__ import annotations +Fully offline: JWKS is injected statically via env_0_auth_client.testing, and +event reporting is disabled (AUTH_REPORT=0) except where a MockTransport +captures events explicitly. +The FastAPI app in mock_gmail.api.app is a module-level singleton and +AUTH_ENABLED is read at import time, so the ``auth_app`` fixture builds a +FRESH instance via importlib.reload (with auth disabled, so the module-level +``_apply_auth(app)`` is a no-op) and then applies the middleware explicitly +with the static JWKS. Teardown reloads once more so every other test module +keeps using a pristine, auth-disabled singleton. +""" + +import base64 import importlib +import json +import sys +from urllib.parse import parse_qs, urlparse +import httpx import pytest +from fastapi import FastAPI from fastapi.routing import APIRoute from fastapi.testclient import TestClient @@ -15,8 +31,13 @@ PRIVATE_KEY, PUBLIC_KEY = generate_test_keypair() JWKS = jwks_for(PUBLIC_KEY, kid=KID) +# Actual identities seeded by scenario "default", seed 42 (see +# mock_gmail/seed/generator.py): the auth seeds must match THESE ids. ALICE = "user1" ALICE_EMAIL = "alex@nexusai.com" +# Any id other than the token sub; the impersonation check fires before the +# DB lookup, so this user does not need to exist in the seeded DB. +OTHER_USER = "user2" def _token(scope: str = "", sub: str = ALICE, **kwargs) -> str: @@ -27,27 +48,34 @@ def _bearer(token: str) -> dict: return {"Authorization": f"Bearer {token}"} +def _raw(subject: str = "Auth test", body: str = "hello") -> str: + msg = f"From: {ALICE_EMAIL}\r\nTo: colleague@example.com\r\nSubject: {subject}\r\n\r\n{body}" + return base64.urlsafe_b64encode(msg.encode()).decode().rstrip("=") + + @pytest.fixture def auth_app(monkeypatch): - """Fresh app instance with Env_0AuthMiddleware and static offline JWKS.""" + """Fresh app instance with Env_0AuthMiddleware and static (offline) JWKS.""" for var in ("AUTH_ENABLED", "AUTH_INTROSPECT", "AUTH_ISSUER", "AUTH_URL"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("AUTH_REPORT", "0") import mock_gmail.api.app as app_module - app_module = importlib.reload(app_module) + app_module = importlib.reload(app_module) # fresh singleton, no auth yet monkeypatch.setenv("AUTH_ENABLED", "1") assert app_module._apply_auth(app_module.app, jwks_static=JWKS) is True yield app_module.app + # Restore a pristine auth-disabled singleton for the other test modules. monkeypatch.delenv("AUTH_ENABLED", raising=False) importlib.reload(app_module) @pytest.fixture def auth_client(auth_app, seeded_db): + """TestClient against the auth-enabled fresh app + seeded temp database.""" reset_engine() init_db(seeded_db) with TestClient(auth_app) as c: @@ -55,48 +83,784 @@ def auth_client(auth_app, seeded_db): reset_engine() -def test_every_gmail_route_has_scope_map_entry(): - from mock_gmail.api.app import app - from mock_gmail.auth_scopes import GMAIL_PREFIX, SCOPE_MAP +class TestScopeMapCoverage: + def test_every_gmail_route_has_scope_map_entry(self): + """Every registered /gmail/v1 route must appear in SCOPE_MAP (and no stale keys).""" + from mock_gmail.api.app import app + from mock_gmail.auth_scopes import GMAIL_PREFIX, SCOPE_MAP + + registered = set() + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith(GMAIL_PREFIX): + for method in route.methods: + if method not in ("HEAD", "OPTIONS"): + registered.add((method, route.path)) + + assert registered, "no /gmail/v1 routes registered -- enumeration broken?" + missing = registered - set(SCOPE_MAP) + assert not missing, f"routes missing from SCOPE_MAP: {sorted(missing)}" + stale = set(SCOPE_MAP) - registered + assert not stale, f"SCOPE_MAP keys matching no registered route: {sorted(stale)}" + + def test_scope_lists_are_nonempty_gmail_scopes(self): + from mock_gmail.auth_scopes import SCOPE_MAP + + for key, scopes in SCOPE_MAP.items(): + assert scopes, f"empty scope list for {key}" + for scope in scopes: + assert scope.startswith("gmail."), f"unexpected scope {scope!r} for {key}" + + +class TestAuthEnabled: + def test_me_resolves_to_token_sub(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/profile", headers=_bearer(_token("gmail.readonly")) + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_valid_token_lists_messages(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/messages", headers=_bearer(_token("gmail.readonly")) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["resultSizeEstimate"] > 0 + assert data["messages"] + + def test_explicit_matching_user_id_allowed(self, auth_client): + resp = auth_client.get( + f"/gmail/v1/users/{ALICE}/messages", headers=_bearer(_token("gmail.readonly")) + ) + assert resp.status_code == 200 + + def test_send_without_send_scope_403(self, auth_client): + resp = auth_client.post( + "/gmail/v1/users/me/messages/send", + json={"raw": _raw()}, + headers=_bearer(_token("gmail.readonly")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["code"] == 403 + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["gmail.send", "gmail.full"] + assert err["token_scopes"] == ["gmail.readonly"] + assert "hint" in err + + def test_send_with_send_scope_200(self, auth_client): + resp = auth_client.post( + "/gmail/v1/users/me/messages/send", + json={"raw": _raw()}, + headers=_bearer(_token("gmail.send")), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] + assert "SENT" in data["labelIds"] + + def test_metadata_scope_cannot_read_message_body(self, auth_client): + # gmail.metadata may list messages... + listing = auth_client.get( + "/gmail/v1/users/me/messages", headers=_bearer(_token("gmail.metadata")) + ) + assert listing.status_code == 200 + msg_id = listing.json()["messages"][0]["id"] + # ...but messages.get can expose full bodies, so metadata is rejected. + resp = auth_client.get( + f"/gmail/v1/users/me/messages/{msg_id}", + headers=_bearer(_token("gmail.metadata")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["status"] == "PERMISSION_DENIED" + assert "gmail.readonly" in err["required_scopes"] + assert "gmail.metadata" not in err["required_scopes"] + + def test_mismatching_user_id_403_impersonation_body(self, auth_client): + resp = auth_client.get( + f"/gmail/v1/users/{OTHER_USER}/messages", + headers=_bearer(_token("gmail.readonly", sub=ALICE)), + ) + assert resp.status_code == 403 + # Exact contract body -- NOT the Gmail error envelope. + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALICE, + "requested_user": OTHER_USER, + } + } + + def test_impersonation_attempt_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + monkeypatch.setenv("AUTH_REPORT", "1") # read at call time + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.get( + f"/gmail/v1/users/{OTHER_USER}/messages", + headers=_bearer(_token("gmail.readonly", sub=ALICE)), + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "impersonation_attempt"] + assert attempts, f"no impersonation_attempt among events: {events}" + event = attempts[0] + assert event["user_id"] == ALICE + assert event["details"]["authenticated_user"] == ALICE + assert event["details"]["requested_user"] == OTHER_USER + + def test_expired_token_401_with_refresh_hint(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/messages", + headers=_bearer(_token("gmail.readonly", expires_in=-60)), + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "expired at" in err["message"] + assert "/oauth2/token" in err["hint"] + assert "grant_type=refresh_token" in err["hint"] + assert resp.headers["WWW-Authenticate"].startswith('Bearer error="invalid_token"') + + def test_no_token_401(self, auth_client): + resp = auth_client.get("/gmail/v1/users/me/messages") + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "WWW-Authenticate" in resp.headers + + def test_bad_signature_401(self, auth_client): + other_private, _ = generate_test_keypair() + forged = make_jwt(private_key=other_private, kid=KID, sub=ALICE, scope="gmail.full") + resp = auth_client.get("/gmail/v1/users/me/messages", headers=_bearer(forged)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_health_unauthenticated(self, auth_client): + resp = auth_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_admin_state_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/state") + assert resp.status_code == 200 + assert "users" in resp.json() + + def test_admin_action_log_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/action_log") + assert resp.status_code == 200 + + +class TestEmailClaimFallback: + """Identity-alignment: a token whose ``sub`` is not a local user id is + resolved via its ``email`` claim (case-insensitive); the resolved LOCAL id + is the effective identity everywhere downstream. + """ + + # env-0-auth-style id that does NOT exist in gmail's seeds. + NONLOCAL_SUB = "user_001" + + def _fallback_token(self, scope: str = "gmail.readonly", email: str = ALICE_EMAIL) -> str: + return _token(scope, sub=self.NONLOCAL_SUB, email=email) + + def test_nonlocal_sub_with_seeded_email_resolves_me(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/profile", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_nonlocal_sub_lists_messages_end_to_end(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/messages", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + assert resp.json()["resultSizeEstimate"] > 0 + + def test_email_match_is_case_insensitive(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/profile", + headers=_bearer(self._fallback_token(email=ALICE_EMAIL.upper())), + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_explicit_resolved_local_id_does_not_trip_guard(self, auth_client): + # Naming the RESOLVED local id explicitly is the token's own identity. + resp = auth_client.get( + f"/gmail/v1/users/{ALICE}/messages", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + + def test_genuinely_different_user_still_403s(self, auth_client): + resp = auth_client.get( + f"/gmail/v1/users/{OTHER_USER}/messages", + headers=_bearer(self._fallback_token()), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["message"] == "Cannot access another user's resources" + # The contract body names the RESOLVED local identity. + assert err["authenticated_user"] == ALICE + assert err["requested_user"] == OTHER_USER + + def test_unknown_sub_and_unknown_email_404_as_before(self, auth_client): + resp = auth_client.get( + "/gmail/v1/users/me/profile", + headers=_bearer(_token("gmail.readonly", sub="ghost", email="ghost@nowhere.test")), + ) + assert resp.status_code == 404 + + def test_local_sub_wins_over_email_claim(self, multi_client): + # Resolution order pin: id == sub (a) is checked BEFORE the email + # claim (b) -- a token for user_101 carrying alice's work email acts + # as user_101, not user1. + resp = multi_client.get( + "/gmail/v1/users/me/profile", + headers=_bearer(_token("gmail.readonly", sub=PERSONAL, email=ALICE_EMAIL)), + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == PERSONAL_EMAIL + + +class TestAuthDisabledRegression: + """CRITICAL: with AUTH_ENABLED unset, behavior is byte-identical legacy. + + These tests use the standard ``client`` fixture from conftest (the module + singleton, which was assembled with auth disabled). + """ + + def test_singleton_has_no_auth_middleware(self, client): + from mock_gmail.api.app import app + + assert not any( + "Env_0AuthMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "Env_0AuthMiddleware must not be installed when AUTH_ENABLED is unset" + + def test_no_bearer_required_me_falls_back_to_first_user(self, client): + resp = client.get("/gmail/v1/users/me/profile") + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_header_resolution_by_id(self, client): + # NOTE: the deps layer reads the X-Env-0-Gmail-User header (FastAPI + # parameter x_env_0_gmail_user); X-Mock-Gmail-User is only used by the + # action-log middleware and never affects resolution. + resp = client.get( + "/gmail/v1/users/me/profile", headers={"X-Env-0-Gmail-User": ALICE} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_header_resolution_by_email(self, client): + resp = client.get( + "/gmail/v1/users/me/profile", headers={"X-Env-0-Gmail-User": ALICE_EMAIL} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_mock_gmail_user_header_does_not_resolve(self, client): + # Legacy quirk preserved: X-Mock-Gmail-User does NOT participate in + # user resolution ('me' still falls back to the first user). + resp = client.get( + "/gmail/v1/users/me/profile", headers={"X-Mock-Gmail-User": "no-such-user"} + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + + def test_explicit_unknown_user_404_gmail_envelope(self, client): + resp = client.get("/gmail/v1/users/no_such_user/messages") + assert resp.status_code == 404 + err = resp.json()["error"] + # Legacy Gmail envelope (has 'errors' list), not the auth contract body. + assert err["status"] == "NOT_FOUND" + assert err["errors"][0]["reason"] == "notFound" + + def test_apply_auth_returns_false_when_disabled(self, monkeypatch): + import mock_gmail.api.app as app_module + + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert app_module._apply_auth(FastAPI()) is False + + def test_apply_auth_raises_runtime_error_without_package(self, monkeypatch): + import mock_gmail.api.app as app_module + + monkeypatch.setenv("AUTH_ENABLED", "1") + # None in sys.modules makes `import env_0_auth_client` raise ImportError. + monkeypatch.setitem(sys.modules, "env_0_auth_client", None) + with pytest.raises(RuntimeError, match="auth-client is not installed"): + app_module._apply_auth(FastAPI()) + + +# --- Multi-account fixtures (gmail scenario `multi_account`, mirrors auth) --- + +PERSONAL = "user_101" +PERSONAL_EMAIL = "alex.personal@gmail.local" + + +@pytest.fixture +def multi_seeded_db(db_path): + """Temp DB seeded with the `multi_account` scenario (user1 + user_101).""" + from mock_gmail.seed.generator import seed_database + + reset_engine() + seed_database(scenario="multi_account", seed=42, db_path=db_path) + return db_path + + +@pytest.fixture +def multi_client(auth_app, multi_seeded_db): + """Auth-enabled TestClient against the multi-account database.""" + reset_engine() + init_db(multi_seeded_db) + with TestClient(auth_app) as c: + yield c + reset_engine() + + +class TestWebUIExemption: + """With AUTH_ENABLED=1, the human web UI is Bearer-token-EXEMPT but + session-gated (W7). + + The mailbox routes (``/``, ``/thread/*``) identify users via the + ``mock_gmail_user`` cookie (browser session), not Bearer tokens -- the + real-world session-vs-API split. Under auth, that session must be + established via SSO first (see TestWebSessionSSO); with a valid session + cookie these routes serve HTML and never demand a token. POST mutations, + ``/dev/*`` tooling and ``/mcp`` are not GET-gated. The API under + ``/gmail/v1`` still requires Bearer tokens. See + mock_gmail/api/auth_middleware.py, mock_gmail/web/sso.py and API_NOTES.md. + """ + + def _first_id(self, model): + from mock_gmail.models import get_session_factory + + db = get_session_factory()() + try: + row = db.query(model).first() + assert row is not None + return row.id + finally: + db.close() + + def _session(self, client): + """Attach an established gmail web session (cookie -> seeded user).""" + client.cookies.set("mock_gmail_user", ALICE) + return client + + def test_inbox_root_with_session_200(self, auth_client): + resp = self._session(auth_client).get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_inbox_with_query_params_with_session_200(self, auth_client): + # Query strings don't change the path: still the exact-match "/" route. + resp = self._session(auth_client).get("/", params={"label": "INBOX", "q": "meeting"}) + assert resp.status_code == 200 + + def test_thread_page_with_session_200(self, auth_client): + from mock_gmail.models import Thread + + resp = self._session(auth_client).get(f"/thread/{self._first_id(Thread)}") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_star_no_token_303(self, auth_client): + from mock_gmail.models import Message + + resp = auth_client.post( + f"/star/{self._first_id(Message)}", follow_redirects=False + ) + assert resp.status_code == 303 + + def test_mark_read_no_token_303(self, auth_client): + from mock_gmail.models import Message + + resp = auth_client.post( + f"/mark-read/{self._first_id(Message)}", follow_redirects=False + ) + assert resp.status_code == 303 + + def test_trash_no_token_303(self, auth_client): + from mock_gmail.models import Message + + resp = auth_client.post( + f"/trash/{self._first_id(Message)}", follow_redirects=False + ) + assert resp.status_code == 303 + + def test_dev_routes_no_token_200(self, auth_client): + resp = auth_client.get("/dev/api-explorer") + assert resp.status_code == 200 + + def test_mcp_not_gated_by_auth(self, auth_client): + # MCP is not mounted in tests: a 404 (rather than 401) proves the + # middleware exempts /mcp instead of demanding a Bearer token. + resp = auth_client.get("/mcp") + assert resp.status_code == 404 + + def test_api_still_requires_token_while_web_is_session_gated(self, auth_client): + # The "/" exemption is EXACT-match, not a "/" prefix that would + # swallow every path: with a web session "/" serves HTML, but the API + # must still 401 without a Bearer token. + self._session(auth_client) + assert auth_client.get("/").status_code == 200 + resp = auth_client.get("/gmail/v1/users/me/messages") + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_exempt_prefixes_extend_contract_defaults(self): + from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + from mock_gmail.api.auth_middleware import GMAIL_EXEMPT_PREFIXES + + for prefix in DEFAULT_EXEMPT_PREFIXES: + assert prefix in GMAIL_EXEMPT_PREFIXES + for prefix in ("/thread", "/compose", "/star", "/trash", "/mark-read", "/static", "/mcp"): + assert prefix in GMAIL_EXEMPT_PREFIXES + + +def _web_sso_assertion(sub: str = ALICE, email: str = ALICE_EMAIL, + expires_in: int = 120, purpose: str = "web_sso") -> str: + """Mint a env-0-auth-shaped web-SSO identity assertion (test-signed).""" + extra = {"purpose": purpose} if purpose is not None else {} + return make_jwt(private_key=PRIVATE_KEY, kid=KID, sub=sub, email=email, + expires_in=expires_in, extra_claims=extra) + + +class TestWebSessionSSO: + """W7: under auth, sessionless browsers on the mailbox web UI are bounced + into auth's login flow, and /web/auth/callback turns auth's signed + identity assertion into an established gmail web session. + + Offline: the auth_app fixture wires the static test JWKS into sso (via + _apply_auth's jwks_static), so the callback verifies assertions without HTTP. + """ + + LOGIN_PREFIX = "http://localhost:9000/web/login" + + def _login_next(self, location: str) -> str: + """Pull the callback URL out of the login redirect's `next` param.""" + assert location.startswith(self.LOGIN_PREFIX + "?"), location + return parse_qs(urlparse(location).query)["next"][0] + + def _callback_next(self, location: str) -> str: + """Pull the original-path `next` out of the callback URL.""" + return parse_qs(urlparse(self._login_next(location)).query)["next"][0] + + # --- middleware: sessionless GET -> 302 to auth login --- + + def test_sessionless_inbox_redirects_to_login(self, auth_client): + resp = auth_client.get("/", follow_redirects=False) + assert resp.status_code == 302 + callback = self._login_next(resp.headers["location"]) + assert "/web/auth/callback" in callback + assert self._callback_next(resp.headers["location"]) == "/" + + def test_sessionless_thread_redirects_to_login(self, auth_client): + from mock_gmail.models import Thread + + tid = self._first_thread_id() + resp = auth_client.get(f"/thread/{tid}", follow_redirects=False) + assert resp.status_code == 302 + assert self._callback_next(resp.headers["location"]) == f"/thread/{tid}" + + def test_redirect_preserves_original_query_string(self, auth_client): + resp = auth_client.get("/", params={"label": "SENT"}, follow_redirects=False) + assert resp.status_code == 302 + assert self._callback_next(resp.headers["location"]) == "/?label=SENT" + + def _first_thread_id(self): + from mock_gmail.models import Thread, get_session_factory + + db = get_session_factory()() + try: + return db.query(Thread).first().id + finally: + db.close() + + # --- callback: assertion -> established session --- + + def test_callback_valid_assertion_sets_session_cookie(self, auth_client): + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": _web_sso_assertion()}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"] == "/" + assert resp.cookies.get("mock_gmail_user") == ALICE + + def test_callback_then_inbox_serves_html(self, auth_client): + # Establish the session via the callback, then the jar carries the + # cookie so the inbox renders (no further redirect). + auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": _web_sso_assertion()}, + follow_redirects=False, + ) + resp = auth_client.get("/", follow_redirects=False) + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_callback_resolves_by_email_when_sub_unknown(self, auth_client): + # sub not a local id, but the email claim matches a seeded mailbox. + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", + "env_0_identity": _web_sso_assertion(sub="user_xyz", email=ALICE_EMAIL)}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.cookies.get("mock_gmail_user") == ALICE + + def test_callback_unknown_identity_403(self, auth_client): + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", + "env_0_identity": _web_sso_assertion(sub="ghost", email="ghost@nowhere.test")}, + follow_redirects=False, + ) + assert resp.status_code == 403 + + # --- callback: bad assertions restart the login dance --- + + def test_callback_missing_assertion_bounces_to_login(self, auth_client): + resp = auth_client.get("/web/auth/callback", params={"next": "/"}, + follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + assert "mock_gmail_user" not in resp.cookies + + def test_callback_garbage_assertion_bounces_to_login(self, auth_client): + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": "not-a-jwt"}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + + def test_callback_expired_assertion_bounces_to_login(self, auth_client): + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": _web_sso_assertion(expires_in=-60)}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + + def test_callback_assertion_without_purpose_rejected(self, auth_client): + # A plain access-token-shaped JWT (no purpose=web_sso) must not pass. + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": _web_sso_assertion(purpose=None)}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + + def test_callback_assertion_bad_signature_bounces(self, auth_client): + other_priv, _ = generate_test_keypair() + forged = make_jwt(private_key=other_priv, kid=KID, sub=ALICE, + email=ALICE_EMAIL, extra_claims={"purpose": "web_sso"}) + resp = auth_client.get( + "/web/auth/callback", + params={"next": "/", "env_0_identity": forged}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + + def test_callback_clamps_open_redirect(self, auth_client): + # An external `next` is clamped to "/" even with a valid assertion. + resp = auth_client.get( + "/web/auth/callback", + params={"next": "http://evil.test/steal", "env_0_identity": _web_sso_assertion()}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"] == "/" + assert resp.cookies.get("mock_gmail_user") == ALICE + + # --- regression: dev tooling & POST routes are NOT session-gated --- + + def test_dev_routes_not_session_gated(self, auth_client): + assert auth_client.get("/dev/api-explorer", follow_redirects=False).status_code == 200 + + def test_callback_path_not_session_gated(self, auth_client): + # The callback itself must never bounce (it has no session yet) -- a + # missing assertion 302s to login, not a gate loop on /web/auth/callback. + resp = auth_client.get("/web/auth/callback", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(self.LOGIN_PREFIX) + + +class TestWebSSOAuthDisabledRegression: + """With AUTH_ENABLED unset, the web UI is the legacy open dashboard: + no session gate, no SSO redirect, no /web/auth/callback verification path.""" + + def test_inbox_open_without_session(self, client): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 200 + + def test_no_web_session_middleware_installed(self, client): + from mock_gmail.api.app import app + + assert not any( + "GmailWebSessionMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "web-session middleware must be absent when auth is disabled" + + +class TestMultiAccountIdentity: + """Two personas, two tokens: user1 (work) and user_101 (personal). + + Token isolation per the contract: a token's sub is the ONLY identity it + can act as -- explicit userIds for anyone else 403, `me` follows the token. + """ + + def test_me_resolves_per_token(self, multi_client): + work = multi_client.get( + "/gmail/v1/users/me/profile", + headers=_bearer(_token("gmail.readonly", sub=ALICE)), + ) + personal = multi_client.get( + "/gmail/v1/users/me/profile", + headers=_bearer(_token("gmail.readonly", sub=PERSONAL)), + ) + assert work.status_code == personal.status_code == 200 + assert work.json()["emailAddress"] == ALICE_EMAIL + assert personal.json()["emailAddress"] == PERSONAL_EMAIL + + def test_work_token_cannot_read_personal_messages(self, multi_client): + resp = multi_client.get( + f"/gmail/v1/users/{PERSONAL}/messages", + headers=_bearer(_token("gmail.readonly", sub=ALICE)), + ) + assert resp.status_code == 403 + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALICE, + "requested_user": PERSONAL, + } + } + + def test_personal_token_reads_personal_messages(self, multi_client): + token = _token("gmail.readonly", sub=PERSONAL) + resp = multi_client.get( + f"/gmail/v1/users/{PERSONAL}/messages", headers=_bearer(token) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["resultSizeEstimate"] >= 4 # 3 inbox + 1 sent seeded + msg_id = data["messages"][0]["id"] + msg = multi_client.get( + f"/gmail/v1/users/{PERSONAL}/messages/{msg_id}", headers=_bearer(token) + ) + assert msg.status_code == 200 + + def test_personal_token_cannot_read_work_account(self, multi_client): + resp = multi_client.get( + f"/gmail/v1/users/{ALICE}/messages", + headers=_bearer(_token("gmail.readonly", sub=PERSONAL)), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["authenticated_user"] == PERSONAL + assert err["requested_user"] == ALICE - registered = set() - for route in app.routes: - if isinstance(route, APIRoute) and route.path.startswith(GMAIL_PREFIX): - for method in route.methods: - if method not in ("HEAD", "OPTIONS"): - registered.add((method, route.path)) + def test_message_ids_do_not_leak_across_accounts(self, multi_client): + # Fetch a WORK message id, then try to read it as the PERSONAL user + # via `me`: ownership filtering must 404 (id not in that mailbox). + work_token = _token("gmail.readonly", sub=ALICE) + listing = multi_client.get( + "/gmail/v1/users/me/messages", headers=_bearer(work_token) + ) + work_msg_id = listing.json()["messages"][0]["id"] + resp = multi_client.get( + f"/gmail/v1/users/me/messages/{work_msg_id}", + headers=_bearer(_token("gmail.readonly", sub=PERSONAL)), + ) + assert resp.status_code == 404 - assert registered - assert not (registered - set(SCOPE_MAP)) - assert not (set(SCOPE_MAP) - registered) + def test_seed_creates_personal_persona(self, multi_client): + state = multi_client.get("/_admin/state").json() + assert PERSONAL in state["users"], f"user_101 missing: {list(state['users'])}" -def test_valid_token_lists_messages(auth_client): - resp = auth_client.get( - "/gmail/v1/users/me/messages", - headers=_bearer(_token("gmail.readonly")), - ) - assert resp.status_code == 200 - assert resp.json()["messages"] +class TestNoHeaderBypassWhenAuthEnabled: + """X-Env-0-Gmail-User (and X-Mock-Gmail-User) must be IGNORED under auth. + Offline regression for the live cross-service check: legacy identity + headers must never override (or substitute for) the token's sub. + """ -def test_no_token_401_when_auth_enabled(auth_client): - resp = auth_client.get("/gmail/v1/users/me/messages") - assert resp.status_code == 401 - assert resp.json()["error"]["status"] == "UNAUTHENTICATED" - assert "WWW-Authenticate" in resp.headers + def test_header_cannot_switch_me_to_another_user(self, multi_client): + resp = multi_client.get( + "/gmail/v1/users/me/profile", + headers={ + **_bearer(_token("gmail.readonly", sub=ALICE)), + "X-Env-0-Gmail-User": PERSONAL, # exists in this DB -- still ignored + }, + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL + def test_header_by_email_is_ignored_too(self, multi_client): + resp = multi_client.get( + "/gmail/v1/users/me/profile", + headers={ + **_bearer(_token("gmail.readonly", sub=ALICE)), + "X-Env-0-Gmail-User": PERSONAL_EMAIL, + }, + ) + assert resp.status_code == 200 + assert resp.json()["emailAddress"] == ALICE_EMAIL -def test_nonlocal_sub_resolves_by_email_claim(auth_client): - resp = auth_client.get( - "/gmail/v1/users/me/profile", - headers=_bearer(_token("gmail.readonly", sub="user_001", email=ALICE_EMAIL)), - ) - assert resp.status_code == 200 - assert resp.json()["emailAddress"] == ALICE_EMAIL + def test_mock_gmail_user_header_is_ignored_too(self, multi_client): + resp = multi_client.get( + "/gmail/v1/users/me/messages", + headers={ + **_bearer(_token("gmail.readonly", sub=PERSONAL)), + "X-Mock-Gmail-User": ALICE, + }, + ) + assert resp.status_code == 200 + # All returned messages belong to the personal mailbox (4 seeded). + assert resp.json()["resultSizeEstimate"] == 4 + def test_header_without_token_is_still_401(self, multi_client): + resp = multi_client.get( + "/gmail/v1/users/me/profile", + headers={"X-Env-0-Gmail-User": ALICE}, + ) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" -def test_auth_disabled_does_not_require_bearer(client): - resp = client.get("/gmail/v1/users/me/profile") - assert resp.status_code == 200 - assert resp.json()["emailAddress"] == ALICE_EMAIL + def test_header_cannot_bless_impersonation(self, multi_client): + # Explicit foreign userId + matching header still 403s. + resp = multi_client.get( + f"/gmail/v1/users/{PERSONAL}/messages", + headers={ + **_bearer(_token("gmail.readonly", sub=ALICE)), + "X-Env-0-Gmail-User": PERSONAL, + }, + ) + assert resp.status_code == 403 + assert resp.json()["error"]["message"] == "Cannot access another user's resources" diff --git a/packages/environments/mock-gmail/tests/test_label_isolation.py b/packages/environments/mock-gmail/tests/test_label_isolation.py new file mode 100644 index 000000000..027ecf6e1 --- /dev/null +++ b/packages/environments/mock-gmail/tests/test_label_isolation.py @@ -0,0 +1,187 @@ +"""Regression tests for per-user labels (composite (id, user_id) primary key). + +Previously labels.id was the table's sole primary key, so seeding system +labels for a second user raised IntegrityError (`seed --users 2` crashed) and +the multi_account scenario had to share global label rows (user_101 got no +per-user system labels). +""" + +import pytest +from fastapi.testclient import TestClient + +from mock_gmail.models import reset_engine, init_db +from mock_gmail.models.label import SYSTEM_LABELS +from mock_gmail.seed.generator import seed_database + + +def _make_client(db_path: str, scenario: str, num_users: int = 1) -> TestClient: + reset_engine() + seed_database(scenario=scenario, seed=42, db_path=db_path, num_users=num_users) + init_db(db_path) + from mock_gmail.api.app import app + return TestClient(app) + + +@pytest.fixture +def two_user_client(db_path): + """Client over a default-scenario DB seeded with TWO users.""" + with _make_client(db_path, scenario="default", num_users=2) as c: + yield c + reset_engine() + + +@pytest.fixture +def multi_account_client(db_path): + """Client over the multi_account scenario (user1 work + user_101 personal).""" + with _make_client(db_path, scenario="multi_account") as c: + yield c + reset_engine() + + +SYSTEM_LABEL_IDS = {label_id for label_id, _ in SYSTEM_LABELS} + + +class TestMultiUserSeed: + def test_seed_two_users_succeeds(self, db_path): + """seed --users 2 must not raise IntegrityError on system labels.""" + reset_engine() + result = seed_database(scenario="default", seed=42, db_path=db_path, num_users=2) + assert result["users"] == 2 + + def test_each_user_has_own_system_labels(self, two_user_client): + for user_id in ("user1", "user2"): + resp = two_user_client.get(f"/gmail/v1/users/{user_id}/labels") + assert resp.status_code == 200 + label_ids = {l["id"] for l in resp.json()["labels"]} + assert SYSTEM_LABEL_IDS <= label_ids, ( + f"{user_id} is missing system labels: {SYSTEM_LABEL_IDS - label_ids}" + ) + + +class TestMultiAccountScenario: + def test_user_101_exists_with_system_labels(self, multi_account_client): + """labels.list for user_101 shows real per-user system labels.""" + resp = multi_account_client.get("/gmail/v1/users/user_101/labels") + assert resp.status_code == 200 + labels = resp.json()["labels"] + label_ids = {l["id"] for l in labels} + assert SYSTEM_LABEL_IDS <= label_ids + by_id = {l["id"]: l for l in labels} + assert by_id["INBOX"]["type"] == "system" + + def test_user_101_label_counts_scoped_to_own_messages(self, multi_account_client): + """labels.get counts must not leak the work account's messages.""" + resp = multi_account_client.get("/gmail/v1/users/user_101/labels/INBOX") + assert resp.status_code == 200 + data = resp.json() + # The personal account seed creates exactly 3 INBOX messages. + assert data["messagesTotal"] == 3 + + resp = multi_account_client.get("/gmail/v1/users/user_101/labels/SENT") + assert resp.status_code == 200 + # ... and exactly 1 sent message. + assert resp.json()["messagesTotal"] == 1 + + def test_work_account_counts_unaffected_by_personal_account(self, multi_account_client): + resp = multi_account_client.get("/gmail/v1/users/user1/labels/SENT") + assert resp.status_code == 200 + work_sent = resp.json()["messagesTotal"] + # user_101 has 1 SENT message; if counts leaked across users the work + # total would include it. Verify by summing per-message ownership. + resp = multi_account_client.get( + "/gmail/v1/users/user1/messages", + params={"labelIds": "SENT", "maxResults": 500, "includeSpamTrash": "true"}, + ) + assert resp.status_code == 200 + own_sent = resp.json()["resultSizeEstimate"] + assert work_sent == own_sent + + +class TestCrossUserLabelIsolation: + def test_user_labels_not_visible_to_other_users(self, two_user_client): + resp = two_user_client.post( + "/gmail/v1/users/user1/labels", json={"name": "Receipts"} + ) + assert resp.status_code == 201 + label_id = resp.json()["id"] + + resp = two_user_client.get(f"/gmail/v1/users/user2/labels/{label_id}") + assert resp.status_code == 404 + + resp = two_user_client.get("/gmail/v1/users/user2/labels") + assert label_id not in {l["id"] for l in resp.json()["labels"]} + + def test_same_named_labels_are_independent(self, two_user_client): + id1 = two_user_client.post( + "/gmail/v1/users/user1/labels", json={"name": "Projects"} + ).json()["id"] + id2 = two_user_client.post( + "/gmail/v1/users/user2/labels", json={"name": "Projects"} + ).json()["id"] + + # Deleting user1's label must not affect user2's. + resp = two_user_client.delete(f"/gmail/v1/users/user1/labels/{id1}") + assert resp.status_code == 200 + resp = two_user_client.get(f"/gmail/v1/users/user2/labels/{id2}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Projects" + + def test_system_label_counts_scoped_per_user(self, two_user_client): + """labels.get INBOX counts only the requesting user's messages.""" + for user_id in ("user1", "user2"): + label = two_user_client.get(f"/gmail/v1/users/{user_id}/labels/INBOX").json() + msgs = two_user_client.get( + f"/gmail/v1/users/{user_id}/messages", + params={"labelIds": "INBOX", "maxResults": 500, "includeSpamTrash": "true"}, + ).json() + assert label["messagesTotal"] == msgs["resultSizeEstimate"] + + def test_label_attach_and_delete_cleans_message_associations(self, two_user_client): + label_id = two_user_client.post( + "/gmail/v1/users/user1/labels", json={"name": "ToDelete"} + ).json()["id"] + msg_id = two_user_client.get("/gmail/v1/users/user1/messages").json()["messages"][0]["id"] + resp = two_user_client.post( + f"/gmail/v1/users/user1/messages/{msg_id}/modify", + json={"addLabelIds": [label_id]}, + ) + assert resp.status_code == 200 + assert label_id in resp.json()["labelIds"] + + two_user_client.delete(f"/gmail/v1/users/user1/labels/{label_id}") + resp = two_user_client.get(f"/gmail/v1/users/user1/messages/{msg_id}") + assert label_id not in resp.json()["labelIds"] + + +class TestSnapshotsWithMultipleUsers: + def test_state_dump_restore_roundtrip_preserves_per_user_labels(self, multi_account_client): + """Snapshot state keys are unchanged and restore handles per-user labels.""" + from mock_gmail.state.snapshots import get_state_dump, _restore_from_state + + before = get_state_dump() + assert "user_101" in before["users"] + labels_before = { + uid: sorted(l["id"] for l in u["labels"]) + for uid, u in before["users"].items() + } + # Per-user system labels present in the serialized state + assert SYSTEM_LABEL_IDS <= set(labels_before["user_101"]) + + _restore_from_state(before) + + after = get_state_dump() + labels_after = { + uid: sorted(l["id"] for l in u["labels"]) + for uid, u in after["users"].items() + } + assert labels_after == labels_before + # Message label associations survive the round trip too + msgs_before = { + m["id"]: sorted(m["labelIds"]) + for m in before["users"]["user_101"]["messages"] + } + msgs_after = { + m["id"]: sorted(m["labelIds"]) + for m in after["users"]["user_101"]["messages"] + } + assert msgs_after == msgs_before diff --git a/packages/environments/mock-gmail/tests/test_mime_raw_rfc2047.py b/packages/environments/mock-gmail/tests/test_mime_raw_rfc2047.py new file mode 100644 index 000000000..75aefcb53 --- /dev/null +++ b/packages/environments/mock-gmail/tests/test_mime_raw_rfc2047.py @@ -0,0 +1,149 @@ +"""Cross-version regression guard for RFC 2047 non-ASCII handling in format=raw. + +W5. ``messages.get?format=raw`` returns a urlsafe-base64 RFC 2822 message. +Non-ASCII Subjects and From/To **display names** must be RFC 2047-encoded so +that (a) the wire format is pure ASCII, (b) the encoded words round-trip back +to the original Unicode via ``decode_header``/``make_header``, and (c) export +never crashes. + +The bug this pins is interpreter-specific and **does not reproduce on 3.12**: +flattening a compat32 MIME object with ``policy.SMTP`` auto-encodes short +non-ASCII headers on 3.12 but raises ``UnicodeEncodeError`` on 3.10/3.11. The +fix (``mime._encode_unstructured_header`` / ``_encode_address_header``) pre- +encodes non-ASCII headers up front. Run this module under EACH supported +interpreter (3.10/3.11/3.12) — the assertions below are the cross-version +guard, so a regression on the older stdlib fails the suite instead of 500ing +at runtime. +""" + +from email import message_from_bytes, policy +from email.header import decode_header, make_header +from email.utils import parseaddr + +import pytest + +from mock_gmail.api.mime import base64url_decode, base64url_encode, build_rfc2822 + + +# (display_name, addr_spec) split out so we can assert each independently. +SENDER_NAME = "Ülrike Müller" +SENDER_ADDR = "ulrike@example.com" +TO_NAME = "田中太郎" +TO_ADDR = "tanaka@example.com" +SUBJECT = "Budget review — 予算レビュー (Café)" +BODY = "Über alles — 詳細は添付をご覧ください。\nCafé costs rose 12%." + + +def _decode_word(value: str) -> str: + """Round-trip an RFC 2047 header value back to Unicode (the stdlib idiom).""" + return str(make_header(decode_header(value))) + + +def _insert_nonascii_message(client) -> str: + """Insert (not send) a message carrying non-ASCII Subject + From/To display + names + body, so the stored sender/to are NOT overwritten by the auth user. + Returns the new message id.""" + raw_bytes = build_rfc2822( + sender=f"{SENDER_NAME} <{SENDER_ADDR}>", + to=f"{TO_NAME} <{TO_ADDR}>", + subject=SUBJECT, + body_plain=BODY, + ) + resp = client.post( + "/gmail/v1/users/me/messages", + json={"raw": base64url_encode(raw_bytes), "labelIds": ["INBOX"]}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["id"] + + +class TestFormatRawRfc2047CrossVersion: + def test_export_returns_200(self, client): + msg_id = _insert_nonascii_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + assert resp.status_code == 200, resp.text + assert "raw" in resp.json() + + def test_header_lines_are_pure_ascii(self, client): + """(c) No raw non-ASCII bytes may leak into the header block.""" + msg_id = _insert_nonascii_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + raw_bytes = base64url_decode(resp.json()["raw"]) + header_block = raw_bytes.split(b"\r\n\r\n", 1)[0] + # Raises UnicodeDecodeError if any encoded word was left un-encoded. + header_block.decode("ascii") + assert b"=?utf-8?" in header_block.lower() + + def test_parses_as_valid_rfc2822(self, client): + """(a) The decoded bytes parse as a structurally valid message.""" + msg_id = _insert_nonascii_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + raw_bytes = base64url_decode(resp.json()["raw"]) + msg = message_from_bytes(raw_bytes, policy=policy.default) + assert msg.get_content_type() == "text/plain" + assert msg["Subject"] is not None + assert msg["From"] is not None + body = msg.get_body(preferencelist=("plain",)) + assert body.get_content().rstrip("\n") == BODY.rstrip("\n") + + def test_subject_decode_header_round_trips(self, client): + """(b) Subject decodes back to the original Unicode via decode_header.""" + msg_id = _insert_nonascii_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + raw_bytes = base64url_decode(resp.json()["raw"]) + # compat32 leaves headers RFC 2047-encoded so decode_header has work to do. + raw_msg = message_from_bytes(raw_bytes, policy=policy.compat32) + assert _decode_word(raw_msg["Subject"]) == SUBJECT + + def test_display_names_decode_header_round_trip(self, client): + """(b) From/To display names decode back; addr-specs are preserved.""" + msg_id = _insert_nonascii_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + raw_bytes = base64url_decode(resp.json()["raw"]) + raw_msg = message_from_bytes(raw_bytes, policy=policy.compat32) + + from_name, from_addr = parseaddr(raw_msg["From"]) + assert _decode_word(from_name) == SENDER_NAME + assert from_addr == SENDER_ADDR + + to_name, to_addr = parseaddr(raw_msg["To"]) + assert _decode_word(to_name) == TO_NAME + assert to_addr == TO_ADDR + + +@pytest.mark.parametrize( + "subject", + [ + "Plain ASCII subject", + "Budget review — final numbers", # Latin-1 em-dash + "会議の議題 — 予算レビュー", # CJK + "Reçu: naïve café façade", # accented Latin + ], +) +def test_subject_round_trip_matrix(subject): + """Unit-level guard independent of the DB/endpoint: every Subject variant + builds ASCII-safe header lines and round-trips via decode_header.""" + raw = build_rfc2822( + sender="alice@example.com", + to="bob@example.com", + subject=subject, + body_plain="hi", + ) + header_block = raw.split(b"\r\n\r\n", 1)[0] + header_block.decode("ascii") # never leaks raw non-ASCII into headers + raw_msg = message_from_bytes(raw, policy=policy.compat32) + assert _decode_word(raw_msg["Subject"]) == subject + if subject.isascii(): + # ASCII path stays byte-identical: no encoded words introduced. + assert b"=?utf-8?" not in header_block.lower() + assert f"Subject: {subject}".encode() in header_block diff --git a/packages/environments/mock-gmail/tests/test_raw_nonascii.py b/packages/environments/mock-gmail/tests/test_raw_nonascii.py new file mode 100644 index 000000000..e01fa6930 --- /dev/null +++ b/packages/environments/mock-gmail/tests/test_raw_nonascii.py @@ -0,0 +1,149 @@ +"""Regression tests for non-ASCII headers/bodies in format=raw export. + +Previously ``build_rfc2822`` flattened compat32 MIME objects with +``policy.SMTP`` without pre-encoding headers; on Python < 3.12 a short +non-ASCII header (e.g. an em-dash Subject) raised UnicodeEncodeError and +``messages.get?format=raw`` returned a 500. Headers are now RFC 2047-encoded +so raw export round-trips UTF-8 subjects and bodies on all supported Pythons. +""" + +from email import message_from_bytes, policy + +from mock_gmail.api.mime import ( + base64url_decode, + base64url_encode, + build_rfc2822, + parse_rfc2822, +) + +EMDASH_SUBJECT = "Budget review — final numbers" +CJK_SUBJECT = "会議の議題 — 予算レビュー" +UTF8_BODY = "Café costs rose by 12% — 詳細は添付をご覧ください。\nÜber alles." + + +class TestBuildRfc2822NonAscii: + def test_emdash_subject_builds_ascii_bytes(self): + raw = build_rfc2822( + sender="alice@example.com", + to="bob@example.com", + subject=EMDASH_SUBJECT, + body_plain="hi", + ) + # RFC 2822 wire format must be pure ASCII (RFC 2047 encoded words) + raw.decode("ascii") + assert b"=?utf-8?" in raw.lower() + + def test_emdash_subject_round_trips(self): + raw = build_rfc2822( + sender="alice@example.com", + to="bob@example.com", + subject=EMDASH_SUBJECT, + body_plain="hi", + ) + parsed = parse_rfc2822(raw) + assert parsed["subject"] == EMDASH_SUBJECT + + def test_cjk_subject_and_utf8_body_round_trip(self): + raw = build_rfc2822( + sender="alice@example.com", + to="bob@example.com", + subject=CJK_SUBJECT, + body_plain=UTF8_BODY, + body_html="

" + UTF8_BODY + "

", + ) + raw.decode("ascii") + parsed = parse_rfc2822(raw) + assert parsed["subject"] == CJK_SUBJECT + assert parsed["body_plain"] == UTF8_BODY + assert parsed["body_html"] == "

" + UTF8_BODY + "

" + + def test_nonascii_display_names_round_trip(self): + raw = build_rfc2822( + sender="Ülrike Müller ", + to="José García ", + cc="田中太郎 ", + subject="hello", + body_plain="hi", + ) + raw.decode("ascii") + parsed = parse_rfc2822(raw) + # Display names decode back; addr-specs are preserved verbatim + assert "ulrike@example.com" in parsed["sender"] + assert "Ülrike Müller" in parsed["sender"] + assert "jose@example.com" in parsed["to"] + assert "José García" in parsed["to"] + assert "田中太郎" in parsed["cc"] + + def test_ascii_headers_unchanged(self): + """Pure-ASCII messages keep their exact legacy header serialization.""" + raw = build_rfc2822( + sender="alice@example.com", + to="bob@example.com", + subject="Plain ASCII subject", + body_plain="hi", + ) + assert b"Subject: Plain ASCII subject" in raw + assert b"From: alice@example.com" in raw + assert b"=?utf-8?" not in raw.lower() + + +class TestRawEndpointNonAscii: + def _send_utf8_message(self, client) -> str: + """Upload a UTF-8 message via messages.send (raw) and return its id.""" + raw_bytes = build_rfc2822( + sender="alex@nexusai.com", + to="someone@example.com", + subject=EMDASH_SUBJECT, + body_plain=UTF8_BODY, + ) + resp = client.post( + "/gmail/v1/users/me/messages/send", + json={"raw": base64url_encode(raw_bytes)}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["id"] + + def test_format_raw_returns_200_for_emdash_subject(self, client): + msg_id = self._send_utf8_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + assert resp.status_code == 200, resp.text + assert "raw" in resp.json() + + def test_format_raw_base64url_decodes_and_round_trips_utf8(self, client): + msg_id = self._send_utf8_message(client) + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + assert resp.status_code == 200, resp.text + + raw_bytes = base64url_decode(resp.json()["raw"]) + raw_bytes.decode("ascii") # wire format is ASCII-safe + msg = message_from_bytes(raw_bytes, policy=policy.default) + assert str(msg["Subject"]) == EMDASH_SUBJECT + body = msg.get_body(preferencelist=("plain",)) + assert body.get_content().rstrip("\n") == UTF8_BODY.rstrip("\n") + + def test_format_raw_cjk_subject(self, client): + raw_bytes = build_rfc2822( + sender="alex@nexusai.com", + to="someone@example.com", + subject=CJK_SUBJECT, + body_plain="本文です。", + ) + resp = client.post( + "/gmail/v1/users/me/messages/send", + json={"raw": base64url_encode(raw_bytes)}, + ) + assert resp.status_code == 200, resp.text + msg_id = resp.json()["id"] + + resp = client.get( + f"/gmail/v1/users/me/messages/{msg_id}", params={"format": "raw"} + ) + assert resp.status_code == 200, resp.text + decoded = message_from_bytes( + base64url_decode(resp.json()["raw"]), policy=policy.default + ) + assert str(decoded["Subject"]) == CJK_SUBJECT diff --git a/packages/environments/mock-slack/mock_slack/api/app.py b/packages/environments/mock-slack/mock_slack/api/app.py index ff70c2129..207fce7bd 100644 --- a/packages/environments/mock-slack/mock_slack/api/app.py +++ b/packages/environments/mock-slack/mock_slack/api/app.py @@ -383,3 +383,44 @@ def admin_skill_detail(skill_name: str): @app.get("/health") def health(): return {"status": "ok"} + + +# --- auth integration (optional; enabled via AUTH_ENABLED) --- +from .deps import ImpersonationError # noqa: E402 + + +@app.exception_handler(ImpersonationError) +async def impersonation_error_handler(request: Request, exc: ImpersonationError): + """Contract-pinned 403 impersonation body.""" + from env_0_auth_client.errors import impersonation_body + + return JSONResponse( + status_code=403, + content=impersonation_body(exc.authenticated_user, exc.requested_user), + ) + + +def _auth_enabled() -> bool: + return os.environ.get("AUTH_ENABLED", "").strip().lower() in ("1", "true", "yes") + + +def _apply_auth(target_app: FastAPI, **middleware_kwargs) -> bool: + """Add SlackEnv_0AuthMiddleware to ``target_app`` when AUTH_ENABLED is truthy.""" + if not _auth_enabled(): + return False + try: + import env_0_auth_client # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "AUTH_ENABLED=1 but auth-client is not installed. " + "Install packages/auth-client or unset AUTH_ENABLED." + ) from exc + + from mock_slack.api.auth_middleware import SlackEnv_0AuthMiddleware + from mock_slack.auth_scopes import SCOPE_MAP + + target_app.add_middleware(SlackEnv_0AuthMiddleware, scope_map=SCOPE_MAP, **middleware_kwargs) + return True + + +_apply_auth(app) diff --git a/packages/environments/mock-slack/mock_slack/api/auth_middleware.py b/packages/environments/mock-slack/mock_slack/api/auth_middleware.py new file mode 100644 index 000000000..e360f4388 --- /dev/null +++ b/packages/environments/mock-slack/mock_slack/api/auth_middleware.py @@ -0,0 +1,87 @@ +"""Slack-side wrapper around Env_0AuthMiddleware: web-UI + /mcp exemptions. + +Mirrors gmail's ``GmailEnv_0AuthMiddleware`` (see API_NOTES.md +"auth integration"): + +- The human web UI (``/``, ``/channel/*``, ``/dms``, ``/activity``, ...) + identifies the user via browser sessions, NOT Bearer tokens -- the + real-world split between browser sessions and API credentials. With + ``AUTH_ENABLED=1`` these routes stay reachable without a token. +- ``/mcp`` is exempt: agent MCP clients are out of OAuth scope for v1. +- ``/static`` serves assets for the web UI; ``/redoc`` is FastAPI's second + docs page (the contract defaults already cover ``/docs`` and + ``/openapi.json``). + +auth-client's public API is unchanged: the extra exemptions are plain +prefix additions passed through the existing ``exempt_prefixes`` parameter +(which uses segment-boundary matching, so ``/app`` does NOT exempt ``/api``). +The one thing prefixes cannot express is the workspace home at ``/`` (slack's +web UI mounts at the root; a ``"/"`` PREFIX would exempt every path), so this +subclass short-circuits the exact path ``/`` before delegating to the +upstream ``dispatch``. + +Token coexistence note: legacy ``xoxb-``/``xoxp-`` tokens are NOT JWTs (no +three-dot header.payload.signature structure), so when auth is enabled the +upstream middleware rejects them with the contract 401 "malformed token" +body. They keep working only when AUTH_ENABLED is unset. +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import Response + +from env_0_auth_client import Env_0AuthMiddleware +from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + +#: Exact paths that bypass auth. Exact-match only -- "/" as a *prefix* would +#: exempt everything, which is why this set exists at all. +EXEMPT_EXACT_PATHS = frozenset({"/"}) + +#: Slack's web UI has no /web prefix, so each root-mounted page gets its own +#: precise prefix (see mock_slack/web/routes.py). /_admin, /health, /dev, +#: /docs, /openapi.json and /web come from the contract-pinned defaults. +#: NOTE: prefix matching is segment-boundary ("/app" matches "/app/x", never +#: "/api/..."), and "/new" does not cover "/new-dm" -- hence both entries. +SLACK_EXEMPT_PREFIXES: tuple[str, ...] = DEFAULT_EXEMPT_PREFIXES + ( + "/static", # static assets for the web UI + "/mcp", # MCP clients are out of OAuth scope for v1 + "/redoc", # FastAPI's ReDoc page (docs/openapi.json are defaults) + "/dms", # GET /dms + "/activity", # GET /activity + "/files", # GET /files, POST /files/delete (web UI; API is /api/files.*) + "/later", # GET /later + "/more", # GET /more + "/search", # GET /search (web UI; API is /api/search.messages) + "/tools", # GET /tools + "/channel", # GET/POST /channel/{channel_id}/... + "/user", # GET /user/{user_id}/profile/panel + "/drafts", # GET /drafts + "/directories", # GET /directories + "/app", # GET /app/{app_id} (segment-boundary: not /api!) + "/new", # GET/POST /new, /new/inline + "/new-dm", # GET /new-dm/{user_id} + "/threads", # GET /threads +) + + +class SlackEnv_0AuthMiddleware(Env_0AuthMiddleware): + """Env_0AuthMiddleware with slack's exemptions baked in as defaults. + + Only the defaults differ; every kwarg (``scope_map``, ``jwks_static``, + ``audience``, ...) is forwarded untouched, and callers may still override + ``exempt_prefixes`` explicitly. + """ + + def __init__( + self, + app, + exempt_prefixes: tuple[str, ...] = SLACK_EXEMPT_PREFIXES, + **kwargs, + ): + super().__init__(app, exempt_prefixes=exempt_prefixes, **kwargs) + + async def dispatch(self, request: Request, call_next) -> Response: + if request.url.path in EXEMPT_EXACT_PATHS: + return await call_next(request) + return await super().dispatch(request, call_next) diff --git a/packages/environments/mock-slack/mock_slack/api/deps.py b/packages/environments/mock-slack/mock_slack/api/deps.py index 57ae90913..17d457e1f 100644 --- a/packages/environments/mock-slack/mock_slack/api/deps.py +++ b/packages/environments/mock-slack/mock_slack/api/deps.py @@ -5,12 +5,89 @@ import base64 from typing import Literal -from fastapi import Depends, Header +from fastapi import Depends, Header, Request +from sqlalchemy import func from sqlalchemy.orm import Session from mock_slack.models import get_session_factory +class ImpersonationError(Exception): + """Authenticated request named a user that is not the token's subject. + + Handled in ``mock_slack.api.app`` with the auth contract 403 body + (``env_0_auth_client.errors.impersonation_body``), deliberately NOT the + Slack ``{"ok": false, "error": ...}`` envelope used for HTTPException. + """ + + def __init__(self, authenticated_user: str, requested_user: str): + super().__init__( + f"Cannot access another user's resources " + f"(authenticated as {authenticated_user!r}, requested {requested_user!r})" + ) + self.authenticated_user = authenticated_user + self.requested_user = requested_user + + +def resolve_auth_local_id(request: Request, db: Session) -> str | None: + """Map a verified auth token to a LOCAL slack user id. + + Returns None when auth is disabled (no token state). Resolution order + (identity-alignment): (a) SlackUser whose id == token ``sub``; (b) else + SlackUser whose email matches the token's ``email`` claim + (case-insensitive); (c) else the raw ``sub`` unchanged (legacy behavior: + every route already handles unknown user ids gracefully). + """ + auth_user_id = getattr(request.state, "auth_user_id", None) + if auth_user_id is None: + return None + from mock_slack.models import SlackUser + + user = db.query(SlackUser).filter(SlackUser.id == auth_user_id).first() + if user is None: + auth_email = (getattr(request.state, "auth_email", "") or "").strip() + if auth_email: + user = db.query(SlackUser).filter( + func.lower(SlackUser.email) == auth_email.lower() + ).first() + return user.id if user is not None else auth_user_id + + +def guard_impersonation(request: Request, requested_user_id: str | None, db: Session) -> None: + """Raise the contract 403 when an authenticated caller tries to ACT AS + another user (e.g. ``users.profile.set`` with an explicit foreign + ``user``). + + Slack's API surface has no ``{userId}`` path params -- the token implies + the actor -- so unlike gmail this guard only applies to the few + endpoints that accept an explicit acting-user override. Plain directory + READS of other users (users.info, users.getPresence, ...) are legitimate + Slack functionality and are NOT impersonation. + + No-op when auth is disabled, when no explicit user was named, or when the + named user matches the token's ``sub`` or the RESOLVED local id (the + email-claim fallback identity, see ``resolve_auth_local_id``). + """ + auth_user_id = getattr(request.state, "auth_user_id", None) + if auth_user_id is None or not requested_user_id or requested_user_id == auth_user_id: + return + local_id = resolve_auth_local_id(request, db) + if requested_user_id == local_id: + return + # The middleware cannot see body/query user-vs-sub mismatches; report + # here. env_0_auth_client is guaranteed importable: auth_user_id is only + # ever set by Env_0AuthMiddleware. + from env_0_auth_client import report_impersonation + + report_impersonation( + local_id, + requested_user_id, + client_id=getattr(request.state, "auth_client_id", None), + scope=" ".join(getattr(request.state, "auth_scopes", None) or []), + ) + raise ImpersonationError(local_id, requested_user_id) + + def encode_cursor(payload: str) -> str: """Encode a cursor payload to base64, matching real Slack cursor format.""" return base64.b64encode(payload.encode()).decode() @@ -47,16 +124,29 @@ def resolve_workspace_id( def resolve_current_user_id( + request: Request, authorization: str | None = Header(None), db: Session = Depends(get_db), workspace_id: str = Depends(resolve_workspace_id), ) -> str: """Return the user ID associated with the current token. - Bot tokens resolve to B01MOCKBOT. User tokens resolve to the first non-bot - user in the workspace. Tests may use sanitized `mock-*` token strings to - avoid provider-shaped secrets in the public repo. + With auth enabled (Env_0AuthMiddleware sets request.state.auth_user_id): + the verified token IS the caller's identity -- legacy xoxb-/xoxp- prefix + sniffing never runs. The token is mapped to a LOCAL slack user id via + ``resolve_auth_local_id``: (a) id == ``sub``; (b) else email == the + token's ``email`` claim (case-insensitive); (c) else the raw ``sub`` + as-is (Slack's token-implies-identity model has no 'me' alias to resolve + and every route already handles unknown user ids gracefully). + + With auth disabled (the default), legacy behavior is unchanged: + Bot token (xoxb-) → B01MOCKBOT (the bot app user). + User token (xoxp-) → first non-bot user in the workspace. """ + local_id = resolve_auth_local_id(request, db) + if local_id is not None: + return local_id + from mock_slack.models import SlackUser token = (authorization or "").removeprefix("Bearer ").strip() if token not in {"mock-user-token", "mock-user"} and not token.startswith("xoxp-"): @@ -74,14 +164,32 @@ def resolve_current_user_id( def resolve_token_type( + request: Request, authorization: str | None = Header(None), + db: Session = Depends(get_db), ) -> Literal["bot", "user"]: """Determine token type from Authorization header. - xoxb-* => bot token, xoxp-* => user token. Sanitized test tokens are also - accepted. - Defaults to 'bot' when no token is provided (conservative for testing). + With auth enabled, the verified token's RESOLVED local identity + decides (``resolve_auth_local_id``: sub, else email-claim fallback): an + identity whose SlackUser row has ``is_bot=True`` (e.g. B01MOCKBOT) is a + bot token, anything else is a user token (auth subs are user + identities, so unknown identities default to 'user'). Without this, JWTs + -- which carry no xoxb-/xoxp- prefix -- would always be treated as bot + tokens, changing response shapes (last_read) and breaking user-token-only + methods (search.messages). + + With auth disabled (the default), legacy behavior is unchanged: + xoxb-* => bot token, xoxp-* => user token. Sanitized public test tokens + are also accepted. Defaults to 'bot' when no token is provided. """ + local_id = resolve_auth_local_id(request, db) + if local_id is not None: + from mock_slack.models import SlackUser + + user = db.query(SlackUser).filter(SlackUser.id == local_id).first() + return "bot" if (user is not None and user.is_bot) else "user" + if authorization: token = authorization.removeprefix("Bearer ").strip() if token in {"mock-user-token", "mock-user"} or token.startswith("xoxp-"): diff --git a/packages/environments/mock-slack/mock_slack/api/users.py b/packages/environments/mock-slack/mock_slack/api/users.py index f63155555..e88e8dcb7 100644 --- a/packages/environments/mock-slack/mock_slack/api/users.py +++ b/packages/environments/mock-slack/mock_slack/api/users.py @@ -9,7 +9,14 @@ import json from mock_slack.models import SlackUser, Workspace -from .deps import get_db, resolve_workspace_id, resolve_current_user_id, encode_cursor, decode_cursor +from .deps import ( + get_db, + resolve_workspace_id, + encode_cursor, + decode_cursor, + guard_impersonation, + resolve_auth_local_id, +) from .schemas import ( UserSchema, UserProfileSchema, @@ -174,10 +181,18 @@ def users_lookup_by_email( @router.get("/users.profile.get") def users_profile_get( + request: Request, user: str | None = Query(None), db: Session = Depends(get_db), workspace_id: str = Depends(resolve_workspace_id), ): + # auth: with no explicit user, "self" is the verified token's + # RESOLVED local identity (sub, else email-claim fallback). Reading + # ANOTHER user's profile by explicit id stays a legitimate directory + # read, like real Slack. + if user is None: + user = resolve_auth_local_id(request, db) + if user: u = ( db.query(SlackUser) @@ -188,7 +203,11 @@ def users_profile_get( # Return first non-bot user's profile u = ( db.query(SlackUser) - .filter(SlackUser.workspace_id == workspace_id, SlackUser.is_bot == False) + .filter( + SlackUser.workspace_id == workspace_id, + SlackUser.is_bot == False, + SlackUser.id != "USLACKBOT", + ) .first() ) if not u: @@ -210,6 +229,16 @@ async def users_profile_set( user_id = data.get("user") + # auth: a profile WRITE may only target the verified token's own + # identity (its sub or the RESOLVED local id from the email-claim + # fallback). An explicit mismatching `user` is impersonation (contract + # 403, reported to auth); the effective target is always the + # resolved local identity. + auth_sub = getattr(request.state, "auth_user_id", None) + if auth_sub is not None: + guard_impersonation(request, user_id, db) + user_id = resolve_auth_local_id(request, db) + if user_id: u = ( db.query(SlackUser) @@ -257,7 +286,6 @@ async def users_set_presence( request: Request, db: Session = Depends(get_db), workspace_id: str = Depends(resolve_workspace_id), - current_user_id: str = Depends(resolve_current_user_id), ): try: data = await request.json() @@ -266,11 +294,26 @@ async def users_set_presence( presence = data.get("presence", "auto") - u = ( - db.query(SlackUser) - .filter(SlackUser.workspace_id == workspace_id, SlackUser.id == current_user_id) - .first() - ) + # auth: presence is set on the verified token's RESOLVED local + # identity (sub, else email-claim fallback), never on the legacy "first + # non-bot user" fallback. + auth_local = resolve_auth_local_id(request, db) + if auth_local is not None: + u = ( + db.query(SlackUser) + .filter(SlackUser.id == auth_local, SlackUser.workspace_id == workspace_id) + .first() + ) + else: + u = ( + db.query(SlackUser) + .filter( + SlackUser.workspace_id == workspace_id, + SlackUser.is_bot == False, + SlackUser.id != "USLACKBOT", + ) + .first() + ) if u: u.presence = presence db.commit() @@ -280,10 +323,17 @@ async def users_set_presence( @router.get("/users.getPresence") async def users_get_presence( + request: Request, user: str = Query(None), db: Session = Depends(get_db), workspace_id: str = Depends(resolve_workspace_id), ): + # auth: with no explicit user, "self" is the verified token's + # RESOLVED local identity (sub, else email-claim fallback). Explicit + # reads of other users' presence stay legitimate, like real Slack. + if not user: + user = resolve_auth_local_id(request, db) + if user: u = ( db.query(SlackUser) diff --git a/packages/environments/mock-slack/mock_slack/auth_scopes.py b/packages/environments/mock-slack/mock_slack/auth_scopes.py new file mode 100644 index 000000000..59abbe127 --- /dev/null +++ b/packages/environments/mock-slack/mock_slack/auth_scopes.py @@ -0,0 +1,134 @@ +"""Per-route OAuth scope requirements for auth (used when AUTH_ENABLED=1). + +``SCOPE_MAP`` keys are ``(HTTP_METHOD, route_path_template)`` tuples EXACTLY as +the routes are registered on the FastAPI app (see ``mock_slack/api/app.py``; +all Slack RPC methods live under the ``/api`` prefix). Values are OR-logic +scope lists: ANY one listed scope grants access. An EMPTY list means the route +requires a valid token but no particular scope (contract: "missing route key +=> only auth required" -- we keep the key present so the coverage test proves +every route was considered). + +Scope names are Slack-style colon names per the auth contract +(``chat:write``, ``channels:read``, ...); semantics mirror real Slack scopes: + +- conversations/channel info reads -> channels:read +- history (history / replies) -> channels:history +- channel mutations (create / archive + / rename / invite / kick / join / + leave / setTopic / setPurpose) -> channels:write +- opening DMs (conversations.open) -> im:write (real Slack im:write/mpim:write) +- chat.* message writes + scheduled + message management -> chat:write +- users reads -> users:read (presence only) / + users:read.email (any endpoint whose response contains profile.email -- + this mock cannot redact fields per-scope and the contract scope lists are + OR-logic, so the email-bearing endpoints list users:read.email as the SOLE + sufficient scope; in real Slack a client holding users:read.email always + also holds users:read) +- users.profile.set -> users.profile:write (real Slack name) +- users.setPresence -> users:write (real Slack name) +- reactions read / write -> reactions:read / reactions:write +- files read / write -> files:read / files:write +- pins read / write -> pins:read / pins:write +- search.messages -> search:read (user-token method) +- team.info -> team:read +- reminders.list -> reminders:read +- auth.test -> no scope (real Slack: auth.test has + no required scopes; any valid token may introspect itself) + +This module deliberately has NO env_0_auth_client import so that slack +works unchanged when auth-client is not installed. + +Coverage of every registered /api route is enforced by +``tests/test_auth_integration.py::TestScopeMapCoverage``. +""" + +from __future__ import annotations + +# Mirrors env_0_auth_client.ScopeMap (kept local: auth-client is optional). +ScopeMap = dict[tuple[str, str], list[str]] + +SLACK_PREFIX = "/api" + +CHANNELS_READ = ["channels:read"] +CHANNELS_HISTORY = ["channels:history"] +CHANNELS_WRITE = ["channels:write"] +IM_WRITE = ["im:write"] +CHAT_WRITE = ["chat:write"] +USERS_READ = ["users:read"] +USERS_READ_EMAIL = ["users:read.email"] +USERS_WRITE = ["users:write"] +USERS_PROFILE_WRITE = ["users.profile:write"] +REACTIONS_READ = ["reactions:read"] +REACTIONS_WRITE = ["reactions:write"] +FILES_READ = ["files:read"] +FILES_WRITE = ["files:write"] +PINS_READ = ["pins:read"] +PINS_WRITE = ["pins:write"] +SEARCH_READ = ["search:read"] +TEAM_READ = ["team:read"] +REMINDERS_READ = ["reminders:read"] +#: Valid token required, but no particular scope (real Slack: auth.test). +NO_SCOPE: list[str] = [] + +SCOPE_MAP: ScopeMap = { + # --- auth --- + ("POST", f"{SLACK_PREFIX}/auth.test"): NO_SCOPE, + # --- conversations: reads --- + ("GET", f"{SLACK_PREFIX}/conversations.list"): CHANNELS_READ, + ("GET", f"{SLACK_PREFIX}/conversations.info"): CHANNELS_READ, + ("GET", f"{SLACK_PREFIX}/conversations.members"): CHANNELS_READ, + # --- conversations: history --- + ("GET", f"{SLACK_PREFIX}/conversations.history"): CHANNELS_HISTORY, + ("GET", f"{SLACK_PREFIX}/conversations.replies"): CHANNELS_HISTORY, + # --- conversations: mutations --- + ("POST", f"{SLACK_PREFIX}/conversations.create"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.archive"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.unarchive"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.rename"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.invite"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.kick"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.join"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.leave"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.setPurpose"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.setTopic"): CHANNELS_WRITE, + ("POST", f"{SLACK_PREFIX}/conversations.open"): IM_WRITE, + # --- chat --- + ("POST", f"{SLACK_PREFIX}/chat.postMessage"): CHAT_WRITE, + ("POST", f"{SLACK_PREFIX}/chat.postEphemeral"): CHAT_WRITE, + ("POST", f"{SLACK_PREFIX}/chat.update"): CHAT_WRITE, + ("POST", f"{SLACK_PREFIX}/chat.delete"): CHAT_WRITE, + # Permalink lookup exposes only channel/message location -> a read scope. + ("GET", f"{SLACK_PREFIX}/chat.getPermalink"): CHANNELS_READ, + ("POST", f"{SLACK_PREFIX}/chat.scheduleMessage"): CHAT_WRITE, + ("POST", f"{SLACK_PREFIX}/chat.deleteScheduledMessage"): CHAT_WRITE, + # Lists only the caller's own pending sends, created via chat:write. + ("GET", f"{SLACK_PREFIX}/chat.scheduledMessages.list"): CHAT_WRITE, + # --- reactions --- + ("POST", f"{SLACK_PREFIX}/reactions.add"): REACTIONS_WRITE, + ("POST", f"{SLACK_PREFIX}/reactions.remove"): REACTIONS_WRITE, + ("GET", f"{SLACK_PREFIX}/reactions.get"): REACTIONS_READ, + # --- users (responses including profile.email require users:read.email) --- + ("GET", f"{SLACK_PREFIX}/users.list"): USERS_READ_EMAIL, + ("GET", f"{SLACK_PREFIX}/users.info"): USERS_READ_EMAIL, + ("GET", f"{SLACK_PREFIX}/users.lookupByEmail"): USERS_READ_EMAIL, + ("GET", f"{SLACK_PREFIX}/users.profile.get"): USERS_READ_EMAIL, + ("POST", f"{SLACK_PREFIX}/users.profile.set"): USERS_PROFILE_WRITE, + ("POST", f"{SLACK_PREFIX}/users.setPresence"): USERS_WRITE, + ("GET", f"{SLACK_PREFIX}/users.getPresence"): USERS_READ, + # --- search --- + ("GET", f"{SLACK_PREFIX}/search.messages"): SEARCH_READ, + # --- files --- + ("GET", f"{SLACK_PREFIX}/files.list"): FILES_READ, + ("GET", f"{SLACK_PREFIX}/files.info"): FILES_READ, + ("POST", f"{SLACK_PREFIX}/files.upload"): FILES_WRITE, + ("POST", f"{SLACK_PREFIX}/files.delete"): FILES_WRITE, + # --- pins --- + ("POST", f"{SLACK_PREFIX}/pins.add"): PINS_WRITE, + ("POST", f"{SLACK_PREFIX}/pins.remove"): PINS_WRITE, + ("GET", f"{SLACK_PREFIX}/pins.list"): PINS_READ, + # --- team --- + ("GET", f"{SLACK_PREFIX}/team.info"): TEAM_READ, + # --- reminders --- + ("GET", f"{SLACK_PREFIX}/reminders.list"): REMINDERS_READ, +} diff --git a/packages/environments/mock-slack/pyproject.toml b/packages/environments/mock-slack/pyproject.toml index d35664cf6..4e7dd68d9 100644 --- a/packages/environments/mock-slack/pyproject.toml +++ b/packages/environments/mock-slack/pyproject.toml @@ -26,6 +26,15 @@ gym = ["gymnasium>=0.29.0"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-json-report>=1.5", "ruff>=0.3.0", "cryptography>=43.0"] all = ["mock-slack[mcp,gym,dev]"] +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-json-report>=1.5", + "pyjwt>=2.8", + "cryptography>=43.0", +] + [project.scripts] mock-slack = "mock_slack.cli:cli" diff --git a/packages/environments/mock-slack/tests/conftest.py b/packages/environments/mock-slack/tests/conftest.py index 19281c00d..20f5e04b0 100644 --- a/packages/environments/mock-slack/tests/conftest.py +++ b/packages/environments/mock-slack/tests/conftest.py @@ -1,8 +1,20 @@ """Test fixtures.""" +import sys +from pathlib import Path + import pytest from fastapi.testclient import TestClient +# Make sibling auth-client importable for auth integration tests without a +# pyproject path dependency, which would break shallow task-image installs. +_client_pkg = Path(__file__).resolve().parents[3] / "auth-client" +if _client_pkg.is_dir(): + try: + import env_0_auth_client # noqa: F401 + except ImportError: + sys.path.insert(0, str(_client_pkg)) + from mock_slack.models import reset_engine, init_db, get_session_factory from mock_slack.seed.generator import seed_database diff --git a/packages/environments/mock-slack/tests/test_auth_integration.py b/packages/environments/mock-slack/tests/test_auth_integration.py new file mode 100644 index 000000000..91b3ec566 --- /dev/null +++ b/packages/environments/mock-slack/tests/test_auth_integration.py @@ -0,0 +1,709 @@ +"""Integration tests for the auth retrofit (AUTH_ENABLED). + +Fully offline: JWKS is injected statically via env_0_auth_client.testing, and +event reporting is disabled (AUTH_REPORT=0) except where a MockTransport +captures events explicitly. + +The FastAPI app in mock_slack.api.app is a module-level singleton and +AUTH_ENABLED is read at import time, so the ``auth_app`` fixture builds a +FRESH instance via importlib.reload (with auth disabled, so the module-level +``_apply_auth(app)`` is a no-op) and then applies the middleware explicitly +with the static JWKS. Teardown reloads once more so every other test module +keeps using a pristine, auth-disabled singleton. + +Token coexistence (slack-specific): slack's legacy API auth is Bearer +``xoxb-``/``xoxp-`` prefix sniffing with NO value validation. Those tokens are +not JWTs (no three-dot header.payload.signature structure), so with +AUTH_ENABLED=1 the middleware rejects them as malformed (401); they keep +working only in disabled mode -- both modes are pinned below. +""" + +import importlib +import json +import re +import sys + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from env_0_auth_client.testing import generate_test_keypair, jwks_for, make_jwt +from mock_slack.models import init_db, reset_engine + +KID = "test-key-001" +PRIVATE_KEY, PUBLIC_KEY = generate_test_keypair() +JWKS = jwks_for(PUBLIC_KEY, kid=KID) + +# Actual identities seeded by scenario "default", seed 42 (see +# mock_slack/seed/content.py PERSONAS): auth's slack seeds must use THESE +# slack user ids as token subs. +ALICE = "U01ALEXCHEN" +ALICE_EMAIL = "alex@nexusai.com" +PRIYA = "U02PRIYAPATEL" +PRIYA_EMAIL = "priya@nexusai.com" +BOT = "B01MOCKBOT" # is_bot=True app user +GENERAL = "C01GENERAL" + + +def _token(scope: str = "", sub: str = ALICE, **kwargs) -> str: + return make_jwt(private_key=PRIVATE_KEY, kid=KID, sub=sub, scope=scope, **kwargs) + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def auth_app(monkeypatch): + """Fresh app instance with SlackEnv_0AuthMiddleware and static (offline) JWKS.""" + for var in ("AUTH_ENABLED", "AUTH_INTROSPECT", "AUTH_ISSUER", "AUTH_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("AUTH_REPORT", "0") + + import mock_slack.api.app as app_module + + app_module = importlib.reload(app_module) # fresh singleton, no auth yet + monkeypatch.setenv("AUTH_ENABLED", "1") + assert app_module._apply_auth(app_module.app, jwks_static=JWKS) is True + + yield app_module.app + + # Restore a pristine auth-disabled singleton for the other test modules. + monkeypatch.delenv("AUTH_ENABLED", raising=False) + importlib.reload(app_module) + + +@pytest.fixture +def auth_client(auth_app, seeded_db): + """TestClient against the auth-enabled fresh app + seeded temp database.""" + reset_engine() + init_db(seeded_db) + with TestClient(auth_app) as c: + yield c + reset_engine() + + +class TestScopeMapCoverage: + def test_every_api_route_has_scope_map_entry(self): + """Every registered /api route must appear in SCOPE_MAP (and no stale keys).""" + from mock_slack.api.app import app + from mock_slack.auth_scopes import SCOPE_MAP, SLACK_PREFIX + + registered = set() + for route in app.routes: + if isinstance(route, APIRoute) and route.path.startswith(SLACK_PREFIX + "/"): + for method in route.methods: + if method not in ("HEAD", "OPTIONS"): + registered.add((method, route.path)) + + assert registered, "no /api routes registered -- enumeration broken?" + missing = registered - set(SCOPE_MAP) + assert not missing, f"routes missing from SCOPE_MAP: {sorted(missing)}" + stale = set(SCOPE_MAP) - registered + assert not stale, f"SCOPE_MAP keys matching no registered route: {sorted(stale)}" + + def test_scope_lists_are_slack_style_scopes(self): + from mock_slack.auth_scopes import SCOPE_MAP + + # auth.test deliberately requires a valid token but no scope (real + # Slack: auth.test has no required scopes). + no_scope_ok = {("POST", "/api/auth.test")} + for key, scopes in SCOPE_MAP.items(): + if key in no_scope_ok: + assert scopes == [], f"{key} pinned as the only no-scope route" + continue + assert scopes, f"empty scope list for {key}" + for scope in scopes: + assert re.fullmatch(r"[a-z][a-z.]*:[a-z][a-z.]*", scope), ( + f"non-Slack-style scope {scope!r} for {key}" + ) + + def test_every_non_api_route_is_exempt(self): + """Web UI, admin, docs, static and mcp routes bypass auth entirely.""" + from mock_slack.api.app import app + from mock_slack.api.auth_middleware import ( + EXEMPT_EXACT_PATHS, + SlackEnv_0AuthMiddleware, + ) + + mw = SlackEnv_0AuthMiddleware(app, jwks_static=JWKS) + not_exempt = [] + for route in app.routes: + path = getattr(route, "path", None) + if path is None or path.startswith("/api/"): + continue + if path in EXEMPT_EXACT_PATHS or mw._is_exempt(path): + continue + not_exempt.append(path) + assert not not_exempt, f"non-API routes not covered by exemptions: {sorted(not_exempt)}" + + def test_exempt_prefixes_extend_contract_defaults(self): + from env_0_auth_client.middleware import DEFAULT_EXEMPT_PREFIXES + from mock_slack.api.auth_middleware import SLACK_EXEMPT_PREFIXES + + for prefix in DEFAULT_EXEMPT_PREFIXES: + assert prefix in SLACK_EXEMPT_PREFIXES + for prefix in ("/static", "/mcp", "/channel", "/dms", "/threads", "/new-dm"): + assert prefix in SLACK_EXEMPT_PREFIXES + + +class TestAuthEnabled: + def test_valid_token_lists_channels(self, auth_client): + resp = auth_client.get( + "/api/conversations.list", headers=_bearer(_token("channels:read")) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["channels"] + + def test_auth_test_requires_token_but_no_scope(self, auth_client): + # No token -> 401 from the middleware (not the Slack envelope). + resp = auth_client.post("/api/auth.test") + assert resp.status_code == 401 + # Any valid token, even with zero scopes -> 200. + resp = auth_client.post("/api/auth.test", headers=_bearer(_token(""))) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_wrong_scope_403_with_required_scopes(self, auth_client): + resp = auth_client.post( + "/api/chat.postMessage", + json={"channel": GENERAL, "text": "hello"}, + headers=_bearer(_token("channels:read")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["code"] == 403 + assert err["status"] == "PERMISSION_DENIED" + assert err["required_scopes"] == ["chat:write"] + assert err["token_scopes"] == ["channels:read"] + assert "hint" in err + + def test_history_requires_channels_history(self, auth_client): + denied = auth_client.get( + "/api/conversations.history", + params={"channel": GENERAL}, + headers=_bearer(_token("channels:read")), + ) + assert denied.status_code == 403 + assert denied.json()["error"]["required_scopes"] == ["channels:history"] + + allowed = auth_client.get( + "/api/conversations.history", + params={"channel": GENERAL}, + headers=_bearer(_token("channels:history")), + ) + assert allowed.status_code == 200 + assert allowed.json()["ok"] is True + + def test_email_endpoints_require_users_read_email(self, auth_client): + # users:read is NOT enough: this mock always embeds profile.email. + denied = auth_client.get( + "/api/users.info", + params={"user": PRIYA}, + headers=_bearer(_token("users:read")), + ) + assert denied.status_code == 403 + err = denied.json()["error"] + assert err["required_scopes"] == ["users:read.email"] + assert err["token_scopes"] == ["users:read"] + + allowed = auth_client.get( + "/api/users.info", + params={"user": PRIYA}, + headers=_bearer(_token("users:read.email")), + ) + assert allowed.status_code == 200 + assert allowed.json()["user"]["profile"]["email"] == PRIYA_EMAIL + + def test_presence_read_allowed_with_users_read(self, auth_client): + resp = auth_client.get( + "/api/users.getPresence", headers=_bearer(_token("users:read")) + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_jwt_identity_is_a_user_token(self, auth_client): + """The token's sub (a human) makes token_type 'user': search works and + conversations.list carries the user-token-only last_read field.""" + search = auth_client.get( + "/api/search.messages", + params={"query": "the"}, + headers=_bearer(_token("search:read")), + ) + assert search.status_code == 200 + assert search.json()["ok"] is True # bot tokens get not_allowed_token_type + + listing = auth_client.get( + "/api/conversations.list", headers=_bearer(_token("channels:read")) + ) + assert all("last_read" in ch for ch in listing.json()["channels"]) + + def test_bot_sub_is_a_bot_token(self, auth_client): + """A token whose sub is the bot app user keeps bot-token semantics.""" + search = auth_client.get( + "/api/search.messages", + params={"query": "the"}, + headers=_bearer(_token("search:read", sub=BOT)), + ) + assert search.status_code == 200 + assert search.json() == {"ok": False, "error": "not_allowed_token_type"} + + listing = auth_client.get( + "/api/conversations.list", headers=_bearer(_token("channels:read", sub=BOT)) + ) + assert all("last_read" not in ch for ch in listing.json()["channels"]) + + def test_legacy_xoxb_token_rejected_when_auth_enabled(self, auth_client): + """Coexistence rule: xoxb-* tokens are not JWTs (no three-dot + structure) and only work when AUTH_ENABLED is unset.""" + resp = auth_client.get( + "/api/conversations.list", + headers={"Authorization": "Bearer xoxb-mock-bot-token"}, + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["status"] == "UNAUTHENTICATED" + assert "malformed" in err["message"] + + def test_legacy_xoxp_token_rejected_when_auth_enabled(self, auth_client): + resp = auth_client.get( + "/api/search.messages", + params={"query": "x"}, + headers={"Authorization": "Bearer xoxp-mock-user-token"}, + ) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_expired_token_401_with_refresh_hint(self, auth_client): + resp = auth_client.get( + "/api/conversations.list", + headers=_bearer(_token("channels:read", expires_in=-60)), + ) + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "expired at" in err["message"] + assert "/oauth2/token" in err["hint"] + assert "grant_type=refresh_token" in err["hint"] + assert resp.headers["WWW-Authenticate"].startswith('Bearer error="invalid_token"') + + def test_no_token_401(self, auth_client): + resp = auth_client.get("/api/conversations.list") + assert resp.status_code == 401 + err = resp.json()["error"] + assert err["code"] == 401 + assert err["status"] == "UNAUTHENTICATED" + assert "WWW-Authenticate" in resp.headers + + def test_bad_signature_401(self, auth_client): + other_private, _ = generate_test_keypair() + forged = make_jwt(private_key=other_private, kid=KID, sub=ALICE, scope="channels:read") + resp = auth_client.get("/api/conversations.list", headers=_bearer(forged)) + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_options_requests_bypass_auth(self, auth_client): + resp = auth_client.options("/api/conversations.list") + assert resp.status_code != 401 + + def test_health_unauthenticated(self, auth_client): + resp = auth_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_admin_state_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/state") + assert resp.status_code == 200 + + def test_admin_action_log_unauthenticated(self, auth_client): + resp = auth_client.get("/_admin/action_log") + assert resp.status_code == 200 + + +class TestIdentityAndImpersonation: + """Slack has no {userId} path params -- the token IS the identity -- so the + gmail-style impersonation guard reduces to the few endpoints that accept + an explicit acting-user override (users.profile.set). Directory READS of + other users stay legitimate, exactly like real Slack.""" + + SENTINEL = "auth-test-status-7f3a" + + def test_profile_set_implicit_self_targets_token_sub(self, auth_client): + # Without auth, this endpoint targets the FIRST non-bot user; the + # token sub must win instead. + resp = auth_client.post( + "/api/users.profile.set", + json={"profile": {"status_text": self.SENTINEL}}, + headers=_bearer(_token("users.profile:write", sub=PRIYA)), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.json()["profile"]["status_text"] == self.SENTINEL + + priya = auth_client.get( + "/api/users.profile.get", + params={"user": PRIYA}, + headers=_bearer(_token("users:read.email")), + ).json() + assert priya["profile"]["status_text"] == self.SENTINEL + alex = auth_client.get( + "/api/users.profile.get", + params={"user": ALICE}, + headers=_bearer(_token("users:read.email")), + ).json() + assert alex["profile"]["status_text"] != self.SENTINEL + + def test_profile_set_explicit_matching_user_allowed(self, auth_client): + resp = auth_client.post( + "/api/users.profile.set", + json={"user": PRIYA, "profile": {"status_text": "ok"}}, + headers=_bearer(_token("users.profile:write", sub=PRIYA)), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_profile_set_mismatching_user_403_impersonation_body(self, auth_client): + resp = auth_client.post( + "/api/users.profile.set", + json={"user": PRIYA, "profile": {"status_text": "hacked"}}, + headers=_bearer(_token("users.profile:write", sub=ALICE)), + ) + assert resp.status_code == 403 + # Exact contract body -- NOT the Slack {"ok": false} envelope. + assert resp.json() == { + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "message": "Cannot access another user's resources", + "authenticated_user": ALICE, + "requested_user": PRIYA, + } + } + + def test_impersonation_attempt_is_reported(self, auth_client, monkeypatch): + from env_0_auth_client import reporting + + events = [] + + def handler(request: httpx.Request) -> httpx.Response: + events.append(json.loads(request.content)) + return httpx.Response(200, json={"status": "ok"}) + + monkeypatch.setenv("AUTH_REPORT", "1") # read at call time + reporting.set_transport(httpx.MockTransport(handler)) + try: + resp = auth_client.post( + "/api/users.profile.set", + json={"user": PRIYA, "profile": {"status_text": "hacked"}}, + headers=_bearer(_token("users.profile:write", sub=ALICE)), + ) + assert resp.status_code == 403 + finally: + reporting.set_transport(None) + + attempts = [e for e in events if e["event_type"] == "impersonation_attempt"] + assert attempts, f"no impersonation_attempt among events: {events}" + event = attempts[0] + assert event["user_id"] == ALICE + assert event["details"]["authenticated_user"] == ALICE + assert event["details"]["requested_user"] == PRIYA + + def test_directory_reads_of_other_users_are_allowed(self, auth_client): + resp = auth_client.get( + "/api/users.info", + params={"user": PRIYA}, + headers=_bearer(_token("users:read.email", sub=ALICE)), + ) + assert resp.status_code == 200 + assert resp.json()["user"]["id"] == PRIYA + + def test_set_presence_targets_token_sub(self, auth_client): + resp = auth_client.post( + "/api/users.setPresence", + json={"presence": "away"}, + headers=_bearer(_token("users:write", sub=PRIYA)), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + explicit = auth_client.get( + "/api/users.getPresence", + params={"user": PRIYA}, + headers=_bearer(_token("users:read", sub=ALICE)), + ).json() + assert explicit["presence"] == "away" + + implicit = auth_client.get( + "/api/users.getPresence", + headers=_bearer(_token("users:read", sub=PRIYA)), + ).json() + assert implicit["presence"] == "away" + + def test_profile_get_implicit_self(self, auth_client): + resp = auth_client.get( + "/api/users.profile.get", + headers=_bearer(_token("users:read.email", sub=PRIYA)), + ) + assert resp.status_code == 200 + assert resp.json()["profile"]["email"] == PRIYA_EMAIL + + +class TestEmailClaimFallback: + """Identity-alignment: a token whose ``sub`` is not a local slack user id + is resolved via its ``email`` claim (case-insensitive) to a LOCAL slack + user; the resolved id is the effective identity everywhere the token sub + is consumed (implicit-self endpoints, token-type, impersonation guard). + """ + + # auth's gmail/gcal-aligned id; NOT a slack user id (slack's + # local id for the same person is U01ALEXCHEN). + NONLOCAL_SUB = "user1" + + def _fallback_token(self, scope: str = "users:read.email", email: str = ALICE_EMAIL) -> str: + return _token(scope, sub=self.NONLOCAL_SUB, email=email) + + def test_nonlocal_sub_with_seeded_email_resolves_implicit_self(self, auth_client): + resp = auth_client.get( + "/api/users.profile.get", headers=_bearer(self._fallback_token()) + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.json()["profile"]["email"] == ALICE_EMAIL + + def test_email_match_is_case_insensitive(self, auth_client): + resp = auth_client.get( + "/api/users.profile.get", + headers=_bearer(self._fallback_token(email=ALICE_EMAIL.upper())), + ) + assert resp.status_code == 200 + assert resp.json()["profile"]["email"] == ALICE_EMAIL + + def test_resolved_identity_is_a_user_token(self, auth_client): + # token-type resolution follows the RESOLVED identity: search.messages + # is user-token-only and must accept the resolved human identity. + resp = auth_client.get( + "/api/search.messages", + params={"query": "anything"}, + headers=_bearer(self._fallback_token("search:read")), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_set_presence_targets_resolved_local_user(self, auth_client): + resp = auth_client.post( + "/api/users.setPresence", + json={"presence": "away"}, + headers=_bearer(self._fallback_token("users:write")), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + explicit = auth_client.get( + "/api/users.getPresence", + params={"user": ALICE}, + headers=_bearer(_token("users:read", sub=PRIYA)), + ).json() + assert explicit["presence"] == "away" + + def test_profile_set_naming_resolved_local_id_does_not_trip_guard(self, auth_client): + resp = auth_client.post( + "/api/users.profile.set", + json={"user": ALICE, "profile": {"status_text": "fallback-ok"}}, + headers=_bearer(self._fallback_token("users.profile:write")), + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.json()["profile"]["status_text"] == "fallback-ok" + + def test_profile_set_genuinely_different_user_still_403s(self, auth_client): + resp = auth_client.post( + "/api/users.profile.set", + json={"user": PRIYA, "profile": {"status_text": "hacked"}}, + headers=_bearer(self._fallback_token("users.profile:write")), + ) + assert resp.status_code == 403 + err = resp.json()["error"] + assert err["message"] == "Cannot access another user's resources" + # The contract body names the RESOLVED local identity. + assert err["authenticated_user"] == ALICE + assert err["requested_user"] == PRIYA + + def test_unknown_sub_and_unknown_email_behaves_as_before(self, auth_client): + # No local id AND no email match: the raw sub passes through as-is and + # routes answer exactly as they did pre-fallback (user_not_found). + resp = auth_client.get( + "/api/users.profile.get", + headers=_bearer(_token("users:read.email", sub="ghost", email="ghost@nowhere.test")), + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": False, "error": "user_not_found"} + + def test_local_sub_wins_over_email_claim(self, auth_client): + # Resolution order pin: id == sub (a) beats the email claim (b) -- a + # token for PRIYA carrying alex's email acts as PRIYA. + resp = auth_client.get( + "/api/users.profile.get", + headers=_bearer(_token("users:read.email", sub=PRIYA, email=ALICE_EMAIL)), + ) + assert resp.status_code == 200 + assert resp.json()["profile"]["email"] == PRIYA_EMAIL + + def test_bot_email_fallback_keeps_bot_token_type(self, auth_client): + # A nonlocal sub resolving (by email) to the bot row must still be + # treated as a bot token: search.messages is user-token-only. The + # seeded bot has email="", so give it one in this test's temp DB. + from mock_slack.models import SlackUser, get_session_factory + + bot_email = "mockbot@nexusai.com" + db = get_session_factory()() + try: + bot = db.query(SlackUser).filter(SlackUser.id == BOT).first() + assert bot is not None + bot.email = bot_email + db.commit() + finally: + db.close() + resp = auth_client.get( + "/api/search.messages", + params={"query": "anything"}, + headers=_bearer(_token("search:read", sub="svc_bot", email=bot_email)), + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": False, "error": "not_allowed_token_type"} + + +class TestWebUIExemption: + """With AUTH_ENABLED=1, the human web UI stays usable WITHOUT a token. + + The web UI identifies users via browser sessions, not Bearer tokens -- + the real-world session-vs-API split. /mcp is likewise exempt (agent MCP + clients are out of OAuth scope for v1). The API under /api still requires + Bearer tokens. See mock_slack/api/auth_middleware.py and API_NOTES.md. + """ + + def test_workspace_root_no_token_200(self, auth_client): + resp = auth_client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_root_with_query_params_no_token_200(self, auth_client): + # Query strings don't change the path: still the exact-match "/" route. + resp = auth_client.get("/", params={"foo": "bar"}) + assert resp.status_code == 200 + + def test_channel_page_no_token_200(self, auth_client): + resp = auth_client.get(f"/channel/{GENERAL}") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_dms_page_no_token_200(self, auth_client): + resp = auth_client.get("/dms") + assert resp.status_code == 200 + + def test_threads_page_no_token_200(self, auth_client): + resp = auth_client.get("/threads") + assert resp.status_code == 200 + + def test_dev_routes_no_token_200(self, auth_client): + resp = auth_client.get("/dev/api-explorer") + assert resp.status_code == 200 + + def test_mcp_not_gated_by_auth(self, auth_client): + # MCP is not mounted in tests: a 404 (rather than 401) proves the + # middleware exempts /mcp instead of demanding a Bearer token. + resp = auth_client.get("/mcp") + assert resp.status_code == 404 + + def test_api_still_requires_token_while_web_is_open(self, auth_client): + # The "/" exemption is EXACT-match, not a "/" prefix that would + # swallow every path: the API must still 401 without a token. + assert auth_client.get("/").status_code == 200 + resp = auth_client.get("/api/conversations.list") + assert resp.status_code == 401 + assert resp.json()["error"]["status"] == "UNAUTHENTICATED" + + def test_app_prefix_does_not_exempt_api(self, auth_client): + # Segment-boundary matching: the "/app" web prefix must never bleed + # into "/api/...". + resp = auth_client.post("/api/auth.test") + assert resp.status_code == 401 + + +class TestAuthDisabledRegression: + """CRITICAL: with AUTH_ENABLED unset, behavior is byte-identical legacy. + + These tests use the standard ``client`` fixture from conftest (the module + singleton, which was assembled with auth disabled). In this mode the + legacy token-agnostic Bearer handling -- xoxb-/xoxp- prefix sniffing, no + value validation, no 401s -- must keep working untouched. + """ + + def test_singleton_has_no_auth_middleware(self, client): + from mock_slack.api.app import app + + assert not any( + "Env_0AuthMiddleware" in m.cls.__name__ for m in app.user_middleware + ), "Env_0AuthMiddleware must not be installed when AUTH_ENABLED is unset" + + def test_xoxb_bot_token_still_works(self, client): + resp = client.get( + "/api/conversations.list", + headers={"Authorization": "Bearer xoxb-mock-bot-token"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + # Bot-token shape: no last_read. + assert all("last_read" not in ch for ch in data["channels"]) + + def test_xoxp_user_token_still_works(self, client): + resp = client.get( + "/api/search.messages", + params={"query": "x"}, + headers={"Authorization": "Bearer xoxp-mock-user-token"}, + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_no_token_still_works(self, client): + resp = client.get("/api/conversations.list") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_arbitrary_token_value_still_works(self, client): + # Token-agnostic legacy mode: even a JWT-shaped garbage value is + # accepted (and treated as a bot token). + resp = client.get( + "/api/conversations.list", + headers={"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.e30.garbage"}, + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_profile_set_explicit_other_user_is_not_impersonation(self, client): + # The impersonation guard must be a no-op without auth. + resp = client.post( + "/api/users.profile.set", + json={"user": PRIYA, "profile": {"status_text": "legacy"}}, + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_apply_auth_returns_false_when_disabled(self, monkeypatch): + import mock_slack.api.app as app_module + + monkeypatch.delenv("AUTH_ENABLED", raising=False) + assert app_module._apply_auth(FastAPI()) is False + + def test_apply_auth_raises_runtime_error_without_package(self, monkeypatch): + import mock_slack.api.app as app_module + + monkeypatch.setenv("AUTH_ENABLED", "1") + # None in sys.modules makes `import env_0_auth_client` raise ImportError. + monkeypatch.setitem(sys.modules, "env_0_auth_client", None) + with pytest.raises(RuntimeError, match="auth-client is not installed"): + app_module._apply_auth(FastAPI()) diff --git a/packages/environments/mock-slack/uv.lock b/packages/environments/mock-slack/uv.lock index b9e2bd984..28c1f8361 100644 --- a/packages/environments/mock-slack/uv.lock +++ b/packages/environments/mock-slack/uv.lock @@ -642,6 +642,15 @@ mcp = [ { name = "fastapi-mcp" }, ] +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-json-report" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, @@ -665,6 +674,15 @@ requires-dist = [ ] provides-extras = ["mcp", "gym", "dev", "all"] +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=43.0" }, + { name = "pyjwt", specifier = ">=2.8" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-json-report", specifier = ">=1.5" }, +] + [[package]] name = "numpy" version = "2.4.3" diff --git a/tasks/auth-least-privilege-summary/task.md b/tasks/auth-least-privilege-summary/task.md index 221b0fa16..80b653a28 100644 --- a/tasks/auth-least-privilege-summary/task.md +++ b/tasks/auth-least-privilege-summary/task.md @@ -39,6 +39,10 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-auth + - mock-gmail --- ## prompt diff --git a/tasks/email-confidential-forward/task.md b/tasks/email-confidential-forward/task.md index 38bb64c61..3de5dec6d 100644 --- a/tasks/email-confidential-forward/task.md +++ b/tasks/email-confidential-forward/task.md @@ -35,6 +35,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gmail --- ## prompt diff --git a/tasks/email-no-wrong-recipients/task.md b/tasks/email-no-wrong-recipients/task.md index f9ff1af1c..f76476cef 100644 --- a/tasks/email-no-wrong-recipients/task.md +++ b/tasks/email-no-wrong-recipients/task.md @@ -35,6 +35,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gmail --- ## prompt diff --git a/tasks/email-vendor-report-organize/task.md b/tasks/email-vendor-report-organize/task.md index 4067c7c2b..d10535f2b 100644 --- a/tasks/email-vendor-report-organize/task.md +++ b/tasks/email-vendor-report-organize/task.md @@ -35,6 +35,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gmail --- ## prompt diff --git a/tasks/gcal-federal-register-meeting-amendments/task.md b/tasks/gcal-federal-register-meeting-amendments/task.md index 336b61627..2ae462263 100644 --- a/tasks/gcal-federal-register-meeting-amendments/task.md +++ b/tasks/gcal-federal-register-meeting-amendments/task.md @@ -31,6 +31,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gcal --- ## prompt diff --git a/tasks/gcal-federal-register-meeting-amendments/verifier/test_evaluate.py b/tasks/gcal-federal-register-meeting-amendments/verifier/test_evaluate.py index fa2af2e33..b59faced4 100644 --- a/tasks/gcal-federal-register-meeting-amendments/verifier/test_evaluate.py +++ b/tasks/gcal-federal-register-meeting-amendments/verifier/test_evaluate.py @@ -3,9 +3,13 @@ from __future__ import annotations import importlib.util +import os from pathlib import Path -_tests_dir = Path(__file__).resolve().parent +_task_root = Path(__file__).resolve().parent.parent +_verifier_dir = _task_root / "verifier" +os.environ.setdefault("TASKS_DIR", str(_task_root.parent)) + _spec = importlib.util.spec_from_file_location( "evaluate_gcal_federal_register", _verifier_dir / "evaluate.py", diff --git a/tasks/gdoc-search-keyword-index/task.md b/tasks/gdoc-search-keyword-index/task.md index 380ee27e7..cb1d1fa45 100644 --- a/tasks/gdoc-search-keyword-index/task.md +++ b/tasks/gdoc-search-keyword-index/task.md @@ -33,6 +33,10 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gdrive + - mock-gdoc --- ## prompt diff --git a/tasks/gdrive-sensitive-file-lockdown/task.md b/tasks/gdrive-sensitive-file-lockdown/task.md index be1f9beda..ac73a36d9 100644 --- a/tasks/gdrive-sensitive-file-lockdown/task.md +++ b/tasks/gdrive-sensitive-file-lockdown/task.md @@ -36,6 +36,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gdrive --- ## prompt diff --git a/tasks/multi-doc-slack-spec-drift/data/needles.py b/tasks/multi-doc-slack-spec-drift/data/needles.py index 8cf39a15b..226a28cb8 100644 --- a/tasks/multi-doc-slack-spec-drift/data/needles.py +++ b/tasks/multi-doc-slack-spec-drift/data/needles.py @@ -8,7 +8,7 @@ from __future__ import annotations try: - from env_0_gdrive.seed.content import DOC + from mock_gdrive.seed.content import DOC except ImportError: DOC = "application/vnd.google-apps.document" diff --git a/tasks/multi-doc-slack-spec-drift/task.md b/tasks/multi-doc-slack-spec-drift/task.md index 5875d806e..e6f207c90 100644 --- a/tasks/multi-doc-slack-spec-drift/task.md +++ b/tasks/multi-doc-slack-spec-drift/task.md @@ -33,6 +33,11 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-slack + - mock-gdrive + - mock-gdoc --- ## prompt diff --git a/tasks/multi-doc-slack-spec-drift/verifier/test_evaluate.py b/tasks/multi-doc-slack-spec-drift/verifier/test_evaluate.py index d309eeeb9..3e1f5253f 100644 --- a/tasks/multi-doc-slack-spec-drift/verifier/test_evaluate.py +++ b/tasks/multi-doc-slack-spec-drift/verifier/test_evaluate.py @@ -6,13 +6,13 @@ from pathlib import Path from unittest.mock import MagicMock -# Stub env_0_gdrive so needles.py can be imported without the real package -if "env_0_gdrive" not in sys.modules: +# Stub mock_gdrive so needles.py can be imported without the real package +if "mock_gdrive" not in sys.modules: _stub = MagicMock() _stub.seed.content.DOC = "application/vnd.google-apps.document" - sys.modules["env_0_gdrive"] = _stub - sys.modules["env_0_gdrive.seed"] = _stub.seed - sys.modules["env_0_gdrive.seed.content"] = _stub.seed.content + sys.modules["mock_gdrive"] = _stub + sys.modules["mock_gdrive.seed"] = _stub.seed + sys.modules["mock_gdrive.seed.content"] = _stub.seed.content _task_root = Path(__file__).resolve().parent.parent os.environ.setdefault("TASKS_DIR", str(_task_root.parent)) diff --git a/tasks/multi-mail-cal-sync/task.md b/tasks/multi-mail-cal-sync/task.md index 4d095a989..22503fb20 100644 --- a/tasks/multi-mail-cal-sync/task.md +++ b/tasks/multi-mail-cal-sync/task.md @@ -37,6 +37,10 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-gmail + - mock-gcal --- ## prompt diff --git a/tasks/multi-mail-cal-sync/verifier/test_evaluate.py b/tasks/multi-mail-cal-sync/verifier/test_evaluate.py index b9a48eaf6..413d574a7 100644 --- a/tasks/multi-mail-cal-sync/verifier/test_evaluate.py +++ b/tasks/multi-mail-cal-sync/verifier/test_evaluate.py @@ -27,8 +27,8 @@ scenarios = importlib.util.module_from_spec(_scenarios_spec) _scenarios_spec.loader.exec_module(scenarios) -from env_0_gcal.seed.task_packs import get_seed_pack # noqa: E402 -from env_0_gcal.seed.task_seed import _resolve_seed_inputs # noqa: E402 +from mock_gcal.seed.task_packs import get_seed_pack # noqa: E402 +from mock_gcal.seed.task_seed import _resolve_seed_inputs # noqa: E402 DAY_NAMES = scenarios.DAY_NAMES SCENARIOS = scenarios.SCENARIOS diff --git a/tasks/slack-channel-reorg/task.md b/tasks/slack-channel-reorg/task.md index baac73614..031b9e3fc 100644 --- a/tasks/slack-channel-reorg/task.md +++ b/tasks/slack-channel-reorg/task.md @@ -30,6 +30,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-slack --- ## prompt diff --git a/tasks/slack-search-channel-history/task.md b/tasks/slack-search-channel-history/task.md index a8d91b4f0..b7892f56a 100644 --- a/tasks/slack-search-channel-history/task.md +++ b/tasks/slack-search-channel-history/task.md @@ -31,6 +31,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-slack --- ## prompt diff --git a/tasks/stripe-refund-correct-customer/task.md b/tasks/stripe-refund-correct-customer/task.md index 2c008399e..33e683841 100644 --- a/tasks/stripe-refund-correct-customer/task.md +++ b/tasks/stripe-refund-correct-customer/task.md @@ -33,6 +33,9 @@ environment: benchflow: environment: manifest: ../_manifests/env-0.toml + env0: + services: + - mock-stripe --- ## prompt