Initial commit: ImpactFlow Discovery + Google OAuth auth layer

Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-05-27 10:59:41 -05:00
commit b8f176bb31
45 changed files with 4679 additions and 0 deletions
View File
+135
View File
@@ -0,0 +1,135 @@
"""Activity feed + admin endpoints."""
from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user, require_admin
from app.database import get_db
from app.models import ActivityLog, User
router = APIRouter(prefix="/api", tags=["activity"])
class ActivityOut(BaseModel):
id: str
user_id: str
action: str
resource: str
resource_id: str | None
source: str
created_at: datetime
ip_address: str | None
user_agent: str | None
class ActivitySummary(BaseModel):
days: int
total: int
actions_per_day: dict[str, int]
top_resources: list[tuple[str, int]]
web_count: int
mcp_count: int
def _to_out(row: ActivityLog) -> ActivityOut:
return ActivityOut(
id=row.id,
user_id=row.user_id,
action=row.action,
resource=row.resource,
resource_id=row.resource_id,
source=row.source,
created_at=row.created_at,
ip_address=row.ip_address,
user_agent=row.user_agent,
)
@router.get("/activity", response_model=list[ActivityOut])
async def list_my_activity(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=200),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.order_by(ActivityLog.created_at.desc())
.offset((page - 1) * limit)
.limit(limit)
)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
@router.get("/activity/summary", response_model=ActivitySummary)
async def my_activity_summary(
days: int = Query(30, ge=1, le=365),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
since = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.where(ActivityLog.created_at >= since)
)
rows = (await db.execute(stmt)).scalars().all()
per_day: Counter[str] = Counter()
resource_counts: Counter[str] = Counter()
web = mcp = 0
for r in rows:
per_day[r.created_at.date().isoformat()] += 1
resource_counts[r.resource] += 1
if r.source == "mcp":
mcp += 1
else:
web += 1
return ActivitySummary(
days=days,
total=len(rows),
actions_per_day=dict(per_day),
top_resources=resource_counts.most_common(5),
web_count=web,
mcp_count=mcp,
)
@router.get("/admin/activity", response_model=list[ActivityOut])
async def admin_list_activity(
user_id: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=500),
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(ActivityLog).order_by(ActivityLog.created_at.desc())
if user_id:
stmt = stmt.where(ActivityLog.user_id == user_id)
stmt = stmt.offset((page - 1) * limit).limit(limit)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
async def prune_old_activity(db: AsyncSession, days: int = 90) -> int:
"""Delete rows older than N days. Returns rows deleted."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
stmt = select(func.count()).select_from(ActivityLog).where(
ActivityLog.created_at < cutoff
)
count = (await db.execute(stmt)).scalar_one()
if count:
from sqlalchemy import delete
await db.execute(delete(ActivityLog).where(
ActivityLog.created_at < cutoff
))
await db.commit()
return int(count or 0)
+288
View File
@@ -0,0 +1,288 @@
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
import os
from datetime import datetime, timedelta, timezone
from authlib.integrations.base_client import OAuthError
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
create_access_token,
email_domain_allowed,
find_or_create_google_user,
get_current_user,
hash_refresh_token,
issue_refresh_token,
oauth,
)
from app.database import get_db
from app.models import (
ActivityLog,
DiscoveryConversation,
DiscoveryProfile,
RefreshToken,
User,
)
from sqlalchemy import func
router = APIRouter(prefix="/api", tags=["auth"])
# -- response shapes ----------------------------------------------------------
class UserOut(BaseModel):
id: str
email: str
display_name: str
avatar_url: str | None = None
role: str
@classmethod
def from_orm_user(cls, user: User) -> "UserOut":
return cls(
id=user.id,
email=user.email,
display_name=user.display_name,
avatar_url=user.avatar_url,
role=user.role,
)
class TokenBundle(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshIn(BaseModel):
refresh_token: str
class AccessOut(BaseModel):
access_token: str
token_type: str = "bearer"
class DisplayNamePatch(BaseModel):
display_name: str
class SessionOut(BaseModel):
id: str
device: str | None
created_at: datetime
expires_at: datetime
# -- OAuth flow ---------------------------------------------------------------
@router.get("/auth/login")
async def login(request: Request):
"""Kick off the OAuth dance: 302 to Google's consent screen."""
redirect_uri = os.getenv("OAUTH_REDIRECT_URI") or str(
request.url_for("auth_callback")
)
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""Google redirects here with `code`; exchange it, mint our tokens."""
try:
token = await oauth.google.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"OAuth error: {exc.error or exc}",
) from exc
user_info = token.get("userinfo")
if not user_info or not user_info.get("email") or not user_info.get("sub"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google did not return email/sub",
)
if user_info.get("email_verified") is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified by Google",
)
if not email_domain_allowed(user_info["email"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email domain not allowed",
)
user = await find_or_create_google_user(db, user_info)
access = create_access_token(user.id, user.email)
refresh = await issue_refresh_token(
db, user, request.headers.get("user-agent")
)
return TokenBundle(
access_token=access,
refresh_token=refresh,
user=UserOut.from_orm_user(user),
)
@router.post("/auth/refresh", response_model=AccessOut)
async def refresh_access_token(
body: RefreshIn, db: AsyncSession = Depends(get_db)
):
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
)
row = (await db.execute(stmt)).scalar_one_or_none()
now = datetime.now(timezone.utc)
if row is None or row.revoked_at is not None or _expired(row.expires_at, now):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token invalid or expired",
)
user = await db.get(User, row.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
access = create_access_token(user.id, user.email)
return AccessOut(access_token=access)
def _expired(expires_at: datetime, now: datetime) -> bool:
# SQLite stores naive datetimes; compare in UTC.
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at < now
@router.post("/auth/logout")
async def logout(
body: RefreshIn, db: AsyncSession = Depends(get_db)
):
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
)
row = (await db.execute(stmt)).scalar_one_or_none()
if row is not None and row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "ok"}
# -- profile / sessions -------------------------------------------------------
@router.get("/auth/me", response_model=UserOut)
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
return UserOut.from_orm_user(user)
@router.patch("/me", response_model=UserOut)
async def patch_me(
body: DisplayNamePatch,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
new_name = body.display_name.strip()
if not new_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="display_name cannot be empty",
)
user.display_name = new_name
await db.commit()
await db.refresh(user)
return UserOut.from_orm_user(user)
class MeStats(BaseModel):
conversations: int
profiles: int
locked_profiles: int
activity_last_30d: int
last_login_at: datetime | None
@router.get("/me/stats", response_model=MeStats)
async def my_stats(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
convo_count = (await db.execute(
select(func.count())
.select_from(DiscoveryConversation)
.where(DiscoveryConversation.user_id == user.id)
)).scalar_one()
profile_count = (await db.execute(
select(func.count())
.select_from(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user.id)
)).scalar_one()
locked_count = (await db.execute(
select(func.count())
.select_from(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user.id)
.where(DiscoveryProfile.locked.is_(True))
)).scalar_one()
since = datetime.now(timezone.utc) - timedelta(days=30)
activity_count = (await db.execute(
select(func.count())
.select_from(ActivityLog)
.where(ActivityLog.user_id == user.id)
.where(ActivityLog.created_at >= since)
)).scalar_one()
return MeStats(
conversations=int(convo_count or 0),
profiles=int(profile_count or 0),
locked_profiles=int(locked_count or 0),
activity_last_30d=int(activity_count or 0),
last_login_at=user.last_login_at,
)
@router.get("/me/sessions", response_model=list[SessionOut])
async def list_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(RefreshToken)
.where(RefreshToken.user_id == user.id)
.where(RefreshToken.revoked_at.is_(None))
.order_by(RefreshToken.created_at.desc())
)
rows = (await db.execute(stmt)).scalars().all()
return [
SessionOut(
id=r.id,
device=r.device,
created_at=r.created_at,
expires_at=r.expires_at,
)
for r in rows
]
@router.delete("/me/sessions/{session_id}")
async def revoke_session(
session_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
row = await db.get(RefreshToken, session_id)
if row is None or row.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
if row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "revoked"}
+230
View File
@@ -0,0 +1,230 @@
"""Discovery API routes: start a conversation, save responses, generate and
confirm a profile.
All routes are user-scoped via the dual-auth dependency. The MCP server uses
the X-API-Key header and operates under the synthetic admin user; the
browser app uses a Google-issued JWT.
"""
import json
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.auth import get_current_user
from app.database import get_db
from app.models import DiscoveryConversation, DiscoveryProfile, User
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
router = APIRouter(prefix="/discovery", tags=["discovery"])
def _now() -> datetime:
return datetime.now(timezone.utc)
def _to_profile_response(
profile: DiscoveryProfile, extraction_notes: str | None = None
) -> schemas.ProfileResponse:
"""Build a ProfileResponse from a stored profile row."""
confidence = None
if profile.confidence_json:
try:
confidence = schemas.Confidence(**json.loads(profile.confidence_json))
except (json.JSONDecodeError, TypeError, ValueError):
confidence = None
return schemas.ProfileResponse(
id=profile.id,
user_id=profile.user_id,
conversation_id=profile.conversation_id,
generated_at=profile.generated_at,
triad=profile.triad,
probable_type=profile.probable_type,
wing=profile.wing,
instinctual_variant=profile.instinctual_variant,
instinctual_stack=profile.instinctual_stack,
love_summary=profile.love_summary,
strength_summary=profile.strength_summary,
mission_summary=profile.mission_summary,
vocation_summary=profile.vocation_summary,
overlap_narrative=profile.overlap_narrative,
confidence=confidence,
locked=profile.locked,
extraction_notes=extraction_notes,
)
async def _latest_profile(
db: AsyncSession, user_id: str
) -> DiscoveryProfile | None:
stmt = (
select(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user_id)
.order_by(DiscoveryProfile.generated_at.desc())
)
result = await db.execute(stmt)
return result.scalars().first()
async def _owned_conversation(
db: AsyncSession, conversation_id: str, user: User
) -> DiscoveryConversation:
"""Fetch a conversation and assert the caller owns it (admins bypass).
Returns 404 for both 'not found' and 'not yours' so the existence of
other users' conversations isn't leaked."""
conversation = await db.get(DiscoveryConversation, conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != user.id and user.role != "admin":
raise HTTPException(status_code=404, detail="Conversation not found")
return conversation
@router.post("/start", response_model=schemas.StartResponse)
async def start_conversation(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = DiscoveryConversation(
id=str(uuid.uuid4()),
user_id=user.id,
started_at=_now(),
)
db.add(conversation)
await db.commit()
return schemas.StartResponse(conversation_id=conversation.id)
@router.put(
"/{conversation_id}/respond", response_model=schemas.RespondResponse
)
async def save_responses(
conversation_id: str,
payload: schemas.RespondRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
conversation.prompt_alive = payload.prompt_alive
conversation.prompt_friction = payload.prompt_friction
conversation.prompt_pull = payload.prompt_pull
conversation.prompt_recognition = payload.prompt_recognition
conversation.prompt_future = payload.prompt_future
await db.commit()
return schemas.RespondResponse(
conversation_id=conversation_id, status="responses_saved"
)
@router.post(
"/{conversation_id}/complete", response_model=schemas.ProfileResponse
)
async def complete_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
responses = {
"alive": conversation.prompt_alive or "",
"friction": conversation.prompt_friction or "",
"pull": conversation.prompt_pull or "",
"recognition": conversation.prompt_recognition or "",
"future": conversation.prompt_future or "",
}
if not any(text.strip() for text in responses.values()):
raise HTTPException(
status_code=400, detail="No responses available to analyze"
)
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
try:
extractor = DiscoveryExtractor(api_key=api_key, model=model)
data = await extractor.extract(responses)
except DiscoveryExtractionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
profile = DiscoveryProfile(
id=str(uuid.uuid4()),
user_id=conversation.user_id,
conversation_id=conversation.id,
generated_at=_now(),
triad=data.get("triad"),
probable_type=_as_int(data.get("probable_type")),
wing=_as_int(data.get("wing")),
instinctual_variant=data.get("instinctual_variant"),
instinctual_stack=data.get("instinctual_stack"),
love_summary=data.get("love_summary"),
strength_summary=data.get("strength_summary"),
mission_summary=data.get("mission_summary"),
vocation_summary=data.get("vocation_summary"),
overlap_narrative=data.get("overlap_narrative"),
confidence_json=json.dumps(data.get("confidence", {})),
locked=False,
)
conversation.completed_at = _now()
db.add(profile)
await db.commit()
return _to_profile_response(
profile, extraction_notes=data.get("extraction_notes")
)
@router.get("/profile/me", response_model=schemas.ProfileResponse)
async def get_my_profile(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
return _to_profile_response(profile)
@router.put(
"/profile/me/confirm", response_model=schemas.ConfirmResponse
)
async def confirm_my_profile(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
profile.locked = True
await db.commit()
return schemas.ConfirmResponse(status="locked")
@router.get(
"/conversation/{conversation_id}",
response_model=schemas.ConversationResponse,
)
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
conversation = await _owned_conversation(db, conversation_id, user)
return schemas.ConversationResponse.model_validate(conversation)
def _as_int(value) -> int | None:
"""Coerce the model's numeric fields to int, tolerating strings/None."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None