feat(10-02): Google Calendar OAuth endpoints and per-tenant calendar tool

- Add calendar_auth.py: OAuth install/callback/status endpoints with HMAC-signed state
- Replace calendar_lookup.py service account stub with per-tenant OAuth token lookup
- Support list, check_availability, and create actions with natural language responses
- Token auto-refresh: write updated credentials back to channel_connections on refresh
- Add migration 013: add google_calendar to channel_type CHECK constraint
- Add unit tests: 16 tests covering all actions, not-connected path, token refresh write-back
This commit is contained in:
2026-03-26 09:07:37 -06:00
parent e8d3e8a108
commit 08572fcc40
5 changed files with 1318 additions and 62 deletions

View File

@@ -1,108 +1,302 @@
"""
Built-in tool: calendar_lookup
Reads calendar events from Google Calendar for a given date.
Reads and creates Google Calendar events using per-tenant OAuth tokens.
Authentication options (in priority order):
1. GOOGLE_SERVICE_ACCOUNT_KEY env var — JSON key for service account impersonation
2. Per-tenant OAuth (future: Phase 3 portal) — not yet implemented
3. Graceful degradation: returns informative message if not configured
Authentication:
Tokens are stored per-tenant in channel_connections (channel_type='google_calendar').
The tenant admin must complete the OAuth flow via /api/portal/calendar/install first.
If no token is found, returns an informative message asking admin to connect.
This tool is read-only (requires_confirmation=False in registry).
Actions:
- list: List events for the given date (default)
- check_availability: Return free/busy summary for the given date
- create: Create a new calendar event
Token auto-refresh:
google.oauth2.credentials.Credentials auto-refreshes expired access tokens
using the stored refresh_token. After each API call, if credentials.token
changed (refresh occurred), the updated token is encrypted and written back
to channel_connections so subsequent calls don't re-trigger refresh.
All results are formatted as natural language strings — no raw JSON exposed.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from datetime import datetime, timezone
import uuid
from typing import Any
# Module-level imports for patchability in tests.
# google-auth and googleapiclient are optional dependencies — import errors handled
# gracefully in the functions that use them.
try:
from googleapiclient.discovery import build # type: ignore[import-untyped]
except ImportError:
build = None # type: ignore[assignment]
from shared.config import settings
from shared.crypto import KeyEncryptionService
logger = logging.getLogger(__name__)
# Google Calendar API scope (must match what was requested during OAuth)
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
def google_credentials_from_token(token_dict: dict[str, Any]) -> Any:
"""
Build a google.oauth2.credentials.Credentials object from a stored token dict.
The token dict is the JSON structure written by calendar_auth.py during OAuth:
{
"token": "ya29.access_token",
"refresh_token": "1//refresh_token",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "...",
"client_secret": "...",
"scopes": ["https://www.googleapis.com/auth/calendar"]
}
Args:
token_dict: Parsed token dictionary.
Returns:
google.oauth2.credentials.Credentials instance.
Raises:
ImportError: If google-auth is not installed.
"""
from google.oauth2.credentials import Credentials # type: ignore[import-untyped]
return Credentials(
token=token_dict.get("token"),
refresh_token=token_dict.get("refresh_token"),
token_uri=token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
client_id=token_dict.get("client_id"),
client_secret=token_dict.get("client_secret"),
scopes=token_dict.get("scopes", [_CALENDAR_SCOPE]),
)
async def calendar_lookup(
date: str,
action: str = "list",
event_summary: str | None = None,
event_start: str | None = None,
event_end: str | None = None,
calendar_id: str = "primary",
tenant_id: str | None = None,
_session: Any = None, # Injected in tests; production uses DB session from task context
**kwargs: object,
) -> str:
"""
Look up calendar events for a specific date.
Look up, check availability, or create Google Calendar events for a specific date.
Args:
date: Date in YYYY-MM-DD format.
calendar_id: Google Calendar ID. Defaults to 'primary'.
date: Date in YYYY-MM-DD format (required).
action: One of "list", "check_availability", "create". Default: "list".
event_summary: Event title (required for action="create").
event_start: ISO 8601 datetime with timezone (required for action="create").
event_end: ISO 8601 datetime with timezone (required for action="create").
calendar_id: Google Calendar ID. Defaults to 'primary'.
tenant_id: Konstruct tenant UUID string. Required for token lookup.
_session: Injected AsyncSession (for testing). Production passes None.
Returns:
Formatted string listing events for the given date,
or an informative message if Google Calendar is not configured.
Natural language string describing the result.
"""
service_account_key_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY", "")
if not service_account_key_json:
return (
"Calendar lookup is not configured. "
"Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to enable calendar access."
)
# Guard: tenant_id is required to look up per-tenant OAuth token
if not tenant_id:
return "Calendar not available: missing tenant context."
# Get DB session
session = _session
if session is None:
# Production: obtain a session from the DB pool
# Import here to avoid circular imports at module load time
try:
from shared.db import async_session_factory
session = async_session_factory()
# Note: caller is responsible for closing the session
# In practice, the orchestrator task context manages session lifecycle
except Exception:
logger.exception("Failed to create DB session for calendar_lookup")
return "Calendar lookup failed: unable to connect to the database."
try:
import asyncio
tenant_uuid = uuid.UUID(tenant_id)
except ValueError:
return f"Calendar lookup failed: invalid tenant ID '{tenant_id}'."
result = await asyncio.get_event_loop().run_in_executor(
None,
_fetch_calendar_events_sync,
service_account_key_json,
calendar_id,
date,
# Load per-tenant OAuth token from channel_connections
try:
from sqlalchemy import select
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
result = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_uuid,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
return result
conn = result.scalar_one_or_none()
except Exception:
logger.exception("Calendar lookup failed for date=%s calendar=%s", date, calendar_id)
logger.exception("DB error loading calendar connection for tenant=%s", tenant_id)
return "Calendar lookup failed: database error loading calendar connection."
if conn is None:
return (
"Google Calendar is not connected for this tenant. "
"Ask an admin to connect it in Settings."
)
# Decrypt token
encrypted_token = conn.config.get("token", "")
if not encrypted_token:
return "Calendar lookup failed: no token found in connection config."
try:
if not settings.platform_encryption_key:
return "Calendar lookup failed: encryption key not configured."
enc_svc = KeyEncryptionService(
primary_key=settings.platform_encryption_key,
previous_key=settings.platform_encryption_key_previous,
)
token_json: str = enc_svc.decrypt(encrypted_token)
token_dict: dict[str, Any] = json.loads(token_json)
except Exception:
logger.exception("Failed to decrypt calendar token for tenant=%s", tenant_id)
return "Calendar lookup failed: unable to decrypt stored credentials."
# Build Google credentials
try:
creds = google_credentials_from_token(token_dict)
except ImportError:
return (
"Google Calendar library not installed. "
"Run: uv add google-api-python-client google-auth"
)
except Exception:
logger.exception("Failed to build Google credentials for tenant=%s", tenant_id)
return "Calendar lookup failed: invalid stored credentials."
# Record the token before the API call to detect refresh
token_before = creds.token
# Execute the API call in a thread executor (blocking SDK)
try:
result_str = await asyncio.get_event_loop().run_in_executor(
None,
_execute_calendar_action,
creds,
action,
date,
calendar_id,
event_summary,
event_start,
event_end,
)
except Exception:
logger.exception("Calendar API call failed for tenant=%s date=%s action=%s", tenant_id, date, action)
return f"Calendar lookup failed for {date}. Please try again."
# Token refresh write-back: if token changed after the API call, persist the update
if creds.token and creds.token != token_before:
try:
new_token_dict = {
"token": creds.token,
"refresh_token": creds.refresh_token or token_dict.get("refresh_token", ""),
"token_uri": token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
"client_id": token_dict.get("client_id", ""),
"client_secret": token_dict.get("client_secret", ""),
"scopes": token_dict.get("scopes", [_CALENDAR_SCOPE]),
}
new_encrypted = enc_svc.encrypt(json.dumps(new_token_dict))
conn.config = {"token": new_encrypted}
await session.commit()
logger.debug("Calendar token refreshed and written back for tenant=%s", tenant_id)
except Exception:
logger.exception("Failed to write back refreshed calendar token for tenant=%s", tenant_id)
# Non-fatal: the API call succeeded, just log the refresh failure
def _fetch_calendar_events_sync(
service_account_key_json: str,
calendar_id: str,
return result_str
def _execute_calendar_action(
creds: Any,
action: str,
date: str,
calendar_id: str,
event_summary: str | None,
event_start: str | None,
event_end: str | None,
) -> str:
"""
Synchronous implementation — runs in thread executor to avoid blocking event loop.
Synchronous calendar action — runs in thread executor to avoid blocking.
Uses google-api-python-client with service account credentials.
Args:
creds: Google Credentials object.
action: One of "list", "check_availability", "create".
date: Date in YYYY-MM-DD format.
calendar_id: Google Calendar ID (default "primary").
event_summary: Title for create action.
event_start: ISO 8601 start for create action.
event_end: ISO 8601 end for create action.
Returns:
Natural language result string.
"""
try:
from google.oauth2 import service_account
from googleapiclient.discovery import build
except ImportError:
if build is None:
return (
"Google Calendar library not installed. "
"Run: uv add google-api-python-client google-auth"
)
try:
key_data = json.loads(service_account_key_json)
except json.JSONDecodeError:
return "Invalid GOOGLE_SERVICE_ACCOUNT_KEY: not valid JSON."
try:
credentials = service_account.Credentials.from_service_account_info(
key_data,
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
)
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
except Exception as exc:
return f"Failed to create Google credentials: {exc}"
logger.warning("Failed to build Google Calendar service: %s", exc)
return f"Calendar service error: {exc}"
# Parse the date and create RFC3339 time boundaries for the day
if action == "create":
return _action_create(service, calendar_id, event_summary, event_start, event_end)
elif action == "check_availability":
return _action_check_availability(service, calendar_id, date)
else:
# Default: "list"
return _action_list(service, calendar_id, date)
def _time_boundaries(date: str) -> tuple[str, str]:
"""Return (time_min, time_max) RFC3339 strings for the full given day (UTC)."""
return f"{date}T00:00:00Z", f"{date}T23:59:59Z"
def _format_event_time(event: dict[str, Any]) -> str:
"""Extract and format the start time of a calendar event."""
start = event.get("start", {})
raw = start.get("dateTime") or start.get("date") or "Unknown time"
# Trim the timezone part for readability if full datetime
if "T" in raw:
try:
# e.g. "2026-03-26T09:00:00+00:00" → "09:00"
time_part = raw.split("T")[1][:5]
return time_part
except IndexError:
return raw
return raw
def _action_list(service: Any, calendar_id: str, date: str) -> str:
"""List calendar events for the given date."""
time_min, time_max = _time_boundaries(date)
try:
date_obj = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
return f"Invalid date format: {date!r}. Expected YYYY-MM-DD."
time_min = date_obj.strftime("%Y-%m-%dT00:00:00Z")
time_max = date_obj.strftime("%Y-%m-%dT23:59:59Z")
try:
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
events_result = (
service.events()
.list(
@@ -115,17 +309,89 @@ def _fetch_calendar_events_sync(
.execute()
)
except Exception as exc:
logger.warning("Google Calendar API error: %s", exc)
return f"Calendar API error: {exc}"
logger.warning("Google Calendar list error: %s", exc)
return f"Calendar error listing events for {date}: {exc}"
items = events_result.get("items", [])
if not items:
return f"No events found on {date}."
lines = [f"Calendar events for {date}:\n"]
lines = [f"Calendar events for {date}:"]
for event in items:
start = event["start"].get("dateTime", event["start"].get("date", "Unknown time"))
time_str = _format_event_time(event)
summary = event.get("summary", "Untitled event")
lines.append(f"- {start}: {summary}")
lines.append(f"- {time_str}: {summary}")
return "\n".join(lines)
def _action_check_availability(service: Any, calendar_id: str, date: str) -> str:
"""Return a free/busy summary for the given date."""
time_min, time_max = _time_boundaries(date)
try:
events_result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=time_min,
timeMax=time_max,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
except Exception as exc:
logger.warning("Google Calendar availability check error: %s", exc)
return f"Calendar error checking availability for {date}: {exc}"
items = events_result.get("items", [])
if not items:
return f"No events on {date} — the entire day is free."
lines = [f"Busy slots on {date}:"]
for event in items:
time_str = _format_event_time(event)
summary = event.get("summary", "Untitled event")
lines.append(f"- {time_str}: {summary}")
return "\n".join(lines)
def _action_create(
service: Any,
calendar_id: str,
event_summary: str | None,
event_start: str | None,
event_end: str | None,
) -> str:
"""Create a new calendar event."""
if not event_summary or not event_start or not event_end:
missing = []
if not event_summary:
missing.append("event_summary")
if not event_start:
missing.append("event_start")
if not event_end:
missing.append("event_end")
return f"Cannot create event: missing required fields: {', '.join(missing)}."
event_body = {
"summary": event_summary,
"start": {"dateTime": event_start},
"end": {"dateTime": event_end},
}
try:
created = (
service.events()
.insert(calendarId=calendar_id, body=event_body)
.execute()
)
except Exception as exc:
logger.warning("Google Calendar create error: %s", exc)
return f"Failed to create calendar event: {exc}"
summary = created.get("summary", event_summary)
start = created.get("start", {}).get("dateTime", event_start)
end = created.get("end", {}).get("dateTime", event_end)
return f"Event created: {summary} from {start} to {end}."

View File

@@ -0,0 +1,310 @@
"""
Google Calendar OAuth API endpoints — per-tenant OAuth install + callback.
Endpoints:
GET /api/portal/calendar/install?tenant_id={id}
→ generates HMAC-signed state, returns Google OAuth URL
GET /api/portal/calendar/callback?code={code}&state={state}
→ verifies state, exchanges code for tokens, stores encrypted in channel_connections
GET /api/portal/calendar/{tenant_id}/status
→ returns {"connected": bool}
OAuth state uses the same HMAC-SHA256 signed state pattern as Slack OAuth
(see shared.api.channels.generate_oauth_state / verify_oauth_state).
Token storage:
Token JSON is encrypted with the platform KeyEncryptionService (Fernet) and
stored in channel_connections with channel_type='google_calendar'.
workspace_id is set to str(tenant_id) — Google Calendar is per-tenant,
not per-workspace, so the tenant UUID serves as the workspace identifier.
Token auto-refresh:
The calendar_lookup tool handles refresh via google-auth library.
This module is responsible for initial OAuth install and status checks only.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.channels import generate_oauth_state, verify_oauth_state
from shared.api.rbac import PortalCaller, require_tenant_admin, require_tenant_member
from shared.config import settings
from shared.crypto import KeyEncryptionService
from shared.db import get_session
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
calendar_auth_router = APIRouter(prefix="/api/portal/calendar", tags=["calendar"])
# Google Calendar OAuth scopes — full read+write (locked decision: operators need CRUD)
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
# Google OAuth endpoints
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
# ---------------------------------------------------------------------------
# Helper: build OAuth URL
# ---------------------------------------------------------------------------
def build_calendar_oauth_url(tenant_id: str, secret: str) -> str:
"""
Build a Google OAuth 2.0 authorization URL for Calendar access.
Args:
tenant_id: Tenant UUID as string — embedded in the HMAC-signed state.
secret: HMAC secret for state generation (oauth_state_secret).
Returns:
Full Google OAuth authorization URL ready to redirect the user to.
"""
state = generate_oauth_state(tenant_id=tenant_id, secret=secret)
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={_CALENDAR_SCOPE}"
f"&access_type=offline"
f"&prompt=consent"
f"&state={state}"
)
return f"{_GOOGLE_AUTH_URL}{params}"
def _get_encryption_service() -> KeyEncryptionService:
"""Return the platform-level KeyEncryptionService."""
if not settings.platform_encryption_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PLATFORM_ENCRYPTION_KEY not configured",
)
return KeyEncryptionService(
primary_key=settings.platform_encryption_key,
previous_key=settings.platform_encryption_key_previous,
)
# ---------------------------------------------------------------------------
# Endpoint: GET /install
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/install")
async def calendar_install(
tenant_id: uuid.UUID = Query(...),
caller: PortalCaller = Depends(require_tenant_admin),
) -> dict[str, str]:
"""
Generate the Google Calendar OAuth authorization URL.
Returns a JSON object with a 'url' key. The operator's browser should
be redirected to this URL to begin the Google OAuth consent flow.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
if not settings.google_client_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="GOOGLE_CLIENT_ID not configured",
)
url = build_calendar_oauth_url(
tenant_id=str(tenant_id),
secret=settings.oauth_state_secret,
)
return {"url": url}
# ---------------------------------------------------------------------------
# Callback handler (shared between endpoint and tests)
# ---------------------------------------------------------------------------
async def handle_calendar_callback(
code: str,
state: str,
session: AsyncSession,
) -> str:
"""
Process the Google OAuth callback: verify state, exchange code, store token.
Args:
code: Authorization code from Google.
state: HMAC-signed state parameter.
session: Async DB session for storing the ChannelConnection.
Returns:
Redirect URL string (portal /settings?calendar=connected).
Raises:
HTTPException 400 if state is invalid or token exchange fails.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
# Verify HMAC state to recover tenant_id
try:
tenant_id_str = verify_oauth_state(state=state, secret=settings.oauth_state_secret)
tenant_id = uuid.UUID(tenant_id_str)
except (ValueError, Exception) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid OAuth state: {exc}",
) from exc
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
_GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Google token exchange failed",
)
token_data: dict[str, Any] = response.json()
if "error" in token_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Google OAuth error: {token_data.get('error_description', token_data['error'])}",
)
# Build token JSON for storage (google-auth credentials format)
token_json = json.dumps({
"token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"token_uri": _GOOGLE_TOKEN_URL,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"scopes": [_CALENDAR_SCOPE],
})
# Encrypt before storage
enc_svc = _get_encryption_service()
encrypted_token = enc_svc.encrypt(token_json)
# Upsert ChannelConnection for google_calendar
existing = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = existing.scalar_one_or_none()
if conn is None:
conn = ChannelConnection(
tenant_id=tenant_id,
channel_type=ChannelTypeEnum.GOOGLE_CALENDAR,
workspace_id=str(tenant_id), # tenant UUID as workspace_id
config={"token": encrypted_token},
)
session.add(conn)
else:
conn.config = {"token": encrypted_token}
await session.commit()
return f"{settings.portal_url}/settings?calendar=connected"
# ---------------------------------------------------------------------------
# Endpoint: GET /callback
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/callback")
async def calendar_callback(
code: str = Query(...),
state: str = Query(...),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""
Handle the Google Calendar OAuth callback from Google.
No auth guard — this endpoint receives an external redirect from Google
(no session cookie available during OAuth flow).
Verifies HMAC state, exchanges code for tokens, stores encrypted token,
then redirects to portal /settings?calendar=connected.
"""
redirect_url = await handle_calendar_callback(code=code, state=state, session=session)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
# ---------------------------------------------------------------------------
# Status check helper (for tests)
# ---------------------------------------------------------------------------
async def get_calendar_status(
tenant_id: uuid.UUID,
session: AsyncSession,
) -> dict[str, bool]:
"""
Check if a Google Calendar connection exists for a tenant.
Args:
tenant_id: Tenant UUID to check.
session: Async DB session.
Returns:
{"connected": True} if a ChannelConnection exists, {"connected": False} otherwise.
"""
result = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = result.scalar_one_or_none()
return {"connected": conn is not None}
# ---------------------------------------------------------------------------
# Endpoint: GET /{tenant_id}/status
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/{tenant_id}/status")
async def calendar_status(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> dict[str, bool]:
"""
Check if Google Calendar is connected for a tenant.
Returns {"connected": true} if the tenant has authorized Google Calendar,
{"connected": false} otherwise.
"""
return await get_calendar_status(tenant_id=tenant_id, session=session)