Files
Joel Salmon b8f176bb31 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>
2026-05-27 10:59:41 -05:00

105 lines
3.4 KiB
Python

"""add users, refresh_tokens, activity_log; FK existing tables to users
Revision ID: 002
Revises: 001
Create Date: 2026-05-27
The Phase 1-3 schema from the OAuth plan, adapted to this repo's existing
tables. SQLite doesn't enforce FK constraints by default but the columns
and indexes are still useful for query planning.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("email", sa.String(), nullable=False, unique=True),
sa.Column("display_name", sa.String(), nullable=False),
sa.Column("avatar_url", sa.String(), nullable=True),
sa.Column("google_id", sa.String(), nullable=True, unique=True),
sa.Column(
"role",
sa.String(),
nullable=False,
server_default=sa.text("'user'"),
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"user_id",
sa.String(),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
sa.Column("device", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
)
op.create_index(
"ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"]
)
op.create_table(
"activity_log",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"user_id",
sa.String(),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("action", sa.String(), nullable=False),
sa.Column("resource", sa.String(), nullable=False),
sa.Column("resource_id", sa.String(), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.Column(
"source",
sa.String(),
nullable=False,
server_default=sa.text("'web'"),
),
sa.Column("ip_address", sa.String(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_activity_log_user_id", "activity_log", ["user_id"]
)
op.create_index(
"ix_activity_log_created_at", "activity_log", ["created_at"]
)
op.create_index(
"ix_activity_log_resource",
"activity_log",
["resource", "resource_id"],
)
def downgrade() -> None:
op.drop_index("ix_activity_log_resource", table_name="activity_log")
op.drop_index("ix_activity_log_created_at", table_name="activity_log")
op.drop_index("ix_activity_log_user_id", table_name="activity_log")
op.drop_table("activity_log")
op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_table("users")