From eda5b2d6d37ec72a9ba89c0255f68bf243e9f7dc Mon Sep 17 00:00:00 2001 From: anthonyrawlins Date: Thu, 10 Jul 2025 22:56:14 +1000 Subject: [PATCH] Unify database schema: Resolve all User model conflicts and auth table incompatibilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Consolidate 3 different User models into single unified model (models/user.py) - Use UUID primary keys throughout (matches existing database schema) - Add comprehensive authentication fields while preserving existing data - Remove duplicate User model from auth.py, keep APIKey/RefreshToken/TokenBlacklist - Update all imports to use unified User model consistently - Create database migration (002_add_auth_fields.sql) for safe schema upgrade - Fix frontend User interface to handle UUID string IDs - Add backward compatibility fields (name property, role field) - Maintain relationships for authentication features (api_keys, refresh_tokens) Schema conflicts resolved: ✅ Migration schema (UUID, 7 fields) + Basic model (Integer, 6 fields) + Auth model (Integer, 10 fields) → Unified model (UUID, 12 fields with full backward compatibility) ✅ Field inconsistencies (name vs full_name) resolved with compatibility property ✅ Database foreign key constraints updated for UUID relationships ✅ JWT token handling fixed for UUID user IDs This completes the holistic database schema unification requested after quick patching caused conflicts. All existing data preserved, full auth system functional. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/app/api/auth.py | 3 +- backend/app/core/auth_deps.py | 2 +- backend/app/core/init_db.py | 15 ++- backend/app/core/security.py | 6 +- backend/app/models/auth.py | 69 ++------------ backend/app/models/user.py | 87 +++++++++++++++-- backend/migrations/002_add_auth_fields.sql | 103 +++++++++++++++++++++ frontend/src/contexts/AuthContext.tsx | 3 +- 8 files changed, 203 insertions(+), 85 deletions(-) create mode 100644 backend/migrations/002_add_auth_fields.sql diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 57f36254..54b30389 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -18,7 +18,8 @@ from app.core.auth_deps import ( get_current_superuser, require_admin ) -from app.models.auth import User, APIKey, RefreshToken, TokenBlacklist, API_SCOPES, DEFAULT_API_SCOPES +from app.models.user import User +from app.models.auth import APIKey, RefreshToken, TokenBlacklist, API_SCOPES, DEFAULT_API_SCOPES router = APIRouter() diff --git a/backend/app/core/auth_deps.py b/backend/app/core/auth_deps.py index 20aed569..39d03bf5 100644 --- a/backend/app/core/auth_deps.py +++ b/backend/app/core/auth_deps.py @@ -61,7 +61,7 @@ def get_current_active_user( db: Session = Depends(get_db), ) -> Dict[str, Any]: """Get current authenticated and active user.""" - from app.models.auth import User + from app.models.user import User user = db.query(User).filter(User.id == user_context["user_id"]).first() if not user: diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py index a7a86d05..331c0d46 100644 --- a/backend/app/core/init_db.py +++ b/backend/app/core/init_db.py @@ -5,19 +5,18 @@ Creates all tables and sets up initial data. import logging from sqlalchemy.orm import Session -from app.core.database import engine, SessionLocal -from app.models.auth import Base as AuthBase, User, API_SCOPES -from app.models.auth import APIKey +from app.core.database import engine, SessionLocal, Base +from app.models.user import User +from app.models.auth import API_SCOPES, APIKey -# Import other model bases here as they're created -# from app.models.workflows import Base as WorkflowsBase -# from app.models.agents import Base as AgentsBase +# Import all models to ensure they're registered with Base +from app.models import user, auth, agent, project def create_tables(): """Create all database tables.""" try: - # Create auth tables - AuthBase.metadata.create_all(bind=engine) + # Create all tables using the unified Base + Base.metadata.create_all(bind=engine) # Add other model bases here # WorkflowsBase.metadata.create_all(bind=engine) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 8aff2154..5e261cc5 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -224,8 +224,8 @@ class AuthManager: raise AuthenticationError("Token has been revoked") # Get user information - user_id = int(payload.get("sub")) - from app.models.auth import User + user_id = payload.get("sub") # UUID as string + from app.models.user import User user = session.query(User).filter(User.id == user_id).first() if not user or not user.is_active: @@ -279,7 +279,7 @@ def create_token_response(user_id: int, user_data: Dict[str, Any]) -> Dict[str, def get_password_hash(password: str) -> str: """Hash a password for storage.""" - from app.models.auth import User + from app.models.user import User return User.hash_password(password) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index de988897..948c3bab 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -1,6 +1,7 @@ """ Authentication and authorization models for Hive platform. -Includes users, API keys, and JWT token management. +Includes API keys and JWT token management. +User model is now in models/user.py for consistency. """ from datetime import datetime, timedelta @@ -8,80 +9,22 @@ from typing import Optional, List import secrets import string from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship -from sqlalchemy.ext.declarative import declarative_base from passlib.context import CryptContext - -Base = declarative_base() +from ..core.database import Base # Password hashing context pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -class User(Base): - """User model for authentication and authorization.""" - - __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) - username = Column(String(50), unique=True, index=True, nullable=False) - email = Column(String(255), unique=True, index=True, nullable=False) - hashed_password = Column(String(255), nullable=False) - full_name = Column(String(255), nullable=True) - - # User status and permissions - is_active = Column(Boolean, default=True) - is_superuser = Column(Boolean, default=False) - is_verified = Column(Boolean, default=False) - - # Timestamps - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - last_login = Column(DateTime, nullable=True) - - # Relationships - api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan") - refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") - - def verify_password(self, password: str) -> bool: - """Verify a password against the hashed password.""" - return pwd_context.verify(password, self.hashed_password) - - @classmethod - def hash_password(cls, password: str) -> str: - """Hash a password for storage.""" - return pwd_context.hash(password) - - def set_password(self, password: str) -> None: - """Set a new password for the user.""" - self.hashed_password = self.hash_password(password) - - def update_last_login(self) -> None: - """Update the last login timestamp.""" - self.last_login = datetime.utcnow() - - def to_dict(self) -> dict: - """Convert user to dictionary (excluding sensitive data).""" - return { - "id": self.id, - "username": self.username, - "email": self.email, - "full_name": self.full_name, - "is_active": self.is_active, - "is_superuser": self.is_superuser, - "is_verified": self.is_verified, - "created_at": self.created_at.isoformat() if self.created_at else None, - "last_login": self.last_login.isoformat() if self.last_login else None, - } - - class APIKey(Base): """API Key model for programmatic access to Hive API.""" __tablename__ = "api_keys" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # API Key details name = Column(String(255), nullable=False) # Human-readable name @@ -178,7 +121,7 @@ class RefreshToken(Base): __tablename__ = "refresh_tokens" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # Token details token_hash = Column(String(255), unique=True, index=True, nullable=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 309a7160..72e50c1a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,14 +1,85 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean +""" +Unified User model for Hive platform. +Combines authentication and basic user functionality with UUID support. +""" + +from datetime import datetime +from typing import Optional, List +import uuid +from sqlalchemy import Column, String, DateTime, Boolean, Text, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from sqlalchemy.sql import func +from passlib.context import CryptContext from ..core.database import Base -class User(Base): - __tablename__ = "users" +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - id = Column(Integer, primary_key=True, index=True) - username = Column(String, unique=True, index=True) - email = Column(String, unique=True, index=True) - hashed_password = Column(String) +class User(Base): + """Unified user model with authentication and authorization support.""" + + __tablename__ = "users" + + # Use UUID to match existing database schema + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4) + username = Column(String(50), unique=True, index=True, nullable=True) # Made nullable for compatibility + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + + # Extended user information (for backward compatibility) + full_name = Column(String(255), nullable=True) + role = Column(String(50), nullable=True) # For backward compatibility with existing code + + # User status and permissions is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + is_verified = Column(Boolean, default=False) + + # Timestamps (match existing database schema) created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + last_login = Column(DateTime(timezone=True), nullable=True) + + # Relationships for authentication features + api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan") + refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") + + def verify_password(self, password: str) -> bool: + """Verify a password against the hashed password.""" + return pwd_context.verify(password, self.hashed_password) + + @classmethod + def hash_password(cls, password: str) -> str: + """Hash a password for storage.""" + return pwd_context.hash(password) + + def set_password(self, password: str) -> None: + """Set a new password for the user.""" + self.hashed_password = self.hash_password(password) + + def update_last_login(self) -> None: + """Update the last login timestamp.""" + self.last_login = datetime.utcnow() + + @property + def name(self) -> str: + """Backward compatibility property for 'name' field.""" + return self.full_name or self.username or self.email.split('@')[0] + + def to_dict(self) -> dict: + """Convert user to dictionary (excluding sensitive data).""" + return { + "id": str(self.id), # Convert UUID to string for JSON serialization + "username": self.username, + "email": self.email, + "full_name": self.full_name, + "name": self.name, # Backward compatibility + "role": self.role, + "is_active": self.is_active, + "is_superuser": self.is_superuser, + "is_verified": self.is_verified, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None, + } \ No newline at end of file diff --git a/backend/migrations/002_add_auth_fields.sql b/backend/migrations/002_add_auth_fields.sql new file mode 100644 index 00000000..117ca37e --- /dev/null +++ b/backend/migrations/002_add_auth_fields.sql @@ -0,0 +1,103 @@ +-- Migration: Add authentication fields to existing users table +-- This migration adds the necessary fields for the unified User model +-- while preserving all existing data + +BEGIN; + +-- Add new columns to users table for authentication features +ALTER TABLE users +ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), +ADD COLUMN IF NOT EXISTS is_superuser BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS is_verified BOOLEAN DEFAULT FALSE; + +-- Add username column if it doesn't exist (make it nullable for existing users) +ALTER TABLE users +ADD COLUMN IF NOT EXISTS username VARCHAR(50); + +-- Create unique index on username (partial index to handle NULLs) +CREATE UNIQUE INDEX IF NOT EXISTS users_username_unique_idx +ON users (username) WHERE username IS NOT NULL; + +-- Update existing users to have a default username if none exists +-- This uses email prefix as username for existing users +UPDATE users +SET username = SPLIT_PART(email, '@', 1) +WHERE username IS NULL; + +-- Add role column for backward compatibility +ALTER TABLE users +ADD COLUMN IF NOT EXISTS role VARCHAR(50) DEFAULT 'user'; + +-- Create the authentication-related tables if they don't exist + +-- API Keys table +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) UNIQUE NOT NULL, + key_prefix VARCHAR(10) NOT NULL, + scopes TEXT, + is_active BOOLEAN DEFAULT TRUE, + last_used TIMESTAMP, + usage_count INTEGER DEFAULT 0, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS api_keys_key_hash_idx ON api_keys(key_hash); + +-- Refresh Tokens table +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + jti VARCHAR(36) UNIQUE NOT NULL, + device_info VARCHAR(512), + is_active BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS refresh_tokens_user_id_idx ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS refresh_tokens_token_hash_idx ON refresh_tokens(token_hash); +CREATE INDEX IF NOT EXISTS refresh_tokens_jti_idx ON refresh_tokens(jti); + +-- Token Blacklist table +CREATE TABLE IF NOT EXISTS token_blacklist ( + id SERIAL PRIMARY KEY, + jti VARCHAR(36) UNIQUE NOT NULL, + token_type VARCHAR(20) NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS token_blacklist_jti_idx ON token_blacklist(jti); +CREATE INDEX IF NOT EXISTS token_blacklist_expires_at_idx ON token_blacklist(expires_at); + +-- Create function to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers for updated_at columns +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_api_keys_updated_at ON api_keys; +CREATE TRIGGER update_api_keys_updated_at + BEFORE UPDATE ON api_keys + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +COMMIT; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d25e89f5..110f3c9e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; interface User { - id: number; + id: string; // UUID as string username: string; email: string; full_name?: string; @@ -16,6 +16,7 @@ interface User { is_superuser: boolean; is_verified: boolean; created_at: string; + updated_at?: string; last_login?: string; }