From 1766f2a36d75d3acc3243390ecc838ea831fdf0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:33:20 +0000 Subject: [PATCH 1/4] Initial plan From 85e63cafbfda4c2e118596f930d0a6a37e2bb73c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:46:39 +0000 Subject: [PATCH 2/4] Implement user role management with RBAC - models, schemas, CRUD, API endpoints, JWT auth, and tests Co-authored-by: Neiland85 <164719485+Neiland85@users.noreply.github.com> --- app/auth/dependencies.py | 143 ++++++++++++++++++++++- app/crud/__init__.py | 7 ++ app/crud/role.py | 67 +++++++++++ app/crud/user.py | 96 +++++++++++++++ app/database.py | 61 ++++++++++ app/main.py | 11 +- app/models/__init__.py | 7 ++ app/models/role.py | 54 +++++++++ app/models/user.py | 71 ++++++++++++ app/routers/roles.py | 207 +++++++++++++++++++++++++++++++++ app/schemas/__init__.py | 14 +++ app/schemas/role.py | 65 +++++++++++ app/schemas/user.py | 101 ++++++++++++++++ app/tests/test_auth.py | 228 ++++++++++++++++++++++++++++++++++++ app/tests/test_roles.py | 244 +++++++++++++++++++++++++++++++++++++++ neurobank.db | 0 requirements.txt | 6 + test.db | Bin 0 -> 40960 bytes 18 files changed, 1380 insertions(+), 2 deletions(-) create mode 100644 app/crud/__init__.py create mode 100644 app/crud/role.py create mode 100644 app/crud/user.py create mode 100644 app/database.py create mode 100644 app/models/__init__.py create mode 100644 app/models/role.py create mode 100644 app/models/user.py create mode 100644 app/routers/roles.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/role.py create mode 100644 app/schemas/user.py create mode 100644 app/tests/test_auth.py create mode 100644 app/tests/test_roles.py create mode 100644 neurobank.db create mode 100644 test.db diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 6633d11..d21d88d 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -1,14 +1,24 @@ import os +from datetime import datetime, timedelta from typing import Optional -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session from ..config import get_settings +from ..database import get_db +from ..models.user import User # Configuración del esquema de seguridad security = HTTPBearer(auto_error=False) +# JWT Configuration +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + def get_api_key() -> str: """Obtiene la API key desde la configuración centralizada""" @@ -67,3 +77,134 @@ def verify_api_key( ) return provided_api_key + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token + + Args: + data: Data to encode in the token (should include user_id and role) + expires_delta: Optional expiration time delta + + Returns: + Encoded JWT token + """ + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: Session = Depends(get_db), +) -> User: + """ + Get the current authenticated user from JWT token + + Extracts and validates JWT token from Authorization header, + then retrieves the user from the database with their role information. + + Args: + credentials: HTTP Bearer credentials containing JWT token + db: Database session + + Returns: + User object with role information + + Raises: + HTTPException: If token is invalid or user not found + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not credentials: + raise credentials_exception + + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise credentials_exception + + if user.is_active != "true": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + + return user + + +def require_role(*allowed_roles: str): + """ + Dependency factory for role-based access control + + Creates a dependency that checks if the current user has one of the allowed roles. + + Args: + allowed_roles: Variable number of role names that are allowed + + Returns: + Dependency function that validates user role + + Example: + @router.get("/admin", dependencies=[Depends(require_role("admin"))]) + async def admin_endpoint(): + return {"message": "Admin access granted"} + """ + def role_checker(current_user: User = Depends(get_current_user)) -> User: + if current_user.role.name not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied. Required role: {', '.join(allowed_roles)}" + ) + return current_user + + return role_checker + + +# Convenient role-specific dependencies +def admin_only(current_user: User = Depends(get_current_user)) -> User: + """Dependency that requires admin role""" + if current_user.role.name != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + return current_user + + +def customer_only(current_user: User = Depends(get_current_user)) -> User: + """Dependency that requires customer role""" + if current_user.role.name != "customer": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Customer access required" + ) + return current_user + + +def auditor_only(current_user: User = Depends(get_current_user)) -> User: + """Dependency that requires auditor role""" + if current_user.role.name != "auditor": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Auditor access required" + ) + return current_user diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..c616a3b --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,7 @@ +""" +CRUD package for NeuroBank FastAPI Toolkit +""" +from app.crud.role import role_crud +from app.crud.user import user_crud + +__all__ = ["role_crud", "user_crud"] diff --git a/app/crud/role.py b/app/crud/role.py new file mode 100644 index 0000000..512577b --- /dev/null +++ b/app/crud/role.py @@ -0,0 +1,67 @@ +""" +CRUD operations for UserRole +""" +from typing import List, Optional +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.models.role import UserRole +from app.schemas.role import UserRoleCreate, UserRoleUpdate + + +class RoleCRUD: + """CRUD operations for UserRole""" + + def get(self, db: Session, role_id: UUID) -> Optional[UserRole]: + """Get a role by ID""" + return db.query(UserRole).filter(UserRole.id == role_id).first() + + def get_by_name(self, db: Session, name: str) -> Optional[UserRole]: + """Get a role by name""" + return db.query(UserRole).filter(UserRole.name == name).first() + + def get_all(self, db: Session, skip: int = 0, limit: int = 100) -> List[UserRole]: + """Get all roles with pagination""" + return db.query(UserRole).offset(skip).limit(limit).all() + + def create(self, db: Session, role_in: UserRoleCreate) -> UserRole: + """Create a new role""" + db_role = UserRole( + name=role_in.name, + description=role_in.description, + ) + db.add(db_role) + db.commit() + db.refresh(db_role) + return db_role + + def update( + self, db: Session, role_id: UUID, role_in: UserRoleUpdate + ) -> Optional[UserRole]: + """Update a role""" + db_role = self.get(db, role_id) + if not db_role: + return None + + update_data = role_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_role, field, value) + + db.commit() + db.refresh(db_role) + return db_role + + def delete(self, db: Session, role_id: UUID) -> Optional[UserRole]: + """Delete a role""" + db_role = self.get(db, role_id) + if not db_role: + return None + + db.delete(db_role) + db.commit() + return db_role + + +# Create a singleton instance +role_crud = RoleCRUD() diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..e5ff580 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,96 @@ +""" +CRUD operations for User +""" +from typing import List, Optional +from uuid import UUID + +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class UserCRUD: + """CRUD operations for User""" + + def get_password_hash(self, password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + + def verify_password(self, plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return pwd_context.verify(plain_password, hashed_password) + + def get(self, db: Session, user_id: UUID) -> Optional[User]: + """Get a user by ID""" + return db.query(User).filter(User.id == user_id).first() + + def get_by_username(self, db: Session, username: str) -> Optional[User]: + """Get a user by username""" + return db.query(User).filter(User.username == username).first() + + def get_by_email(self, db: Session, email: str) -> Optional[User]: + """Get a user by email""" + return db.query(User).filter(User.email == email).first() + + def get_all(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]: + """Get all users with pagination""" + return db.query(User).offset(skip).limit(limit).all() + + def create(self, db: Session, user_in: UserCreate) -> User: + """Create a new user""" + db_user = User( + username=user_in.username, + email=user_in.email, + hashed_password=self.get_password_hash(user_in.password), + full_name=user_in.full_name, + role_id=user_in.role_id, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + def update( + self, db: Session, user_id: UUID, user_in: UserUpdate + ) -> Optional[User]: + """Update a user""" + db_user = self.get(db, user_id) + if not db_user: + return None + + update_data = user_in.model_dump(exclude_unset=True) + + # Hash password if it's being updated + if "password" in update_data: + update_data["hashed_password"] = self.get_password_hash(update_data["password"]) + del update_data["password"] + + # Convert is_active boolean to string for database + if "is_active" in update_data: + update_data["is_active"] = "true" if update_data["is_active"] else "false" + + for field, value in update_data.items(): + setattr(db_user, field, value) + + db.commit() + db.refresh(db_user) + return db_user + + def delete(self, db: Session, user_id: UUID) -> Optional[User]: + """Delete a user""" + db_user = self.get(db, user_id) + if not db_user: + return None + + db.delete(db_user) + db.commit() + return db_user + + +# Create a singleton instance +user_crud = UserCRUD() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..42bb2fb --- /dev/null +++ b/app/database.py @@ -0,0 +1,61 @@ +""" +Database configuration and session management for NeuroBank FastAPI Toolkit +""" +import os +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, declarative_base, sessionmaker + +# Database URL from environment variable +# Default to SQLite for development/testing +DATABASE_URL = os.getenv( + "DATABASE_URL", "sqlite:///./neurobank.db" +) + +# Create SQLAlchemy engine +# For SQLite, we need to enable check_same_thread=False for FastAPI compatibility +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + pool_pre_ping=True, # Enable connection health checks +) + +# Create SessionLocal class +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create Base class for declarative models +Base = declarative_base() + + +def get_db() -> Generator[Session, None, None]: + """ + Database session dependency for FastAPI + + Yields a database session and ensures it's properly closed after use. + + Usage: + @app.get("/items") + def read_items(db: Session = Depends(get_db)): + return db.query(Item).all() + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + """ + Initialize database tables + + Creates all tables defined in models. + Should be called on application startup. + """ + # Import all models here to ensure they are registered with Base + from app.models import user, role # noqa: F401 + + Base.metadata.create_all(bind=engine) diff --git a/app/main.py b/app/main.py index 3f00b62..f7982de 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,8 @@ from fastapi.responses import JSONResponse from .backoffice import router as backoffice_router -from .routers import operator +from .database import init_db +from .routers import operator, roles from .utils.logging import setup_logging # Configuración constantes @@ -89,6 +90,13 @@ settings = get_settings() +# Initialize database tables on startup +@app.on_event("startup") +async def startup_event(): + """Initialize database on startup""" + init_db() + logger.info("Database initialized successfully") + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, @@ -99,6 +107,7 @@ # Incluir routers app.include_router(operator.router, prefix="/api", tags=["api"]) +app.include_router(roles.router, prefix="/api", tags=["roles"]) app.include_router(backoffice_router.router, tags=["backoffice"]) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..2c4acf1 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +""" +Models package for NeuroBank FastAPI Toolkit +""" +from app.models.role import UserRole +from app.models.user import User + +__all__ = ["UserRole", "User"] diff --git a/app/models/role.py b/app/models/role.py new file mode 100644 index 0000000..e1aa270 --- /dev/null +++ b/app/models/role.py @@ -0,0 +1,54 @@ +""" +UserRole model for role-based access control +""" +import uuid +from datetime import datetime + +from sqlalchemy import Column, DateTime, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.database import Base + + +class UserRole(Base): + """ + User role model for managing user permissions + + Attributes: + id: Unique identifier for the role (UUID) + name: Unique name of the role (e.g., 'admin', 'customer', 'auditor') + description: Human-readable description of the role + created_at: Timestamp when the role was created + updated_at: Timestamp when the role was last updated + """ + __tablename__ = "user_roles" + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + index=True, + ) + name = Column( + String(50), + unique=True, + nullable=False, + index=True, + ) + description = Column(String(255), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + # Relationship to users + users = relationship("User", back_populates="role") + + def __repr__(self) -> str: + return f"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..3cfc2e8 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,71 @@ +""" +User model with role-based access control +""" +import uuid +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.database import Base + + +class User(Base): + """ + User model with role-based permissions + + Attributes: + id: Unique identifier for the user (UUID) + username: Unique username + email: Unique email address + hashed_password: Hashed password for authentication + full_name: Full name of the user + role_id: Foreign key to UserRole + is_active: Whether the user account is active + created_at: Timestamp when the user was created + updated_at: Timestamp when the user was last updated + """ + __tablename__ = "users" + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + unique=True, + nullable=False, + index=True, + ) + username = Column( + String(50), + unique=True, + nullable=False, + index=True, + ) + email = Column( + String(100), + unique=True, + nullable=False, + index=True, + ) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(100), nullable=True) + role_id = Column( + UUID(as_uuid=True), + ForeignKey("user_roles.id"), + nullable=False, + ) + is_active = Column(String(10), default="true", nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + # Relationship to role + role = relationship("UserRole", back_populates="users") + + def __repr__(self) -> str: + return f"" diff --git a/app/routers/roles.py b/app/routers/roles.py new file mode 100644 index 0000000..5178763 --- /dev/null +++ b/app/routers/roles.py @@ -0,0 +1,207 @@ +""" +API routes for UserRole management +""" +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.auth.dependencies import verify_api_key +from app.crud.role import role_crud +from app.database import get_db +from app.schemas.role import UserRoleCreate, UserRoleResponse, UserRoleUpdate + +router = APIRouter( + prefix="/roles", + tags=["👥 User Role Management"], + dependencies=[Depends(verify_api_key)], + responses={ + 401: {"description": "API Key missing or invalid"}, + 404: {"description": "Role not found"}, + 500: {"description": "Internal server error"}, + }, +) + + +@router.get( + "", + response_model=List[UserRoleResponse], + summary="📋 List All Roles", + description=""" + **Retrieve all user roles in the system** + + Returns a list of all available user roles with pagination support. + + ### 🔍 Use Cases: + - View all available roles for user assignment + - Role management and administration + - System configuration verification + + ### 🔐 Authentication: + Requires valid API Key in the header. + """, +) +async def list_roles( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + """ + **List all user roles** + + Retrieves all roles with optional pagination parameters. + """ + roles = role_crud.get_all(db, skip=skip, limit=limit) + return roles + + +@router.post( + "", + response_model=UserRoleResponse, + status_code=status.HTTP_201_CREATED, + summary="➕ Create New Role", + description=""" + **Create a new user role** + + Creates a new role in the system with a unique name and description. + + ### 📋 Requirements: + - Role name must be unique + - Name must be 1-50 characters + - Description is optional (max 255 characters) + + ### 🔐 Authentication: + Requires valid API Key in the header. + """, +) +async def create_role( + role_in: UserRoleCreate, + db: Session = Depends(get_db), +): + """ + **Create a new role** + + Creates a role with the provided name and description. + """ + # Check if role with this name already exists + existing_role = role_crud.get_by_name(db, name=role_in.name) + if existing_role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with name '{role_in.name}' already exists" + ) + + role = role_crud.create(db, role_in=role_in) + return role + + +@router.get( + "/{role_id}", + response_model=UserRoleResponse, + summary="🔍 Get Role by ID", + description=""" + **Retrieve a specific user role** + + Returns detailed information about a role identified by its UUID. + + ### 🔐 Authentication: + Requires valid API Key in the header. + """, +) +async def get_role( + role_id: UUID, + db: Session = Depends(get_db), +): + """ + **Get role by ID** + + Retrieves a role by its unique identifier. + """ + role = role_crud.get(db, role_id=role_id) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role with id '{role_id}' not found" + ) + return role + + +@router.put( + "/{role_id}", + response_model=UserRoleResponse, + summary="✏️ Update Role", + description=""" + **Update an existing user role** + + Updates role information such as name or description. + + ### 📋 Notes: + - Only provided fields will be updated + - Role name must remain unique if changed + + ### 🔐 Authentication: + Requires valid API Key in the header. + """, +) +async def update_role( + role_id: UUID, + role_in: UserRoleUpdate, + db: Session = Depends(get_db), +): + """ + **Update role** + + Updates the specified role with new information. + """ + # If updating name, check if new name is already taken + if role_in.name: + existing_role = role_crud.get_by_name(db, name=role_in.name) + if existing_role and existing_role.id != role_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with name '{role_in.name}' already exists" + ) + + role = role_crud.update(db, role_id=role_id, role_in=role_in) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role with id '{role_id}' not found" + ) + return role + + +@router.delete( + "/{role_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="🗑️ Delete Role", + description=""" + **Delete a user role** + + Permanently removes a role from the system. + + ### ⚠️ Warning: + - This operation cannot be undone + - Users with this role should be reassigned first + + ### 🔐 Authentication: + Requires valid API Key in the header. + """, +) +async def delete_role( + role_id: UUID, + db: Session = Depends(get_db), +): + """ + **Delete role** + + Removes the specified role from the system. + """ + role = role_crud.delete(db, role_id=role_id) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role with id '{role_id}' not found" + ) + return None diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..72f6117 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,14 @@ +""" +Schemas package for NeuroBank FastAPI Toolkit +""" +from app.schemas.role import UserRoleCreate, UserRoleUpdate, UserRoleResponse +from app.schemas.user import UserCreate, UserUpdate, UserResponse + +__all__ = [ + "UserRoleCreate", + "UserRoleUpdate", + "UserRoleResponse", + "UserCreate", + "UserUpdate", + "UserResponse", +] diff --git a/app/schemas/role.py b/app/schemas/role.py new file mode 100644 index 0000000..ad61400 --- /dev/null +++ b/app/schemas/role.py @@ -0,0 +1,65 @@ +""" +Pydantic schemas for UserRole +""" +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class UserRoleBase(BaseModel): + """Base schema for UserRole""" + name: str = Field( + ..., + min_length=1, + max_length=50, + description="Unique name of the role", + examples=["admin", "customer", "auditor"] + ) + description: Optional[str] = Field( + None, + max_length=255, + description="Description of the role", + examples=["Administrator with full access"] + ) + + +class UserRoleCreate(UserRoleBase): + """Schema for creating a new UserRole""" + pass + + +class UserRoleUpdate(BaseModel): + """Schema for updating a UserRole""" + name: Optional[str] = Field( + None, + min_length=1, + max_length=50, + description="Unique name of the role" + ) + description: Optional[str] = Field( + None, + max_length=255, + description="Description of the role" + ) + + +class UserRoleResponse(UserRoleBase): + """Schema for UserRole response""" + id: UUID = Field(..., description="Unique identifier of the role") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + model_config = { + "from_attributes": True, + "json_schema_extra": { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "admin", + "description": "Administrator with full access", + "created_at": "2025-07-20T15:30:45.123456Z", + "updated_at": "2025-07-20T15:30:45.123456Z" + } + } + } diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..390b323 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,101 @@ +""" +Pydantic schemas for User +""" +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + """Base schema for User""" + username: str = Field( + ..., + min_length=3, + max_length=50, + description="Unique username", + examples=["john_doe"] + ) + email: EmailStr = Field( + ..., + description="User's email address", + examples=["john.doe@example.com"] + ) + full_name: Optional[str] = Field( + None, + max_length=100, + description="Full name of the user", + examples=["John Doe"] + ) + + +class UserCreate(UserBase): + """Schema for creating a new User""" + password: str = Field( + ..., + min_length=8, + description="User password (will be hashed)", + examples=["SecurePassword123!"] + ) + role_id: UUID = Field( + ..., + description="ID of the user's role" + ) + + +class UserUpdate(BaseModel): + """Schema for updating a User""" + username: Optional[str] = Field( + None, + min_length=3, + max_length=50, + description="Unique username" + ) + email: Optional[EmailStr] = Field( + None, + description="User's email address" + ) + full_name: Optional[str] = Field( + None, + max_length=100, + description="Full name of the user" + ) + password: Optional[str] = Field( + None, + min_length=8, + description="New password (will be hashed)" + ) + role_id: Optional[UUID] = Field( + None, + description="ID of the user's role" + ) + is_active: Optional[bool] = Field( + None, + description="Whether the user account is active" + ) + + +class UserResponse(UserBase): + """Schema for User response""" + id: UUID = Field(..., description="Unique identifier of the user") + role_id: UUID = Field(..., description="ID of the user's role") + is_active: bool = Field(..., description="Whether the user is active") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + model_config = { + "from_attributes": True, + "json_schema_extra": { + "example": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "john_doe", + "email": "john.doe@example.com", + "full_name": "John Doe", + "role_id": "660e8400-e29b-41d4-a716-446655440001", + "is_active": True, + "created_at": "2025-07-20T15:30:45.123456Z", + "updated_at": "2025-07-20T15:30:45.123456Z" + } + } + } diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py new file mode 100644 index 0000000..e649528 --- /dev/null +++ b/app/tests/test_auth.py @@ -0,0 +1,228 @@ +""" +Tests for role-based access control and JWT authentication +""" +import os +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.auth.dependencies import create_access_token +from app.config import get_settings +from app.crud.role import role_crud +from app.crud.user import user_crud +from app.database import Base, get_db +from app.main import app +from app.schemas.role import UserRoleCreate +from app.schemas.user import UserCreate + +# Get API key from settings +settings = get_settings() +TEST_API_KEY = settings.api_key + + +@pytest.fixture(scope="function") +def test_db(): + """Create a test database for each test""" + # Use unique database for each test + import tempfile + temp_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db') + temp_db.close() + + db_url = f"sqlite:///{temp_db.name}" + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + Base.metadata.create_all(bind=engine) + + def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + + yield TestingSessionLocal + + # Cleanup + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + engine.dispose() + os.unlink(temp_db.name) + + +@pytest.fixture(scope="function") +def setup_test_users(test_db): + """Create test users with roles""" + db = test_db() + + # Create roles + admin_role = role_crud.create( + db, UserRoleCreate(name="admin", description="Administrator") + ) + customer_role = role_crud.create( + db, UserRoleCreate(name="customer", description="Customer") + ) + auditor_role = role_crud.create( + db, UserRoleCreate(name="auditor", description="Auditor") + ) + + # Create users + admin_user = user_crud.create( + db, + UserCreate( + username="admin_user", + email="admin@test.com", + password="adminpass123", + full_name="Admin User", + role_id=admin_role.id, + ), + ) + customer_user = user_crud.create( + db, + UserCreate( + username="customer_user", + email="customer@test.com", + password="customerpass123", + full_name="Customer User", + role_id=customer_role.id, + ), + ) + auditor_user = user_crud.create( + db, + UserCreate( + username="auditor_user", + email="auditor@test.com", + password="auditorpass123", + full_name="Auditor User", + role_id=auditor_role.id, + ), + ) + + # Store IDs before closing session + result = { + "admin": str(admin_user.id), + "customer": str(customer_user.id), + "auditor": str(auditor_user.id), + } + + db.close() + return result + + +def test_create_access_token(): + """Test JWT token creation""" + token_data = {"sub": "550e8400-e29b-41d4-a716-446655440000", "role": "admin"} + token = create_access_token(token_data) + + assert token is not None + assert isinstance(token, str) + assert len(token) > 0 + + +@pytest.mark.asyncio +async def test_get_current_user_valid_token(setup_test_users): + """Test getting current user with valid JWT token""" + # This test verifies the token creation works + # The actual get_current_user function would be tested in integration tests + # with real endpoints that use it + admin_id = setup_test_users["admin"] + token = create_access_token({"sub": str(admin_id)}) + + assert token is not None + + +@pytest.mark.asyncio +async def test_password_hashing(): + """Test password hashing and verification""" + password = "SecurePassword123!" + hashed = user_crud.get_password_hash(password) + + assert hashed != password + assert user_crud.verify_password(password, hashed) is True + assert user_crud.verify_password("WrongPassword", hashed) is False + + +@pytest.mark.asyncio +async def test_role_validation(test_db, setup_test_users): + """Test that roles are correctly assigned to users""" + db = test_db() + + # Get admin user + from uuid import UUID + admin_user = user_crud.get(db, UUID(setup_test_users["admin"])) + assert admin_user is not None + assert admin_user.role.name == "admin" + + # Get customer user + customer_user = user_crud.get(db, UUID(setup_test_users["customer"])) + assert customer_user is not None + assert customer_user.role.name == "customer" + + # Get auditor user + auditor_user = user_crud.get(db, UUID(setup_test_users["auditor"])) + assert auditor_user is not None + assert auditor_user.role.name == "auditor" + + db.close() + + +@pytest.mark.asyncio +async def test_inactive_user_check(test_db, setup_test_users): + """Test that inactive users are properly identified""" + db = test_db() + + # Get a user and make them inactive + from uuid import UUID + customer_id = UUID(setup_test_users["customer"]) + user = user_crud.get(db, customer_id) + assert user.is_active == "true" + + # Update user to inactive + from app.schemas.user import UserUpdate + + updated_user = user_crud.update( + db, customer_id, UserUpdate(is_active=False) + ) + assert updated_user.is_active == "false" + + db.close() + + +@pytest.mark.asyncio +async def test_user_crud_operations(test_db, setup_test_users): + """Test basic user CRUD operations""" + db = test_db() + + # Test get by username + user = user_crud.get_by_username(db, "admin_user") + assert user is not None + assert user.username == "admin_user" + + # Test get by email + user = user_crud.get_by_email(db, "customer@test.com") + assert user is not None + assert user.email == "customer@test.com" + + # Test get all users + all_users = user_crud.get_all(db) + assert len(all_users) >= 3 + + db.close() + + +@pytest.mark.asyncio +async def test_role_relationship(test_db, setup_test_users): + """Test that role relationship works correctly""" + db = test_db() + + # Get a user with role + from uuid import UUID + user = user_crud.get(db, UUID(setup_test_users["admin"])) + assert user.role is not None + assert user.role.name == "admin" + assert user.role.description == "Administrator" + + db.close() diff --git a/app/tests/test_roles.py b/app/tests/test_roles.py new file mode 100644 index 0000000..2582fba --- /dev/null +++ b/app/tests/test_roles.py @@ -0,0 +1,244 @@ +""" +Tests for UserRole CRUD operations and API endpoints +""" +import os +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import get_settings +from app.database import get_db +from app.main import app + +# Get API key from settings +settings = get_settings() +TEST_API_KEY = settings.api_key + + +@pytest.fixture(scope="function") +def test_db(): + """Create a test database for each test""" + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.database import Base + + # Use unique database for each test + import tempfile + temp_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db') + temp_db.close() + + db_url = f"sqlite:///{temp_db.name}" + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + Base.metadata.create_all(bind=engine) + + def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + + yield TestingSessionLocal + + # Cleanup + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=engine) + engine.dispose() + os.unlink(temp_db.name) + + +@pytest.mark.asyncio +async def test_create_role(test_db): + """Test creating a new role""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.post( + "/api/roles", + json={"name": "admin", "description": "Administrator with full access"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "admin" + assert data["description"] == "Administrator with full access" + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + + +@pytest.mark.asyncio +async def test_create_duplicate_role(test_db): + """Test creating a role with duplicate name fails""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create first role + await ac.post( + "/api/roles", + json={"name": "customer", "description": "Customer role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + + # Try to create duplicate + response = await ac.post( + "/api/roles", + json={"name": "customer", "description": "Another customer role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_list_roles(test_db): + """Test listing all roles""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create some roles + await ac.post( + "/api/roles", + json={"name": "admin", "description": "Admin"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + await ac.post( + "/api/roles", + json={"name": "customer", "description": "Customer"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + + # List roles + response = await ac.get("/api/roles", headers={"X-API-Key": TEST_API_KEY}) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert any(role["name"] == "admin" for role in data) + assert any(role["name"] == "customer" for role in data) + + +@pytest.mark.asyncio +async def test_get_role_by_id(test_db): + """Test getting a specific role by ID""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create a role + create_response = await ac.post( + "/api/roles", + json={"name": "auditor", "description": "Auditor role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + role_id = create_response.json()["id"] + + # Get the role + response = await ac.get( + f"/api/roles/{role_id}", headers={"X-API-Key": TEST_API_KEY} + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == role_id + assert data["name"] == "auditor" + + +@pytest.mark.asyncio +async def test_get_nonexistent_role(test_db): + """Test getting a role that doesn't exist""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.get( + "/api/roles/550e8400-e29b-41d4-a716-446655440000", + headers={"X-API-Key": TEST_API_KEY}, + ) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_update_role(test_db): + """Test updating a role""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create a role + create_response = await ac.post( + "/api/roles", + json={"name": "manager", "description": "Manager role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + role_id = create_response.json()["id"] + + # Update the role + response = await ac.put( + f"/api/roles/{role_id}", + json={"description": "Updated manager role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == role_id + assert data["name"] == "manager" + assert data["description"] == "Updated manager role" + + +@pytest.mark.asyncio +async def test_delete_role(test_db): + """Test deleting a role""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create a role + create_response = await ac.post( + "/api/roles", + json={"name": "temp", "description": "Temporary role"}, + headers={"X-API-Key": TEST_API_KEY}, + ) + role_id = create_response.json()["id"] + + # Delete the role + response = await ac.delete( + f"/api/roles/{role_id}", headers={"X-API-Key": TEST_API_KEY} + ) + + assert response.status_code == 204 + + # Verify role is deleted + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + get_response = await ac.get( + f"/api/roles/{role_id}", headers={"X-API-Key": TEST_API_KEY} + ) + assert get_response.status_code == 404 + + +@pytest.mark.asyncio +async def test_role_unauthorized_access(test_db): + """Test accessing role endpoints without API key""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.get("/api/roles") + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_role_invalid_api_key(test_db): + """Test accessing role endpoints with invalid API key""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + response = await ac.get("/api/roles", headers={"X-API-Key": "invalid-key"}) + + assert response.status_code == 403 diff --git a/neurobank.db b/neurobank.db new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index a6bc634..43b6982 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.116.1 uvicorn[standard]==0.29.0 uvloop==0.21.0 pydantic==2.7.0 +pydantic[email]==2.7.0 pydantic-settings==2.2.1 python-dotenv==1.0.1 loguru==0.7.2 @@ -17,3 +18,8 @@ python-json-logger==2.0.7 jinja2==3.1.6 python-multipart==0.0.18 requests==2.32.4 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +python-jose[cryptography]==3.4.0 +passlib[bcrypt]==1.7.4 +email-validator==2.2.0 diff --git a/test.db b/test.db new file mode 100644 index 0000000000000000000000000000000000000000..d5c7229140e36db1af7292990bd36245c29ae451 GIT binary patch literal 40960 zcmeIuy9ok86hP6p{y!*|Fcp~CfCZR{6&NTOh#+DQwqa_MzC}0P$V50ZT;9UWY;bnl z&FOY1$IJD+zm-)iqN-wDN<_S}$+vONXV%M_RW%f1Pl)oHj^&Dn{te^F=Zq~^x0t5&UAV7cs0RjXF5FkL{PX*rB^$dal literal 0 HcmV?d00001 From a4e80ba1261b11d467db7eaacf0f8717dedac82a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:52:32 +0000 Subject: [PATCH 3/4] Fix Docker Cloud Build & Push workflow - add Dockerfile.api and update workflow configuration Co-authored-by: Neiland85 <164719485+Neiland85@users.noreply.github.com> --- .dockerignore | 72 ++++++++++++++++++ .github/workflows/production-pipeline.yml | 11 ++- docker/Dockerfile.api | 84 +++++++++++++++++++++ docker/README.md | 89 +++++++++++++++++++++++ 4 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 docker/Dockerfile.api create mode 100644 docker/README.md diff --git a/.dockerignore b/.dockerignore index e69de29..eab5508 100644 --- a/.dockerignore +++ b/.dockerignore @@ -0,0 +1,72 @@ +# Git +.git +.gitignore +.github + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +venv/ +ENV/ +env/ + +# Testing +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +*.db +test.db +test_auth.db +neurobank.db + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Documentation +*.md +docs/ +*.txt +!requirements.txt +!requirements-dev.txt + +# CI/CD +.travis.yml +.circleci/ +azure-pipelines.yml + +# Docker +docker-compose.yml + +# Misc +node_modules/ +*.log +*.pid +*.seed +*.pid.lock +tmp/ +temp/ diff --git a/.github/workflows/production-pipeline.yml b/.github/workflows/production-pipeline.yml index 8c26e19..8efcb17 100644 --- a/.github/workflows/production-pipeline.yml +++ b/.github/workflows/production-pipeline.yml @@ -159,6 +159,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: ./docker/Dockerfile.api push: false load: true tags: neurobank-fastapi:test @@ -203,6 +204,7 @@ jobs: - name: 🔐 Log in to Docker Hub uses: docker/login-action@v3 + if: github.event_name != 'pull_request' with: username: neiland password: ${{ secrets.DOCKER_PAT }} @@ -218,12 +220,15 @@ jobs: uses: docker/build-push-action@v6 with: context: . + file: ./docker/Dockerfile.api + push: ${{ github.event_name != 'pull_request' }} tags: "neiland/neurobank-fastapi:latest,neiland/neurobank-fastapi:${{ github.sha }}" - # For pull requests, export results to the build cache. - # Otherwise, push to a registry. - outputs: ${{ github.event_name == 'pull_request' && 'type=cacheonly' || 'type=registry' }} cache-from: type=registry,ref=neiland/neurobank-fastapi:buildcache cache-to: type=registry,ref=neiland/neurobank-fastapi:buildcache,mode=max + build-args: | + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + # ============================================================================ # 4. FRONTEND ASSET OPTIMIZATION diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..1f3066a --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,84 @@ +# Multi-stage Dockerfile for NeuroBank FastAPI Toolkit +# Optimized for production deployment with uvicorn + +# ============================================================================ +# Stage 1: Builder - Install dependencies +# ============================================================================ +FROM python:3.11-slim as builder + +# Set working directory +WORKDIR /build + +# Install system dependencies required for building Python packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ + pip install --no-cache-dir --user -r requirements.txt + +# ============================================================================ +# Stage 2: Runtime - Minimal production image +# ============================================================================ +FROM python:3.11-slim + +# Set labels for image metadata +LABEL maintainer="NeuroBank Development Team " +LABEL description="NeuroBank FastAPI Banking System - Production API" +LABEL version="1.0.0" + +# Set working directory +WORKDIR /app + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Copy Python dependencies from builder stage +COPY --from=builder /root/.local /root/.local + +# Copy application code +COPY ./app ./app +COPY lambda_handler.py . +COPY lambda_function.py . + +# Create non-root user for security +RUN groupadd -r appuser && \ + useradd -r -g appuser appuser && \ + chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Set environment variables +ENV PYTHONPATH=/app \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PORT=8000 \ + ENVIRONMENT=production \ + PATH=/root/.local/bin:$PATH + +# Expose port (will be overridden by $PORT in cloud environments) +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8000}/health || exit 1 + +# Run the application with uvicorn +# Use shell form to allow environment variable expansion +CMD uvicorn app.main:app \ + --host 0.0.0.0 \ + --port ${PORT:-8000} \ + --workers ${WORKERS:-1} \ + --loop uvloop \ + --timeout-keep-alive 120 \ + --access-log \ + --log-level info diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..2fe2337 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,89 @@ +# Docker Configuration for NeuroBank FastAPI Toolkit + +This directory contains Docker configurations for building and deploying the NeuroBank FastAPI application. + +## Dockerfile.api + +Production-ready multi-stage Dockerfile optimized for: +- **FastAPI** application deployment +- **Uvicorn** ASGI server with uvloop for performance +- **Non-root user** for enhanced security +- **Health checks** for container orchestration +- **Multi-stage build** for minimal image size + +### Building the Image + +```bash +# Build from repository root +docker build -f docker/Dockerfile.api -t neurobank-fastapi:latest . + +# Build with custom tag +docker build -f docker/Dockerfile.api -t neurobank-fastapi:v1.0.0 . +``` + +### Running the Container + +```bash +# Run with default settings (port 8000) +docker run -p 8000:8000 neurobank-fastapi:latest + +# Run with custom port +docker run -p 3000:3000 -e PORT=3000 neurobank-fastapi:latest + +# Run with custom workers +docker run -p 8000:8000 -e WORKERS=4 neurobank-fastapi:latest + +# Run with environment variables +docker run -p 8000:8000 \ + -e API_KEY=your-api-key \ + -e ENVIRONMENT=production \ + -e DATABASE_URL=postgresql://... \ + neurobank-fastapi:latest +``` + +### Environment Variables + +- `PORT` - Port to bind the application (default: 8000) +- `WORKERS` - Number of uvicorn workers (default: 1) +- `API_KEY` - API key for authentication +- `ENVIRONMENT` - Environment name (development, staging, production) +- `DATABASE_URL` - Database connection string +- `SECRET_KEY` - Secret key for JWT token generation + +### Health Check + +The container includes a health check that tests the `/health` endpoint: +- Interval: 30 seconds +- Timeout: 10 seconds +- Start period: 10 seconds +- Retries: 3 + +### Image Layers + +1. **Builder Stage**: Compiles and installs Python dependencies +2. **Runtime Stage**: Creates minimal production image with only runtime dependencies + +### Security Features + +- Non-root user execution +- Minimal base image (python:3.11-slim) +- No unnecessary packages +- Separate build and runtime stages + +### Best Practices + +1. Always use specific version tags in production +2. Set appropriate resource limits in container orchestration +3. Use secrets management for sensitive environment variables +4. Mount volumes for persistent data if needed +5. Configure proper logging and monitoring + +## CI/CD Integration + +This Dockerfile is used in the GitHub Actions workflow: +- `.github/workflows/production-pipeline.yml` + +The workflow automatically builds and pushes images to Docker Hub with: +- `latest` tag for main branch +- Git SHA tag for versioning +- Build cache optimization From 5c13bd6dd1dc8486691af7bf4273fa6ca40ab6cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:55:22 +0000 Subject: [PATCH 4/4] Fix Dockerfile.api permissions and PATH for non-root user execution Co-authored-by: Neiland85 <164719485+Neiland85@users.noreply.github.com> --- .dockerignore | 8 ++++++-- docker/Dockerfile.api | 31 ++++++++++++++++--------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.dockerignore b/.dockerignore index eab5508..e272847 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,9 +47,13 @@ neurobank.db *~ .DS_Store -# Documentation -*.md +# Documentation - exclude most but keep important ones docs/ +*.md +!README.md +!docker/README.md + +# Dependencies *.txt !requirements.txt !requirements-dev.txt diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api index 1f3066a..69fb63d 100644 --- a/docker/Dockerfile.api +++ b/docker/Dockerfile.api @@ -4,7 +4,7 @@ # ============================================================================ # Stage 1: Builder - Install dependencies # ============================================================================ -FROM python:3.11-slim as builder +FROM python:3.11-slim AS builder # Set working directory WORKDIR /build @@ -16,20 +16,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install Python dependencies +# Copy requirements and install Python dependencies to /opt/venv +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ - pip install --no-cache-dir --user -r requirements.txt + pip install --no-cache-dir -r requirements.txt # ============================================================================ # Stage 2: Runtime - Minimal production image # ============================================================================ FROM python:3.11-slim +# Build arguments for metadata +ARG BUILD_DATE +ARG VCS_REF + # Set labels for image metadata LABEL maintainer="NeuroBank Development Team " LABEL description="NeuroBank FastAPI Banking System - Production API" LABEL version="1.0.0" +LABEL org.opencontainers.image.created="${BUILD_DATE}" +LABEL org.opencontainers.image.revision="${VCS_REF}" # Set working directory WORKDIR /app @@ -41,8 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean -# Copy Python dependencies from builder stage -COPY --from=builder /root/.local /root/.local +# Copy Python virtual environment from builder stage +COPY --from=builder /opt/venv /opt/venv # Copy application code COPY ./app ./app @@ -63,7 +72,7 @@ ENV PYTHONPATH=/app \ PYTHONDONTWRITEBYTECODE=1 \ PORT=8000 \ ENVIRONMENT=production \ - PATH=/root/.local/bin:$PATH + PATH="/opt/venv/bin:$PATH" # Expose port (will be overridden by $PORT in cloud environments) EXPOSE 8000 @@ -73,12 +82,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${PORT:-8000}/health || exit 1 # Run the application with uvicorn -# Use shell form to allow environment variable expansion -CMD uvicorn app.main:app \ - --host 0.0.0.0 \ - --port ${PORT:-8000} \ - --workers ${WORKERS:-1} \ - --loop uvloop \ - --timeout-keep-alive 120 \ - --access-log \ - --log-level info +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WORKERS:-1} --loop uvloop --timeout-keep-alive 120 --access-log --log-level info"]