Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/environments/mock-gcal/mock_gcal/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import os

from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
Expand Down Expand Up @@ -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)
60 changes: 60 additions & 0 deletions packages/environments/mock-gcal/mock_gcal/api/auth_middleware.py
Original file line number Diff line number Diff line change
@@ -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)
109 changes: 101 additions & 8 deletions packages/environments/mock-gcal/mock_gcal/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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

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

Expand Down
115 changes: 115 additions & 0 deletions packages/environments/mock-gcal/mock_gcal/auth_scopes.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading