From 8c1ab057ad72a037ca431f4a8d9beaf40fc0ccad Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Mon, 16 Jun 2025 18:34:11 -0400 Subject: [PATCH 01/10] Add support for token refresh workflow --- papi/base/user_auth_system/api/auth.py | 337 ++++++++++++--- papi/base/user_auth_system/api/users.py | 43 ++ papi/base/user_auth_system/crud/users.py | 24 ++ papi/base/user_auth_system/models/__init__.py | 2 + papi/base/user_auth_system/models/group.py | 85 +++- papi/base/user_auth_system/models/jwt_key.py | 48 +++ papi/base/user_auth_system/models/role.py | 71 +++- papi/base/user_auth_system/models/token.py | 167 ++++++-- papi/base/user_auth_system/models/user.py | 212 ++++++++-- .../base/user_auth_system/schemas/__init__.py | 1 + papi/base/user_auth_system/schemas/auth.py | 186 +++++++-- .../user_auth_system/schemas/user_devices.py | 11 + papi/base/user_auth_system/security/auth.py | 116 ++++-- .../user_auth_system/security/dependencies.py | 146 +++++-- .../user_auth_system/security/jwt_utils.py | 193 +++++---- .../user_auth_system/security/key_manager.py | 284 +++++++++++-- .../security/key_manager.py.new | 0 papi/base/user_auth_system/security/tokens.py | 391 +++++++++++++++++- papi/base/user_auth_system/setup.py | 175 ++++---- 19 files changed, 2053 insertions(+), 439 deletions(-) create mode 100644 papi/base/user_auth_system/models/jwt_key.py create mode 100644 papi/base/user_auth_system/schemas/user_devices.py delete mode 100644 papi/base/user_auth_system/security/key_manager.py.new diff --git a/papi/base/user_auth_system/api/auth.py b/papi/base/user_auth_system/api/auth.py index a617c18..5bf6bc2 100644 --- a/papi/base/user_auth_system/api/auth.py +++ b/papi/base/user_auth_system/api/auth.py @@ -1,99 +1,342 @@ from datetime import datetime, timedelta, timezone from typing import Annotated -from fastapi import BackgroundTasks, Depends, Request, status -from fastapi.exceptions import HTTPException -from fastapi.security import OAuth2PasswordRequestForm +from fastapi import BackgroundTasks, Body, Depends, HTTPException, Request, status from loguru import logger +from sqlalchemy import select, update +from papi.core.db import get_sql_session from papi.core.exceptions import APIException +from papi.core.models.response import APIResponse +from papi.core.response import create_response from papi.core.router import RESTRouter from user_auth_system.config import security -from user_auth_system.schemas import Token +from user_auth_system.models.token import AccessToken, RefreshToken +from user_auth_system.schemas import LoginRequest, Token, UserRead from user_auth_system.security.auth import authenticate_user -from user_auth_system.security.dependencies import PREFIX -from user_auth_system.security.tokens import create_access_token +from user_auth_system.security.dependencies import PREFIX, get_current_active_user +from user_auth_system.security.tokens import ( + create_access_token, + create_or_replace_refresh_token, + revoke_access_token_from_request, + revoke_refresh_token, + validate_token, +) router = RESTRouter(prefix=PREFIX, tags=["Authentication"]) @router.post( - "/token", + "/login", response_model=Token, - summary="Obtain access token", + summary="Authenticate user and obtain tokens", + status_code=status.HTTP_200_OK, ) async def login_for_access_token( + login_request: LoginRequest, request: Request, - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], background_tasks: BackgroundTasks, ) -> Token: """ - Authenticate a user and return a JWT access token if credentials are valid. + Authenticates user credentials and issues JWT tokens. - This endpoint handles user authentication and implements security measures including: - - Brute force protection with IP-based rate limiting - - Account lockout after multiple failed attempts - - Background audit logging of all authentication attempts - - Secure token generation with configurable expiration + Security features: + - Brute-force protection + - Device-based authentication + - Secure token issuance with rotation + - Audit logging Args: - request (Request): The current HTTP request containing client information like IP address - form_data (OAuth2PasswordRequestForm): OAuth2 form containing: - - username: User's unique identifier - - password: User's password (will be verified against hashed storage) - background_tasks (BackgroundTasks): FastAPI background tasks handler for audit logging + login_request: User credentials and device info + request: HTTP request object + background_tasks: For security background processing Returns: - Token: JWT access token object containing: - - access_token: The JWT token string - - token_type: Type of token (always "bearer") - - expires_in: Token expiration timestamp in UTC + Token response with access and refresh tokens Raises: - APIException: - - HTTP_423_LOCKED: If the system is temporarily locked due to excessive attempts - - HTTP_401_UNAUTHORIZED: If credentials are invalid - HTTPException: For other authentication-related errors + APIException: For authentication failures or locked accounts """ - client_ip = request.client.host + client_ip = request.client.host if request.client else "unknown" + username = login_request.username.strip() + masked_username = f"{username[:3]}***" # For secure logging try: + # Authenticate user with brute-force protection user = await authenticate_user( - form_data.username, form_data.password, request, background_tasks + username, login_request.password, request, background_tasks ) except HTTPException as exc: - logger.warning( - f"[AUTH] Account lockout triggered from IP {client_ip} for user '{form_data.username}'" - ) + logger.warning(f"Account lockout for IP {client_ip} user '{masked_username}'") raise APIException( status_code=status.HTTP_423_LOCKED, - message="System temporarily locked due to excessive attempts.", - code="SYSTEM_LOCKED", + message="System temporarily locked due to excessive attempts", + code="account_locked", ) from exc if not user: - logger.info( - f"[AUTH] Failed login from IP {client_ip} for user '{form_data.username}'" - ) + logger.info(f"Failed login from {client_ip} for user '{masked_username}'") raise APIException( status_code=status.HTTP_401_UNAUTHORIZED, - message="Incorrect username or password.", - code="UNAUTHORIZED", + message="Incorrect username or password", + code="authentication_failed", ) - logger.info( - f"[AUTH] Successful login for user '{user.username}' from IP {client_ip}" - ) + logger.info(f"Successful login: '{user.username}' from {client_ip}") - token_expiration = timedelta(minutes=security.access_token_expire_minutes) - expiration_time = datetime.now(timezone.utc) + token_expiration + # Validate device ID presence + device_id = (login_request.device_id or "").strip() + if not device_id: + logger.warning(f"Missing device ID for user '{user.username}'") + raise APIException( + status_code=status.HTTP_400_BAD_REQUEST, + message="Device ID is required", + code="missing_device_id", + ) + + # Prepare token metadata + user_agent = request.headers.get("User-Agent", "unknown")[:500] + # Issue tokens access_token = await create_access_token( - data={"sub": user.username}, expires_delta=token_expiration + data={"sub": user.username, "device_id": device_id} + ) + refresh_token, _, _ = await create_or_replace_refresh_token( + subject=user.username, + device_id=device_id, + user_agent=user_agent, ) return Token( access_token=access_token, + refresh_token=refresh_token, token_type="bearer", - expires_in=expiration_time, ) + + +@router.post( + "/refresh", + response_model=Token, + summary="Refresh access token", + status_code=status.HTTP_200_OK, +) +async def refresh_token_endpoint( + refresh_token: Annotated[str, Body(..., embed=True)], +) -> Token: + """ + Issues new access and refresh tokens using a valid refresh token. + + Security process: + 1. Validates refresh token + 2. Checks token status in database + 3. Revokes old refresh token + 4. Issues new token pair + + Args: + refresh_token: Valid refresh token string + + Returns: + New token pair with expiration information + + Raises: + APIException: 401 for invalid tokens + APIException: 500 for internal errors + """ + try: + # Validate token structure and signature + payload = validate_token(refresh_token, expected_type="refresh") + jti = payload["jti"] + subject = payload["sub"] + device_id = payload["device_id"] + + async with get_sql_session() as session: + # Verify token status in database + db_token = await session.scalar( + select(RefreshToken).where(RefreshToken.jti == jti) + ) + + if not db_token: + logger.warning(f"Refresh token not found: {jti}") + raise APIException( + status_code=status.HTTP_401_UNAUTHORIZED, + code="UNAUTHORIZED", + message="Invalid refresh token", + ) + + if db_token.revoked: + logger.info(f"Attempted use of revoked token: {jti}") + raise APIException( + status_code=status.HTTP_401_UNAUTHORIZED, + code="UNAUTHORIZED", + message="Token has been revoked", + ) + + if db_token.expires_at < datetime.now(timezone.utc): + logger.info(f"Expired token presented: {jti}") + raise APIException( + status_code=status.HTTP_401_UNAUTHORIZED, + code="UNAUTHORIZED", + message="Token expired", + ) + + # Token rotation: revoke old, issue new + await revoke_refresh_token(jti) + new_refresh_token, _, _ = await create_or_replace_refresh_token( + subject, db_token.device_id, db_token.user_agent + ) + + # Create new access token + access_token = await create_access_token( + data={"sub": subject, "device_id": device_id} + ) + + # Calculate expiration time for response + access_exp = datetime.now(timezone.utc) + timedelta( + minutes=security.access_token_expire_minutes + ) + + return Token( + access_token=access_token, + refresh_token=new_refresh_token, + token_type="bearer", + expires_in=access_exp, + ) + + except HTTPException: + # Re-raise handled authentication errors + raise + except Exception as e: + logger.exception(f"Token refresh failed: {str(e)}") + raise APIException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="Token refresh failed", + code="token_refresh_error", + ) + + +@router.post( + "/logout", + summary="Logout from current device", + response_model=APIResponse, + status_code=status.HTTP_200_OK, +) +async def logout_current_device( + request: Request, + refresh_token: Annotated[str, Body(embed=True)], +) -> APIResponse: + """ + Revokes both access and refresh tokens for the current device session. + + Security process: + 1. Validates refresh token + 2. Revokes access token from request header + 3. Revokes presented refresh token + + Args: + request: HTTP request containing access token + refresh_token: Refresh token to revoke + + Returns: + Success confirmation + + Raises: + APIException: For invalid tokens or revocation failures + """ + try: + # Validate refresh token + payload = validate_token(refresh_token, expected_type="refresh") + + # Revoke both tokens + await revoke_access_token_from_request(request) + await revoke_refresh_token(payload["jti"]) + + logger.info(f"Successful logout for device {payload['device_id']}") + return create_response(message="Logged out successfully") + + except HTTPException as he: + logger.warning(f"Logout warning: {he.detail}") + raise APIException( + status_code=status.HTTP_400_BAD_REQUEST, + message="Logout failed", + code="logout_failed", + detail=he.detail, + ) + except Exception as e: + logger.exception(f"Logout error: {str(e)}") + raise APIException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="internal_error", + message="Logout failed", + ) + + +@router.post( + "/logout-all", + summary="Logout from all devices", + response_model=APIResponse, + status_code=status.HTTP_200_OK, +) +async def logout_all_devices( + current_user: Annotated[UserRead, Depends(get_current_active_user)], +) -> APIResponse: + """ + Revokes all active tokens for the authenticated user across all devices. + + Use cases: + - Account compromise recovery + - Full account logout + - Security policy enforcement + + Args: + current_user: Authenticated user from dependency + + Returns: + Success confirmation with revocation counts + + Raises: + APIException: For revocation failures + """ + username = current_user.username + try: + async with get_sql_session() as session: + # Revoke all refresh tokens + refresh_result = await session.execute( + update(RefreshToken) + .where( + RefreshToken.subject == username, + RefreshToken.revoked.is_(False), + ) + .values(revoked=True, revoked_at=datetime.now(timezone.utc)) + ) + refresh_count = refresh_result.rowcount + + # Revoke all access tokens + access_result = await session.execute( + update(AccessToken) + .where( + AccessToken.subject == username, + AccessToken.revoked.is_(False), + ) + .values(revoked=True, revoked_at=datetime.now(timezone.utc)) + ) + access_count = access_result.rowcount + + await session.commit() + + logger.info( + f"Logged out all devices: " + f"{refresh_count} refresh tokens and " + f"{access_count} access tokens revoked for '{username}'" + ) + + return create_response( + message=f"Logged out of all devices. {refresh_count + access_count} tokens revoked" + ) + + except Exception as e: + logger.exception(f"Logout-all failed for '{username}': {str(e)}") + raise APIException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="logout_error", + message="Full logout failed", + ) diff --git a/papi/base/user_auth_system/api/users.py b/papi/base/user_auth_system/api/users.py index 208a5fa..774ee16 100644 --- a/papi/base/user_auth_system/api/users.py +++ b/papi/base/user_auth_system/api/users.py @@ -13,6 +13,7 @@ delete_user, get_all_users, get_user_by_username, + get_user_devices, update_user, ) from user_auth_system.schemas import ( @@ -30,6 +31,10 @@ ) from user_auth_system.security.enums import PolicyAction from user_auth_system.security.password import hash_password +from user_auth_system.security.tokens import ( + revoke_access_token_by_device_id, + revoke_refresh_token, +) router = RESTRouter(prefix="/user", tags=["Users Management & Access Control"]) @@ -243,6 +248,44 @@ async def get_current_user_info( ) +@router.get( + "/me/devices", + response_model=APIResponse, + dependencies=[permission_required(PolicyAction.READ)], +) +async def get_current_user_devices( + current_user: Annotated[UserRead, Depends(get_current_active_user)], +): + """ + Retrieves all registered devices with active refresh tokens. + """ + return create_response( + data=await get_user_devices(current_user.username), + message="User devices retrieved successfully.", + ) + + +@router.post( + "/me/devices/{session_id}/{device_id}/logout", + response_model=APIResponse, + dependencies=[permission_required(PolicyAction.READ)], +) +async def logout_current_user_device( + device_id: str, + session_id: str, + current_user: Annotated[UserRead, Depends(get_current_active_user)], +): + """ + Retrieves all registered devices with active refresh tokens. + """ + await revoke_refresh_token(refresh_jti=session_id) + await revoke_access_token_by_device_id(device_id=device_id) + return create_response( + data=await get_user_devices(current_user.username), + message=f"Device {device_id} logged out", + ) + + @router.patch( "/me", response_model=APIResponse, diff --git a/papi/base/user_auth_system/crud/users.py b/papi/base/user_auth_system/crud/users.py index 0ca99b8..947d25c 100644 --- a/papi/base/user_auth_system/crud/users.py +++ b/papi/base/user_auth_system/crud/users.py @@ -10,7 +10,9 @@ from user_auth_system.config import auth_settings from user_auth_system.crud.roles import create_role from user_auth_system.models import Group, Role, User +from user_auth_system.models.token import RefreshToken from user_auth_system.schemas import ( + Device, PolicyCreate, RoleCreate, RoleRead, @@ -296,3 +298,25 @@ async def get_all_users( "users": [UserInDB.model_validate(user) for user in users], "total": total_count, } + + +async def get_user_devices(username: str) -> List[Device]: + devices = [] + async with get_sql_session() as session: + query = ( + select(RefreshToken) + .where(RefreshToken.subject == username) + .where(RefreshToken.revoked.is_(False)) + ) + tokens = await session.execute(query) + for token in tokens.scalars(): + devices.append( + Device( + session_id=token.jti, + device_id=token.device_id, + user_agent=token.user_agent, + expires_at=token.expires_at, + created_at=token.created_at, + ) + ) + return devices diff --git a/papi/base/user_auth_system/models/__init__.py b/papi/base/user_auth_system/models/__init__.py index 506b0ca..395edab 100644 --- a/papi/base/user_auth_system/models/__init__.py +++ b/papi/base/user_auth_system/models/__init__.py @@ -1,4 +1,6 @@ from .audit import AuditLog from .group import Group +from .jwt_key import JWTKey from .role import Role +from .token import AccessToken, RefreshToken from .user import User diff --git a/papi/base/user_auth_system/models/group.py b/papi/base/user_auth_system/models/group.py index 811e30c..1b0fb0a 100644 --- a/papi/base/user_auth_system/models/group.py +++ b/papi/base/user_auth_system/models/group.py @@ -1,21 +1,92 @@ -from sqlalchemy import Column, ForeignKey, Integer, String, Table +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table from sqlalchemy.orm import relationship +from sqlalchemy.sql import func from .base import Base user_groups = Table( "user_groups", Base.metadata, - Column("user_id", Integer, ForeignKey("users.id"), primary_key=True), - Column("group_id", Integer, ForeignKey("groups.id"), primary_key=True), + Column( + "user_id", + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + comment="Foreign key to users table", + ), + Column( + "group_id", + Integer, + ForeignKey("groups.id", ondelete="CASCADE"), + primary_key=True, + comment="Foreign key to groups table", + ), + comment="Many-to-many relationship between users and groups", + extend_existing=True, ) class Group(Base): + """ + Represents a user group for organizing users and managing permissions. + + Attributes: + id: Auto-incrementing primary key + name: Unique group identifier + description: Human-readable group description + is_system_group: Flag indicating system-protected groups + users: Relationship to users belonging to this group + + System-protected groups: + - Prevent accidental deletion of critical groups + - Typically include 'admins', 'managers', or other core groups + """ + __tablename__ = "groups" + __table_args__ = { + "comment": "Stores user groups for organization and access control" + } + + id = Column( + Integer, primary_key=True, index=True, comment="Auto-incrementing primary key" + ) + name = Column( + String(100), unique=True, nullable=False, comment="Unique group identifier" + ) + description = Column( + String(255), nullable=True, comment="Human-readable group description" + ) + + is_protected = Column( + Boolean, + nullable=False, + default=False, + server_default="false", + comment="Flag indicating system-protected groups (cannot be deleted)", + ) + + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="Timestamp of group creation", + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="Timestamp of last group update", + ) - id = Column(Integer, primary_key=True, index=True) - name = Column(String, unique=True, nullable=False) - description = Column(String, nullable=True) + users = relationship( + "User", + secondary=user_groups, + back_populates="groups", + cascade="all, delete", + passive_deletes=True, + ) - users = relationship("User", secondary=user_groups, back_populates="groups") + def __repr__(self) -> str: + """Provides developer-friendly representation.""" + return f"" diff --git a/papi/base/user_auth_system/models/jwt_key.py b/papi/base/user_auth_system/models/jwt_key.py new file mode 100644 index 0000000..c4c6c5d --- /dev/null +++ b/papi/base/user_auth_system/models/jwt_key.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy.sql import func + +from .base import Base + + +class JWTKey(Base): + """ + Represents a JWT signing key for token validation and rotation. + + This model stores cryptographic keys used for signing and verifying JWTs, + enabling secure key rotation practices. Each key record contains: + + - A unique cryptographic key string + - Timestamp of when the key was created + - Automatic key rotation capabilities + + Attributes: + id: Auto-incrementing primary key + key: Base64-encoded cryptographic key material (512-bit equivalent) + created_at: UTC timestamp of key creation (automatically set) + """ + + __tablename__ = "jwt_keys" + __table_args__ = {"comment": "Stores cryptographic keys for JWT signing"} + + id = Column( + Integer, + primary_key=True, + index=True, + comment="Auto-incrementing primary key identifier", + ) + key = Column( + String(128), # Base64 encoded 512-bit key (64 bytes * 8 = 512 bits) + nullable=False, + unique=True, + comment="Base64-encoded cryptographic key material for JWT signing", + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), # Use database server time + nullable=False, + comment="UTC timestamp of when the key was generated", + ) + + def __repr__(self) -> str: + """Provides developer-friendly representation of key instance.""" + return f"" diff --git a/papi/base/user_auth_system/models/role.py b/papi/base/user_auth_system/models/role.py index 1a0c6e2..301c945 100644 --- a/papi/base/user_auth_system/models/role.py +++ b/papi/base/user_auth_system/models/role.py @@ -1,21 +1,78 @@ -from sqlalchemy import Column, ForeignKey, Integer, String, Table +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table from sqlalchemy.orm import relationship from .base import Base +# Solución: Usar extend_existing=True para permitir la redefinición user_roles = Table( "user_roles", Base.metadata, - Column("user_id", Integer, ForeignKey("users.id"), primary_key=True), - Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), + Column( + "user_id", + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + comment="Foreign key to users table", + ), + Column( + "role_id", + Integer, + ForeignKey("roles.id", ondelete="CASCADE"), + primary_key=True, + comment="Foreign key to roles table", + ), + comment="Many-to-many relationship between users and roles", + extend_existing=True, ) class Role(Base): + """ + Represents a user role with specific permissions and access rights. + + Attributes: + id: Auto-incrementing primary key + name: Unique role identifier (e.g., 'admin', 'user') + description: Human-readable role description + is_protected: Flag indicating system-protected roles (cannot be deleted) + users: Relationship to users assigned to this role + + System-protected roles: + - Prevent accidental deletion of critical roles + - Typically include 'admin', 'superuser', or other core roles + """ + __tablename__ = "roles" + __table_args__ = {"comment": "Stores user roles and permissions"} + + id = Column( + Integer, primary_key=True, index=True, comment="Auto-incrementing primary key" + ) + name = Column( + String(50), + unique=True, + nullable=False, + comment="Unique role identifier (e.g., 'admin', 'user')", + ) + description = Column( + String(255), nullable=True, comment="Human-readable role description" + ) + is_protected = Column( + Boolean, + nullable=False, + default=False, + server_default="false", + comment="Flag indicating system-protected roles (cannot be deleted)", + ) - id = Column(Integer, primary_key=True, index=True) - name = Column(String, unique=True, nullable=False) - description = Column(String, nullable=True) + users = relationship( + "User", + secondary=user_roles, + back_populates="roles", + passive_deletes=True, + cascade="save-update, merge", # Cambiado por seguridad + ) - users = relationship("User", secondary=user_roles, back_populates="roles") + def __repr__(self) -> str: + """Provides developer-friendly representation.""" + return f"" diff --git a/papi/base/user_auth_system/models/token.py b/papi/base/user_auth_system/models/token.py index fa5956d..69d980f 100644 --- a/papi/base/user_auth_system/models/token.py +++ b/papi/base/user_auth_system/models/token.py @@ -1,54 +1,173 @@ -from datetime import datetime, timezone +import hashlib from sqlalchemy import Boolean, Column, DateTime, Index, String from sqlalchemy.orm import declarative_base +from sqlalchemy.sql import func Base = declarative_base() class RefreshToken(Base): + """ + Represents a refresh token for JWT authentication. + + Stores refresh tokens with security features: + - SHA-256 token hashing (prevents plaintext storage) + - Device binding + - Revocation tracking + - Automatic expiration + + Attributes: + jti: Unique token identifier (primary key) + subject: User identifier (typically username) + token_hash: SHA-256 hash of token value + device_id: Associated device identifier + user_agent: Client browser/device info + revoked: Revocation status + expires_at: Token expiration timestamp + created_at: Token creation timestamp + """ + __tablename__ = "refresh_tokens" + __table_args__ = ( + Index("ix_refresh_token_subject_device", "subject", "device_id"), + Index("ix_refresh_token_expiration", "expires_at"), + {"comment": "Stores refresh tokens with security features"}, + ) - token = Column(String, primary_key=True, index=True) - subject = Column(String, nullable=False) - jti = Column(String, unique=True, nullable=False) - device_id = Column(String, nullable=True) - user_agent = Column(String, nullable=True) - revoked = Column(Boolean, default=False, nullable=False) + jti = Column( + String(44), # Base64 URL-safe encoded 32-byte token (44 chars) + primary_key=True, + index=True, + comment="Unique token identifier (JWT ID)", + ) + subject = Column( + String(255), + nullable=False, + index=True, + comment="User identifier (subject claim)", + ) + token_hash = Column( + String(64), # SHA-256 produces 64-character hex digest + nullable=False, + unique=True, + comment="SHA-256 hash of token value", + ) + device_id = Column( + String(36), # UUID length + nullable=False, + comment="Associated device identifier", + ) + user_agent = Column( + String(500), nullable=True, comment="Client browser/device information" + ) + revoked = Column( + Boolean, default=False, nullable=False, comment="Revocation status flag" + ) + revoked_at = Column( + DateTime(timezone=True), nullable=True, comment="Token revocation timestamp" + ) expires_at = Column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.now(timezone.utc), + DateTime(timezone=True), nullable=False, comment="Token expiration timestamp" ) created_at = Column( DateTime(timezone=True), + server_default=func.now(), # Use database server time nullable=False, - default=lambda: datetime.now(timezone.utc), + comment="Token creation timestamp", ) - def __repr__(self): + def __repr__(self) -> str: + """Provides developer-friendly representation.""" return ( - f"" + f"" ) + @staticmethod + def compute_token_hash(token: str) -> str: + """ + Computes SHA-256 hash of a token for secure storage. + + Args: + token: Raw token string + + Returns: + 64-character hexadecimal digest of the token + """ + return hashlib.sha256(token.encode()).hexdigest() + class AccessToken(Base): - __tablename__ = "access_tokens" + """ + Represents an access token for JWT authentication. + + Stores access token metadata with: + - Device binding + - Revocation tracking + - Expiration enforcement + - Audit capabilities - jti = Column(String, primary_key=True, index=True, unique=True) - subject = Column(String, nullable=False, index=True) - expires_at = Column(DateTime(timezone=True), nullable=False) - revoked_at = Column(DateTime(timezone=True), nullable=True) - blacklisted = Column(Boolean, default=False, nullable=False) + Attributes: + jti: Unique token identifier (primary key) + subject: User identifier + device_id: Associated device identifier + revoked: Revocation status + expires_at: Token expiration timestamp + revoked_at: Timestamp of revocation + created_at: Token creation timestamp + """ + __tablename__ = "access_tokens" __table_args__ = ( - Index("ix_access_tokens_subject_expires_at", "subject", "expires_at"), + Index("ix_access_tokens_subject_device", "subject", "device_id"), + Index("ix_access_tokens_expiration", "expires_at"), + {"comment": "Stores access token metadata"}, + ) + + jti = Column( + String(44), # Base64 URL-safe encoded 32-byte token + primary_key=True, + index=True, + unique=True, + comment="Unique token identifier (JWT ID)", + ) + subject = Column( + String(255), + nullable=False, + index=True, + comment="User identifier (subject claim)", + ) + device_id = Column( + String(36), # UUID length + nullable=False, + comment="Associated device identifier", + ) + revoked = Column( + Boolean, default=False, nullable=False, comment="Revocation status flag" + ) + + expires_at = Column( + DateTime(timezone=True), nullable=False, comment="Token expiration timestamp" + ) + revoked_at = Column( + DateTime(timezone=True), nullable=True, comment="Timestamp of revocation" + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), # Use database server time + nullable=False, + comment="Token creation timestamp", ) - def __repr__(self): + def __repr__(self) -> str: + """Provides developer-friendly representation.""" + status = "REVOKED" if self.revoked else "ACTIVE" return ( - f"" + f"" ) diff --git a/papi/base/user_auth_system/models/user.py b/papi/base/user_auth_system/models/user.py index 9e875f6..effa291 100644 --- a/papi/base/user_auth_system/models/user.py +++ b/papi/base/user_auth_system/models/user.py @@ -10,60 +10,198 @@ class User(Base): - __tablename__ = "users" + """ + Represents a system user with authentication capabilities and access control. + + Attributes: + id: Unique user identifier + username: Unique username for authentication + email: Unique email address + avatar: URL to user avatar image + full_name: User's full name + hashed_password: Securely hashed password + is_active: Account activation status + is_superuser: Superuser privileges flag + created_at: Account creation timestamp + updated_at: Last account update timestamp + roles: Assigned security roles + groups: Membership in user groups - id = Column(Integer, primary_key=True, index=True) - username = Column(String, unique=True, nullable=False, index=True) - email = Column(String, unique=True, index=True) - avatar = Column(String, nullable=True) - full_name = Column(String, nullable=True) - hashed_password = Column(String, nullable=False) - is_active = Column(Boolean, default=True) - is_superuser = Column(Boolean, default=False) + Relationships: + roles: Many-to-many relationship with Role + groups: Many-to-many relationship with Group + """ - created_at = Column(DateTime(timezone=True), server_default=func.now()) + __tablename__ = "users" + __table_args__ = {"comment": "Stores system user accounts and credentials"} + + id = Column( + Integer, + primary_key=True, + index=True, + comment="Auto-incrementing unique user ID", + ) + username = Column( + String(50), + unique=True, + nullable=False, + index=True, + comment="Unique username for authentication", + ) + email = Column( + String(255), + unique=True, + index=True, + comment="Unique email address for account recovery", + ) + avatar = Column(String(512), nullable=True, comment="URL to user avatar image") + full_name = Column(String(100), nullable=True, comment="User's full name") + hashed_password = Column( + String(128), nullable=False, comment="BCrypt hashed password" + ) + is_active = Column( + Boolean, + default=True, + server_default="true", + comment="Account activation status (false = disabled)", + ) + is_superuser = Column( + Boolean, + default=False, + server_default="false", + comment="Superuser privileges flag", + ) + is_protected = Column( + Boolean, + default=False, + server_default="false", + comment="System-protected account flag (cannot be deleted)", + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="Account creation timestamp", + ) updated_at = Column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + comment="Last account update timestamp", ) - roles = relationship( - "Role", secondary=user_roles, back_populates="users", lazy="selectin" + "Role", + secondary=user_roles, + back_populates="users", + lazy="selectin", + cascade="save-update, merge", + passive_deletes=True, ) groups = relationship( - "Group", secondary=user_groups, back_populates="users", lazy="selectin" + "Group", + secondary=user_groups, + back_populates="users", + lazy="selectin", + cascade="save-update, merge", + passive_deletes=True, ) - @property - async def casbin_assignments(self) -> list[str]: - """Retrieves all user role and group assignments from Casbin. + async def get_applicable_rules(self) -> list[dict]: + """ + Retrieves all Casbin policy rules that apply to the user, including: + - Direct user assignments + - Role-based assignments + - Group-based assignments + - Group role-based assignments Returns: - list[str]: List of role and group assignments in Casbin format - (e.g., 'role:admin', 'group:users') + List of policy rules as dictionaries with keys: + - v0: Subject (user, role, or group) + - v1: Object/resource + - v2: Action + - v3: Condition + - v4: Effect (allow/deny) + - v5: Additional context """ - async with get_sql_session() as s: - stmt = select(AuthRules.v1).where( - AuthRules.ptype == "g", AuthRules.v0 == self.username - ) - result = await s.execute(stmt) - return result.scalars().all() + subjects = set() + + subjects.add(self.username) + + for role in self.roles: + subjects.add(f"role:{role}") + + for group in self.groups: + subjects.add(f"group:{group.name}") - @property - async def casbin_roles(self) -> list[str]: - """Gets list of role names assigned to the user. + query = select(AuthRules).where( + AuthRules.ptype == "p", AuthRules.v0.in_(list(subjects)) + ) + + async with get_sql_session() as session: + result = await session.execute(query) + rules = result.scalars().all() + + return [ + { + "v0": rule.v0, + "v1": rule.v1, + "v2": rule.v2, + "v3": rule.v3, + "v4": rule.v4, + "v5": rule.v5, + } + for rule in rules + ] + + async def get_permissions(self) -> list[tuple]: + """ + Gets simplified permissions in (resource, action) format + + Returns: + List of tuples: [(resource, action), ...] + """ + rules = await self.get_applicable_rules() + return list(set((rule["v1"], rule["v2"]) for rule in rules)) + + async def has_permission(self, resource: str, action: str) -> bool: + """ + Checks if user has permission for a specific resource-action pair + + Args: + resource: Resource path (e.g., "/users") + action: Action (e.g., "read", "write") Returns: - list[str]: List of role names without the 'role:' prefix + True if permission is granted, False otherwise """ - assignments = await self.casbin_assignments - return [r[5:] for r in assignments if r.startswith("role:")] + permissions = await self.get_permissions() + return (resource, action) in permissions - @property - async def casbin_groups(self) -> list[str]: - """Gets list of group names the user belongs to. + async def get_allowed_resources(self) -> dict[str, list[str]]: + """ + Gets all allowed resources and actions Returns: - list[str]: List of group names without the 'group:' prefix + Dictionary: {resource: [actions]} """ - assignments = await self.casbin_assignments - return [g[6:] for g in assignments if g.startswith("group:")] + rules = await self.get_applicable_rules() + resources = {} + + for rule in rules: + if rule["v4"] == "allow": + resource = rule["v1"] + action = rule["v2"] + + if resource not in resources: + resources[resource] = [] + + if action not in resources[resource]: + resources[resource].append(action) + + return resources + + def __repr__(self) -> str: + """Provides developer-friendly representation.""" + status = "active" if self.is_active else "inactive" + return f"" diff --git a/papi/base/user_auth_system/schemas/__init__.py b/papi/base/user_auth_system/schemas/__init__.py index 77753de..11ef0fa 100644 --- a/papi/base/user_auth_system/schemas/__init__.py +++ b/papi/base/user_auth_system/schemas/__init__.py @@ -6,3 +6,4 @@ from .role import * from .root import * from .user import * +from .user_devices import * diff --git a/papi/base/user_auth_system/schemas/auth.py b/papi/base/user_auth_system/schemas/auth.py index 199129e..631028d 100644 --- a/papi/base/user_auth_system/schemas/auth.py +++ b/papi/base/user_auth_system/schemas/auth.py @@ -1,58 +1,150 @@ -from datetime import datetime -from typing import List, Optional, Union +from typing import Annotated, List -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, SecretStr, field_validator class KeyRotation(BaseModel): - """Configuration for JWT key rotation. + """ + Configuration for JWT key rotation settings. Attributes: - rotation_interval_days: Number of days between key rotations - max_historical_keys: Maximum number of old keys to keep + enabled: Flag to enable/disable automatic key rotation + rotation_interval_days: Days between automatic key rotations + max_historical_keys: Maximum historical keys to retain """ - rotation_interval_days: int = 30 - max_historical_keys: int = 5 + enabled: bool = Field( + default=True, description="Enable automatic periodic key rotation" + ) + rotation_interval_days: int = Field( + default=30, + ge=1, + le=365, + description="Days between automatic key rotations (min 1, max 365)", + ) + max_historical_keys: int = Field( + default=5, + ge=1, + le=12, + description="Maximum historical keys to retain (min 1, max 12)", + ) class BaseSecurity(BaseModel): - access_token_expire_minutes: int = 60 - allow_weak_passwords: bool = False - bcrypt_rounds: int = 5 - hash_algorithm: str = "HS256" - max_login_attempts: int = 5 - lockout_duration_minutes: int = 15 - secret_key: str - token_audience: str - token_issuer: str - key_rotation: KeyRotation + """ + Core security configuration for authentication system. + + Security Settings: + secret_key: Cryptographic secret for token signing + token_issuer: JWT issuer claim + token_audience: JWT audience claim + key_rotation: Key rotation configuration + access_token_expire_minutes: Access token validity period + allow_weak_passwords: Flag to allow insecure passwords + bcrypt_rounds: Bcrypt hashing complexity + hash_algorithm: JWT signing algorithm + max_login_attempts: Failed attempts before lockout + lockout_duration_minutes: Account lockout period + """ + + secret_key: SecretStr = Field( + ..., + min_length=64, + description="Cryptographic secret for token signing (min 64 chars)", + ) + token_issuer: str = Field( + default="auth-system", + min_length=3, + description="JWT issuer claim (usually your domain)", + ) + token_audience: str = Field( + default="client-app", + min_length=3, + description="JWT audience claim (client application identifier)", + ) + key_rotation: KeyRotation = Field( + default_factory=KeyRotation, description="JWT key rotation configuration" + ) + access_token_expire_minutes: int = Field( + default=60, + ge=5, + le=1440, + description="Access token validity in minutes (5 min - 24 hours)", + ) + allow_weak_passwords: bool = Field( + default=False, description="Allow insecure passwords (not recommended)" + ) + bcrypt_rounds: int = Field( + default=12, + ge=5, + le=15, + description="Bcrypt hashing complexity rounds (security vs performance)", + ) + hash_algorithm: str = Field( + default="HS256", + pattern="^(HS256|HS384|HS512|RS256|RS384|RS512|ES256|ES384)$", + description="JWT signing algorithm (recommended: HS256, RS256)", + ) + max_login_attempts: int = Field( + default=5, + ge=3, + le=10, + description="Failed login attempts before account lockout", + ) + lockout_duration_minutes: int = Field( + default=15, + ge=1, + le=1440, + description="Account lockout duration in minutes (1 min - 24 hours)", + ) class AuthSettings(BaseModel): - security: BaseSecurity - allow_registration: bool = True - password_min_length: int = 8 - default_user_roles: Union[List[str], str] = ["user"] + """ + Application authentication configuration settings. + + Attributes: + security: Core security configuration + allow_registration: Enable user registration + password_min_length: Minimum password length + default_user_roles: Default roles assigned to new users + """ + + security: BaseSecurity = Field(..., description="Core security configuration") + allow_registration: bool = Field( + default=True, description="Enable new user registration" + ) + password_min_length: int = Field( + default=12, + ge=8, + le=128, + description="Minimum password length (8-128 characters)", + ) + default_user_roles: Annotated[ + List[str], + Field(min_length=1, description="Default roles assigned to new users"), + ] = ["user"] @field_validator("default_user_roles", mode="before") def normalize_user_roles(cls, value) -> list: + """Normalize role input to list format.""" if value is None: return ["user"] if isinstance(value, str): - return [value] + return [role.strip() for role in value.split(",") if role.strip()] return value @field_validator("default_user_roles") def validate_user_roles(cls, value: list) -> list: + """Validate and deduplicate roles.""" if not value: raise ValueError("At least one default role is required") validated_roles = [] for role in value: - clean_role = str(role).strip() + clean_role = role.strip() if not clean_role: - raise ValueError("Role names cannot be empty or whitespace-only") + raise ValueError("Role names cannot be empty") if clean_role not in validated_roles: validated_roles.append(clean_role) @@ -60,16 +152,46 @@ def validate_user_roles(cls, value: list) -> list: class Token(BaseModel): - access_token: str - token_type: str = "bearer" - expires_in: datetime + """ + JWT token response model. + Attributes: + access_token: Short-lived access token + refresh_token: Long-lived refresh token + token_type: Always 'bearer' + """ -class TokenData(BaseModel): - """Data embedded in JWT token. + access_token: str = Field( + ..., description="Short-lived access token for API authorization" + ) + refresh_token: str = Field( + ..., description="Long-lived refresh token for obtaining new access tokens" + ) + token_type: str = Field( + default="bearer", description="Token type (always 'bearer')" + ) + + +class LoginRequest(BaseModel): + """ + User authentication request model. Attributes: - username: The username of the token owner + username: User identifier + password: User credentials + device_id: Unique device identifier """ - username: Optional[str] = None + username: str = Field( + ..., + min_length=3, + max_length=255, + description="User identifier (email or username)", + ) + password: str = Field(..., min_length=8, description="User credentials") + device_id: str = Field( + ..., + min_length=8, + max_length=36, + description="Unique device identifier (min 8 chars)", + ) diff --git a/papi/base/user_auth_system/schemas/user_devices.py b/papi/base/user_auth_system/schemas/user_devices.py new file mode 100644 index 0000000..f3d022c --- /dev/null +++ b/papi/base/user_auth_system/schemas/user_devices.py @@ -0,0 +1,11 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class Device(BaseModel): + session_id: str + device_id: str + user_agent: str + expires_at: datetime + created_at: datetime diff --git a/papi/base/user_auth_system/security/auth.py b/papi/base/user_auth_system/security/auth.py index 89e42f6..482c2b3 100644 --- a/papi/base/user_auth_system/security/auth.py +++ b/papi/base/user_auth_system/security/auth.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Optional import bcrypt @@ -7,7 +7,7 @@ from user_auth_system.schemas import UserInDB -from .audit import log_security_event_sync +from .audit import log_security_event_async from .dependencies import get_user_by_username from .enums import AuditLogKeys from .lockout import ( @@ -23,89 +23,119 @@ async def authenticate_user( username: str, password: str, request: Request, - background_tasks: Optional[BackgroundTasks] = None, + background_tasks: BackgroundTasks, ) -> Optional[UserInDB]: """ - Authenticates a user with brute-force protection and background logging. + Authenticates a user with comprehensive security measures: + - System-wide lockout protection + - Per-user account lockout + - Timing attack prevention + - Security event auditing + - Credential validation Args: - username: Username to authenticate - password: Password to verify - request: Current request object - background_tasks: FastAPI background task handler + username: User identifier + password: Plaintext password + request: HTTP request object + background_tasks: For security logging Returns: - Authenticated user object or None + Authenticated user object if successful, None otherwise + + Raises: + HTTPException: 423 for locked accounts/system, 401 for invalid credentials """ - # Rejection: system-wide lockout + # System-wide lockout check if await is_system_locked_out(): - logger.warning("Authentication attempt during system lockout") + logger.warning("Authentication attempt during system-wide lockout") raise HTTPException( status_code=status.HTTP_423_LOCKED, detail="System temporarily locked due to excessive attempts", ) - logger.info(f"Authentication attempt for user: {username}") - user = await get_user_by_username(username) + # Prepare security event details ip_address = request.client.host if request.client else "unknown" - event_details = {"username": username, "ip": ip_address} + event_details = { + "username": username, + "ip": ip_address, + "user_agent": request.headers.get("User-Agent", "unknown")[:100], + } - # Rejection: per-user lockout + # Per-user lockout check if await has_excessive_failed_attempts(username): - if background_tasks: - background_tasks.add_task( - log_security_event_sync, AuditLogKeys.LOGIN_LOCKOUT, event_details - ) - logger.warning(f"Account temporarily locked: {username}") + await log_security_event_async(AuditLogKeys.LOGIN_LOCKOUT, event_details) + logger.warning(f"Account locked for '{username}' from {ip_address}") raise HTTPException( status_code=status.HTTP_423_LOCKED, detail="Account temporarily locked due to excessive attempts", ) - # Timing defense + # Start timing for consistent response time start_time = datetime.now(timezone.utc) valid_credentials = False + user = None + + try: + # Retrieve user (if exists) + user = await get_user_by_username(username) - if user: - if verify_password(password, user.hashed_password): - if user.is_active: - valid_credentials = True - await reset_failed_attempts(ip_address, username) - if background_tasks: + # Validate credentials + if user: + if verify_password(password, user.hashed_password): + if user.is_active: + valid_credentials = True + await reset_failed_attempts(ip_address, username) background_tasks.add_task( - log_security_event_sync, + log_security_event_async, AuditLogKeys.LOGIN_SUCCESS, event_details, ) - else: - if background_tasks: + logger.info(f"Successful login for '{username}' from {ip_address}") + else: background_tasks.add_task( - log_security_event_sync, + log_security_event_async, AuditLogKeys.LOGIN_INACTIVE, event_details, ) + logger.warning(f"Login attempt for inactive account: '{username}'") + else: + # Simulate password check timing for non-existent users + bcrypt.checkpw( + b"dummy_password", + b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", + ) + logger.info(f"Invalid password for '{username}' from {ip_address}") else: - # Fall-through to simulate delay + # Simulate password check timing for non-existent users bcrypt.checkpw( b"dummy_password", b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", ) - else: - # Simulate password check if user does not exist + logger.info(f"Login attempt for unknown user: '{username}'") + + # Record failed attempt if credentials invalid + if not valid_credentials: + await record_failed_attempt(ip_address, username) + background_tasks.add_task( + log_security_event_async, AuditLogKeys.LOGIN_FAILED, event_details + ) + + except Exception as e: + logger.error(f"Authentication error for '{username}': {str(e)}") + # Fail securely without revealing details bcrypt.checkpw( b"dummy_password", b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", ) - - if not valid_credentials: await record_failed_attempt(ip_address, username) - if background_tasks: - background_tasks.add_task( - log_security_event_sync, AuditLogKeys.LOGIN_FAILED, event_details - ) + return None - if logger.level("DEBUG").no >= logger.level("DEBUG").no: - duration = (datetime.now(timezone.utc) - start_time).total_seconds() - logger.debug(f"Authentication completed in {duration:.4f}s") + finally: + # Ensure consistent timing regardless of outcome + elapsed = datetime.now(timezone.utc) - start_time + min_duration = timedelta(milliseconds=500) + if elapsed < min_duration: + remaining = min_duration - elapsed + await asyncio.sleep(remaining.total_seconds()) return user if valid_credentials else None diff --git a/papi/base/user_auth_system/security/dependencies.py b/papi/base/user_auth_system/security/dependencies.py index 4b0e5aa..5bf9f4a 100644 --- a/papi/base/user_auth_system/security/dependencies.py +++ b/papi/base/user_auth_system/security/dependencies.py @@ -7,12 +7,16 @@ from loguru import logger from pydantic import TypeAdapter -from user_auth_system import config +from user_auth_system.config import config from user_auth_system.crud.users import get_user_by_username from user_auth_system.schemas import UserInDB +from user_auth_system.security.tokens import is_access_token_revoked from .audit import log_security_event_async -from .cache_utils import cache_user, get_cached_user +from .cache_utils import ( + cache_user, + get_cached_user, +) from .enforcer import ( CasbinRequest, build_temp_enforcer, @@ -20,12 +24,12 @@ get_enforcer, ) from .enums import AuditLogKeys, PolicyAction -from .jwt_utils import decode_jwt, try_historical_keys +from .jwt_utils import validate_token from .rate_limit import check_auth_rate_limit PREFIX = "/auth" oauth2_scheme = OAuth2PasswordBearer( - tokenUrl=f"{PREFIX}/token", + tokenUrl=f"{PREFIX}/login", scheme_name="JWT", auto_error=False, # Let the dependency handle the error ) @@ -34,7 +38,18 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> UserInDB: """ Retrieves and authenticates the current user from a JWT token. - If the token is valid, fetches user information from cache or DB. + Implements multiple security layers including token validation, + revocation checking, and rate limiting. + + Security Features: + - Token presence validation + - Rate limiting for authentication attempts + - JWT signature verification + - Expiration validation + - Token revocation (blacklist) check + - Historical key rotation support + - Cache-first user lookup + - Secure error handling Args: token (str): JWT token provided in the Authorization header. @@ -43,10 +58,11 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> Use UserInDB: Authenticated user model. Raises: - HTTPException: For missing or invalid token, or if user not found. + HTTPException: For any authentication failure """ + # Validate token presence if not token: - # Don't expose specific authentication errors + logger.warning("Authentication attempt without token") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required", @@ -54,64 +70,120 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> Use ) try: - # Add brute force protection + # Brute force protection - rate limit token verification attempts await check_auth_rate_limit(token) - payload = await decode_jwt(token) + # Decode and verify token with current key + payload = validate_token(token, "access") + + # Validate critical claims + jti = payload.get("jti") + if not jti: + logger.error(f"Token missing jti claim: {token[:20]}...") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) - # Verify token expiration explicitly if "exp" not in payload: - raise jwt.InvalidTokenError("Token has no expiration") + logger.error(f"Token missing expiration claim: {token[:20]}...") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) + + # Check token revocation status + if await is_access_token_revoked(jti): + logger.info(f"Token revocado detectado: {jti}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token revoked", + ) except jwt.ExpiredSignatureError: - # Don't try historical keys for expired tokens + # Special handling for expired tokens (don't try historical keys) + logger.info(f"Expired token attempt: {token[:20]}...") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired", ) - except jwt.InvalidSignatureError: - # Log potential tampering attempts - logger.warning("Invalid token signature detected") - payload = await try_historical_keys(token) - except jwt.PyJWTError: - # Don't expose internal JWT errors + + except jwt.PyJWTError as e: + # Generic JWT error handling + logger.warning(f"JWT validation error: {str(e)} - Token: {token[:20]}...") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token", ) + except HTTPException: + # Re-raise our own exceptions + raise + + except Exception as e: + # Catch-all for unexpected errors + logger.error(f"Unexpected authentication error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal authentication error", + ) + + # Validate subject claim username = payload.get("sub") if not username: - logger.error("Token missing subject claim") + logger.error(f"Token missing subject claim: {token[:20]}...") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token claims", ) - # Try to get from cache first + # User retrieval - cache-first strategy user = None try: - cached = await get_cached_user(username) - if cached: - user = TypeAdapter(UserInDB).validate_json(cached) - except Exception: - # Skip logging cache errors to reduce overhead - pass - - # If not in cache, fetch from DB + # Try to get user from cache + cached_data = await get_cached_user(username) + if cached_data: + user = TypeAdapter(UserInDB).validate_json(cached_data) + logger.debug(f"User {username} retrieved from cache") + except Exception as e: + logger.warning(f"Cache read error for {username}: {str(e)}") + + # Fallback to database if not in cache if not user: - user = await get_user_by_username(username) - if not user: + try: + user = await get_user_by_username(username) + if not user: + logger.warning(f"User not found in DB: {username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + + # Cache user for future requests + try: + await cache_user(username, user.model_dump_json()) + logger.debug(f"Cached user data for {username}") + except Exception as e: + logger.warning(f"Cache write error for {username}: {str(e)}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Database error for {username}: {str(e)}") raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Authentication service unavailable", ) - # Only try to cache if fetched from DB - try: - await cache_user(username, user.model_dump_json()) - except Exception: - # Continue without logging cache failures - pass + # # Additional security check: validate token issue time against user's last password change + # if security.validate_token_against_password_change: + # token_iat = datetime.fromtimestamp(payload["iat"], tz=timezone.utc) + # if user.password_changed_at and token_iat < user.password_changed_at: + # logger.info(f"Token issued before password change for {username}") + # raise HTTPException( + # status_code=status.HTTP_401_UNAUTHORIZED, + # detail="Token invalidated by password change", + # ) return user diff --git a/papi/base/user_auth_system/security/jwt_utils.py b/papi/base/user_auth_system/security/jwt_utils.py index c40bf2f..b8b10a3 100644 --- a/papi/base/user_auth_system/security/jwt_utils.py +++ b/papi/base/user_auth_system/security/jwt_utils.py @@ -1,90 +1,145 @@ -from typing import Optional - import jwt from fastapi import HTTPException, status +from jwt import get_unverified_header from loguru import logger from user_auth_system.config import security - -from .key_manager import key_manager +from user_auth_system.security.key_manager import key_manager -async def decode_jwt(token: str, key: Optional[str] = None) -> dict: - """Decodes and validates a JWT with comprehensive security checks. +def get_signing_key_by_kid(kid: str) -> str: + """ + Retrieves the signing key associated with a Key ID (kid). Args: - token: JWT to decode - key: Optional specific key to use for verification + kid: Key identifier from JWT header Returns: - dict: Decoded JWT payload + Corresponding signing key Raises: - jwt.InvalidSignatureError: If signature verification fails - HTTPException: For other JWT validation failures - - Validates: - - Signature - - Expiration - - Issuer - - Audience - - Not-before time - - Required claims + HTTPException: 401 for invalid/missing kid """ - key = key or key_manager.current_key - return jwt.decode( - token, - key, - algorithms=[security.hash_algorithm], - audience=security.token_audience, - issuer=security.token_issuer, - options={ - "require": ["exp", "iat", "sub", "iss", "aud", "jti"], - "verify_signature": True, - "verify_aud": True, - "verify_iss": True, - "verify_exp": True, - "verify_nbf": True, - }, - leeway=30, # 30 seconds leeway for clock skew - ) - - -async def try_historical_keys(token: str) -> dict: - """Attempts to decode a token with historical signing keys. + if not kid: + logger.warning("Missing KID in token header") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token signature" + ) + + try: + return key_manager.get_key_by_kid(kid) + except HTTPException: + logger.warning(f"Invalid KID in token: {kid}") + raise + except Exception as e: + logger.critical(f"Key retrieval failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Token validation error", + ) + + +def validate_token(token: str, expected_type: str) -> dict: + """ + Validates a JWT token with comprehensive security checks. + + Verification includes: + 1. Token structure and header + 2. Key ID retrieval + 3. Cryptographic signature + 4. Standard claims (exp, iat, nbf, iss, aud) + 5. Required custom claims (jti, sub, device_id for refresh) + 6. Token type verification Args: - token: JWT to decode + token: JWT token string + expected_type: Expected token type ('access' or 'refresh') Returns: - dict: Decoded JWT payload + Decoded token payload Raises: - HTTPException: If no valid key is found for the token - - Prioritizes keys based on the 'kid' (Key ID) header if present, - then tries all historical keys in sequence. + HTTPException: For any validation failure with appropriate status code """ - kid = jwt.get_unverified_header(token).get("kid") - keys_to_try = [] - - # Prioritize key by KID if available - if kid and int(kid) < len(key_manager.all_keys): - keys_to_try.append(key_manager.all_keys[int(kid)][0]) - - # Try all historical keys - keys_to_try.extend([ - key for key, _ in key_manager.all_keys if key not in keys_to_try - ]) - - for key in keys_to_try: - try: - return await decode_jwt(token, key) - except jwt.InvalidSignatureError: - continue - - logger.error("Token signature invalid with all keys") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token signature", - ) + try: + # Step 1: Basic token structure validation + if not token or len(token) < 50: + raise ValueError("Invalid token structure") + + # Step 2: Decode header to get Key ID + header = get_unverified_header(token) + kid = header.get("kid") + if not kid: + raise jwt.InvalidAlgorithmError("Missing KID in token header") + + # Step 3: Retrieve signing key + key = get_signing_key_by_kid(kid) + + # Step 4: Decode and verify token + payload = jwt.decode( + token, + key, + algorithms=[security.hash_algorithm], + audience=security.token_audience, + issuer=security.token_issuer, + options={ + "require": ["exp", "iat", "nbf", "iss", "aud", "jti", "sub"], + "verify_signature": True, + "verify_aud": True, + "verify_iss": True, + "verify_exp": True, + "verify_nbf": True, + }, + leeway=30, # 30 seconds leeway for clock skew + ) + + # Step 5: Verify token type + token_type = payload.get("typ") + if token_type != expected_type: + logger.warning( + f"Token type mismatch: expected {expected_type}, got {token_type}" + ) + raise ValueError("Invalid token type") + + # Step 6: Verify required claims for specific token types + if expected_type == "refresh" and "device_id" not in payload: + logger.error("Refresh token missing device_id claim") + raise ValueError("Missing required claim: device_id") + + return payload + + except jwt.ExpiredSignatureError: + logger.info(f"Expired {expected_type} token presented") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"{expected_type.capitalize()} token expired", + ) + except jwt.InvalidAudienceError: + logger.warning(f"Invalid audience in {expected_type} token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token audience" + ) + except jwt.InvalidIssuerError: + logger.warning(f"Invalid issuer in {expected_type} token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token issuer" + ) + except jwt.ImmatureSignatureError: + logger.info(f"Premature {expected_type} token usage") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token not yet valid" + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid {expected_type} token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + except ValueError as e: + logger.warning(f"Token validation failed: {str(e)}") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected token validation error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Token validation failed", + ) diff --git a/papi/base/user_auth_system/security/key_manager.py b/papi/base/user_auth_system/security/key_manager.py index 835430c..33ae46f 100644 --- a/papi/base/user_auth_system/security/key_manager.py +++ b/papi/base/user_auth_system/security/key_manager.py @@ -1,80 +1,284 @@ -from datetime import datetime, timezone +import base64 +import secrets +from datetime import datetime, timedelta, timezone from typing import List, Tuple -import jwt +from fastapi import HTTPException, status +from loguru import logger +from pydantic import SecretStr +from sqlalchemy.future import select +from papi.core.db import get_sql_session from user_auth_system.config import security +from user_auth_system.models.jwt_key import JWTKey from user_auth_system.schemas.auth import KeyRotation +# Configuration constants KEY_ROTATION_CONFIG: KeyRotation = security.key_rotation +DEFAULT_BASE_KEY = security.secret_key +DEFAULT_KEY_ID = "default" class KeyManager: - """Manages JWT key rotation with historical key support. + """ + Manages JWT signing keys with support for key rotation and historical key storage. - Attributes: - _keys: List of tuples containing (key, creation_timestamp) - _current_key_index: Index of the currently active key - _last_rotation: Timestamp of the last key rotation + This class handles: + - Initial key loading from database or default configuration + - Periodic key rotation based on configured intervals + - Key pruning to maintain historical key limits + - Key retrieval by Key ID (kid) - Methods: - rotate_key: Generates a new signing key and updates key history - should_rotate: Checks if key rotation is due based on configuration + When key rotation is disabled, a single static key is used for all operations. + + Attributes: + _keys: List of tuples (key_string, creation_datetime) + _current_key_index: Index of the active key in _keys + _last_rotation: Timestamp of last key rotation + _session: Database session for key persistence """ - _keys: List[Tuple[str, datetime]] = [] - _current_key_index: int = -1 + def __init__(self) -> None: + """Initializes the KeyManager with empty state.""" + self._keys: List[Tuple[str, datetime]] = [] + self._current_key_index: int = -1 + self._last_rotation: datetime = datetime.now(timezone.utc) + + async def initialize(self) -> None: + """ + Initializes key manager by loading keys from database or creating initial key. + + If key rotation is disabled: + - Uses the default base key from configuration + - Sets key ID to "default" + + If key rotation is enabled: + - Loads existing keys from database + - Creates initial key if no keys exist + - Sets current key to the most recent + + Args: + session: Database session for key operations + + Raises: + RuntimeError: If database operations fail during initialization + """ + + if not security.key_rotation.enabled: + self._setup_static_key() + logger.info("Key rotation disabled - using static key") + return + + await self._load_keys_from_database() + + def _setup_static_key(self) -> None: + """Configures manager to use static key from configuration.""" + now = datetime.now(timezone.utc) - def __init__(self): - """Initializes the key manager with the initial secret key.""" - self._keys = [(security.secret_key, datetime.now(timezone.utc))] + if isinstance(DEFAULT_BASE_KEY, SecretStr): + key_value = DEFAULT_BASE_KEY.get_secret_value() + else: + key_value = DEFAULT_BASE_KEY + + self._keys = [(key_value, now)] + self._current_key_index = 0 + self._last_rotation = now + + async def _load_keys_from_database(self) -> None: + """Loads keys from database or creates initial key if none exist.""" + try: + async with get_sql_session() as session: + result = await session.execute(select(JWTKey).order_by(JWTKey.id.asc())) + db_keys = result.scalars().all() + + if db_keys: + self._keys = [(row.key, row.created_at) for row in db_keys] + self._current_key_index = len(self._keys) - 1 + self._last_rotation = self._keys[-1][1] + logger.info(f"Loaded {len(self._keys)} existing keys from database") + else: + await self._create_initial_key() + except Exception as e: + logger.critical(f"Key initialization failed: {str(e)}") + raise RuntimeError("Key manager initialization failed") from e + + async def _create_initial_key(self) -> None: + """Creates and persists initial key in database.""" + now = datetime.now(timezone.utc) + + if isinstance(DEFAULT_BASE_KEY, SecretStr): + key_value = DEFAULT_BASE_KEY.get_secret_value() + else: + key_value = DEFAULT_BASE_KEY + + self._keys = [(key_value, now)] self._current_key_index = 0 - self._last_rotation = datetime.now(timezone.utc) + self._last_rotation = now + + base_key_record = JWTKey(key=key_value, created_at=now) + async with get_sql_session() as session: + session.add(base_key_record) + await session.commit() + logger.info("Created initial key from configuration") @property def current_key(self) -> str: - """Gets the currently active signing key. + """ + Retrieves the current active signing key. Returns: - str: The active JWT signing key + Current active key string + + Raises: + RuntimeError: If manager is not initialized """ - return self._keys[self._current_key_index][0] + if not self._keys: + raise RuntimeError("KeyManager is not initialized") + + key_value = self._keys[self._current_key_index][0] + + return key_value @property - def all_keys(self) -> List[Tuple[str, datetime]]: - """Gets all historical keys with their creation timestamps. + def current_kid(self) -> str: + """ + Retrieves Key ID (kid) of the current active key. Returns: - List[Tuple[str, datetime]]: All managed keys with timestamps + "default" when rotation disabled, otherwise stringified index """ - return self._keys + if not security.key_rotation.enabled: + return DEFAULT_KEY_ID - def rotate_key(self): - """Generates a new signing key and updates key history. + return str(self._current_key_index) - Maintains a limited history of keys based on configuration and - updates the current key index to the newly generated key. + async def rotate_key(self) -> None: """ - new_key = jwt.utils.base64url_encode(jwt.utils.get_random_bytes(64)).decode() - self._keys.append((new_key, datetime.now(timezone.utc))) + Rotates the signing key and persists to database. + + Steps: + 1. Generate new cryptographically secure key + 2. Store in database + 3. Update internal state + 4. Prune excess historical keys + + Raises: + RuntimeError: If rotation fails or database operations error + """ + if not security.key_rotation.enabled: + logger.warning("Key rotation attempted while disabled") + return + + try: + new_key = self._generate_secure_key() + now = datetime.now(timezone.utc) + + # Persist new key + new_jwt_key = JWTKey(key=new_key, created_at=now) + async with get_sql_session() as session: + session.add(new_jwt_key) + await session.commit() + + # Update state + self._keys.append((new_key, now)) + self._current_key_index = len(self._keys) - 1 + self._last_rotation = now + + # Maintain historical key limit + await self._prune_old_keys() + + logger.info(f"Rotated key (new kid: {self.current_kid})") + except Exception as e: + logger.critical(f"Key rotation failed: {str(e)}") + raise RuntimeError("Key rotation aborted") from e + + def _generate_secure_key(self) -> str: + """Generates a new base64-encoded cryptographically secure key.""" + key_bytes = secrets.token_bytes(64) + return base64.urlsafe_b64encode(key_bytes).decode() + + async def _prune_old_keys(self) -> None: + """Removes oldest keys exceeding historical key limit.""" + if not security.key_rotation.enabled: + return + + max_keys = KEY_ROTATION_CONFIG.max_historical_keys + if len(self._keys) <= max_keys: + return + + # Calculate keys to remove + excess_count = len(self._keys) - max_keys + oldest_allowed_time = self._keys[excess_count][1] + + # Remove from database + async with get_sql_session() as session: + await session.execute( + JWTKey.__table__.delete().where(JWTKey.created_at < oldest_allowed_time) + ) + await session.commit() + + # Update in-memory state + self._keys = self._keys[excess_count:] self._current_key_index = len(self._keys) - 1 - self._last_rotation = datetime.now(timezone.utc) - # Prune old keys - if len(self._keys) > KEY_ROTATION_CONFIG.max_historical_keys: - self._keys.pop(0) - self._current_key_index -= 1 + logger.info(f"Pruned {excess_count} historical keys") def should_rotate(self) -> bool: - """Determines if key rotation is due based on rotation interval. + """ + Determines if key rotation is needed based on rotation interval. Returns: - bool: True if rotation is needed, False otherwise + True if rotation interval has elapsed since last rotation """ - time_since_last_rotation = datetime.now(timezone.utc) - self._last_rotation - return ( - time_since_last_rotation.days >= KEY_ROTATION_CONFIG.rotation_interval_days - ) + if not security.key_rotation.enabled: + return False + + rotation_interval = timedelta(days=KEY_ROTATION_CONFIG.rotation_interval_days) + time_since_rotation = datetime.now(timezone.utc) - self._last_rotation + return time_since_rotation >= rotation_interval + + def get_key_by_kid(self, kid: str) -> str: + """ + Retrieves signing key by Key ID (kid). + + Args: + kid: Key ID string from JWT header + + Returns: + Corresponding signing key + + Raises: + HTTPException: For invalid/missing keys (401 Unauthorized) + """ + # Handle static key scenario + if not security.key_rotation.enabled: + if kid == DEFAULT_KEY_ID: + return self._keys[0][0] if self._keys else "" + + logger.warning(f"Invalid KID '{kid}' for static key configuration") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token signature", + ) + + # Handle key rotation scenario + try: + index = int(kid) + if 0 <= index < len(self._keys): + return self._keys[index][0] + + logger.warning(f"Out-of-range KID requested: {kid}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token signature", + ) + except (ValueError, TypeError): + logger.warning(f"Malformed KID format: {kid}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Malformed token header", + ) +# Global instance for application-wide key management key_manager = KeyManager() diff --git a/papi/base/user_auth_system/security/key_manager.py.new b/papi/base/user_auth_system/security/key_manager.py.new deleted file mode 100644 index e69de29..0000000 diff --git a/papi/base/user_auth_system/security/tokens.py b/papi/base/user_auth_system/security/tokens.py index 4f0ae0e..773eafd 100644 --- a/papi/base/user_auth_system/security/tokens.py +++ b/papi/base/user_auth_system/security/tokens.py @@ -1,13 +1,19 @@ import secrets from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, Tuple import jwt +from fastapi import Request +from jwt import get_unverified_header from loguru import logger from redis.exceptions import LockError +from sqlalchemy import delete, or_, select, update +from sqlalchemy.exc import SQLAlchemyError -from papi.core.db import get_redis_client +from papi.core.db import get_redis_client, get_sql_session from user_auth_system.config import security +from user_auth_system.models.token import AccessToken, RefreshToken +from user_auth_system.security.jwt_utils import get_signing_key_by_kid, validate_token from .audit import log_security_event_async from .enums import AuditLogKeys @@ -15,29 +21,38 @@ async def create_access_token( - data: dict, expires_delta: Optional[timedelta] = None + data: dict, + expires_delta: Optional[timedelta] = None, ) -> str: - """Generates a JWT access token with rotating keys and security headers. + """ + Generates a JWT access token and revokes previous tokens for the same subject/device. + + Performs key rotation if needed and ensures token uniqueness per device by: + 1. Checking for key rotation requirements + 2. Generating new token metadata + 3. Revoking previous valid tokens for the subject/device + 4. Persisting the new token in the database Args: - data: Payload data to include in the token - expires_delta: Optional custom token expiration time + data: Dictionary containing token claims (must include 'sub' and 'device_id') + expires_delta: Optional custom token expiration period Returns: - str: Signed JWT access token + Encoded JWT access token string - Implements distributed locking for safe key rotation in multi-instance environments. - Includes comprehensive security claims in the token payload. + Raises: + SQLAlchemyError: If database operations fail """ redis_client = await get_redis_client() lock = redis_client.lock("rotate_key_lock", timeout=10, blocking_timeout=1) + # Handle key rotation if required if key_manager.should_rotate(): try: if await lock.acquire(): if key_manager.should_rotate(): logger.info("Rotating JWT signing keys") - key_manager.rotate_key() + await key_manager.rotate_key() await log_security_event_async(AuditLogKeys.KEY_ROTATION) except LockError: logger.warning("Failed to acquire lock for key rotation") @@ -45,27 +60,365 @@ async def create_access_token( try: await lock.release() except Exception: - pass + logger.warning("Failed to release lock after rotation attempt") - to_encode = data.copy() + # Prepare token expiration and metadata now = datetime.now(timezone.utc) expires_delta = expires_delta or timedelta( minutes=security.access_token_expire_minutes ) expire = now + expires_delta + jti = secrets.token_urlsafe(32) # Unique token identifier + kid = str(key_manager.current_kid) + headers = {"kid": kid, "alg": security.hash_algorithm, "typ": "JWT"} + subject = data["sub"] + device_id = data["device_id"] + ttl = 30 # Not-before time buffer - to_encode.update({ + # Build token payload + payload = { "exp": expire, "iat": now, - "nbf": now - timedelta(seconds=30), + "nbf": now - timedelta(seconds=ttl), "iss": security.token_issuer, "aud": security.token_audience, + "sub": subject, + "jti": jti, "typ": "access", - "sub": to_encode.get("sub", ""), - "jti": secrets.token_urlsafe(32), - "kid": str(len(key_manager.all_keys) - 1), - }) + "device_id": device_id, + } + async with get_sql_session() as session: + # Revoke previous tokens for this subject/device + result = await session.execute( + select(AccessToken).where( + AccessToken.subject == subject, + AccessToken.device_id == device_id, + AccessToken.revoked.is_(False), + ) + ) + for token in result.scalars(): + token.revoked = True + token.revoked_at = now + + # Register new token + session.add( + AccessToken( + jti=jti, + subject=subject, + device_id=device_id, + expires_at=expire, + revoked=False, + ) + ) + await session.commit() + + logger.debug( + f"Generated access token for subject '{subject}' on device '{device_id}'" + ) return jwt.encode( - to_encode, key_manager.current_key, algorithm=security.hash_algorithm + payload, + key_manager.current_key, + algorithm=security.hash_algorithm, + headers=headers, ) + + +async def create_or_replace_refresh_token( + subject: str, + device_id: str, + user_agent: Optional[str] = None, + expires_in_days: int = 30, +) -> Tuple[str, str, datetime]: + """ + Generates a new refresh token and revokes previous tokens for the subject/device. + + Ensures only one valid refresh token exists per device by: + 1. Generating new token with unique JTI + 2. Revoking previous valid tokens for the subject/device + 3. Storing the new token hash in the database + + Args: + subject: User identifier (subject claim) + device_id: Device identifier + user_agent: Optional client user agent string + expires_in_days: Token validity period in days + + Returns: + Tuple containing: + - Encoded refresh token string + - Unique token identifier (JTI) + - Expiration datetime + + Raises: + SQLAlchemyError: If database operations fail + """ + now = datetime.now(timezone.utc) + expire = now + timedelta(days=expires_in_days) + jti = secrets.token_urlsafe(32) + kid = key_manager.current_kid + headers = {"kid": kid, "alg": security.hash_algorithm, "typ": "JWT"} + + # Build token payload + payload = { + "exp": expire, + "iat": now, + "nbf": now - timedelta(seconds=30), + "iss": security.token_issuer, + "aud": security.token_audience, + "typ": "refresh", + "sub": subject, + "jti": jti, + "device_id": device_id, + } + + token_str = jwt.encode( + payload=payload, + key=key_manager.current_key, + algorithm=security.hash_algorithm, + headers=headers, + ) + + async with get_sql_session() as session: + # Revoke previous refresh tokens + result = await session.execute( + select(RefreshToken).where( + RefreshToken.subject == subject, + RefreshToken.device_id == device_id, + RefreshToken.revoked.is_(False), + RefreshToken.expires_at > now, + ) + ) + for token in result.scalars(): + token.revoked = True + token.revoked_at = now + + # Register new token + session.add( + RefreshToken( + jti=jti, + subject=subject, + token_hash=RefreshToken.compute_token_hash(token_str), + device_id=device_id, + user_agent=user_agent, + expires_at=expire, + revoked=False, + ) + ) + await session.commit() + + logger.info( + f"Generated refresh token for subject '{subject}' on device '{device_id}'" + ) + return token_str, jti, expire + + +async def revoke_refresh_token(refresh_jti: str) -> None: + """ + Revokes a specific refresh token by its unique identifier (JTI). + + Args: + refresh_jti: Unique token identifier (JTI) + + Returns: + None + + Note: + - Assumes jti is globally unique (enforced at database level) + - Logs warning if token is not found or already revoked + - Includes basic security audit logging + """ + async with get_sql_session() as session: + # Execute the update + result = await session.execute( + update(RefreshToken) + .where(RefreshToken.jti == refresh_jti) + .values(revoked=True, revoked_at=datetime.now(timezone.utc)) + ) + await session.commit() + + # Log appropriate message based on result + if result.rowcount == 0: + logger.info(f"Refresh token not found or already revoked: {refresh_jti}") + else: + logger.info(f"Refresh token revoked: {refresh_jti}") + + +async def revoke_access_token_from_request(request: Request) -> None: + """ + Extracts and revokes an access token from the Authorization header. + + Args: + request: Incoming FastAPI request object + + Returns: + None + + Note: + Silently handles missing/invalid tokens and logs errors + """ + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + logger.debug("No access token in Authorization header") + return + + token = auth_header.split(" ")[1] + + try: + header = get_unverified_header(token) + kid = header.get("kid", False) + key = get_signing_key_by_kid(kid) + + payload = validate_token(token, "access") + if payload.get("typ") != "access": + logger.warning("Provided token is not an access token") + return + + jti = payload.get("jti") + exp = payload.get("exp") + if not jti or not exp: + logger.warning("Access token missing jti or exp claim") + return + + # Skip revocation if token already expired + ttl = exp - datetime.now(timezone.utc).timestamp() + if ttl <= 0: + logger.debug("Access token already expired") + return + + await revoke_access_token(jti) + + except jwt.PyJWTError as e: + logger.warning(f"Access token decode error: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error during token revocation: {str(e)}") + + +async def revoke_access_token(jti: str) -> None: + """ + Revokes an access token and adds it to the revocation cache. + + Args: + jti: Unique token identifier + + Returns: + None + + Raises: + SQLAlchemyError: If database operations fail + """ + async with get_sql_session() as session: + try: + token = await session.scalar( + select(AccessToken).where(AccessToken.jti == jti) + ) + + if not token: + logger.info(f"Token {jti} not found in database") + return + + if token.revoked: + logger.info(f"Token {jti} already revoked") + return + + # Update database record + token.revoked = True + token.revoked_at = datetime.now(timezone.utc) + await session.commit() + + # Add to Redis revocation cache + ttl = int((token.expires_at - datetime.now(timezone.utc)).total_seconds()) + if ttl > 0: + redis_client = await get_redis_client() + await redis_client.setex( + f"papi:access_token_revoked:{jti}", + ttl, + "1", # Using string value for consistency + ) + + logger.info(f"Revoked access token: {jti}") + + except SQLAlchemyError as db_err: + await session.rollback() + logger.error(f"Database error revoking token: {db_err}") + raise + + +async def revoke_access_token_by_device_id(device_id: str) -> None: + async with get_sql_session() as session: + token = await session.scalar( + select(AccessToken) + .where(AccessToken.device_id == device_id) + .where(AccessToken.revoked.is_(False)) + ) + + if not token: + logger.info(f"No active access tokens for {device_id} found") + return + else: + await revoke_access_token(token.jti) + + +async def is_access_token_revoked(jti: str) -> bool: + """ + Checks if an access token is revoked or expired. + + Verification steps: + 1. Check Redis revocation cache + 2. Check database for revocation status + 3. Verify token expiration + + Args: + jti: Unique token identifier + + Returns: + True if token is invalid (revoked/expired/not found), False otherwise + """ + # Check Redis cache first + redis_client = await get_redis_client() + if await redis_client.exists(f"papi:access_token_revoked:{jti}"): + return True + + # Check database record + async with get_sql_session() as session: + token = await session.scalar(select(AccessToken).where(AccessToken.jti == jti)) + + if not token: + return True # Treat non-existent tokens as revoked + + # Check if token is expired or revoked + current_time = datetime.now(timezone.utc) + return token.revoked or token.expires_at < current_time + + +async def cleanup_expired_tokens() -> None: + """Deletes expired access and refresh tokens from the database.""" + now = datetime.now(timezone.utc) + + async with get_sql_session() as session: + # Delete expired access tokens + access_result = await session.execute( + delete(AccessToken).where( + or_( + AccessToken.expires_at < now, + AccessToken.revoked.is_(True), + ) + ) + ) + + # Delete expired refresh tokens + refresh_result = await session.execute( + delete(RefreshToken).where( + or_( + RefreshToken.expires_at < now, + RefreshToken.revoked.is_(True), + ) + ) + ) + + await session.commit() + + logger.info( + f"Token cleanup: Removed {access_result.rowcount} access tokens " + f"and {refresh_result.rowcount} refresh tokens" + ) diff --git a/papi/base/user_auth_system/setup.py b/papi/base/user_auth_system/setup.py index fe6197a..01a58d6 100644 --- a/papi/base/user_auth_system/setup.py +++ b/papi/base/user_auth_system/setup.py @@ -1,4 +1,3 @@ -import logging import os import re from datetime import datetime, timezone @@ -20,18 +19,21 @@ ) from user_auth_system.security.enforcer import get_enforcer from user_auth_system.security.enums import PolicyAction, PolicyEffect -from user_auth_system.security.password import hash_password, verify_password +from user_auth_system.security.key_manager import key_manager +from user_auth_system.security.password import hash_password +from user_auth_system.security.tokens import cleanup_expired_tokens config = get_config() -logger = logging.getLogger(__name__) -# RFC 5322 compliant email validation regex (simplified version) -EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") +# RFC 5322 compliant email validation regex +EMAIL_REGEX = re.compile( + r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" +) def is_valid_email(email: str) -> bool: """ - Validate email format using RFC 5322 compliant regex. + Validates email format against RFC 5322 specification. Args: email: Email address to validate @@ -44,16 +46,17 @@ def is_valid_email(email: str) -> bool: async def init_root_policies(root_env: RootUserEnv) -> None: """ - Initialize default Casbin policies granting full access to: - 1. The root username directly - 2. Any user with the root role + Initializes default access policies for root user and root role. + + Policies grant: + - Full access to root username + - Full access to root role members Args: - root_env: Root environment configuration object + root_env: Root environment configuration Raises: RuntimeError: If policy initialization fails - ValueError: For invalid policy configuration """ try: # Policy for direct root user access @@ -63,6 +66,7 @@ async def init_root_policies(root_env: RootUserEnv) -> None: action=PolicyAction.ALL, condition="True", effect=PolicyEffect.ALLOW, + description="Root user full access policy", ) # Policy for root role access @@ -72,67 +76,68 @@ async def init_root_policies(root_env: RootUserEnv) -> None: action=PolicyAction.ALL, condition="True", effect=PolicyEffect.ALLOW, + description="Root role full access policy", ) await add_policy(root_user_policy) await add_policy(root_role_policy) logger.info("Root access policies initialized successfully") - except ValueError as ve: - logger.error(f"Policy configuration error: {ve}") - raise except Exception as e: - logger.exception("Unexpected error during policy initialization") + logger.critical(f"Policy initialization failed: {str(e)}") raise RuntimeError("Root policy initialization failed") from e class AuthSystemInitializer(AddonSetupHook): """ - System initialization hook for authentication subsystem. Handles: - - Root user creation + Authentication system initialization hook. Handles: - Root role creation - - Casbin policy initialization - - Security warnings for production environments + - Root user creation + - Access policy initialization + - Security warnings for sensitive configurations - Supports both empty-system initialization and environment-based configuration. + Execution is idempotent and only runs on first system startup. """ async def _create_root_role( self, root_env: RootUserEnv, session: AsyncSession ) -> Role: """ - Ensure the root role exists in the database (idempotent operation). + Ensures the root role exists in the database (idempotent). Args: root_env: Root environment configuration - session: Async database session + session: Database session Returns: - Existing or newly created Role instance + Existing or created root Role instance Raises: RuntimeError: If database operation fails """ try: - root_role = await session.scalar( - select(Role).where(Role.name == root_env.role_name).limit(1) + # Check for existing role + existing_role = await session.scalar( + select(Role).where(Role.name == root_env.role_name) ) - if not root_role: - root_role = Role( - name=root_env.role_name, - description="Superadmin role with full system access", - ) - session.add(root_role) - await session.commit() - logger.info(f"Created root role: {root_env.role_name}") - else: + if existing_role: logger.debug(f"Root role already exists: {root_env.role_name}") + return existing_role - return root_role + # Create new role + new_role = Role( + name=root_env.role_name, + description="System super-administrator role with full privileges", + is_protected=True, # Prevent accidental deletion + ) + session.add(new_role) + await session.commit() + logger.info(f"Created root role: {root_env.role_name}") + return new_role except SQLAlchemyError as e: - logger.exception("Failed to create root role") + logger.error("Root role creation failed") await session.rollback() raise RuntimeError("Database error during role creation") from e @@ -140,10 +145,10 @@ async def _create_root_user( self, session: AsyncSession, root_env: RootUserEnv, role: Role ) -> None: """ - Create root user with hashed credentials and superuser privileges. + Creates root user account with secure credentials. Args: - session: Async database session + session: Database session root_env: Root environment configuration role: Root role instance @@ -151,14 +156,23 @@ async def _create_root_user( RuntimeError: If user creation fails ValueError: For invalid email format """ - # Validate email format before creation + # Validate email format if not is_valid_email(root_env.email): raise ValueError(f"Invalid root email format: {root_env.email}") try: - root_user = User( + # Check for existing user + existing_user = await session.scalar( + select(User).where(User.username == root_env.username) + ) + if existing_user: + logger.debug(f"Root user already exists: {root_env.username}") + return + + # Create new root user + new_user = User( username=root_env.username, - full_name="Root Administrator", + full_name="System Administrator", email=root_env.email, hashed_password=hash_password(root_env.password), created_at=datetime.now(timezone.utc), @@ -166,77 +180,59 @@ async def _create_root_user( roles=[role], is_active=True, is_superuser=True, + is_protected=True, # Prevent accidental deletion ) - session.add(root_user) + session.add(new_user) await session.commit() - logger.info(f"Root user created: {root_env.username}") + logger.info(f"Created root user: {root_env.username}") except SQLAlchemyError as e: - logger.exception("Failed to create root user") + logger.error("Root user creation failed") await session.rollback() raise RuntimeError("Database error during user creation") from e async def _warn_env_exposure(self, root_env: RootUserEnv) -> None: """ - Generate security warnings about .env file exposure if: - - .env file exists - - Contains root credentials + Generates security warnings about .env file exposure if credentials exist. Args: root_env: Root environment configuration """ - if not os.path.exists(".env"): + env_path = ".env" + if not os.path.exists(env_path): return warning_msg = ( - f"⚠️ SECURITY WARNING: Environment file '.env' contains active " - f"credentials for root user '{root_env.username}'. " - f"Remove root credentials from '.env' in production environments " - f"to prevent unauthorized access. ⚠️" + "SECURITY WARNING: Environment file '.env' contains active root credentials. " + "Remove root credentials from '.env' in production environments." ) logger.warning(warning_msg) async def run(self) -> None: """ Main initialization workflow: - 1. Check for existing users (skip if system already initialized) + 1. Check for existing users 2. Validate environment configuration 3. Create root role and user 4. Initialize access policies - 5. Start policy listener + 5. Start policy synchronization 6. Generate security warnings Raises: - RuntimeError: For any critical initialization failure + RuntimeError: For critical initialization failures """ try: async with get_sql_session() as session: - # Check if system already has users + # Check if system is already initialized user_count = await session.scalar(select(func.count(User.id))) root_env = RootUserEnv() if user_count and user_count > 0: - logger.info( - "Users exist in DB, skipping root account system bootstrap." - ) - - result = await session.execute( - select(User).where(User.username == root_env.username) - ) - existing_root_user = result.scalars().first() - - if existing_root_user and verify_password( - root_env.password, existing_root_user.hashed_password - ): - await self._warn_env_exposure(root_env) + logger.info("Existing users found - skipping root initialization") return # New system initialization - logger.info("Initializing root authentication system") - - # Process security warnings from environment config - for warning in root_env.get_security_warnings(): - logger.warning(warning) + logger.info("Initializing authentication system") # Create root role and user root_role = await self._create_root_role(root_env, session) @@ -249,12 +245,37 @@ async def run(self) -> None: casbin_enforcer = await get_enforcer() await start_redis_policy_listener(casbin_enforcer) - logger.success("Authentication system initialized successfully") + logger.success("Authentication system initialized") await self._warn_env_exposure(root_env) except SQLAlchemyError as e: - logger.exception("Database error during initialization") + logger.critical(f"Database error: {str(e)}") raise RuntimeError("Database operation failed") from e except Exception as e: - logger.exception("Critical initialization error") + logger.critical(f"Initialization failed: {str(e)}") raise RuntimeError("System initialization aborted") from e + + +class TokenCleanUpHook(AddonSetupHook): + """Periodic hook to clean up expired tokens from the database.""" + + async def run(self): + """Executes token cleanup process.""" + try: + await cleanup_expired_tokens() + logger.debug("Expired tokens cleaned up successfully") + except Exception as e: + logger.error(f"Token cleanup failed: {str(e)}") + + +class KeyManagerInitHook(AddonSetupHook): + """Initialization hook for JWT key manager.""" + + async def run(self): + """Initializes the key manager with database session.""" + try: + await key_manager.initialize() + logger.info("Key manager initialized successfully") + except Exception as e: + logger.critical(f"Key manager initialization failed: {str(e)}") + raise RuntimeError("Key manager setup failed") from e From 54acd4af502ae9f357fd5855500150c929d82027 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Mon, 16 Jun 2025 18:35:45 -0400 Subject: [PATCH 02/10] Improve docstrings and prepare for production ready settings --- papi/core/db/sql_session.py | 129 ++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 44 deletions(-) diff --git a/papi/core/db/sql_session.py b/papi/core/db/sql_session.py index 840d3f7..b6f4b03 100644 --- a/papi/core/db/sql_session.py +++ b/papi/core/db/sql_session.py @@ -1,71 +1,112 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from loguru import logger from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import AsyncAdaptedQueuePool from papi.core.settings import get_config +# Module-level logger +log = logger.bind(module="database") + @asynccontextmanager async def get_sql_session() -> AsyncGenerator[AsyncSession, None]: """ - Asynchronous context manager that yields an SQLAlchemy asynchronous session. + Asynchronous context manager for database sessions with production-grade features. + + Features: + - Connection pooling with configurable size + - Automatic transaction management + - Error handling with rollback safety + - Connection recycling + - Timeout configurations + - Comprehensive logging - This function creates a SQLAlchemy async engine and session factory using - the SQL URI specified in the application settings. It ensures that the session - is properly committed or rolled back depending on whether an exception occurs. + Usage: + async with get_sql_session() as session: + result = await session.execute(select(User)) + users = result.scalars().all() Raises: - RuntimeError: If the SQL URI is not configured in `config.yaml`. - SQLAlchemyError: If an exception occurs during the session. + RuntimeError: For configuration errors + SQLAlchemyError: For database operation errors Yields: - AsyncSession: An instance of SQLAlchemy asynchronous session. - - Example: - ```python - async with get_sql_session() as session: - result = await session.execute(select(MyModel)) - data = result.scalars().all() - ``` + AsyncSession: Database session instance """ - settings = get_config() - if not settings.database.sql_uri: - raise RuntimeError("sql_uri is not configured in config.yaml.") - - engine = create_async_engine(settings.database.sql_uri, future=True) - factory = async_sessionmaker(engine, expire_on_commit=False) - - async with factory() as session: - try: - yield session - await session.commit() - except SQLAlchemyError: - await session.rollback() - raise + config = get_config() + sql_uri = config.database.sql_uri + + # Validate configuration + if not sql_uri: + log.critical("Database SQL_URI not configured") + raise RuntimeError("Database configuration missing: SQL_URI not set") + + # Create engine with production-ready settings + engine = create_async_engine( + sql_uri, + future=True, + poolclass=AsyncAdaptedQueuePool, + # pool_size=config.database.pool_size, + # max_overflow=config.database.max_overflow, + # pool_timeout=config.database.pool_timeout, + # pool_recycle=config.database.pool_recycle, + # echo=config.database.echo_sql, + # connect_args={"timeout": config.database.connect_timeout}, + ) + + # Configure session factory + session_factory = async_sessionmaker( + bind=engine, expire_on_commit=False, autoflush=False, class_=AsyncSession + ) + + # Session management + session = session_factory() + try: + log.debug("Database session opened") + yield session + await session.commit() + log.debug("Transaction committed successfully") + except SQLAlchemyError as e: + log.error(f"Database operation failed: {str(e)}") + await session.rollback() + log.warning("Transaction rolled back due to error") + raise + except Exception as e: + log.critical(f"Unexpected error in session: {str(e)}") + await session.rollback() + raise RuntimeError("Database session aborted") from e + finally: + await session.close() + log.debug("Database session closed") async def sql_session() -> AsyncGenerator[AsyncSession, None]: """ - Async generator dependency for frameworks like FastAPI that require dependency injection. - - Yields: - AsyncSession: A SQLAlchemy async session from the context manager. - - Example (FastAPI): - ```python - from fastapi import Depends, APIRouter - from sqlalchemy.ext.asyncio import AsyncSession + Dependency generator for FastAPI route handlers with session management. - router = APIRouter() + Designed for use with FastAPI dependency injection system: + @app.get("/items") + async def get_items(session: AsyncSession = Depends(sql_session)): + ... + Features: + - Proper session lifecycle management + - Automatic commit/rollback + - Error handling + - Resource cleanup - @router.get("/items") - async def list_items(session: AsyncSession = Depends(sql_session)): - result = await session.execute(select(Item)) - return result.scalars().all() - ``` + Yields: + AsyncSession: Database session instance """ async with get_sql_session() as session: - yield session + try: + yield session + finally: + # Ensures session closure even if middleware catches exceptions + if session.is_active: + await session.close() + log.debug("Route session closed in dependency") From 44deb724e3b31aa45db99f15b027b36b37d977ed Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Tue, 17 Jun 2025 12:27:21 -0400 Subject: [PATCH 03/10] Keep it simple: pAPI will not provide inbuild addons, all addons. -image, user-auth and website are moved to the extra_addons branch --- papi/base/image_storage/__init__.py | 15 - papi/base/image_storage/api.py | 363 ---------- papi/base/image_storage/cache.py | 101 --- papi/base/image_storage/config.py | 47 -- papi/base/image_storage/manifest.yaml | 85 --- papi/base/image_storage/models.py | 88 --- papi/base/image_storage/optimization.py | 101 --- papi/base/image_storage/service.py | 209 ------ papi/base/user_auth_system/README.md | 232 ------- papi/base/user_auth_system/__init__.py | 11 - papi/base/user_auth_system/api/__init__.py | 13 - papi/base/user_auth_system/api/auth.py | 342 ---------- papi/base/user_auth_system/api/groups.py | 239 ------- papi/base/user_auth_system/api/policies.py | 243 ------- papi/base/user_auth_system/api/roles.py | 234 ------- papi/base/user_auth_system/api/users.py | 634 ------------------ papi/base/user_auth_system/config.py | 7 - papi/base/user_auth_system/crud/__init__.py | 9 - papi/base/user_auth_system/crud/groups.py | 115 ---- papi/base/user_auth_system/crud/roles.py | 117 ---- papi/base/user_auth_system/crud/users.py | 322 --------- papi/base/user_auth_system/manifest.yaml | 30 - papi/base/user_auth_system/models/__init__.py | 6 - papi/base/user_auth_system/models/audit.py | 21 - papi/base/user_auth_system/models/base.py | 3 - papi/base/user_auth_system/models/casbin.py | 68 -- papi/base/user_auth_system/models/group.py | 92 --- papi/base/user_auth_system/models/jwt_key.py | 48 -- papi/base/user_auth_system/models/role.py | 78 --- papi/base/user_auth_system/models/token.py | 173 ----- papi/base/user_auth_system/models/user.py | 207 ------ .../base/user_auth_system/schemas/__init__.py | 9 - papi/base/user_auth_system/schemas/auth.py | 197 ------ papi/base/user_auth_system/schemas/group.py | 19 - .../base/user_auth_system/schemas/password.py | 15 - papi/base/user_auth_system/schemas/policy.py | 58 -- .../user_auth_system/schemas/registration.py | 19 - papi/base/user_auth_system/schemas/role.py | 23 - papi/base/user_auth_system/schemas/root.py | 144 ---- papi/base/user_auth_system/schemas/user.py | 125 ---- .../user_auth_system/schemas/user_devices.py | 11 - .../user_auth_system/security/__init__.py | 7 - papi/base/user_auth_system/security/audit.py | 81 --- papi/base/user_auth_system/security/auth.py | 141 ---- .../user_auth_system/security/cache_utils.py | 53 -- .../security/casbin_adapter.py | 71 -- .../security/casbin_policies.py | 256 ------- .../user_auth_system/security/dependencies.py | 385 ----------- .../user_auth_system/security/enforcer.py | 274 -------- papi/base/user_auth_system/security/enums.py | 66 -- .../user_auth_system/security/jwt_utils.py | 145 ---- .../user_auth_system/security/key_manager.py | 284 -------- .../base/user_auth_system/security/lockout.py | 91 --- .../user_auth_system/security/password.py | 91 --- .../user_auth_system/security/rate_limit.py | 70 -- papi/base/user_auth_system/security/tokens.py | 424 ------------ papi/base/user_auth_system/security/user.py | 49 -- papi/base/user_auth_system/setup.py | 281 -------- papi/base/website/__init__.py | 1 - papi/base/website/manifest.yaml | 17 - papi/base/website/routes.py | 93 --- papi/cli.py | 414 +++++++----- papi/core/addons.py | 154 +++-- papi/core/cli/registry.py | 58 +- papi/core/init.py | 64 +- papi/core/models/config.py | 2 + 66 files changed, 430 insertions(+), 8015 deletions(-) delete mode 100644 papi/base/image_storage/__init__.py delete mode 100644 papi/base/image_storage/api.py delete mode 100644 papi/base/image_storage/cache.py delete mode 100644 papi/base/image_storage/config.py delete mode 100644 papi/base/image_storage/manifest.yaml delete mode 100644 papi/base/image_storage/models.py delete mode 100644 papi/base/image_storage/optimization.py delete mode 100644 papi/base/image_storage/service.py delete mode 100644 papi/base/user_auth_system/README.md delete mode 100644 papi/base/user_auth_system/__init__.py delete mode 100644 papi/base/user_auth_system/api/__init__.py delete mode 100644 papi/base/user_auth_system/api/auth.py delete mode 100644 papi/base/user_auth_system/api/groups.py delete mode 100644 papi/base/user_auth_system/api/policies.py delete mode 100644 papi/base/user_auth_system/api/roles.py delete mode 100644 papi/base/user_auth_system/api/users.py delete mode 100644 papi/base/user_auth_system/config.py delete mode 100644 papi/base/user_auth_system/crud/__init__.py delete mode 100644 papi/base/user_auth_system/crud/groups.py delete mode 100644 papi/base/user_auth_system/crud/roles.py delete mode 100644 papi/base/user_auth_system/crud/users.py delete mode 100644 papi/base/user_auth_system/manifest.yaml delete mode 100644 papi/base/user_auth_system/models/__init__.py delete mode 100644 papi/base/user_auth_system/models/audit.py delete mode 100644 papi/base/user_auth_system/models/base.py delete mode 100644 papi/base/user_auth_system/models/casbin.py delete mode 100644 papi/base/user_auth_system/models/group.py delete mode 100644 papi/base/user_auth_system/models/jwt_key.py delete mode 100644 papi/base/user_auth_system/models/role.py delete mode 100644 papi/base/user_auth_system/models/token.py delete mode 100644 papi/base/user_auth_system/models/user.py delete mode 100644 papi/base/user_auth_system/schemas/__init__.py delete mode 100644 papi/base/user_auth_system/schemas/auth.py delete mode 100644 papi/base/user_auth_system/schemas/group.py delete mode 100644 papi/base/user_auth_system/schemas/password.py delete mode 100644 papi/base/user_auth_system/schemas/policy.py delete mode 100644 papi/base/user_auth_system/schemas/registration.py delete mode 100644 papi/base/user_auth_system/schemas/role.py delete mode 100644 papi/base/user_auth_system/schemas/root.py delete mode 100644 papi/base/user_auth_system/schemas/user.py delete mode 100644 papi/base/user_auth_system/schemas/user_devices.py delete mode 100644 papi/base/user_auth_system/security/__init__.py delete mode 100644 papi/base/user_auth_system/security/audit.py delete mode 100644 papi/base/user_auth_system/security/auth.py delete mode 100644 papi/base/user_auth_system/security/cache_utils.py delete mode 100644 papi/base/user_auth_system/security/casbin_adapter.py delete mode 100644 papi/base/user_auth_system/security/casbin_policies.py delete mode 100644 papi/base/user_auth_system/security/dependencies.py delete mode 100644 papi/base/user_auth_system/security/enforcer.py delete mode 100644 papi/base/user_auth_system/security/enums.py delete mode 100644 papi/base/user_auth_system/security/jwt_utils.py delete mode 100644 papi/base/user_auth_system/security/key_manager.py delete mode 100644 papi/base/user_auth_system/security/lockout.py delete mode 100644 papi/base/user_auth_system/security/password.py delete mode 100644 papi/base/user_auth_system/security/rate_limit.py delete mode 100644 papi/base/user_auth_system/security/tokens.py delete mode 100644 papi/base/user_auth_system/security/user.py delete mode 100644 papi/base/user_auth_system/setup.py delete mode 100644 papi/base/website/__init__.py delete mode 100644 papi/base/website/manifest.yaml delete mode 100644 papi/base/website/routes.py diff --git a/papi/base/image_storage/__init__.py b/papi/base/image_storage/__init__.py deleted file mode 100644 index ebb91b7..0000000 --- a/papi/base/image_storage/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from .api import ImageResponse -from .api import router as image_router -from .config import ImageStorageConfig -from .models import Image -from .optimization import optimize_image -from .service import ImageService - -__all__ = [ - "ImageResponse", - "image_router", - "ImageStorageConfig", - "Image", - "optimize_image", - "ImageService", -] diff --git a/papi/base/image_storage/api.py b/papi/base/image_storage/api.py deleted file mode 100644 index c486a3d..0000000 --- a/papi/base/image_storage/api.py +++ /dev/null @@ -1,363 +0,0 @@ -from pathlib import Path -from uuid import UUID - -from fastapi import File, UploadFile, status -from fastapi.responses import FileResponse -from pydantic import UUID4, BaseModel - -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter - -from .cache import get_cached_image_info, invalidate_image_cache, set_cached_image_info -from .models import Image, ImageMetadata -from .service import ImageService - -router = RESTRouter(prefix="/images", tags=["Images"]) - -image_service = ImageService() - - -class ImageResponse(BaseModel): - """ - Response model for exposing public image information via the API. - """ - - id: UUID4 - md5: str - file_size: int - mime_type: str - metadata: ImageMetadata - - class Config: - from_attributes = True - - -@router.get("/", response_model=APIResponse, expose_as_mcp_tool=True) -async def list_images(): - """ - List all stored images with their metadata. - - Returns: - APIResponse: A paginated list of images with their metadata. - { - "data": { - "items": [ - { - "id": "uuid", - "md5": "hash-string", - "file_size": 12345, - "mime_type": "image/jpeg", - "metadata": { - "width": 800, - "height": 600, - "format": "JPEG" - } - } - ], - "total": 10 - }, - "message": "Images retrieved successfully." - } - - Raises: - APIException: If there's an error accessing the image storage. - Status codes: - - 500: Internal server error while retrieving images. - - Example: - ```http - GET /images/ - ``` - """ - query = Image.find() - images = await query.to_list() - total = len(images) - return create_response( - data={"items": images, "total": total}, message="Images retrieved successfully." - ) - - -@router.get("/{image_id}") -async def get_image_file(image_id: str): - """ - Retrieve the binary file of an image by its ID. - - This endpoint returns the actual image file. The response includes appropriate - Content-Type and Content-Disposition headers for browser handling. The file path - is cached in Redis to improve performance. - - Args: - image_id (str): UUID of the image to retrieve. - - Returns: - FileResponse: The binary image file with appropriate headers. - Content-Type will match the image's mime_type. - Content-Disposition will be set to 'inline' for browser display. - - Raises: - APIException: - - 404: Image not found - { - "code": "NOT_FOUND", - "message": "Image file not found." - } - - 500: Error accessing image storage - - Example: - ```http - GET /images/123e4567-e89b-12d3-a456-426614174000 - ``` - - Response headers: - ``` - Content-Type: image/jpeg - Content-Disposition: inline; filename="image.jpg" - ``` - """ - # Try to get file path from cache - cached_data = await get_cached_image_info(image_id) - if cached_data and "file_path" in cached_data: - file_path = Path(cached_data["file_path"]) - if file_path.exists(): - return FileResponse( - file_path, - headers={ - "Cache-Control": "public, max-age=31536000", # 1 year - "ETag": cached_data.get("md5", ""), # Use MD5 as ETag - }, - ) - - # If not in cache or file doesn't exist, get from service - file_path = await image_service.get_image_file_path(UUID(image_id)) - if not file_path or not file_path.exists(): - raise APIException( - status_code=status.HTTP_404_NOT_FOUND, - code="NOT_FOUND", - message="Image file not found.", - ) - - # Get image metadata for caching and ETag - image = await image_service.get_image_by_id(UUID(image_id)) - if image: - cache_data = image.model_dump() - cache_data["file_path"] = str(file_path) - await set_cached_image_info(image_id, cache_data) - etag = image.md5 - else: - etag = "" - - return FileResponse( - file_path, - headers={ - "Cache-Control": "public, max-age=31536000", # 1 year - "ETag": etag, - }, - ) - - -@router.get("/{image_id}/metadata", response_model=APIResponse) -async def get_image_info(image_id: str): - """ - Get the metadata for a specific image by its ID. - - This endpoint returns detailed metadata about the image including its - dimensions, format, size, and other technical information. Results are cached - in Redis for improved performance. - - Args: - image_id (str): UUID of the image to retrieve metadata for. - - Returns: - APIResponse: Detailed image metadata. - { - "data": { - "id": "uuid", - "md5": "hash-string", - "file_size": 12345, - "mime_type": "image/jpeg", - "metadata": { - "width": 800, - "height": 600, - "format": "JPEG", - "color_space": "RGB", - "bits_per_pixel": 24 - } - }, - "message": "Image metadata retrieved successfully." - } - - Raises: - APIException: - - 404: Image not found - { - "code": "NOT_FOUND", - "message": "Image file not found." - } - - 500: Error accessing metadata - - Example: - ```http - GET /images/123e4567-e89b-12d3-a456-426614174000/metadata - ``` - - Note: - Results are cached for 1 hour to improve performance. - """ - # Try to get from cache first - cached_data = await get_cached_image_info(image_id) - if cached_data: - return create_response( - data=ImageResponse.model_validate(cached_data), - message="Image metadata retrieved successfully from cache.", - ) - - # If not in cache, get from database - image = await image_service.get_image_by_id(UUID(image_id)) - if not image: - raise APIException( - status_code=status.HTTP_404_NOT_FOUND, - code="NOT_FOUND", - message="Image file not found.", - ) - - # Cache the result - image_data = image.model_dump() - await set_cached_image_info(image_id, image_data) - - return create_response( - data=ImageResponse.model_validate(image), - message="Image metadata retrieved successfully.", - ) - - -@router.post("/", response_model=APIResponse) -async def upload_image(file: UploadFile = File(...)): - """ - Upload and store a new image file. - - The endpoint accepts multipart/form-data with an image file. The image will be - validated, processed, and stored. Metadata like dimensions and format will be - automatically extracted. - - Args: - file (UploadFile): The image file to upload. - Supported formats: JPEG, PNG, GIF, WebP - Max file size: 10MB - - Returns: - APIResponse: The saved image metadata. - { - "data": { - "id": "uuid", - "md5": "hash-string", - "file_size": 12345, - "mime_type": "image/jpeg", - "metadata": { - "width": 800, - "height": 600, - "format": "JPEG" - } - }, - "message": "Image uploaded successfully." - } - - Raises: - APIException: - - 400: Invalid image file or format - { - "code": "INVALID_IMAGE", - "message": "Invalid Image File" - } - - 413: File too large - - 500: Error saving image - { - "code": "IMAGE_SAVE_ERROR", - "message": "Error saving image, due to unexpected server error." - } - - Example: - ```bash - curl -X POST /images/ \\ - -H "Content-Type: multipart/form-data" \\ - -F "file=@image.jpg" - ``` - """ - if not file.filename: - raise APIException( - status_code=status.HTTP_400_BAD_REQUEST, - code="BAD_REQUEST", - message="File name not provided.", - ) - - try: - file_content = await file.read() - image = await image_service.process_and_save_image( - file_content=file_content, - original_filename=file.filename, - ) - return create_response( - data=ImageResponse.model_validate(image), - message="Image uploaded successfully.", - ) - except ValueError as e: - raise APIException( - status_code=status.HTTP_400_BAD_REQUEST, - code="INVALID_IMAGE", - message="Invalid Image File", - detail=str(e), - ) - except Exception: - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - code="IMAGE_SAVE_ERROR", - message="Error saving image, due to unexpected server error.", - ) - - -@router.delete("/{image_id}", response_model=APIResponse) -async def delete_image(image_id: str): - """ - Delete an image by its ID. - - This operation permanently removes both the image file and its associated metadata, - and invalidates any cached data. The operation cannot be undone. - - Args: - image_id (str): UUID of the image to delete. - - Returns: - APIResponse: Success response with deletion confirmation. - { - "message": "Image deleted successfully." - } - - Raises: - APIException: - - 404: Image not found - { - "code": "NOT_FOUND", - "message": "Image not found or could not be deleted." - } - - 500: Error during deletion process - - Example: - ```http - DELETE /images/123e4567-e89b-12d3-a456-426614174000 - ``` - - Note: - - This operation requires appropriate permissions - - The deletion is permanent and cannot be undone - - Both the file and database records will be removed - """ - # Delete the image and invalidate cache - if await image_service.delete_image(UUID(image_id)): - await invalidate_image_cache(image_id) - return create_response(message="Image deleted successfully.") - raise APIException( - status_code=status.HTTP_404_NOT_FOUND, - code="NOT_FOUND", - message="Image not found or could not be deleted.", - ) diff --git a/papi/base/image_storage/cache.py b/papi/base/image_storage/cache.py deleted file mode 100644 index 257efd9..0000000 --- a/papi/base/image_storage/cache.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Redis cache implementation for image storage module. -Uses the global Redis client from papi.core.db. -""" - -import json -from typing import Any, Dict, Optional - -from loguru import logger - -from papi.core.db import get_redis_client - -from .config import images_settings - -# Cache configuration -CACHE_TTL = images_settings.cache_ttl -CACHE_PREFIX = images_settings.cache_prefix - - -async def get_cached_image_info(image_id: str) -> Optional[Dict[str, Any]]: - """ - Retrieve image metadata from Redis cache. - - Args: - image_id (str): The UUID of the image to retrieve - - Returns: - Optional[dict]: The cached image metadata or None if not found - - Note: - Uses the global Redis client from papi.core.db - """ - try: - redis = await get_redis_client() - cached_data = await redis.get(f"{CACHE_PREFIX}{image_id}") - if cached_data: - return json.loads(cached_data) - except Exception as e: - logger.warning(f"Failed to retrieve from cache: {e}") - # Let the system fallback to database - return None - - -async def set_cached_image_info(image_id: str, data: Dict[str, Any]) -> bool: - """ - Store image metadata in Redis cache. - - Args: - image_id (str): The UUID of the image - data (Dict[str, Any]): The image metadata to cache - - Returns: - bool: True if cached successfully, False otherwise - - Note: - Sets TTL automatically based on CACHE_TTL configuration - """ - try: - redis = await get_redis_client() - await redis.set( - f"{CACHE_PREFIX}{image_id}", - json.dumps(data), - ex=CACHE_TTL, - ) - return True - except Exception as e: - logger.warning(f"Failed to store in cache: {e}") - return False - - -async def invalidate_image_cache(image_id: str) -> None: - """ - Remove image metadata from cache. - - Args: - image_id (str): The UUID of the image to remove from cache - - Note: - Silently fails if the key doesn't exist - """ - try: - redis = await get_redis_client() - await redis.delete(f"{CACHE_PREFIX}{image_id}") - except Exception as e: - logger.warning(f"Failed to invalidate cache: {e}") - - -async def clear_all_image_cache() -> None: - """ - Clear all image metadata from cache. - - This is useful for maintenance or when needing to force a refresh - of all cached data. - """ - try: - redis = await get_redis_client() - keys = await redis.keys(f"{CACHE_PREFIX}*") - if keys: - await redis.delete(*keys) - except Exception as e: - logger.warning(f"Failed to clear image cache: {e}") diff --git a/papi/base/image_storage/config.py b/papi/base/image_storage/config.py deleted file mode 100644 index 333c011..0000000 --- a/papi/base/image_storage/config.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Configuration schema for image storage -""" - -from typing import Optional - -from pydantic import BaseModel - -from papi.core.settings import get_config - - -class OptimizationConfig(BaseModel): - """Configuration for image optimization.""" - - max_dimension: int = 2048 # Maximum width/height - jpeg_quality: int = 85 # JPEG quality (0-100) - png_compression: int = 6 # PNG compression level (0-9) - webp_quality: int = 80 # WebP quality (0-100) - force_format: Optional[str] = None # Target format to convert to (e.g., 'WEBP') - - -class ImageStorageConfig(BaseModel): - """Configuration for image storage module.""" - - image_optimization: OptimizationConfig = OptimizationConfig() - cache_ttl: int = 3600 # Cache TTL in seconds - cache_prefix: str = "pAPI:image_storage:" # Redis cache prefix - max_image_size: int = 10 * 1024 * 1024 # Maximum image size in bytes (10 MB) - allowed_formats: list[str] = [ - "JPEG", - "PNG", - "WEBP", - "GIF", - ] # Supported image formats - - # Only local is implemented - storage_backend: str = "local" # Storage backend type (e.g., 'local', 's3') - s3_bucket: Optional[str] = None # S3 bucket name if using S3 backend - s3_region: Optional[str] = None # S3 region if using S3 backend - s3_access_key: Optional[str] = None # S3 access key if using S3 backend - s3_secret_key: Optional[str] = None # S3 secret key if using S3 backend - - -config = get_config() -main_config = config.addons.config.get("image_storage", {}) -images_settings = ImageStorageConfig(**main_config) -image_optimization_settings = images_settings.image_optimization diff --git a/papi/base/image_storage/manifest.yaml b/papi/base/image_storage/manifest.yaml deleted file mode 100644 index 2391fd0..0000000 --- a/papi/base/image_storage/manifest.yaml +++ /dev/null @@ -1,85 +0,0 @@ -version: "0.2.0" -title: "Image Storage" -description: > - Service for storing and managing images with support for metadata extraction, - EXIF processing, and efficient file organization. Features Redis-based caching, - duplicate detection via MD5 hashing, hierarchical storage structure, and - automatic image optimization with format-specific compression. - -authors: - - name: "pAPI Team" - -maintainers: - - name: "Eduardo Fírvida" - email: "efirvida@gmail.com" - -keywords: - - image - - storage - - metadata - - EXIF - - file management - - redis - - caching - -python_dependencies: - - pillow - -endpoints: - - path: /images/ - method: GET - description: List all stored images with their metadata. - features: - - Returns paginated list of images - - Includes basic metadata for each image - - Image size and format information - - - path: /images/ - method: POST - description: Upload and store a new image file. - features: - - MIME type detection - - MD5-based duplicate detection - - EXIF metadata extraction - - Hierarchical file storage - - Automatic cache updates - - Image optimization and compression - - Format-specific quality settings - - Automatic size reduction - - Optional format conversion - - - path: /images/{image_id} - method: GET - description: Retrieve the binary file of an image by its ID. - features: - - Redis-based path caching - - Appropriate Content-Type headers - - Content-Disposition headers - - Browser caching support - - - path: /images/{image_id}/metadata - method: GET - description: Get detailed metadata for a specific image. - features: - - Redis-based metadata caching - - Complete EXIF data - - Image technical information - - Format details - - - path: /images/{image_id} - method: DELETE - description: Remove an image and its associated data. - features: - - File removal - - Cache invalidation - - Storage cleanup - - Metadata cleanup - -# Future planned features: -# - Thumbnail generation -# - Quota management -# - Size validation -# - Malware scanning -# - Monitoring and metrics -# - WebP conversion options -# - Progressive JPEG support \ No newline at end of file diff --git a/papi/base/image_storage/models.py b/papi/base/image_storage/models.py deleted file mode 100644 index f357bd3..0000000 --- a/papi/base/image_storage/models.py +++ /dev/null @@ -1,88 +0,0 @@ -from pathlib import Path -from typing import Optional -from uuid import UUID, uuid4 - -from beanie import Document -from pydantic import BaseModel, ConfigDict, Field - - -class ImageMetadata(BaseModel): - """ - Model to store optional metadata extracted from the image. - """ - - width: Optional[int] = Field( - default=None, description="Width of the image in pixels." - ) - height: Optional[int] = Field( - default=None, description="Height of the image in pixels." - ) - format: Optional[str] = Field( - default=None, description="Image format (e.g., JPEG, PNG)." - ) - mode: Optional[str] = Field(default=None, description="Color mode (e.g., RGB, L).") - exif: dict = Field( - default_factory=dict, description="EXIF metadata extracted from the image." - ) - optimization: dict = Field( - default_factory=dict, description="Optimization data for image compression." - ) - - model_config = {"populate_by_name": True} - - -class Image(Document): - """Image document model for storing image metadata and file references.""" - - id: UUID = Field( - default_factory=uuid4, - alias="_id", - description="Unique image identifier (UUID).", - ) - md5: str = Field(index=True, description="MD5 checksum of the image file.") - file_size: int = Field(description="Size of the image file in bytes.") - mime_type: str = Field(description="MIME type of the image (e.g., image/png).") - metadata: ImageMetadata = Field(description="Embedded image metadata.") - - def storage_path(self, storage_root: Path) -> Path: - """Get the physical storage path for this image.""" - image_id = str(self.id) - file_name = f"{self.id}.{self.mime_type.split('/')[-1]}" - subdirs = [image_id[:2], image_id[2:4], image_id[4:6]] - return storage_root.joinpath(*subdirs, file_name) - - class Settings: - name = "images" - use_state_management = True - use_uuid_primary_key = True - - model_config = ConfigDict( - arbitrary_types_allowed=True, - json_schema_extra={ - "example": { - "id": "654b7cc7-c578-4d53-a5ca-43396d62fdb8", - "md5": "d41d8cd98f00b204e9800998ecf8427e", - "file_size": 1024, - "mime_type": "image/png", - "metadata": { - "width": 800, - "height": 600, - "format": "PNG", - "mode": "RGB", - }, - } - }, - ) - - @classmethod - async def get_image_by_id(cls, image_id: UUID) -> Optional["Image"]: - """ - Retrieve image metadata from the database by its ID. - - Args: - image_id (UUID): Image UUID. - - Returns: - Optional[Image]: Image document if found, otherwise None. - """ - return await cls.get(image_id) diff --git a/papi/base/image_storage/optimization.py b/papi/base/image_storage/optimization.py deleted file mode 100644 index b07fdec..0000000 --- a/papi/base/image_storage/optimization.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Image optimization and compression utilities. -""" - -from io import BytesIO -from typing import Tuple - -from loguru import logger -from PIL import Image -from PIL.Image import Resampling - -from .config import OptimizationConfig - - -def optimize_image( - image_data: bytes, config: OptimizationConfig = OptimizationConfig() -) -> Tuple[bytes, dict]: - """ - Optimize an image by resizing and compressing it according to configuration. - - Args: - image_data: Raw image bytes - config: Optimization configuration - - Returns: - Tuple containing: - - Optimized image bytes - - Dictionary with optimization metadata - """ - try: - # Open image - img = Image.open(BytesIO(image_data)) - original_format = img.format - original_size = len(image_data) - - # Convert RGBA to RGB if alpha channel is not needed - if img.mode == "RGBA" and not _needs_alpha(img): - img = img.convert("RGB") - - # Resize if needed - if max(img.size) > config.max_dimension: - img = _resize_image(img, config.max_dimension) - - # Determine output format - output_format = config.force_format or original_format - - # Apply format-specific optimizations - output_buffer = BytesIO() - optimize_kwargs = _get_optimization_params(output_format, config) - - # Save optimized image - img.save(output_buffer, format=output_format, **optimize_kwargs) - optimized_data = output_buffer.getvalue() - - metadata = { - "original_size": original_size, - "optimized_size": len(optimized_data), - "original_format": original_format, - "output_format": output_format, - "dimensions": img.size, - "compression_ratio": len(optimized_data) / original_size, - } - - return optimized_data, metadata - - except Exception as e: - logger.error(f"Image optimization failed: {e}") - return image_data, {"error": str(e)} - - -def _needs_alpha(img: Image.Image) -> bool: - """Check if image actually uses alpha channel.""" - if "A" not in img.getbands(): - return False - - # Check if alpha channel has any non-fully-opaque pixels - alpha = img.getchannel("A") - return alpha.getextrema()[0] < 255 - - -def _resize_image(img: Image.Image, max_dimension: int) -> Image.Image: - """Resize image maintaining aspect ratio.""" - ratio = max_dimension / max(img.size) - if ratio < 1: - new_size = tuple(int(dim * ratio) for dim in img.size) - return img.resize(new_size, Resampling.LANCZOS) - return img - - -def _get_optimization_params(format: str, config: OptimizationConfig) -> dict: - """Get format-specific optimization parameters.""" - params = {"optimize": True} - - if format == "JPEG": - params["quality"] = config.jpeg_quality - elif format == "PNG": - params["compress_level"] = config.png_compression - elif format == "WEBP": - params["quality"] = config.webp_quality - - return params diff --git a/papi/base/image_storage/service.py b/papi/base/image_storage/service.py deleted file mode 100644 index ecb25ab..0000000 --- a/papi/base/image_storage/service.py +++ /dev/null @@ -1,209 +0,0 @@ -import hashlib -from io import BytesIO -from pathlib import Path -from typing import Any, Dict, Optional -from uuid import UUID - -import filetype -from loguru import logger -from PIL import Image as PILImage -from PIL.ExifTags import TAGS - -from papi.core.settings import get_config - -from .config import image_optimization_settings -from .models import Image, ImageMetadata -from .optimization import optimize_image - - -class ImageService: - """ - Service responsible for processing, storing, and retrieving images - using a hierarchical file system and metadata indexing. - """ - - def __init__(self) -> None: - """ - Initialize the image service with configuration. - """ - config = get_config() - storage_root = config.storage.images or "data/images" - - # Initialize optimization config with defaults - self.optimization_config = image_optimization_settings - self.storage_root = Path(storage_root) - self.storage_root.mkdir(parents=True, exist_ok=True) - - def _calculate_md5(self, content: bytes) -> str: - """ - Calculate the MD5 hash of the given binary content. - - Args: - content (bytes): Binary content of the file. - - Returns: - str: MD5 hash string. - """ - return hashlib.md5(content).hexdigest() - - def _extract_exif(self, image: PILImage.Image) -> Dict[str, Any]: - """ - Extract EXIF metadata from a PIL image object safely. - """ - try: - exif_data = {} - if hasattr(image, "getexif") and callable(image.getexif): - exif = image.getexif() - if exif: - for tag_id in exif: - tag_name = TAGS.get(tag_id, tag_id) - value = exif[tag_id] - if isinstance(value, bytes): - try: - value = value.decode("utf-8") - except UnicodeDecodeError: - value = value.hex() - exif_data[tag_name] = value - return exif_data - except Exception as e: - logger.warning(f"Error extracting EXIF data: {e}") - return {} - - def _build_file_path(self, image_id: str, extension: str) -> Path: - """ - Build a hierarchical file path for the image using its ID. - - Args: - image_id (str): Unique ID of the image. - extension (str): File extension (e.g. '.jpg'). - - Returns: - Path: Full path where the image should be stored. - """ - subdirs = [image_id[:2], image_id[2:4], image_id[4:6]] - file_dir = self.storage_root.joinpath(*subdirs) - file_dir.mkdir(parents=True, exist_ok=True) - return file_dir / f"{image_id}{extension}" - - async def process_and_save_image( - self, - file_content: bytes, - original_filename: str, - **extra_metadata: Any, - ) -> Image: - """ - Process and save a new image, extracting metadata and avoiding duplicates. - Now includes optimization and compression. - - Args: - file_content (bytes): Raw binary content of the image file. - original_filename (str): Name of the uploaded file. - **extra_metadata: Additional metadata to store with the image. - - Returns: - Image: Saved Image document. - """ - # Optimize the image - optimized_content, optimization_info = optimize_image( - file_content, self.optimization_config - ) - - # Calculate MD5 of optimized content - md5_hash = self._calculate_md5(optimized_content) - - # Check for duplicates - existing_image = await Image.find_one({"md5": md5_hash}) - if existing_image: - return existing_image - - # Load image for metadata extraction - img = PILImage.open(BytesIO(optimized_content)) - - # Create ImageMetadata instance - metadata = ImageMetadata( - width=img.width, - height=img.height, - format=img.format, - mode=img.mode, - optimization=optimization_info, - exif=self._extract_exif(img), - **extra_metadata, - ) - - # Get file type for mime type - file_type = filetype.guess(optimized_content) - mime_type = file_type.mime if file_type else "application/octet-stream" - extension = file_type.extension if file_type else "bin" - - # Create and save image document to generate UUID - image = Image( - md5=md5_hash, - file_size=len(optimized_content), - mime_type=mime_type, - metadata=metadata, - ) - await image.save() - - # Save the physical file using the generated UUID - file_path = self._build_file_path(str(image.id), f".{extension}") - file_path.write_bytes(optimized_content) - - return image - - async def get_image_by_id(self, image_id: UUID) -> Optional[Image]: - """ - Retrieve image metadata from the database by its ID. - - Args: - image_id (str): Image UUID. - - Returns: - Optional[Image]: Image document if found, otherwise None. - """ - return await Image.get(image_id) - - async def get_image_file_path(self, image_id: UUID) -> Optional[Path]: - """ - Retrieve the physical path of an image by its ID. - - Args: - image_id (str): Image UUID. - - Returns: - Optional[Path]: Path to the image file if found. - """ - image = await self.get_image_by_id(image_id) - if not image: - return None - return image.storage_path(storage_root=self.storage_root) - - async def delete_image(self, image_id: UUID) -> bool: - """ - Delete both the image file and its metadata record. - - Args: - image_id (str): Image UUID. - - Returns: - bool: True if deletion was successful, False otherwise. - """ - image = await self.get_image_by_id(image_id) - if not image: - return False - - file_path = image.storage_path(self.storage_root) - if file_path.exists(): - try: - file_path.unlink() - - # Attempt to clean up empty parent directories - for parent in file_path.parents: - if parent == self.storage_root: - break - if not any(parent.iterdir()): - parent.rmdir() - except Exception: - pass - - await image.delete() - return True diff --git a/papi/base/user_auth_system/README.md b/papi/base/user_auth_system/README.md deleted file mode 100644 index 10a22da..0000000 --- a/papi/base/user_auth_system/README.md +++ /dev/null @@ -1,232 +0,0 @@ -# User Authentication and Authorization System - -A robust, high-performance user authentication and authorization system implementing both Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC) for secure identity and permission management in FastAPI applications. - -## Key Features - -### Authentication -- **JWT-Based Authentication** - - Secure token-based authentication with OAuth2 - - Token rotation and automatic expiration - - Historical key support for graceful key rotation - - Brute force protection with sliding window rate limiting - - Configurable token expiration and refresh - - Password hashing with bcrypt - - User session management - - Account lockout protection - - IP-based security measures - -- **Comprehensive Authorization** - - Role-Based Access Control (RBAC) - - Attribute-Based Access Control (ABAC) - - Fine-grained permission management using Casbin policies - - Dynamic policy evaluation and enforcement - - Support for role hierarchies - - Group-based access control - -- **User Management** - - Complete user CRUD operations - - Group management with hierarchical structure - - Role assignments and inheritance - - User activation/deactivation - - Password management and reset capabilities - - User profile management (avatar, full name, etc.) - -- **Security Features** - - Comprehensive audit logging of all security events - - Security event tracking with timestamps - - Account state management (active/inactive) - - System lockout protection for critical resources - - Token rotation and expiration management - - SQL injection protection through SQLAlchemy - - XSS protection through FastAPI's automatic response sanitization - -## API Endpoints - -### Authentication -- `POST /auth/token` - - **Purpose**: Obtain JWT access token - - **Input**: OAuth2 form with username and password - - **Returns**: JWT token with expiration - - **Security**: Implements brute force protection and account lockout - -### Users Management -- `GET /users` - - **Purpose**: List all users - - **Parameters**: - - `skip`: Number of records to skip (pagination) - - `limit`: Maximum number of records to return - - **Returns**: List of user objects with roles and groups - - **Required Role**: Admin or appropriate read permissions - -- `POST /users` - - **Purpose**: Create new user - - **Input**: UserCreate schema with: - - username (required) - - email (required) - - password (required) - - full_name (optional) - - avatar (optional) - - is_active (default: true) - - roles (optional) - - groups (optional) - - **Returns**: Created user object - - **Required Role**: Admin - -- `PATCH /users/{user_id}` - - **Purpose**: Update user details - - **Parameters**: User ID - - **Input**: UserUpdate schema with modifiable fields - - **Returns**: Updated user object - - **Required Role**: Admin or user self-update - -- `DELETE /users/{user_id}` - - **Purpose**: Delete user - - **Parameters**: User ID - - **Returns**: Success message - - **Required Role**: Admin - -### Groups Management -- `GET /groups` - - **Purpose**: List all groups - - **Returns**: List of group objects with members - - **Required Role**: Root or appropriate read permissions - -- `POST /groups` - - **Purpose**: Create new group - - **Input**: GroupCreate schema with name and description - - **Returns**: Created group object - - **Required Role**: Root - -- `PATCH /groups/{group_id}` - - **Purpose**: Update group details - - **Parameters**: Group ID - - **Input**: GroupUpdate schema - - **Returns**: Updated group object - - **Required Role**: Root - -- `DELETE /groups/{group_id}` - - **Purpose**: Delete group - - **Parameters**: Group ID - - **Returns**: Success message - - **Required Role**: Root - -## Security Enums - -### Audit Log Keys -- `LOGIN_SUCCESS`: Successful login attempt -- `LOGIN_FAILED`: Failed login attempt -- `ACCOUNT_LOCKED`: Account locked due to multiple failures -- `SYSTEM_LOCKOUT`: System-wide lockout triggered -- `PERMISSION_DENIED`: Access denied to resource -- `KEY_ROTATION`: Security key rotation event -- `LOGIN_INACTIVE`: Login attempt on inactive account -- `ACCESS_ATTEMPT_INACTIVE`: Resource access attempt by inactive user - -### Policy Actions -- `ALL`: Wildcard permission for all actions -- `READ`: View or retrieve resources -- `WRITE`: Create or submit new data -- `UPDATE`: Modify existing resources -- `DELETE`: Remove resources -- `PATCH`: Partial update of resources -- `EXECUTE`: Execute commands or operations -- `APPROVE`: Approve actions or resources -- `REJECT`: Reject actions or resources - -## Database Schema -- `users`: Core user information and authentication -- `roles`: Role definitions and permissions -- `groups`: Group management -- `user_roles`: Many-to-many user-role relationships -- `user_groups`: Many-to-many user-group relationships -- `auth_rules`: Casbin policy rules storage - -## Dependencies - -- FastAPI: ^0.100.0 - Modern web framework for building APIs -- SQLAlchemy: ^2.0.0 - SQL toolkit and ORM -- python-jose[cryptography]: ^3.3.0 - JWT token handling -- passlib[bcrypt]: ^1.7.4 - Password hashing -- casbin: ^1.1.1 - Authorization library -- redis: ^4.5.0 (optional) - Distributed caching -- loguru: ^0.7.0 - Advanced logging - -## Installation - -```bash -# Install from requirements -pip install -r requirements.txt - -# Set up environment variables -export SECRET_KEY="your-secret-key" -export ACCESS_TOKEN_EXPIRE_MINUTES=30 -``` - -## Configuration and Security Best Practices - -### Environment Variables -```bash -# Required Settings -SECRET_KEY="your-secure-secret-key" # Min 32 chars -ACCESS_TOKEN_EXPIRE_MINUTES=30 # Token lifetime -REFRESH_TOKEN_EXPIRE_DAYS=7 # Refresh token lifetime - -# Optional Security Settings -MAX_LOGIN_ATTEMPTS=5 # Failed login threshold -LOCKOUT_DURATION=15 # Minutes to lock account -ALGORITHM="HS256" # JWT algorithm -DEBUG=false # Disable in production - -# Cache Configuration -REDIS_URL="redis://localhost:6379/0" # Optional Redis cache -CACHE_TTL=3600 # Cache lifetime in seconds -``` - -### Security Recommendations - -1. **Token Management** - - Use strong secret keys (min 32 characters) - - Enable short token expiration (30 minutes recommended) - - Implement token rotation - - Use secure cookie storage for tokens - -2. **Rate Limiting Configuration** - - Set appropriate attempt thresholds (default: 5) - - Use sliding window rate limiting - - Implement IP-based blocking - - Configure adequate block duration - -3. **Caching Strategy** - - Enable Redis for distributed environments - - Set appropriate TTL values - - Implement cache invalidation - - Handle cache failures gracefully - -4. **Audit Configuration** - - Enable comprehensive logging - - Monitor authentication failures - - Track permission violations - - Configure log rotation - - Sanitize sensitive data - -## Authors and Maintainers -- **pAPI Team** -- **Eduardo Fírvida** (efirvida@gmail.com) - -## Version History - -### 1.0.0 (2025-06-05) -- Implemented rate limiting and brute force protection -- Added comprehensive audit logging system -- Improved caching with Redis support -- Enhanced security with token rotation -- Added detailed security documentation -- Fixed performance issues in user authentication -- Improved error handling and logging - -### 0.1.0 (Initial Release) -- Basic authentication and authorization -- Role and group-based access control -- User management features -- Basic security features diff --git a/papi/base/user_auth_system/__init__.py b/papi/base/user_auth_system/__init__.py deleted file mode 100644 index 38e57c3..0000000 --- a/papi/base/user_auth_system/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .api import * -from .models import Group, Role, User -from .security.dependencies import permission_required -from .security.enums import PolicyAction, PolicyEffect -from .setup import AuthSystemInitializer - -__all__ = [ - "permission_required", - "PolicyAction", - "PolicyEffect", -] diff --git a/papi/base/user_auth_system/api/__init__.py b/papi/base/user_auth_system/api/__init__.py deleted file mode 100644 index 3d2b593..0000000 --- a/papi/base/user_auth_system/api/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .auth import router as auth_router -from .policies import router as policies_router -from .users import router as users_router -from .roles import router as roles_router -from .groups import router as groups_router - -__all__ = [ - "auth_router", - "policies_router", - "users_router", - "roles_router", - "groups_router", -] diff --git a/papi/base/user_auth_system/api/auth.py b/papi/base/user_auth_system/api/auth.py deleted file mode 100644 index 5bf6bc2..0000000 --- a/papi/base/user_auth_system/api/auth.py +++ /dev/null @@ -1,342 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Annotated - -from fastapi import BackgroundTasks, Body, Depends, HTTPException, Request, status -from loguru import logger -from sqlalchemy import select, update - -from papi.core.db import get_sql_session -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter -from user_auth_system.config import security -from user_auth_system.models.token import AccessToken, RefreshToken -from user_auth_system.schemas import LoginRequest, Token, UserRead -from user_auth_system.security.auth import authenticate_user -from user_auth_system.security.dependencies import PREFIX, get_current_active_user -from user_auth_system.security.tokens import ( - create_access_token, - create_or_replace_refresh_token, - revoke_access_token_from_request, - revoke_refresh_token, - validate_token, -) - -router = RESTRouter(prefix=PREFIX, tags=["Authentication"]) - - -@router.post( - "/login", - response_model=Token, - summary="Authenticate user and obtain tokens", - status_code=status.HTTP_200_OK, -) -async def login_for_access_token( - login_request: LoginRequest, - request: Request, - background_tasks: BackgroundTasks, -) -> Token: - """ - Authenticates user credentials and issues JWT tokens. - - Security features: - - Brute-force protection - - Device-based authentication - - Secure token issuance with rotation - - Audit logging - - Args: - login_request: User credentials and device info - request: HTTP request object - background_tasks: For security background processing - - Returns: - Token response with access and refresh tokens - - Raises: - APIException: For authentication failures or locked accounts - """ - client_ip = request.client.host if request.client else "unknown" - username = login_request.username.strip() - masked_username = f"{username[:3]}***" # For secure logging - - try: - # Authenticate user with brute-force protection - user = await authenticate_user( - username, login_request.password, request, background_tasks - ) - except HTTPException as exc: - logger.warning(f"Account lockout for IP {client_ip} user '{masked_username}'") - raise APIException( - status_code=status.HTTP_423_LOCKED, - message="System temporarily locked due to excessive attempts", - code="account_locked", - ) from exc - - if not user: - logger.info(f"Failed login from {client_ip} for user '{masked_username}'") - raise APIException( - status_code=status.HTTP_401_UNAUTHORIZED, - message="Incorrect username or password", - code="authentication_failed", - ) - - logger.info(f"Successful login: '{user.username}' from {client_ip}") - - # Validate device ID presence - device_id = (login_request.device_id or "").strip() - if not device_id: - logger.warning(f"Missing device ID for user '{user.username}'") - raise APIException( - status_code=status.HTTP_400_BAD_REQUEST, - message="Device ID is required", - code="missing_device_id", - ) - - # Prepare token metadata - user_agent = request.headers.get("User-Agent", "unknown")[:500] - - # Issue tokens - access_token = await create_access_token( - data={"sub": user.username, "device_id": device_id} - ) - refresh_token, _, _ = await create_or_replace_refresh_token( - subject=user.username, - device_id=device_id, - user_agent=user_agent, - ) - - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type="bearer", - ) - - -@router.post( - "/refresh", - response_model=Token, - summary="Refresh access token", - status_code=status.HTTP_200_OK, -) -async def refresh_token_endpoint( - refresh_token: Annotated[str, Body(..., embed=True)], -) -> Token: - """ - Issues new access and refresh tokens using a valid refresh token. - - Security process: - 1. Validates refresh token - 2. Checks token status in database - 3. Revokes old refresh token - 4. Issues new token pair - - Args: - refresh_token: Valid refresh token string - - Returns: - New token pair with expiration information - - Raises: - APIException: 401 for invalid tokens - APIException: 500 for internal errors - """ - try: - # Validate token structure and signature - payload = validate_token(refresh_token, expected_type="refresh") - jti = payload["jti"] - subject = payload["sub"] - device_id = payload["device_id"] - - async with get_sql_session() as session: - # Verify token status in database - db_token = await session.scalar( - select(RefreshToken).where(RefreshToken.jti == jti) - ) - - if not db_token: - logger.warning(f"Refresh token not found: {jti}") - raise APIException( - status_code=status.HTTP_401_UNAUTHORIZED, - code="UNAUTHORIZED", - message="Invalid refresh token", - ) - - if db_token.revoked: - logger.info(f"Attempted use of revoked token: {jti}") - raise APIException( - status_code=status.HTTP_401_UNAUTHORIZED, - code="UNAUTHORIZED", - message="Token has been revoked", - ) - - if db_token.expires_at < datetime.now(timezone.utc): - logger.info(f"Expired token presented: {jti}") - raise APIException( - status_code=status.HTTP_401_UNAUTHORIZED, - code="UNAUTHORIZED", - message="Token expired", - ) - - # Token rotation: revoke old, issue new - await revoke_refresh_token(jti) - new_refresh_token, _, _ = await create_or_replace_refresh_token( - subject, db_token.device_id, db_token.user_agent - ) - - # Create new access token - access_token = await create_access_token( - data={"sub": subject, "device_id": device_id} - ) - - # Calculate expiration time for response - access_exp = datetime.now(timezone.utc) + timedelta( - minutes=security.access_token_expire_minutes - ) - - return Token( - access_token=access_token, - refresh_token=new_refresh_token, - token_type="bearer", - expires_in=access_exp, - ) - - except HTTPException: - # Re-raise handled authentication errors - raise - except Exception as e: - logger.exception(f"Token refresh failed: {str(e)}") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - message="Token refresh failed", - code="token_refresh_error", - ) - - -@router.post( - "/logout", - summary="Logout from current device", - response_model=APIResponse, - status_code=status.HTTP_200_OK, -) -async def logout_current_device( - request: Request, - refresh_token: Annotated[str, Body(embed=True)], -) -> APIResponse: - """ - Revokes both access and refresh tokens for the current device session. - - Security process: - 1. Validates refresh token - 2. Revokes access token from request header - 3. Revokes presented refresh token - - Args: - request: HTTP request containing access token - refresh_token: Refresh token to revoke - - Returns: - Success confirmation - - Raises: - APIException: For invalid tokens or revocation failures - """ - try: - # Validate refresh token - payload = validate_token(refresh_token, expected_type="refresh") - - # Revoke both tokens - await revoke_access_token_from_request(request) - await revoke_refresh_token(payload["jti"]) - - logger.info(f"Successful logout for device {payload['device_id']}") - return create_response(message="Logged out successfully") - - except HTTPException as he: - logger.warning(f"Logout warning: {he.detail}") - raise APIException( - status_code=status.HTTP_400_BAD_REQUEST, - message="Logout failed", - code="logout_failed", - detail=he.detail, - ) - except Exception as e: - logger.exception(f"Logout error: {str(e)}") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - code="internal_error", - message="Logout failed", - ) - - -@router.post( - "/logout-all", - summary="Logout from all devices", - response_model=APIResponse, - status_code=status.HTTP_200_OK, -) -async def logout_all_devices( - current_user: Annotated[UserRead, Depends(get_current_active_user)], -) -> APIResponse: - """ - Revokes all active tokens for the authenticated user across all devices. - - Use cases: - - Account compromise recovery - - Full account logout - - Security policy enforcement - - Args: - current_user: Authenticated user from dependency - - Returns: - Success confirmation with revocation counts - - Raises: - APIException: For revocation failures - """ - username = current_user.username - try: - async with get_sql_session() as session: - # Revoke all refresh tokens - refresh_result = await session.execute( - update(RefreshToken) - .where( - RefreshToken.subject == username, - RefreshToken.revoked.is_(False), - ) - .values(revoked=True, revoked_at=datetime.now(timezone.utc)) - ) - refresh_count = refresh_result.rowcount - - # Revoke all access tokens - access_result = await session.execute( - update(AccessToken) - .where( - AccessToken.subject == username, - AccessToken.revoked.is_(False), - ) - .values(revoked=True, revoked_at=datetime.now(timezone.utc)) - ) - access_count = access_result.rowcount - - await session.commit() - - logger.info( - f"Logged out all devices: " - f"{refresh_count} refresh tokens and " - f"{access_count} access tokens revoked for '{username}'" - ) - - return create_response( - message=f"Logged out of all devices. {refresh_count + access_count} tokens revoked" - ) - - except Exception as e: - logger.exception(f"Logout-all failed for '{username}': {str(e)}") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - code="logout_error", - message="Full logout failed", - ) diff --git a/papi/base/user_auth_system/api/groups.py b/papi/base/user_auth_system/api/groups.py deleted file mode 100644 index d419603..0000000 --- a/papi/base/user_auth_system/api/groups.py +++ /dev/null @@ -1,239 +0,0 @@ -from fastapi import Path, status -from sqlalchemy.exc import IntegrityError - -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter -from user_auth_system.crud.groups import ( - create_group, - delete_group, - get_groups, - update_group, -) -from user_auth_system.schemas import Group, GroupBase, GroupCreate -from user_auth_system.security.dependencies import permission_required -from user_auth_system.security.enums import PolicyAction - -router = RESTRouter(prefix="/groups", tags=["Users Management & Access Control"]) - - -@router.get( - "/", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ, required_roles=["root"])], -) -async def list_groups() -> APIResponse: - """ - Retrieve a list of all user groups in the system. - - This endpoint provides a comprehensive list of all user groups with their basic information. - Access to this endpoint requires root privileges or specific read permissions. - Groups are essential for role-based access control and permission management. - - Permissions Required: - - PolicyAction.READ permission - - 'root' role membership - - Returns: - APIResponse: Response object containing: - - data: List of Group objects with: - - id: Unique group identifier - - name: Group name - - description: Group description - - message: Success confirmation message - - status: HTTP status code - - Security: - - Requires authentication - - Role-based access control - - Activity is logged for audit purposes - """ - groups = await get_groups() - data = [ - Group( - name=g.name, - id=g.id, - description=g.description, - ) - for g in groups - if g is not None - ] - return create_response(data=data, message="All groups retrieved successfully.") - - -@router.post( - "/", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.WRITE, required_roles=["root"])], -) -async def create_new_group(group: GroupCreate) -> APIResponse: - """ - Create a new user group in the system. - - This endpoint allows creation of new groups for organizing users and managing permissions. - Groups are fundamental components of the RBAC system and can be assigned specific permissions. - - Permissions Required: - - PolicyAction.WRITE permission - - 'root' role membership - - Args: - group (GroupCreate): Group creation data with: - - name: Unique group name (required) - - description: Group description (optional) - - Returns: - APIResponse: Response object containing: - - data: Created Group object with: - - id: Generated group ID - - name: Group name - - description: Group description - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_409_CONFLICT: If group name already exists - - HTTP_500_INTERNAL_SERVER_ERROR: For database integrity errors - - Security: - - Requires authentication - - Role-based access control - - Input validation - - Activity logging - """ - try: - new_group = await create_group(group) - return create_response( - data=Group( - name=new_group.name, - id=new_group.id, - description=new_group.description, - ), - message=f"New group '{group.name}' created successfully.", - ) - except ValueError: - raise APIException( - code="ALREADY_EXISTS", - message=f"Group '{group.name}' already exists.", - status_code=status.HTTP_409_CONFLICT, - ) - except IntegrityError: - raise APIException( - code="INTEGRITY_ERROR", - message="Database integrity error while creating group.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -@router.patch( - "/{group_id}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.PATCH, required_roles=["root"])], -) -async def update_existing_group( - group_id: int = Path(..., title="Group ID", ge=1), - group_in: GroupBase = ..., -) -> APIResponse: - """ - Update an existing group's information by its ID. - - This endpoint allows modification of group details while maintaining group relationships - and permissions. The update operation is atomic and validates all constraints. - - Permissions Required: - - PolicyAction.PATCH permission - - 'root' role membership - - Args: - group_id (int): Unique identifier of the group to update (Path parameter) - group_in (GroupBase): Updated group data with: - - name: New group name (optional) - - description: New group description (optional) - - Returns: - APIResponse: Response object containing: - - data: Updated Group object with: - - id: Group ID - - name: Updated name - - description: Updated description - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If group with given ID doesn't exist - - HTTP_409_CONFLICT: If new name conflicts with existing group - - Security: - - Requires authentication - - Role-based access control - - Input validation - - Activity logging - """ - updated = await update_group(group_id, group_in) - if not updated: - raise APIException( - code="NOT_FOUND", - message=f"Group with ID '{group_id}' not found.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response( - data=Group( - name=updated.name, - id=updated.id, - description=updated.description, - ), - message=f"Group '{updated.name}' updated successfully.", - ) - - -@router.delete( - "/{group_id}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.DELETE, required_roles=["root"])], -) -async def delete_existing_group( - group_id: int = Path(..., title="Group ID", ge=1), -) -> APIResponse: - """ - Delete an existing group from the system. - - This endpoint permanently removes a group and all its associations. - The operation will fail if the group doesn't exist or if it's a protected system group. - Group deletion will automatically remove all group memberships but won't affect users. - - Permissions Required: - - PolicyAction.DELETE permission - - 'root' role membership - - Args: - group_id (int): Unique identifier of the group to delete (Path parameter, must be >= 1) - - Returns: - APIResponse: Response object containing: - - message: Success confirmation message - - status: HTTP status code - - data: None - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If group with given ID doesn't exist - - HTTP_400_BAD_REQUEST: If attempting to delete a protected system group - - HTTP_409_CONFLICT: If group has active dependencies - - Security: - - Requires authentication - - Role-based access control - - Deletion validation - - Activity logging for audit trail - """ - success = await delete_group(group_id) - if not success: - raise APIException( - code="NOT_FOUND", - message=f"Group with ID '{group_id}' not found.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response(message="Group deleted successfully.") diff --git a/papi/base/user_auth_system/api/policies.py b/papi/base/user_auth_system/api/policies.py deleted file mode 100644 index 841d558..0000000 --- a/papi/base/user_auth_system/api/policies.py +++ /dev/null @@ -1,243 +0,0 @@ -from fastapi import status -from loguru import logger -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError - -from papi.core.db import get_sql_session -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter -from user_auth_system.models.casbin import AuthRules -from user_auth_system.schemas import PolicyCreate, PolicyInDB -from user_auth_system.security.dependencies import permission_required -from user_auth_system.security.enforcer import get_enforcer -from user_auth_system.security.enums import PolicyAction - -router = RESTRouter( - prefix="/access-control", - tags=["Users Management & Access Control"], -) - - -@router.post( - "/policies", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.WRITE, required_roles=["root"])], -) -async def create_policy(policy_rule: PolicyCreate) -> APIResponse: - """ - Create a new access control policy in the Casbin policy store. - - This endpoint allows creation of fine-grained access control policies that define - what actions users or roles can perform on specific resources. Policies are the - cornerstone of the ABAC (Attribute-Based Access Control) system. - - Permissions Required: - - PolicyAction.WRITE permission - - 'root' role membership - - Args: - policy_rule (PolicyCreate): Policy creation data containing: - - policy: List containing the policy rule elements: - [0]: subject (user/role) - [1]: object (resource) - [2]: action (permitted operation) - [3]: condition (optional) - [4]: effect (allow/deny) - - Returns: - APIResponse: Response object containing: - - data: Boolean indicating success - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_409_CONFLICT: If policy already exists - - HTTP_500_INTERNAL_SERVER_ERROR: If policy creation fails - - HTTP_403_FORBIDDEN: If user lacks required permissions - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Requires authentication - - Root-only access - - Policy validation - - Automatic policy reloading - - Activity logging - - Conflict prevention - - Example Policy: - - Subject: "role:admin" - - Object: "users" - - Action: "create" - - Condition: null - - Effect: "allow" - """ - enforcer = await get_enforcer() - try: - # Check if policy already exists - if enforcer.has_policy(*policy_rule.policy): - raise APIException( - status_code=status.HTTP_409_CONFLICT, - message="Policy already exists.", - detail="POLICY_ALREADY_EXISTS", - ) - - created = await enforcer.add_policy(*policy_rule.policy) - await enforcer.load_policy() - - return create_response(data=created, message="New policy created") - except Exception as e: - logger.exception("Error creating policy") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - message=f"Failed to create policy: {str(e)}", - detail="POLICY_CREATION_ERROR", - ) - - -@router.get( - "/policies", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ, required_roles=["root"])], -) -async def list_policies() -> APIResponse: - """ - Retrieve all defined access control policies from the system. - - This endpoint provides access to all Casbin policy rules that define the access - control matrix for the system. It returns both user-specific and role-based - policies, allowing administrators to audit and manage access control rules. - - Permissions Required: - - PolicyAction.READ permission - - 'root' role membership - - Returns: - APIResponse: Response object containing: - - data: List of PolicyInDB objects, each with: - - id: Unique policy identifier - - ptype: Policy type ('p' for policy) - - subject: User or role the policy applies to - - object: Resource being protected - - action: Permitted action - - condition: Additional conditions (optional) - - effect: Policy effect (allow/deny) - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_500_INTERNAL_SERVER_ERROR: For database errors - - HTTP_403_FORBIDDEN: If user lacks required permissions - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Requires authentication - - Root-only access - - Query optimization - - Activity logging - - Rate limiting - - Notes: - - Only returns active policies (ptype='p') - - Policies are foundational to both RBAC and ABAC - - Used for auditing and compliance verification - """ - try: - async with get_sql_session() as session: - query = select(AuthRules).where(AuthRules.ptype == "p") - result = await session.execute(query) - - data = [ - PolicyInDB( - id=rule.id, - ptype=rule.ptype, - subject=rule.v0, - object=rule.v1, - action=rule.v2, - condition=rule.v3, - effect=rule.v4, - ) - for rule in result.scalars().all() - ] - - return create_response(data=data, message="Policies listed successfully") - except SQLAlchemyError: - logger.exception("Database error while listing policies") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - code="INTERNAL_SERVER_ERROR", - message="Database error while listing policies.", - detail="LIST_POLICIES_ERROR", - ) - - -@router.delete( - "/policies/{id}", - dependencies=[permission_required(PolicyAction.DELETE, required_roles=["root"])], -) -async def delete_policy(id: int) -> None: - """ - Delete a specific access control policy from the system. - - This endpoint allows removal of individual policy rules by their unique identifier. - Deletion of policies should be done carefully as it directly affects access control. - The system prevents deletion of critical system policies. - - Permissions Required: - - PolicyAction.DELETE permission - - 'root' role membership - - Args: - id (int): The unique identifier of the policy to delete - - Returns: - APIResponse: Response object containing: - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If policy with given ID doesn't exist - - HTTP_403_FORBIDDEN: If attempting to delete protected policy - - HTTP_500_INTERNAL_SERVER_ERROR: For database errors - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Requires authentication - - Root-only access - - Protected policy validation - - Transaction safety - - Activity logging - - Casbin enforcer reload - - Impact: - - Immediately affects access control decisions - - May require cache invalidation - - Affects all users/roles referenced in policy - - Logged for audit purposes - """ - try: - async with get_sql_session() as session: - rule = await session.get(AuthRules, id) - if not rule: - raise APIException( - status_code=status.HTTP_404_NOT_FOUND, - code="NOT_FOUND", - message="Policy not found", - ) - - await session.delete(rule) - await session.commit() - - enforcer = await get_enforcer() - await enforcer.load_policy() - except SQLAlchemyError: - logger.exception("Database error while deleting policy") - raise APIException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - message="Database error while deleting policy.", - detail="DELETE_POLICY_ERROR", - ) diff --git a/papi/base/user_auth_system/api/roles.py b/papi/base/user_auth_system/api/roles.py deleted file mode 100644 index cd2a092..0000000 --- a/papi/base/user_auth_system/api/roles.py +++ /dev/null @@ -1,234 +0,0 @@ -from fastapi import status -from sqlalchemy.exc import IntegrityError - -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter -from user_auth_system.crud.roles import ( - create_role, - delete_role, - get_roles, - update_role, -) -from user_auth_system.schemas import Role, RoleBase, RoleCreate -from user_auth_system.security.dependencies import permission_required -from user_auth_system.security.enums import PolicyAction - -router = RESTRouter( - prefix="/roles", - tags=["Users Management & Access Control"], -) - - -@router.get( - "/", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ, required_roles=["root"])], -) -async def list_roles() -> APIResponse: - """ - Retrieve a list of all roles in the system. - - This endpoint provides access to all defined roles in the system. Roles are fundamental - components of the RBAC system and define sets of permissions that can be assigned to users. - Only root users can access this endpoint to maintain security. - - Permissions Required: - - PolicyAction.READ permission - - 'root' role membership - - Returns: - APIResponse: Response object containing: - - data: List of Role objects, each with: - - id: Unique role identifier - - name: Role name - - description: Role description - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_403_FORBIDDEN: If user lacks root privileges - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Requires authentication - - Root-only access - - Activity logging - - Role hierarchy validation - """ - roles = await get_roles() - data = [ - Role( - name=g.name, - id=g.id, - description=g.description, - ) - for g in roles - if g is not None - ] - return create_response(data=data, message="All roles retrieved successfully.") - - -@router.post( - "/", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.WRITE, required_roles=["root"])], -) -async def create_new_role(role: RoleCreate) -> APIResponse: - """ - Create a new role in the system. - - This endpoint allows creation of new roles for permission management. Roles are used - to group permissions and can be assigned to users. Only root users can create roles - to maintain security integrity. - - Permissions Required: - - PolicyAction.WRITE permission - - 'root' role membership - - Args: - role (RoleCreate): Role creation data containing: - - name: Unique role name (required) - - description: Role description (optional) - - permissions: List of permission strings (optional) - - Returns: - APIResponse: Response object containing: - - data: Created Role object with: - - id: Generated role ID - - name: Role name - - description: Role description - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_409_CONFLICT: If role name already exists - - HTTP_500_INTERNAL_SERVER_ERROR: For database integrity errors - - HTTP_403_FORBIDDEN: If user lacks root privileges - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Requires authentication - - Root-only access - - Name uniqueness validation - - Permission validation - - Activity logging - """ - try: - new_role = await create_role(role) - return create_response( - data=Role( - name=new_role.name, - id=new_role.id, - description=new_role.description, - ), - message=f"New role '{new_role.name}' created successfully.", - ) - except ValueError: - raise APIException( - code="ALREADY_EXISTS", - message=f"Role '{role.name}' already exists.", - status_code=status.HTTP_409_CONFLICT, - ) - except IntegrityError: - raise APIException( - code="INTEGRITY_ERROR", - message="Database integrity error while creating role.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - except Exception as e: - raise APIException( - code="UNEXPECTED_ERROR", - message=f"Unexpected error: {str(e)}", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -@router.patch( - "/{role_id}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.PATCH, required_roles=["root"])], -) -async def edit_role(role_id: int, role_data: RoleBase) -> APIResponse: - """ - Update an existing role's information. - - This endpoint allows modification of role details and permissions. Only root users - can modify roles to ensure system security. Some system-defined roles may have - additional protection against modifications. - - Permissions Required: - - PolicyAction.PATCH permission - - 'root' role membership - - Args: - role_id (int): The unique identifier of the role to update - role_data (RoleBase): Update data containing: - - name: New role name (optional) - - description: New role description (optional) - - permissions: Updated permission list (optional) - - Returns: - APIResponse: Response object containing: - - data: Updated Role object with: - - id: Role ID - - name: Updated role name - - description: Updated description - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If role with given ID doesn't exist - - HTTP_409_CONFLICT: If new role name conflicts - - HTTP_403_FORBIDDEN: If attempting to modify protected role - - HTTP_401_UNAUTHORIZED: If not authenticated - - HTTP_400_BAD_REQUEST: If no update data provided - - Security: - - Requires authentication - - Root-only access - - Protected role validation - - Permission integrity checks - - Activity logging - - Change tracking - """ - updated = await update_role(role_id, role_data) - if not updated: - raise APIException( - code="NOT_FOUND", - message=f"Role with ID '{role_id}' not found.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response( - data=Role( - name=updated.name, - id=updated.id, - description=updated.description, - ), - message=f"Role '{updated.name}' updated successfully.", - ) - - -@router.delete( - "/{role_id}", - dependencies=[permission_required(PolicyAction.DELETE, required_roles=["root"])], -) -async def delete_existing_role(role_id: int): - """ - Delete a role by ID. - - Raises: - - APIException: If role does not exist. - """ - success = await delete_role(role_id) - if not success: - raise APIException( - code="NOT_FOUND", - message=f"Role with ID '{role_id}' not found.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response(message="Role deleted successfully.") diff --git a/papi/base/user_auth_system/api/users.py b/papi/base/user_auth_system/api/users.py deleted file mode 100644 index 774ee16..0000000 --- a/papi/base/user_auth_system/api/users.py +++ /dev/null @@ -1,634 +0,0 @@ -from typing import Annotated, Optional - -from fastapi import Body, Depends, Query, status -from sqlalchemy.exc import IntegrityError - -from papi.core.exceptions import APIException -from papi.core.models.response import APIResponse -from papi.core.response import create_response -from papi.core.router import RESTRouter -from user_auth_system.config import auth_settings -from user_auth_system.crud.users import ( - create_user, - delete_user, - get_all_users, - get_user_by_username, - get_user_devices, - update_user, -) -from user_auth_system.schemas import ( - UserAdminUpdate, - UserCreate, - UserInDB, - UserPublic, - UserRead, - UserSelfUpdate, - UsersListResponse, -) -from user_auth_system.security.dependencies import ( - get_current_active_user, - permission_required, -) -from user_auth_system.security.enums import PolicyAction -from user_auth_system.security.password import hash_password -from user_auth_system.security.tokens import ( - revoke_access_token_by_device_id, - revoke_refresh_token, -) - -router = RESTRouter(prefix="/user", tags=["Users Management & Access Control"]) - - -@router.get( - "/", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ)], -) -async def list_users( - skip: int = Query(0, description="Pagination offset"), - limit: int = Query(100, description="Items per page", le=200), - is_active: Optional[bool] = Query(None, description="Filter by active status"), - username: Optional[str] = Query( - None, description="Filter by username (partial match)" - ), - email: Optional[str] = Query(None, description="Filter by email (partial match)"), - role: Optional[str] = Query(None, description="Filter by role name"), - group: Optional[str] = Query(None, description="Filter by group name"), -): - """ - List and filter users in the system with comprehensive filtering options. - - This endpoint provides paginated access to the user database with multiple filtering options. - Results are sorted and can be filtered by various user attributes. - Includes user status, role assignments, and group memberships. - - Permissions Required: - - PolicyAction.READ permission - - Administrative access - - Args: - skip (int): Number of records to skip for pagination (default: 0) - limit (int): Maximum number of records to return (default: 100, max: 200) - is_active (bool, optional): Filter by user active status - username (str, optional): Filter by partial username match - email (str, optional): Filter by partial email match - role (str, optional): Filter by exact role name - group (str, optional): Filter by exact group name - - Returns: - APIResponse: Response object containing: - - data: UsersListResponse object with: - - users: List of UserPublic objects - - total: Total number of users matching filters - - page: Current page number - - per_page: Items per page - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_403_FORBIDDEN: If user lacks required permissions - - HTTP_400_BAD_REQUEST: If invalid filter parameters are provided - - Security: - - Requires authentication - - Role-based access control - - Pagination to prevent data dumps - - Data filtering and sanitization - """ - users = await get_all_users( - skip=skip, - limit=limit, - is_active=is_active, - username=username, - email=email, - role_name=role, - group_name=group, - ) - - page = (skip // limit) if limit else 0 - - data = UsersListResponse( - users=[format_user_public(user) for user in users["users"]], - total=users.get("total", 0), - page=page, - per_page=limit, - ) - return create_response( - data=data, success=True, message="Users retrieved successfully." - ) - - -@router.post("/", response_model=APIResponse) -async def create_new_user(user_data: UserCreate): - """ - Create a new user account in the system. - - This endpoint handles new user registration with automatic password hashing, - role assignment, and validation. Can be disabled system-wide through settings. - - The endpoint performs multiple validations: - - Username/email uniqueness - - Password strength requirements - - Required field validation - - Role and group assignment permissions - - Args: - user_data (UserCreate): User creation data containing: - - username: Unique username (required) - - email: Valid email address (required) - - password: Strong password meeting requirements (required) - - full_name: User's full name (optional) - - is_active: Account status (default: True) - - roles: List of role names to assign (optional) - - groups: List of group names to assign (optional) - - Returns: - APIResponse: Response object containing: - - data: Created UserPublic object with: - - id: Generated user ID - - username: User's username - - email: User's email - - full_name: User's full name - - is_active: Account status - - roles: Assigned roles - - groups: Assigned groups - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_400_BAD_REQUEST: If registration is disabled - - HTTP_409_CONFLICT: If username/email already exists - - HTTP_422_UNPROCESSABLE_ENTITY: If validation fails - - Security: - - Password hashing - - Input validation - - Duplicate prevention - - Role validation - - Activity logging - """ - if not auth_settings.allow_registration: - raise APIException( - message="User registration is currently disabled.", - status_code=status.HTTP_400_BAD_REQUEST, - code="BAD_REQUEST", - ) - - try: - created_user = await create_user(user_data) - return create_response( - data=format_user_public(created_user), - message="User created successfully.", - ) - except IntegrityError: - raise APIException( - message="Username or email already exists.", - status_code=status.HTTP_409_CONFLICT, - code="CONFLICT", - ) - except ValueError as e: - raise APIException( - message=str(e.args[0]) if e.args else "Server error.", - status_code=status.HTTP_412_PRECONDITION_FAILED, - code="PRECONDITION_FAILED", - ) - except Exception: - raise APIException( - message="Failed to create user due to server error.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - code="INTERNAL_SERVER_ERROR", - ) - - -@router.get( - "/me", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ)], -) -async def get_current_user_info( - current_user: Annotated[UserRead, Depends(get_current_active_user)], -): - """ - Retrieve the current authenticated user's public profile and information. - - This endpoint allows users to access their own profile information, including - roles, groups, and permissions. Sensitive data like password hashes are excluded. - - Permissions Required: - - PolicyAction.READ permission - - Valid authentication token - - Returns: - APIResponse: Response object containing: - - data: UserPublic object with: - - username: User's username - - email: User's email address - - full_name: User's full name - - is_active: Account status - - roles: List of assigned roles - - groups: List of group memberships - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_401_UNAUTHORIZED: If not authenticated - - HTTP_403_FORBIDDEN: If insufficient permissions - - HTTP_404_NOT_FOUND: If user account not found - - Security: - - Requires valid JWT token - - Role-based access control - - Data sanitization - """ - return create_response( - data=format_user_public(current_user), message="User retrieved successfully." - ) - - -@router.get( - "/me/devices", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ)], -) -async def get_current_user_devices( - current_user: Annotated[UserRead, Depends(get_current_active_user)], -): - """ - Retrieves all registered devices with active refresh tokens. - """ - return create_response( - data=await get_user_devices(current_user.username), - message="User devices retrieved successfully.", - ) - - -@router.post( - "/me/devices/{session_id}/{device_id}/logout", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ)], -) -async def logout_current_user_device( - device_id: str, - session_id: str, - current_user: Annotated[UserRead, Depends(get_current_active_user)], -): - """ - Retrieves all registered devices with active refresh tokens. - """ - await revoke_refresh_token(refresh_jti=session_id) - await revoke_access_token_by_device_id(device_id=device_id) - return create_response( - data=await get_user_devices(current_user.username), - message=f"Device {device_id} logged out", - ) - - -@router.patch( - "/me", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.PATCH)], -) -async def update_current_user_info( - current_user: Annotated[UserRead, Depends(get_current_active_user)], - update_data: UserSelfUpdate = Body(...), -): - """ - Update the current authenticated user's profile information. - - This endpoint allows users to modify their own profile data including email, - password, and personal information. Certain fields like roles and permissions - cannot be modified through this endpoint. - - Permissions Required: - - PolicyAction.PATCH permission - - Valid authentication token - - Args: - current_user (UserRead): Current authenticated user (from token) - update_data (UserSelfUpdate): Update data containing: - - email: New email address (optional) - - password: New password (optional) - - full_name: New full name (optional) - - avatar: New avatar URL (optional) - - Returns: - APIResponse: Response object containing: - - data: Updated UserPublic object - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_400_BAD_REQUEST: If no update data provided - - HTTP_404_NOT_FOUND: If user not found - - HTTP_409_CONFLICT: If email already in use - - HTTP_500_INTERNAL_SERVER_ERROR: For server errors - - Security: - - Password hashing for password updates - - Input validation - - Field-level access control - - Update logging - - Conflict prevention - """ - data = update_data.model_dump(exclude_unset=True) - - if not data: - raise APIException( - message="No update data provided.", - status_code=status.HTTP_400_BAD_REQUEST, - code="NO_DATA", - ) - - if "password" in data: - data["hashed_password"] = hash_password(data.pop("password")) - - try: - updated_user = await update_user(current_user.username, data) - if not updated_user: - raise APIException( - message="User not found.", - status_code=status.HTTP_404_NOT_FOUND, - code="NOT_FOUND", - ) - - return create_response( - data=format_user_public(updated_user), message="User updated successfully." - ) - - except IntegrityError: - raise APIException( - status_code=status.HTTP_409_CONFLICT, - code="INTEGRITY_ERROR", - message="Email or username already in use.", - ) - except Exception: - raise APIException( - code="UNEXPECTED_ERROR", - message="Failed to update user due to server error.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -@router.delete( - "/me", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.DELETE)], -) -async def remove_current_user( - current_user: Annotated[UserRead, Depends(get_current_active_user)], -): - """ - Delete the currently authenticated user's account. - - This endpoint allows users to remove their own account from the system. - The operation is irreversible and removes all user data, roles, and group memberships. - Superusers/admins are prevented from deleting their own accounts for security. - - Permissions Required: - - PolicyAction.DELETE permission - - Valid authentication token - - Args: - current_user (UserRead): Current authenticated user (from token) - - Returns: - APIResponse: Response object containing: - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_403_FORBIDDEN: If user is a superuser - - HTTP_404_NOT_FOUND: If user not found - - HTTP_401_UNAUTHORIZED: If not authenticated - - HTTP_500_INTERNAL_SERVER_ERROR: For server errors - - Security: - - Requires authentication - - Superuser protection - - Cascade deletion handling - - Activity logging - - Session invalidation - """ - if current_user.is_superuser: - raise APIException( - message="Admins cannot delete their own account.", - code="FORBIDDEN", - status_code=status.HTTP_403_FORBIDDEN, - ) - success = await delete_user(current_user.username) - if not success: - raise APIException( - message="User not found.", - code="NOT_FOUND", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response(message="User account deleted successfully.") - - -@router.get( - "/{username}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.READ)], -) -async def read_user_by_username(username: str): - """ - Retrieve a user's public profile by their username. - - This endpoint allows querying user information by username. It returns the public - profile information while protecting sensitive data. Useful for user lookups - and profile viewing. - - Permissions Required: - - PolicyAction.READ permission - - Valid authentication token - - Args: - username (str): The username of the user to retrieve - - Returns: - APIResponse: Response object containing: - - data: UserPublic object with: - - username: User's username - - email: User's email (if visible) - - full_name: User's full name - - is_active: Account status - - roles: List of assigned roles - - groups: List of group memberships - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If user not found - - HTTP_403_FORBIDDEN: If insufficient permissions - - HTTP_401_UNAUTHORIZED: If not authenticated - - Security: - - Data visibility control - - Permission checking - - Input validation - - Activity logging - - Rate limiting - """ - user = await get_user_by_username(username) - if not user: - raise APIException( - message="User not found.", - code="NOT_FOUND", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response( - data=format_user_public(user), message="User retrieved successfully." - ) - - -@router.patch( - "/{username}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.PATCH)], -) -async def update_user_by_username( - username: str, - update_data: UserAdminUpdate = Body(...), -): - """ - Update an existing user's profile by an administrator. - - This endpoint allows administrators to modify user profiles, including sensitive - fields like activation status and role assignments. Special protections are in - place for superuser accounts. - - Permissions Required: - - PolicyAction.PATCH permission - - Administrative privileges - - Valid authentication token - - Args: - username (str): Username of the user to update - update_data (UserAdminUpdate): Update data containing: - - email: New email address (optional) - - password: New password (optional) - - full_name: New full name (optional) - - is_active: Account status (optional) - - is_superuser: Superuser status (optional, restricted) - - roles: New role assignments (optional) - - groups: New group assignments (optional) - - Returns: - APIResponse: Response object containing: - - data: Updated UserPublic object - - message: Success confirmation message - - status: HTTP status code - - Raises: - APIException: - - HTTP_404_NOT_FOUND: If user not found - - HTTP_400_BAD_REQUEST: If no update data provided - - HTTP_409_CONFLICT: If email/username conflict - - HTTP_403_FORBIDDEN: If trying to modify protected fields - - HTTP_500_INTERNAL_SERVER_ERROR: For server errors - - Security: - - Role-based access control - - Superuser protection - - Password hashing - - Field-level permissions - - Activity logging - - Input validation - """ - target_user = await get_user_by_username(username) - if not target_user: - raise APIException( - message="User not found.", - code="NOT_FOUND", - status_code=status.HTTP_404_NOT_FOUND, - ) - - update_fields = update_data.model_dump(exclude_unset=True) - if not update_fields: - raise APIException( - message="No update data provided.", - code="NO_DATA", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - if "password" in update_fields: - password = update_fields.pop("password") - update_fields["hashed_password"] = hash_password(password) - - if not target_user.is_superuser: - for field in {"is_active", "is_superuser"}: - update_fields.pop(field, None) - - try: - updated_user = await update_user(username, update_fields) - if not updated_user: - raise APIException( - code="NOT_FOUND", - message="User not found during update.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response( - data=format_user_public(updated_user), message="User updated successfully." - ) - - except IntegrityError: - raise APIException( - code="INTEGRITY_ERROR", - message="Email or username already in use.", - status_code=status.HTTP_409_CONFLICT, - ) - except Exception: - raise APIException( - code="UNEXPECTED_ERROR", - message="Failed to update user due to server error.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -@router.delete( - "/{username}", - response_model=APIResponse, - dependencies=[permission_required(PolicyAction.DELETE)], -) -async def remove_user_by_username( - username: str, - current_user: Annotated[UserRead, Depends(get_current_active_user)], -): - """ - Delete a user account by username. - """ - if current_user.is_superuser and current_user.username == username: - raise APIException( - code="FORBIDDEN", - message="Admins cannot delete their own account.", - status_code=status.HTTP_403_FORBIDDEN, - ) - success = await delete_user(username) - if not success: - raise APIException( - code="NOT_FOUND", - message="User not found.", - status_code=status.HTTP_404_NOT_FOUND, - ) - return create_response(message="User account deleted successfully.") - - -# Utility functions - - -def format_user_public(user: UserRead | UserInDB) -> UserPublic: - """ - Convert a UserRead instance to a public-facing UserPublic object. - """ - user_data = user.model_dump(exclude={"hashed_password", "groups", "roles"}) - return UserPublic( - **user_data, - roles=[role.name for role in user.roles], - groups=[attr.name for attr in user.groups], - ) diff --git a/papi/base/user_auth_system/config.py b/papi/base/user_auth_system/config.py deleted file mode 100644 index 043dc6d..0000000 --- a/papi/base/user_auth_system/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from papi.core.settings import get_config -from user_auth_system.schemas import AuthSettings, BaseSecurity - -config = get_config() -main_config = config.addons.config.get("user_auth_system", {}) -auth_settings = AuthSettings(**main_config) -security: BaseSecurity = auth_settings.security diff --git a/papi/base/user_auth_system/crud/__init__.py b/papi/base/user_auth_system/crud/__init__.py deleted file mode 100644 index fd4ac86..0000000 --- a/papi/base/user_auth_system/crud/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .groups import create_group, get_groups, update_group, delete_group -from .roles import create_role, get_roles, update_role, delete_role -from .users import ( - create_user, - delete_user, - get_all_users, - get_user_by_username, - update_user, -) diff --git a/papi/base/user_auth_system/crud/groups.py b/papi/base/user_auth_system/crud/groups.py deleted file mode 100644 index 68f34c1..0000000 --- a/papi/base/user_auth_system/crud/groups.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import Optional - -from sqlalchemy.exc import IntegrityError -from sqlalchemy.future import select - -from papi.core.db import get_sql_session -from user_auth_system.models import Group -from user_auth_system.schemas import GroupBase, GroupCreate - - -async def get_groups() -> list[Group]: - """ - Retrieve all groups from the database. - - Returns: - list[Group]: A list of all group objects. - """ - async with get_sql_session() as db: - result = await db.execute(select(Group)) - return result.scalars().all() - - -async def create_group(group_in: GroupCreate) -> Group: - """ - Create a new group in the database. - - Args: - group_in (GroupCreate): Group data to be created. - - Returns: - Group: The newly created group object. - - Raises: - ValueError: If the group already exists or cannot be created. - """ - async with get_sql_session() as db: - result = await db.execute(select(Group).where(Group.name == group_in.name)) - existing_group = result.scalar_one_or_none() - if existing_group: - raise ValueError(f"Group '{group_in.name}' already exists.") - - group = Group(**group_in.model_dump()) - db.add(group) - try: - await db.commit() - await db.refresh(group) - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not create group '{group_in.name}'.") - - return group - - -async def update_group(group_id: int, group_in: GroupBase) -> Optional[Group]: - """ - Update an existing group by ID. - - Args: - group_id (int): The ID of the group to update. - group_in (GroupBase): The fields to update. - - Returns: - Optional[Group]: The updated group object if found, otherwise None. - - Raises: - ValueError: If the update operation fails. - """ - async with get_sql_session() as db: - result = await db.execute(select(Group).where(Group.id == group_id)) - group = result.scalar_one_or_none() - - if not group: - return None - - for key, value in group_in.model_dump(exclude_unset=True).items(): - setattr(group, key, value) - - try: - await db.commit() - await db.refresh(group) - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not update group with ID {group_id}.") - - return group - - -async def delete_group(group_id: int) -> bool: - """ - Delete a group by ID. - - Args: - group_id (int): The ID of the group to delete. - - Returns: - bool: True if the group was deleted, False if not found. - - Raises: - ValueError: If the deletion operation fails. - """ - async with get_sql_session() as db: - result = await db.execute(select(Group).where(Group.id == group_id)) - group = result.scalar_one_or_none() - - if not group: - return False - - await db.delete(group) - try: - await db.commit() - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not delete group with ID {group_id}.") - - return True diff --git a/papi/base/user_auth_system/crud/roles.py b/papi/base/user_auth_system/crud/roles.py deleted file mode 100644 index a2b5204..0000000 --- a/papi/base/user_auth_system/crud/roles.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import Optional - -from loguru import logger -from sqlalchemy.exc import IntegrityError -from sqlalchemy.future import select - -from papi.core.db import get_sql_session -from user_auth_system.models import Role -from user_auth_system.schemas import RoleBase, RoleCreate - - -async def get_roles() -> list[Role]: - """ - Retrieve all roles from the database. - - Returns: - list[Role]: A list of all role objects. - """ - async with get_sql_session() as db: - result = await db.execute(select(Role)) - return result.scalars().all() - - -async def create_role(role_in: RoleCreate) -> Role: - """ - Creates a new role in the database if it does not already exist. - - Args: - role_in (RoleCreate): The input data containing the name and attributes of the role to be created. - - Returns: - Role: The newly created role object, or the existing one if it already exists. - - Raises: - ValueError: If the role could not be created due to a database integrity error. - """ - async with get_sql_session() as db: - result = await db.execute(select(Role).where(Role.name == role_in.name)) - existing_role = result.scalar_one_or_none() - if existing_role: - logger.warning(f"Role '{role_in.name}' already exists.") - return existing_role - - role = Role(**role_in.model_dump()) - db.add(role) - try: - await db.commit() - await db.refresh(role) - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not create role '{role_in.name}'.") - - return role - - -async def update_role(role_id: int, role_in: RoleBase) -> Optional[Role]: - """ - Update an existing role by ID. - - Args: - role_id (int): The ID of the role to update. - role_in (RoleBase): The fields to update. - - Returns: - Optional[Role]: The updated role object if found, otherwise None. - - Raises: - ValueError: If the update operation fails. - """ - async with get_sql_session() as db: - result = await db.execute(select(Role).where(Role.id == role_id)) - role = result.scalar_one_or_none() - - if not role: - return None - - for key, value in role_in.model_dump(exclude_unset=True).items(): - setattr(role, key, value) - - try: - await db.commit() - await db.refresh(role) - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not update role with ID {role_id}.") - - return role - - -async def delete_role(role_id: int) -> bool: - """ - Delete a role by ID. - - Args: - role_id (int): The ID of the role to delete. - - Returns: - bool: True if the role was deleted, False if not found. - - Raises: - ValueError: If the deletion operation fails. - """ - async with get_sql_session() as db: - result = await db.execute(select(Role).where(Role.id == role_id)) - role = result.scalar_one_or_none() - - if not role: - return False - - await db.delete(role) - try: - await db.commit() - except IntegrityError: - await db.rollback() - raise ValueError(f"Could not delete role with ID {role_id}.") - - return True diff --git a/papi/base/user_auth_system/crud/users.py b/papi/base/user_auth_system/crud/users.py deleted file mode 100644 index 947d25c..0000000 --- a/papi/base/user_auth_system/crud/users.py +++ /dev/null @@ -1,322 +0,0 @@ -from datetime import datetime, timezone -from typing import Dict, List, Optional, Union - -from loguru import logger -from sqlalchemy import func, select -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import selectinload - -from papi.core.db import get_sql_session -from user_auth_system.config import auth_settings -from user_auth_system.crud.roles import create_role -from user_auth_system.models import Group, Role, User -from user_auth_system.models.token import RefreshToken -from user_auth_system.schemas import ( - Device, - PolicyCreate, - RoleCreate, - RoleRead, - UserCreate, - UserInDB, -) -from user_auth_system.security.casbin_policies import add_policy -from user_auth_system.security.enums import PolicyAction, PolicyEffect -from user_auth_system.security.password import hash_password - - -async def get_user_by_username(username: str) -> Optional[UserInDB]: - """ - Retrieve a user and their relationships from the database by username. - - Args: - username (str): Username to search for. - - Returns: - Optional[UserInDB]: A validated user object, or None if not found. - """ - try: - async with get_sql_session() as session: - stmt = ( - select(User) - .where(User.username == username) - .options( - selectinload(User.roles), - selectinload(User.groups), - ) - ) - result = await session.execute(stmt) - user = result.scalar_one_or_none() - - if user is None: - logger.trace(f"User not found: {username}") - return None - - return UserInDB.model_validate(user) - except SQLAlchemyError: - logger.exception("SQLAlchemy error while fetching user.") - raise - except Exception: - logger.exception("Unexpected error while fetching user.") - raise - - -async def create_user(user: UserCreate) -> UserInDB: - """ - Creates a new user in the database with proper role assignment and security measures. - - Args: - user_data: Dictionary containing validated user data with keys: - - username: Unique user identifier - - email: Valid email address - - password: Plaintext password (will be hashed) - - full_name: Optional full name - - is_active: Account status flag (default True) - - is_superuser: Admin privileges flag (default False) - - Returns: - UserInDB: Created user object with populated fields - - Raises: - ValueError: For invalid input data - - Steps: - 1. Hash password - 2. Create user in database - 3. Create roles if needed - 4. Assign default roles to the new user - 5. Create ABAC policies for the new user - 6. Return complete user object - """ - - async with get_sql_session() as session: - try: - existing_user = await session.execute( - select(User).where( - (User.username == user.username) | (User.email == user.email) - ) - ) - result = existing_user.scalar_one_or_none() - if result: - if result.username == user.username: - detail = f"Username {user.username} already exists" - else: - detail = f"Email {user.email} already registered" - logger.warning(f"User creation aborted: {detail}") - raise ValueError(detail) - - user_data = user.model_dump(exclude_unset=True) - - # Hash password before storage - if "password" in user_data: - user_data["hashed_password"] = hash_password(user_data.pop("password")) - else: - raise ValueError("Password is required for new users") - - # Create user object - new_user = User(**user_data) - session.add(new_user) - await session.commit() - - result = await session.execute( - select(User) - .options(selectinload(User.roles)) - .where(User.id == new_user.id) - ) - new_user = result.scalar_one() - logger.info(f"User created: {new_user.username}") - - # Create roles and assign - assigned_roles = [] - for role_name in auth_settings.default_user_roles: - role_schema = RoleCreate(name=role_name, description="") - role = await create_role(role_schema) - if role not in new_user.roles: - new_user.roles.append(role) - assigned_roles.append(role) - - base_usre_policy = PolicyCreate( - subject=f"role:{role.name}", - object="/user/me", - action=PolicyAction.ALL, - condition=f"'{role.name}' in r.sub['roles']", - effect=PolicyEffect.ALLOW, - ) - - await add_policy(base_usre_policy) - - await session.commit() - logger.info( - f"Assigned roles to {new_user.username}: {[r.name for r in assigned_roles]}" - ) - roles = [ - RoleRead(id=role.id, name=role.name, description=role.description) - for role in new_user.roles - ] - return UserInDB( - id=new_user.id, - username=new_user.username, - email=new_user.email, - full_name=new_user.full_name, - hashed_password=new_user.hashed_password, - is_active=new_user.is_active, - is_superuser=new_user.is_superuser, - roles=roles, - groups=[], - created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc), - ) - - except Exception as e: - await session.rollback() - logger.exception(f"User creation failed: {str(e)}") - raise ValueError(e) - - -async def update_user(username: str, update_data: dict) -> Optional[UserInDB]: - """ - Update an existing user. - - Args: - username: Username to update - update_data: Data to update - - Returns: - Updated UserInDB object or None if not found - """ - async with get_sql_session() as session: - try: - result = await session.execute( - select(User) - .options(selectinload(User.roles), selectinload(User.groups)) - .where(User.username == username) - ) - user = result.scalar_one_or_none() - - if not user: - return None - - for key, value in update_data.items(): - setattr(user, key, value) - user.updated_at = datetime.now() - - await session.commit() - await session.refresh(user) - return UserInDB.model_validate(user, from_attributes=True) - except Exception as e: - await session.rollback() - logger.error(f"User update failed: {str(e)}") - raise - - -async def delete_user(username: str) -> bool: - """ - Delete a user from the database. - - Args: - username: Username to delete - - Returns: - True if deleted successfully, False if user not found - """ - async with get_sql_session() as session: - try: - result = await session.execute( - select(User).where(User.username == username) - ) - user = result.scalar_one_or_none() - - if not user: - return False - - await session.delete(user) - await session.commit() - return True - except Exception as e: - await session.rollback() - logger.error(f"User deletion failed: {str(e)}") - raise - - -async def get_all_users( - skip: int = 0, - limit: int = 100, - is_active: Optional[bool] = None, - username: Optional[str] = None, - email: Optional[str] = None, - role_name: Optional[str] = None, - group_name: Optional[str] = None, -) -> Dict[str, Union[int, List[UserInDB]]]: - """ - Retrieve paginated and filtered list of users from the database. - - Args: - skip: Number of records to skip (for pagination) - limit: Maximum number of records to return - is_active: Filter by active status - username: Filter by username (partial match) - email: Filter by email (partial match) - role_name: Filter by role name - group_name: Filter by attribute name - - Returns: - Dictionary with: - - users: List of UserInDB objects - - total: Total number of users matching the filters - """ - async with get_sql_session() as session: - # Base query with relationships - base_query = select(User).options( - selectinload(User.roles), selectinload(User.groups) - ) - - # Apply filters - if is_active is not None: - base_query = base_query.where(User.is_active == is_active) - - if username: - base_query = base_query.where(User.username.ilike(f"%{username}%")) - - if email: - base_query = base_query.where(User.email.ilike(f"%{email}%")) - - if role_name: - base_query = base_query.join(User.roles).where(Role.name == role_name) - - if group_name: - base_query = base_query.join(User.groups).where(Group.name == group_name) - - # Get total count of matching records - count_query = select(func.count()).select_from(base_query.subquery()) - total_count = (await session.execute(count_query)).scalar() - - # Apply pagination and execute - paginated_query = base_query.offset(skip).limit(limit) - result = await session.execute(paginated_query) - users = result.scalars().all() - - return { - "users": [UserInDB.model_validate(user) for user in users], - "total": total_count, - } - - -async def get_user_devices(username: str) -> List[Device]: - devices = [] - async with get_sql_session() as session: - query = ( - select(RefreshToken) - .where(RefreshToken.subject == username) - .where(RefreshToken.revoked.is_(False)) - ) - tokens = await session.execute(query) - for token in tokens.scalars(): - devices.append( - Device( - session_id=token.jti, - device_id=token.device_id, - user_agent=token.user_agent, - expires_at=token.expires_at, - created_at=token.created_at, - ) - ) - return devices diff --git a/papi/base/user_auth_system/manifest.yaml b/papi/base/user_auth_system/manifest.yaml deleted file mode 100644 index 785a352..0000000 --- a/papi/base/user_auth_system/manifest.yaml +++ /dev/null @@ -1,30 +0,0 @@ -version: "0.1.0" -title: "User Authentication and Authorization System" -description: > - A flexible and extensible user control system implementing both - Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC) - for secure identity and permission management. - -authors: - - name: "pAPI Team" -maintainers: - - name: "Eduardo Fírvida" - email: "efirvida@gmail.com" - -keywords: - - authentication - - authorization - - RBAC - - ABAC - - user-management - - access-control - -dependencies: - - image_storage - -python_dependencies: - - bcrypt - - casbin - - pyjwt[crypto] - - pydantic[email] - - pydantic-settings diff --git a/papi/base/user_auth_system/models/__init__.py b/papi/base/user_auth_system/models/__init__.py deleted file mode 100644 index 395edab..0000000 --- a/papi/base/user_auth_system/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .audit import AuditLog -from .group import Group -from .jwt_key import JWTKey -from .role import Role -from .token import AccessToken, RefreshToken -from .user import User diff --git a/papi/base/user_auth_system/models/audit.py b/papi/base/user_auth_system/models/audit.py deleted file mode 100644 index ebe0e3a..0000000 --- a/papi/base/user_auth_system/models/audit.py +++ /dev/null @@ -1,21 +0,0 @@ -from sqlalchemy import ( - Column, - DateTime, - Integer, - String, - func, -) - -from .base import Base - - -class AuditLog(Base): - __tablename__ = "audit_logs" - - id = Column(Integer, primary_key=True, index=True) - event_type = Column(String, nullable=False, index=True) - details = Column(String, nullable=True) - timestamp = Column(DateTime(timezone=True), server_default=func.now()) - timestamp_updated = Column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) diff --git a/papi/base/user_auth_system/models/base.py b/papi/base/user_auth_system/models/base.py deleted file mode 100644 index 59be703..0000000 --- a/papi/base/user_auth_system/models/base.py +++ /dev/null @@ -1,3 +0,0 @@ -from sqlalchemy.orm import declarative_base - -Base = declarative_base() diff --git a/papi/base/user_auth_system/models/casbin.py b/papi/base/user_auth_system/models/casbin.py deleted file mode 100644 index e683a22..0000000 --- a/papi/base/user_auth_system/models/casbin.py +++ /dev/null @@ -1,68 +0,0 @@ -from sqlalchemy import Column, Integer, String, UniqueConstraint - -from .base import Base - - -class AuthRules(Base): - """SQLAlchemy model for Casbin policy rules storage. - - This model stores both policy rules (p) and role inheritance rules (g). - The column names (v0-v5) follow Casbin's conventions: - - For policy rules (ptype='p'): - - v0: subject (user or role) - - v1: object (resource) - - v2: action - - v3: condition (optional) - - v4: effect (allow/deny) - - v5: additional info (optional) - - For role rules (ptype='g'): - - v0: user or role - - v1: role to inherit from - - v2-v5: not used - """ - - __tablename__ = "auth_rules" - - id = Column(Integer, primary_key=True) - ptype = Column( - String(255), nullable=False, comment="Rule type: 'p' for policy, 'g' for role" - ) - v0 = Column(String(255), nullable=False, comment="Subject/User") - v1 = Column(String(255), nullable=False, comment="Object/Role") - v2 = Column(String(255), comment="Action") - v3 = Column(String(255), comment="Condition") - v4 = Column(String(255), comment="Effect") - v5 = Column(String(255), comment="Additional data") - - def __str__(self): - """Returns a human-readable representation of the rule. - - For policy rules: "p, subject, object, action[, condition][, effect]" - For role rules: "g, user, role" - """ - components = [self.ptype] - for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5): - if v is None: - break - components.append(v) - return ", ".join(components) - - def __repr__(self): - """Returns a detailed string representation for debugging.""" - return f'' - - __table_args__ = ( - # Ensure uniqueness of policy rules (excluding id and v5) - UniqueConstraint( - "ptype", - "v0", - "v1", - "v2", - "v3", - "v4", - name="uq_policy", - comment="Prevents duplicate policy rules", - ), - ) diff --git a/papi/base/user_auth_system/models/group.py b/papi/base/user_auth_system/models/group.py deleted file mode 100644 index 1b0fb0a..0000000 --- a/papi/base/user_auth_system/models/group.py +++ /dev/null @@ -1,92 +0,0 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func - -from .base import Base - -user_groups = Table( - "user_groups", - Base.metadata, - Column( - "user_id", - Integer, - ForeignKey("users.id", ondelete="CASCADE"), - primary_key=True, - comment="Foreign key to users table", - ), - Column( - "group_id", - Integer, - ForeignKey("groups.id", ondelete="CASCADE"), - primary_key=True, - comment="Foreign key to groups table", - ), - comment="Many-to-many relationship between users and groups", - extend_existing=True, -) - - -class Group(Base): - """ - Represents a user group for organizing users and managing permissions. - - Attributes: - id: Auto-incrementing primary key - name: Unique group identifier - description: Human-readable group description - is_system_group: Flag indicating system-protected groups - users: Relationship to users belonging to this group - - System-protected groups: - - Prevent accidental deletion of critical groups - - Typically include 'admins', 'managers', or other core groups - """ - - __tablename__ = "groups" - __table_args__ = { - "comment": "Stores user groups for organization and access control" - } - - id = Column( - Integer, primary_key=True, index=True, comment="Auto-incrementing primary key" - ) - name = Column( - String(100), unique=True, nullable=False, comment="Unique group identifier" - ) - description = Column( - String(255), nullable=True, comment="Human-readable group description" - ) - - is_protected = Column( - Boolean, - nullable=False, - default=False, - server_default="false", - comment="Flag indicating system-protected groups (cannot be deleted)", - ) - - created_at = Column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - comment="Timestamp of group creation", - ) - updated_at = Column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - comment="Timestamp of last group update", - ) - - users = relationship( - "User", - secondary=user_groups, - back_populates="groups", - cascade="all, delete", - passive_deletes=True, - ) - - def __repr__(self) -> str: - """Provides developer-friendly representation.""" - return f"" diff --git a/papi/base/user_auth_system/models/jwt_key.py b/papi/base/user_auth_system/models/jwt_key.py deleted file mode 100644 index c4c6c5d..0000000 --- a/papi/base/user_auth_system/models/jwt_key.py +++ /dev/null @@ -1,48 +0,0 @@ -from sqlalchemy import Column, DateTime, Integer, String -from sqlalchemy.sql import func - -from .base import Base - - -class JWTKey(Base): - """ - Represents a JWT signing key for token validation and rotation. - - This model stores cryptographic keys used for signing and verifying JWTs, - enabling secure key rotation practices. Each key record contains: - - - A unique cryptographic key string - - Timestamp of when the key was created - - Automatic key rotation capabilities - - Attributes: - id: Auto-incrementing primary key - key: Base64-encoded cryptographic key material (512-bit equivalent) - created_at: UTC timestamp of key creation (automatically set) - """ - - __tablename__ = "jwt_keys" - __table_args__ = {"comment": "Stores cryptographic keys for JWT signing"} - - id = Column( - Integer, - primary_key=True, - index=True, - comment="Auto-incrementing primary key identifier", - ) - key = Column( - String(128), # Base64 encoded 512-bit key (64 bytes * 8 = 512 bits) - nullable=False, - unique=True, - comment="Base64-encoded cryptographic key material for JWT signing", - ) - created_at = Column( - DateTime(timezone=True), - server_default=func.now(), # Use database server time - nullable=False, - comment="UTC timestamp of when the key was generated", - ) - - def __repr__(self) -> str: - """Provides developer-friendly representation of key instance.""" - return f"" diff --git a/papi/base/user_auth_system/models/role.py b/papi/base/user_auth_system/models/role.py deleted file mode 100644 index 301c945..0000000 --- a/papi/base/user_auth_system/models/role.py +++ /dev/null @@ -1,78 +0,0 @@ -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table -from sqlalchemy.orm import relationship - -from .base import Base - -# Solución: Usar extend_existing=True para permitir la redefinición -user_roles = Table( - "user_roles", - Base.metadata, - Column( - "user_id", - Integer, - ForeignKey("users.id", ondelete="CASCADE"), - primary_key=True, - comment="Foreign key to users table", - ), - Column( - "role_id", - Integer, - ForeignKey("roles.id", ondelete="CASCADE"), - primary_key=True, - comment="Foreign key to roles table", - ), - comment="Many-to-many relationship between users and roles", - extend_existing=True, -) - - -class Role(Base): - """ - Represents a user role with specific permissions and access rights. - - Attributes: - id: Auto-incrementing primary key - name: Unique role identifier (e.g., 'admin', 'user') - description: Human-readable role description - is_protected: Flag indicating system-protected roles (cannot be deleted) - users: Relationship to users assigned to this role - - System-protected roles: - - Prevent accidental deletion of critical roles - - Typically include 'admin', 'superuser', or other core roles - """ - - __tablename__ = "roles" - __table_args__ = {"comment": "Stores user roles and permissions"} - - id = Column( - Integer, primary_key=True, index=True, comment="Auto-incrementing primary key" - ) - name = Column( - String(50), - unique=True, - nullable=False, - comment="Unique role identifier (e.g., 'admin', 'user')", - ) - description = Column( - String(255), nullable=True, comment="Human-readable role description" - ) - is_protected = Column( - Boolean, - nullable=False, - default=False, - server_default="false", - comment="Flag indicating system-protected roles (cannot be deleted)", - ) - - users = relationship( - "User", - secondary=user_roles, - back_populates="roles", - passive_deletes=True, - cascade="save-update, merge", # Cambiado por seguridad - ) - - def __repr__(self) -> str: - """Provides developer-friendly representation.""" - return f"" diff --git a/papi/base/user_auth_system/models/token.py b/papi/base/user_auth_system/models/token.py deleted file mode 100644 index 69d980f..0000000 --- a/papi/base/user_auth_system/models/token.py +++ /dev/null @@ -1,173 +0,0 @@ -import hashlib - -from sqlalchemy import Boolean, Column, DateTime, Index, String -from sqlalchemy.orm import declarative_base -from sqlalchemy.sql import func - -Base = declarative_base() - - -class RefreshToken(Base): - """ - Represents a refresh token for JWT authentication. - - Stores refresh tokens with security features: - - SHA-256 token hashing (prevents plaintext storage) - - Device binding - - Revocation tracking - - Automatic expiration - - Attributes: - jti: Unique token identifier (primary key) - subject: User identifier (typically username) - token_hash: SHA-256 hash of token value - device_id: Associated device identifier - user_agent: Client browser/device info - revoked: Revocation status - expires_at: Token expiration timestamp - created_at: Token creation timestamp - """ - - __tablename__ = "refresh_tokens" - __table_args__ = ( - Index("ix_refresh_token_subject_device", "subject", "device_id"), - Index("ix_refresh_token_expiration", "expires_at"), - {"comment": "Stores refresh tokens with security features"}, - ) - - jti = Column( - String(44), # Base64 URL-safe encoded 32-byte token (44 chars) - primary_key=True, - index=True, - comment="Unique token identifier (JWT ID)", - ) - subject = Column( - String(255), - nullable=False, - index=True, - comment="User identifier (subject claim)", - ) - token_hash = Column( - String(64), # SHA-256 produces 64-character hex digest - nullable=False, - unique=True, - comment="SHA-256 hash of token value", - ) - device_id = Column( - String(36), # UUID length - nullable=False, - comment="Associated device identifier", - ) - user_agent = Column( - String(500), nullable=True, comment="Client browser/device information" - ) - revoked = Column( - Boolean, default=False, nullable=False, comment="Revocation status flag" - ) - - revoked_at = Column( - DateTime(timezone=True), nullable=True, comment="Token revocation timestamp" - ) - expires_at = Column( - DateTime(timezone=True), nullable=False, comment="Token expiration timestamp" - ) - created_at = Column( - DateTime(timezone=True), - server_default=func.now(), # Use database server time - nullable=False, - comment="Token creation timestamp", - ) - - def __repr__(self) -> str: - """Provides developer-friendly representation.""" - return ( - f"" - ) - - @staticmethod - def compute_token_hash(token: str) -> str: - """ - Computes SHA-256 hash of a token for secure storage. - - Args: - token: Raw token string - - Returns: - 64-character hexadecimal digest of the token - """ - return hashlib.sha256(token.encode()).hexdigest() - - -class AccessToken(Base): - """ - Represents an access token for JWT authentication. - - Stores access token metadata with: - - Device binding - - Revocation tracking - - Expiration enforcement - - Audit capabilities - - Attributes: - jti: Unique token identifier (primary key) - subject: User identifier - device_id: Associated device identifier - revoked: Revocation status - expires_at: Token expiration timestamp - revoked_at: Timestamp of revocation - created_at: Token creation timestamp - """ - - __tablename__ = "access_tokens" - __table_args__ = ( - Index("ix_access_tokens_subject_device", "subject", "device_id"), - Index("ix_access_tokens_expiration", "expires_at"), - {"comment": "Stores access token metadata"}, - ) - - jti = Column( - String(44), # Base64 URL-safe encoded 32-byte token - primary_key=True, - index=True, - unique=True, - comment="Unique token identifier (JWT ID)", - ) - subject = Column( - String(255), - nullable=False, - index=True, - comment="User identifier (subject claim)", - ) - device_id = Column( - String(36), # UUID length - nullable=False, - comment="Associated device identifier", - ) - revoked = Column( - Boolean, default=False, nullable=False, comment="Revocation status flag" - ) - - expires_at = Column( - DateTime(timezone=True), nullable=False, comment="Token expiration timestamp" - ) - revoked_at = Column( - DateTime(timezone=True), nullable=True, comment="Timestamp of revocation" - ) - created_at = Column( - DateTime(timezone=True), - server_default=func.now(), # Use database server time - nullable=False, - comment="Token creation timestamp", - ) - - def __repr__(self) -> str: - """Provides developer-friendly representation.""" - status = "REVOKED" if self.revoked else "ACTIVE" - return ( - f"" - ) diff --git a/papi/base/user_auth_system/models/user.py b/papi/base/user_auth_system/models/user.py deleted file mode 100644 index effa291..0000000 --- a/papi/base/user_auth_system/models/user.py +++ /dev/null @@ -1,207 +0,0 @@ -from sqlalchemy import Boolean, Column, DateTime, Integer, String, func, select -from sqlalchemy.orm import relationship - -from papi.core.db import get_sql_session - -from .base import Base -from .casbin import AuthRules -from .group import user_groups -from .role import user_roles - - -class User(Base): - """ - Represents a system user with authentication capabilities and access control. - - Attributes: - id: Unique user identifier - username: Unique username for authentication - email: Unique email address - avatar: URL to user avatar image - full_name: User's full name - hashed_password: Securely hashed password - is_active: Account activation status - is_superuser: Superuser privileges flag - created_at: Account creation timestamp - updated_at: Last account update timestamp - roles: Assigned security roles - groups: Membership in user groups - - Relationships: - roles: Many-to-many relationship with Role - groups: Many-to-many relationship with Group - """ - - __tablename__ = "users" - __table_args__ = {"comment": "Stores system user accounts and credentials"} - - id = Column( - Integer, - primary_key=True, - index=True, - comment="Auto-incrementing unique user ID", - ) - username = Column( - String(50), - unique=True, - nullable=False, - index=True, - comment="Unique username for authentication", - ) - email = Column( - String(255), - unique=True, - index=True, - comment="Unique email address for account recovery", - ) - avatar = Column(String(512), nullable=True, comment="URL to user avatar image") - full_name = Column(String(100), nullable=True, comment="User's full name") - hashed_password = Column( - String(128), nullable=False, comment="BCrypt hashed password" - ) - is_active = Column( - Boolean, - default=True, - server_default="true", - comment="Account activation status (false = disabled)", - ) - is_superuser = Column( - Boolean, - default=False, - server_default="false", - comment="Superuser privileges flag", - ) - is_protected = Column( - Boolean, - default=False, - server_default="false", - comment="System-protected account flag (cannot be deleted)", - ) - created_at = Column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - comment="Account creation timestamp", - ) - updated_at = Column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - comment="Last account update timestamp", - ) - roles = relationship( - "Role", - secondary=user_roles, - back_populates="users", - lazy="selectin", - cascade="save-update, merge", - passive_deletes=True, - ) - groups = relationship( - "Group", - secondary=user_groups, - back_populates="users", - lazy="selectin", - cascade="save-update, merge", - passive_deletes=True, - ) - - async def get_applicable_rules(self) -> list[dict]: - """ - Retrieves all Casbin policy rules that apply to the user, including: - - Direct user assignments - - Role-based assignments - - Group-based assignments - - Group role-based assignments - - Returns: - List of policy rules as dictionaries with keys: - - v0: Subject (user, role, or group) - - v1: Object/resource - - v2: Action - - v3: Condition - - v4: Effect (allow/deny) - - v5: Additional context - """ - subjects = set() - - subjects.add(self.username) - - for role in self.roles: - subjects.add(f"role:{role}") - - for group in self.groups: - subjects.add(f"group:{group.name}") - - query = select(AuthRules).where( - AuthRules.ptype == "p", AuthRules.v0.in_(list(subjects)) - ) - - async with get_sql_session() as session: - result = await session.execute(query) - rules = result.scalars().all() - - return [ - { - "v0": rule.v0, - "v1": rule.v1, - "v2": rule.v2, - "v3": rule.v3, - "v4": rule.v4, - "v5": rule.v5, - } - for rule in rules - ] - - async def get_permissions(self) -> list[tuple]: - """ - Gets simplified permissions in (resource, action) format - - Returns: - List of tuples: [(resource, action), ...] - """ - rules = await self.get_applicable_rules() - return list(set((rule["v1"], rule["v2"]) for rule in rules)) - - async def has_permission(self, resource: str, action: str) -> bool: - """ - Checks if user has permission for a specific resource-action pair - - Args: - resource: Resource path (e.g., "/users") - action: Action (e.g., "read", "write") - - Returns: - True if permission is granted, False otherwise - """ - permissions = await self.get_permissions() - return (resource, action) in permissions - - async def get_allowed_resources(self) -> dict[str, list[str]]: - """ - Gets all allowed resources and actions - - Returns: - Dictionary: {resource: [actions]} - """ - rules = await self.get_applicable_rules() - resources = {} - - for rule in rules: - if rule["v4"] == "allow": - resource = rule["v1"] - action = rule["v2"] - - if resource not in resources: - resources[resource] = [] - - if action not in resources[resource]: - resources[resource].append(action) - - return resources - - def __repr__(self) -> str: - """Provides developer-friendly representation.""" - status = "active" if self.is_active else "inactive" - return f"" diff --git a/papi/base/user_auth_system/schemas/__init__.py b/papi/base/user_auth_system/schemas/__init__.py deleted file mode 100644 index 11ef0fa..0000000 --- a/papi/base/user_auth_system/schemas/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .auth import * -from .group import * -from .password import * -from .policy import * -from .registration import * -from .role import * -from .root import * -from .user import * -from .user_devices import * diff --git a/papi/base/user_auth_system/schemas/auth.py b/papi/base/user_auth_system/schemas/auth.py deleted file mode 100644 index 631028d..0000000 --- a/papi/base/user_auth_system/schemas/auth.py +++ /dev/null @@ -1,197 +0,0 @@ -from typing import Annotated, List - -from pydantic import BaseModel, Field, SecretStr, field_validator - - -class KeyRotation(BaseModel): - """ - Configuration for JWT key rotation settings. - - Attributes: - enabled: Flag to enable/disable automatic key rotation - rotation_interval_days: Days between automatic key rotations - max_historical_keys: Maximum historical keys to retain - """ - - enabled: bool = Field( - default=True, description="Enable automatic periodic key rotation" - ) - rotation_interval_days: int = Field( - default=30, - ge=1, - le=365, - description="Days between automatic key rotations (min 1, max 365)", - ) - max_historical_keys: int = Field( - default=5, - ge=1, - le=12, - description="Maximum historical keys to retain (min 1, max 12)", - ) - - -class BaseSecurity(BaseModel): - """ - Core security configuration for authentication system. - - Security Settings: - secret_key: Cryptographic secret for token signing - token_issuer: JWT issuer claim - token_audience: JWT audience claim - key_rotation: Key rotation configuration - access_token_expire_minutes: Access token validity period - allow_weak_passwords: Flag to allow insecure passwords - bcrypt_rounds: Bcrypt hashing complexity - hash_algorithm: JWT signing algorithm - max_login_attempts: Failed attempts before lockout - lockout_duration_minutes: Account lockout period - """ - - secret_key: SecretStr = Field( - ..., - min_length=64, - description="Cryptographic secret for token signing (min 64 chars)", - ) - token_issuer: str = Field( - default="auth-system", - min_length=3, - description="JWT issuer claim (usually your domain)", - ) - token_audience: str = Field( - default="client-app", - min_length=3, - description="JWT audience claim (client application identifier)", - ) - key_rotation: KeyRotation = Field( - default_factory=KeyRotation, description="JWT key rotation configuration" - ) - access_token_expire_minutes: int = Field( - default=60, - ge=5, - le=1440, - description="Access token validity in minutes (5 min - 24 hours)", - ) - allow_weak_passwords: bool = Field( - default=False, description="Allow insecure passwords (not recommended)" - ) - bcrypt_rounds: int = Field( - default=12, - ge=5, - le=15, - description="Bcrypt hashing complexity rounds (security vs performance)", - ) - hash_algorithm: str = Field( - default="HS256", - pattern="^(HS256|HS384|HS512|RS256|RS384|RS512|ES256|ES384)$", - description="JWT signing algorithm (recommended: HS256, RS256)", - ) - max_login_attempts: int = Field( - default=5, - ge=3, - le=10, - description="Failed login attempts before account lockout", - ) - lockout_duration_minutes: int = Field( - default=15, - ge=1, - le=1440, - description="Account lockout duration in minutes (1 min - 24 hours)", - ) - - -class AuthSettings(BaseModel): - """ - Application authentication configuration settings. - - Attributes: - security: Core security configuration - allow_registration: Enable user registration - password_min_length: Minimum password length - default_user_roles: Default roles assigned to new users - """ - - security: BaseSecurity = Field(..., description="Core security configuration") - allow_registration: bool = Field( - default=True, description="Enable new user registration" - ) - password_min_length: int = Field( - default=12, - ge=8, - le=128, - description="Minimum password length (8-128 characters)", - ) - default_user_roles: Annotated[ - List[str], - Field(min_length=1, description="Default roles assigned to new users"), - ] = ["user"] - - @field_validator("default_user_roles", mode="before") - def normalize_user_roles(cls, value) -> list: - """Normalize role input to list format.""" - if value is None: - return ["user"] - if isinstance(value, str): - return [role.strip() for role in value.split(",") if role.strip()] - return value - - @field_validator("default_user_roles") - def validate_user_roles(cls, value: list) -> list: - """Validate and deduplicate roles.""" - if not value: - raise ValueError("At least one default role is required") - - validated_roles = [] - for role in value: - clean_role = role.strip() - if not clean_role: - raise ValueError("Role names cannot be empty") - if clean_role not in validated_roles: - validated_roles.append(clean_role) - - return validated_roles - - -class Token(BaseModel): - """ - JWT token response model. - - Attributes: - access_token: Short-lived access token - refresh_token: Long-lived refresh token - token_type: Always 'bearer' - """ - - access_token: str = Field( - ..., description="Short-lived access token for API authorization" - ) - refresh_token: str = Field( - ..., description="Long-lived refresh token for obtaining new access tokens" - ) - token_type: str = Field( - default="bearer", description="Token type (always 'bearer')" - ) - - -class LoginRequest(BaseModel): - """ - User authentication request model. - - Attributes: - username: User identifier - password: User credentials - device_id: Unique device identifier - """ - - username: str = Field( - ..., - min_length=3, - max_length=255, - description="User identifier (email or username)", - ) - password: str = Field(..., min_length=8, description="User credentials") - device_id: str = Field( - ..., - min_length=8, - max_length=36, - description="Unique device identifier (min 8 chars)", - ) diff --git a/papi/base/user_auth_system/schemas/group.py b/papi/base/user_auth_system/schemas/group.py deleted file mode 100644 index b2bd1d7..0000000 --- a/papi/base/user_auth_system/schemas/group.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - - -class GroupBase(BaseModel): - name: str - description: Optional[str] = None - - -class GroupCreate(GroupBase): - pass - - -class Group(GroupBase): - id: int - - class Config: - from_attributes = True diff --git a/papi/base/user_auth_system/schemas/password.py b/papi/base/user_auth_system/schemas/password.py deleted file mode 100644 index b03bc1b..0000000 --- a/papi/base/user_auth_system/schemas/password.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import BaseModel, EmailStr - - -class PasswordChangeRequest(BaseModel): - current_password: str - new_password: str - - -class PasswordResetRequest(BaseModel): - email: EmailStr - - -class PasswordResetConfirm(BaseModel): - token: str - new_password: str diff --git a/papi/base/user_auth_system/schemas/policy.py b/papi/base/user_auth_system/schemas/policy.py deleted file mode 100644 index 1d48674..0000000 --- a/papi/base/user_auth_system/schemas/policy.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel - - -class PolicyBase(BaseModel): - ptype: Literal["p"] = "p" - subject: str - object: str - action: str - condition: str - effect: str - - @property - def policy(self): - return ( - self.ptype, - self.subject, - self.object, - self.action, - self.condition, - self.effect, - ) - - -class PolicyCreate(PolicyBase): - pass - - -class PolicyRead(PolicyBase): - pass - - -class PolicyInDB(PolicyBase): - id: int - - class Config: - from_attributes = True - - -class PolicyRole(BaseModel): - username: set - role: str - - -class PolicyRoleCreate(PolicyRole): - pass - - -class PolicyRoleRead(PolicyRole): - pass - - -class PolicyRoleInDB(PolicyRole): - id: int - - class Config: - from_attributes = True diff --git a/papi/base/user_auth_system/schemas/registration.py b/papi/base/user_auth_system/schemas/registration.py deleted file mode 100644 index 7f27a26..0000000 --- a/papi/base/user_auth_system/schemas/registration.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import Field, model_validator - -from .user import UserCreate - - -class UserRegister(UserCreate): - """Schema for user registration with terms acceptance. - - Extends UserCreate to add terms and conditions acceptance requirement. - """ - - terms_accepted: bool = Field(False, description="Must accept terms and conditions") - - @model_validator(mode="after") - def check_terms_accepted(self): - """Validates that terms and conditions have been accepted.""" - if not self.terms_accepted: - raise ValueError("You must accept the terms and conditions") - return self diff --git a/papi/base/user_auth_system/schemas/role.py b/papi/base/user_auth_system/schemas/role.py deleted file mode 100644 index 7bbb758..0000000 --- a/papi/base/user_auth_system/schemas/role.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - - -class RoleBase(BaseModel): - name: str - description: Optional[str] = None - - -class Role(RoleBase): - id: int - - class Config: - from_attributes = True - - -class RoleCreate(RoleBase): - pass - - -class RoleRead(Role): - pass diff --git a/papi/base/user_auth_system/schemas/root.py b/papi/base/user_auth_system/schemas/root.py deleted file mode 100644 index 25a89e3..0000000 --- a/papi/base/user_auth_system/schemas/root.py +++ /dev/null @@ -1,144 +0,0 @@ -import re -from typing import Self - -from loguru import logger -from pydantic import EmailStr, Field, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class RootUserEnv(BaseSettings): - """Root user environment configuration. - - This class manages the configuration for the root/admin user account. - It loads settings from environment variables with fallback defaults. - - Security Note: - The default values are intentionally weak and should be changed - in production environments using environment variables. - - Environment Variables: - ROOT_USERNAME: Username for the root user (default: "root") - ROOT_USER_EMAIL: Email address for the root user (default: "root@papi.com") - ROOT_USER_PASSWORD: Password for the root user (default: "root") - ROOT_ROLE_NAME: Role name for the root user (default: "root") - """ - - username: str = Field( - default="root", - validation_alias="ROOT_USERNAME", - description="Username for the root user account", - min_length=1, - max_length=50, - ) - - email: EmailStr = Field( - default="root@papi.com", - validation_alias="ROOT_USER_EMAIL", - description="Email address for the root user account", - ) - - password: str = Field( - default="root", - validation_alias="ROOT_USER_PASSWORD", - description="Password for the root user account", - min_length=1, - ) - - role_name: str = Field( - default="root", - validation_alias="ROOT_ROLE_NAME", - description="Role name assigned to the root user", - min_length=1, - max_length=50, - ) - - model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore" - ) - - @field_validator("username", "role_name", mode="before") - @classmethod - def validate_alphanumeric_fields(cls, v: str, info) -> str: - """Validate that username and role_name contain only safe characters.""" - v = v.strip() - if not v: - raise ValueError(f"{info.field_name} cannot be empty or whitespace-only") - - if not re.match(r"^[a-zA-Z0-9_.-]+$", v): - raise ValueError( - f"{info.field_name} can only contain letters, numbers, underscores, hyphens, and dots" - ) - - return v - - @field_validator("password", mode="before") - @classmethod - def validate_password(cls, v: str) -> str: - """Validate password and warn about weak defaults.""" - v = v.strip() - if not v: - raise ValueError("Password cannot be empty or whitespace-only") - - weak_passwords = {"root", "admin", "password", "123456", "password123"} - if v.lower() in weak_passwords: - logger.warning( - "Using a weak default password! Please set ROOT_USER_PASSWORD " - "environment variable with a strong password for production use.", - ) - - if len(v) < 8: - logger.warning( - "Password is shorter than 8 characters. Consider using a longer password " - "for better security.", - ) - - return v - - @model_validator(mode="after") - def validate_security_settings(self) -> Self: - """Perform cross-field validation and security checks.""" - if self.email == "root@papi.com": - logger.warning( - "Using default email address! Please set ROOT_USER_EMAIL " - "environment variable with a valid email for production use.", - ) - - if self.username == self.password: - raise ValueError("Username and password cannot be the same") - - if self.role_name == self.password: - raise ValueError("Role name and password cannot be the same") - - return self - - def is_using_defaults(self) -> dict[str, bool]: - """Check which fields are using default values.""" - return { - "username": self.username == "root", - "email": self.email == "root@papi.com", - "password": self.password == "root", - "role_name": self.role_name == "root", - } - - def get_security_warnings(self) -> list[str]: - """Get a list of security warnings for current configuration.""" - warnings_list = [] - defaults = self.is_using_defaults() - - if defaults["password"]: - warnings_list.append("Using default password 'root' - change immediately!") - - if defaults["email"]: - warnings_list.append( - "Using default email 'root@papi.com' - update for production" - ) - - if len(self.password) < 8: - warnings_list.append("Password is too short - use at least 8 characters") - - if self.password.lower() in {"root", "admin", "password", "123456"}: - warnings_list.append( - "Password is too common - use a unique strong password" - ) - - return warnings_list diff --git a/papi/base/user_auth_system/schemas/user.py b/papi/base/user_auth_system/schemas/user.py deleted file mode 100644 index 611f964..0000000 --- a/papi/base/user_auth_system/schemas/user.py +++ /dev/null @@ -1,125 +0,0 @@ -from datetime import datetime -from typing import List, Optional, Tuple - -from pydantic import BaseModel, EmailStr, Field, field_validator - -from user_auth_system.security.user import validate_username - -from .group import Group -from .role import Role - - -class UserBase(BaseModel): - username: str = Field(..., min_length=2, max_length=50) - email: EmailStr - full_name: Optional[str] = Field(None, max_length=100) - avatar: Optional[str] = None - - -class UserCreate(UserBase): - password: str - is_active: Optional[bool] = True - - @field_validator("username", mode="before") - def check_username(cls, v): - return validate_username(v) - - -class UserUpdateBase(BaseModel): - """Base class for user update operations. - - Contains common fields that can be updated by users. - """ - email: Optional[EmailStr] = None - full_name: Optional[str] = Field(None, max_length=100) - avatar: Optional[str] = None - password: Optional[str] = None - - -class UserSelfUpdate(UserUpdateBase): - """Schema for users updating their own information.""" - pass - - -class UserAdminUpdate(UserUpdateBase): - """Schema for administrators updating user information. - - Extends UserUpdateBase with additional fields that only admins can modify. - """ - is_active: Optional[bool] = None - is_superuser: Optional[bool] = None - roles: Optional[List[int]] = None - groups: Optional[List[int]] = None - - -class UserRead(UserBase): - id: int - is_active: bool - is_superuser: bool - created_at: datetime - updated_at: datetime - roles: List[Role] = [] - groups: List[Group] = [] - - class Config: - from_attributes = True - - -class UserInDB(UserBase): - id: int - hashed_password: str - is_active: bool - is_superuser: bool - created_at: datetime - updated_at: datetime - roles: List[Role] = [] - groups: List[Group] = [] - - class Config: - from_attributes = True - - @property - def casbin_roles(self) -> List[Tuple[str, str, str]]: - """ - Returns the Casbin 'g' grouping policies for this user. - Format: [("g", "username", "role:role_name"), ("g", "username", "group:group_name")] - """ - username = self.username - links = [] - - for role in self.roles: - links.append(("g", username, f"role:{role.name}")) - - for group in self.groups: - links.append(("g", username, f"group:{group.name}")) - - return links - - -class UserPublic(BaseModel): - id: int - username: str - email: Optional[str] = None - full_name: Optional[str] = None - avatar: Optional[str] = None - is_superuser: bool - is_active: bool - created_at: datetime - updated_at: datetime - roles: List[str] = [] - groups: List[str] = [] - - class Config: - from_attributes = True - - -class UserDetailResponse(UserPublic): - is_superuser: bool - last_login: Optional[datetime] = None - - -class UsersListResponse(BaseModel): - users: List[UserPublic] - total: int - page: int - per_page: int diff --git a/papi/base/user_auth_system/schemas/user_devices.py b/papi/base/user_auth_system/schemas/user_devices.py deleted file mode 100644 index f3d022c..0000000 --- a/papi/base/user_auth_system/schemas/user_devices.py +++ /dev/null @@ -1,11 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel - - -class Device(BaseModel): - session_id: str - device_id: str - user_agent: str - expires_at: datetime - created_at: datetime diff --git a/papi/base/user_auth_system/security/__init__.py b/papi/base/user_auth_system/security/__init__.py deleted file mode 100644 index 043dc6d..0000000 --- a/papi/base/user_auth_system/security/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from papi.core.settings import get_config -from user_auth_system.schemas import AuthSettings, BaseSecurity - -config = get_config() -main_config = config.addons.config.get("user_auth_system", {}) -auth_settings = AuthSettings(**main_config) -security: BaseSecurity = auth_settings.security diff --git a/papi/base/user_auth_system/security/audit.py b/papi/base/user_auth_system/security/audit.py deleted file mode 100644 index fe9fc8f..0000000 --- a/papi/base/user_auth_system/security/audit.py +++ /dev/null @@ -1,81 +0,0 @@ -import asyncio -import json -from datetime import datetime, timezone -from typing import Dict, Optional - -from loguru import logger - -from papi.core.db import get_sql_session -from user_auth_system.models import AuditLog - - -async def log_security_event_async(event_type: str, details: Optional[Dict] = None): - """ - Asynchronously logs a security-relevant event in the audit database. - - Use this function directly in async contexts (e.g., inside FastAPI routes). - - Args: - event_type (str): A string indicating the type of the event. - details (Optional[Dict]): Additional context or metadata. - """ - try: - await _log_event_async(event_type, details) - except Exception as e: - logger.exception(f"Failed to log audit event: {e}") - - -def log_security_event_sync(event_type: str, details: Optional[Dict] = None): - """ - Synchronously logs a security-relevant event using an isolated event loop. - - Safe to use in background tasks or non-async contexts. - - Args: - event_type (str): A string indicating the type of the event. - details (Optional[Dict]): Additional context or metadata. - """ - try: - loop = asyncio.new_event_loop() - loop.run_until_complete(_log_event_async(event_type, details)) - except Exception as e: - logger.exception(f"Failed to log audit event: {e}") - finally: - loop.close() - - -async def _log_event_async(event_type: str, details: Optional[Dict]): - """ - Internal coroutine to persist the audit log into the database. - - Args: - event_type (str): The type of event to log. - details (Optional[Dict]): Additional details, serialized to JSON. - """ - if not event_type: - logger.error("Attempted to log event with empty event_type") - return - - # Sanitize and validate details before logging - if details: - # Remove any sensitive information - sanitized_details = { - k: v for k, v in details.items() - if not any(sensitive in k.lower() - for sensitive in ['password', 'token', 'secret', 'key', 'auth']) - } - else: - sanitized_details = {} - - try: - async with get_sql_session() as session: - event = AuditLog( - event_type=event_type[:255], # Prevent overflow - details=json.dumps(sanitized_details), - timestamp=datetime.now(timezone.utc), - ) - session.add(event) - await session.commit() - except Exception as e: - # Don't expose internal errors in logs - logger.error("Audit log persistence failed") diff --git a/papi/base/user_auth_system/security/auth.py b/papi/base/user_auth_system/security/auth.py deleted file mode 100644 index 482c2b3..0000000 --- a/papi/base/user_auth_system/security/auth.py +++ /dev/null @@ -1,141 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Optional - -import bcrypt -from fastapi import BackgroundTasks, HTTPException, Request, status -from loguru import logger - -from user_auth_system.schemas import UserInDB - -from .audit import log_security_event_async -from .dependencies import get_user_by_username -from .enums import AuditLogKeys -from .lockout import ( - has_excessive_failed_attempts, - is_system_locked_out, - record_failed_attempt, - reset_failed_attempts, -) -from .password import verify_password - - -async def authenticate_user( - username: str, - password: str, - request: Request, - background_tasks: BackgroundTasks, -) -> Optional[UserInDB]: - """ - Authenticates a user with comprehensive security measures: - - System-wide lockout protection - - Per-user account lockout - - Timing attack prevention - - Security event auditing - - Credential validation - - Args: - username: User identifier - password: Plaintext password - request: HTTP request object - background_tasks: For security logging - - Returns: - Authenticated user object if successful, None otherwise - - Raises: - HTTPException: 423 for locked accounts/system, 401 for invalid credentials - """ - # System-wide lockout check - if await is_system_locked_out(): - logger.warning("Authentication attempt during system-wide lockout") - raise HTTPException( - status_code=status.HTTP_423_LOCKED, - detail="System temporarily locked due to excessive attempts", - ) - - # Prepare security event details - ip_address = request.client.host if request.client else "unknown" - event_details = { - "username": username, - "ip": ip_address, - "user_agent": request.headers.get("User-Agent", "unknown")[:100], - } - - # Per-user lockout check - if await has_excessive_failed_attempts(username): - await log_security_event_async(AuditLogKeys.LOGIN_LOCKOUT, event_details) - logger.warning(f"Account locked for '{username}' from {ip_address}") - raise HTTPException( - status_code=status.HTTP_423_LOCKED, - detail="Account temporarily locked due to excessive attempts", - ) - - # Start timing for consistent response time - start_time = datetime.now(timezone.utc) - valid_credentials = False - user = None - - try: - # Retrieve user (if exists) - user = await get_user_by_username(username) - - # Validate credentials - if user: - if verify_password(password, user.hashed_password): - if user.is_active: - valid_credentials = True - await reset_failed_attempts(ip_address, username) - background_tasks.add_task( - log_security_event_async, - AuditLogKeys.LOGIN_SUCCESS, - event_details, - ) - logger.info(f"Successful login for '{username}' from {ip_address}") - else: - background_tasks.add_task( - log_security_event_async, - AuditLogKeys.LOGIN_INACTIVE, - event_details, - ) - logger.warning(f"Login attempt for inactive account: '{username}'") - else: - # Simulate password check timing for non-existent users - bcrypt.checkpw( - b"dummy_password", - b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", - ) - logger.info(f"Invalid password for '{username}' from {ip_address}") - else: - # Simulate password check timing for non-existent users - bcrypt.checkpw( - b"dummy_password", - b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", - ) - logger.info(f"Login attempt for unknown user: '{username}'") - - # Record failed attempt if credentials invalid - if not valid_credentials: - await record_failed_attempt(ip_address, username) - background_tasks.add_task( - log_security_event_async, AuditLogKeys.LOGIN_FAILED, event_details - ) - - except Exception as e: - logger.error(f"Authentication error for '{username}': {str(e)}") - # Fail securely without revealing details - bcrypt.checkpw( - b"dummy_password", - b"$2b$12$012345678901234567890uDYoY8eDvtvY7vjWzozOAzFvwP9m", - ) - await record_failed_attempt(ip_address, username) - return None - - finally: - # Ensure consistent timing regardless of outcome - elapsed = datetime.now(timezone.utc) - start_time - min_duration = timedelta(milliseconds=500) - if elapsed < min_duration: - remaining = min_duration - elapsed - await asyncio.sleep(remaining.total_seconds()) - - return user if valid_credentials else None diff --git a/papi/base/user_auth_system/security/cache_utils.py b/papi/base/user_auth_system/security/cache_utils.py deleted file mode 100644 index 8b753f6..0000000 --- a/papi/base/user_auth_system/security/cache_utils.py +++ /dev/null @@ -1,53 +0,0 @@ -from loguru import logger - -from papi.core.db import get_redis_client - -# Time-to-live for cached user data in seconds -CACHE_TTL_SECONDS = 60 - - -async def cache_user(username: str, user_json: str): - """ - Caches the serialized user data in Redis with a time-to-live. - - Args: - username (str): The unique identifier (e.g., username or ID) of the user. - user_json (str): The JSON-serialized representation of the user data. - - Notes: - The cached value will expire after `CACHE_TTL_SECONDS`. - In case of failure, the exception is logged but not raised. - """ - redis = await get_redis_client() - try: - await redis.set(f"user_cache:{username}", user_json, ex=CACHE_TTL_SECONDS) - logger.debug(f"User {username} cached successfully") - except Exception as e: - logger.warning(f"Failed to cache user {username}: {e}") - - -async def get_cached_user(username: str) -> str | None: - """ - Retrieves cached user data from Redis. - - Args: - username (str): The unique identifier of the user whose data is being retrieved. - - Returns: - Optional[str]: The JSON-serialized user data if it exists in the cache, - otherwise `None`. - - Notes: - This function does not raise exceptions; it assumes Redis availability has been handled upstream. - """ - redis = await get_redis_client() - try: - cached_data = await redis.get(f"user_cache:{username}") - if cached_data is not None: - logger.debug(f"Cache hit for user {username}") - else: - logger.debug(f"Cache miss for user {username}") - return cached_data - except Exception as e: - logger.warning(f"Failed to retrieve cached user {username}: {e}") - return None diff --git a/papi/base/user_auth_system/security/casbin_adapter.py b/papi/base/user_auth_system/security/casbin_adapter.py deleted file mode 100644 index 4ef2566..0000000 --- a/papi/base/user_auth_system/security/casbin_adapter.py +++ /dev/null @@ -1,71 +0,0 @@ -# adapter.py - - -from casbin import persist -from casbin.persist.adapters.asyncio import AsyncAdapter -from sqlalchemy import delete, select - -from papi.core.db import get_sql_session -from user_auth_system.models.casbin import AuthRules - - -class AsyncCasbinAdapter(AsyncAdapter): - """the interface for Casbin adapters.""" - - def __init__(self): - self._db_class = AuthRules - - async def _save_policy_line(self, ptype, rule): - async with get_sql_session() as session: - line = self._db_class(ptype=ptype) - for i, v in enumerate(rule): - setattr(line, "v{}".format(i), v) - session.add(line) - - async def load_policy(self, model): - """loads all policy rules from the storage.""" - async with get_sql_session() as session: - lines = await session.execute(select(self._db_class)) - for line in lines.scalars(): - persist.load_policy_line(str(line), model) - - async def save_policy(self, model): - """saves all policy rules to the storage.""" - async with get_sql_session() as session: - stmt = delete(self._db_class) - await session.execute(stmt) - for sec in ["p", "g"]: - if sec not in model.model.keys(): - continue - for ptype, ast in model.model[sec].items(): - for rule in ast.policy: - await self._save_policy_line(ptype, rule) - - async def add_policy(self, sec, ptype, rule): - """adds a policy rule to the storage.""" - await self._save_policy_line(ptype, rule) - - async def remove_policy(self, sec, ptype, rule): - """removes a policy rule from the storage.""" - async with get_sql_session() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - for i, v in enumerate(rule): - stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) - await session.execute(stmt) - - async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): - """removes policy rules that match the filter from the storage. - This is part of the Auto-Save feature. - """ - async with get_sql_session() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - - if not (0 <= field_index <= 5): - return - if not (1 <= field_index + len(field_values) <= 6): - return - for i, v in enumerate(field_values): - if v != "": - v_value = getattr(self._db_class, "v{}".format(field_index + i)) - stmt = stmt.where(v_value == v) - await session.execute(stmt) diff --git a/papi/base/user_auth_system/security/casbin_policies.py b/papi/base/user_auth_system/security/casbin_policies.py deleted file mode 100644 index de5b4e8..0000000 --- a/papi/base/user_auth_system/security/casbin_policies.py +++ /dev/null @@ -1,256 +0,0 @@ -import asyncio -from typing import List, Optional - -from casbin import AsyncEnforcer -from loguru import logger -from sqlalchemy import and_, select - -from papi.core.db import get_redis_client, get_sql_session -from user_auth_system.models.casbin import AuthRules -from user_auth_system.schemas.policy import PolicyCreate, PolicyInDB - -POLICY_UPDATE_CHANNEL = "casbin:policy_updated" - - -async def start_redis_policy_listener(enforcer: AsyncEnforcer) -> None: - """ - Starts a listener on a Redis Pub/Sub channel to detect policy updates. - - The listener is launched as a background task to avoid blocking the event loop. - """ - - async def _listen() -> None: - redis_client = await get_redis_client() - pubsub = redis_client.pubsub() - await pubsub.subscribe(POLICY_UPDATE_CHANNEL) - - logger.info("Subscribed to Redis channel for policy updates") - - async for message in pubsub.listen(): - if message["type"] == "message": - logger.info("Policy update received from Redis") - await enforcer.load_policy() - logger.info("Policies reloaded") - - # Launch the listener in the background - asyncio.create_task(_listen()) - - -async def add_policy(policy: PolicyCreate) -> bool: - """ - Adds a policy to the database if it does not already exist and notifies via Redis. - - Args: - policy (PolicyCreate): Object containing the fields of the policy to be added. - - Returns: - bool: True if the policy was inserted, False if it already existed. - """ - subject = policy.subject.strip() - object_ = policy.object.strip() - action = policy.action.strip().lower() - condition = policy.condition.strip() - effect = policy.effect.strip().lower() - - redis_client = await get_redis_client() - - async with get_sql_session() as session: - query = select(AuthRules).where( - and_( - AuthRules.v0 == subject, - AuthRules.v1 == object_, - AuthRules.v2 == action, - AuthRules.v3 == condition, - AuthRules.v4 == effect, - ) - ) - result = await session.execute(query) - existing = result.scalar_one_or_none() - - if existing: - return False - - new_policy = AuthRules( - ptype="p", - v0=subject, - v1=object_, - v2=action, - v3=condition, - v4=effect, - v5=None, - ) - session.add(new_policy) - await session.commit() - - await redis_client.publish(POLICY_UPDATE_CHANNEL, "policy_updated") - return True - - -async def remove_policy(policy: PolicyCreate) -> bool: - """ - Removes a specific policy from the database and notifies the update via Redis. - - Args: - policy (PolicyCreate): Object containing the fields of the policy to be removed. - - Returns: - bool: True if the policy was removed, False if it did not exist. - """ - subject = policy.subject.strip() - object_ = policy.object.strip() - action = policy.action.strip().lower() - condition = policy.condition.strip() - effect = policy.effect.strip().lower() - - redis_client = await get_redis_client() - - async with get_sql_session() as session: - query = select(AuthRules).where( - and_( - AuthRules.v0 == subject, - AuthRules.v1 == object_, - AuthRules.v2 == action, - AuthRules.v3 == condition, - AuthRules.v4 == effect, - ) - ) - result = await session.execute(query) - policy_obj = result.scalar_one_or_none() - - if not policy_obj: - return False - - await session.delete(policy_obj) - await session.commit() - - await redis_client.publish(POLICY_UPDATE_CHANNEL, "policy_updated") - return True - - -async def remove_policy_by_id(policy_id: int) -> bool: - """ - Removes a policy from the database using its ID. - - Args: - policy_id (int): ID of the policy to be removed. - - Returns: - bool: True if the policy was removed, False if it was not found. - """ - redis_client = await get_redis_client() - - async with get_sql_session() as session: - policy_obj = await session.get(AuthRules, policy_id) - if not policy_obj: - return False - - await session.delete(policy_obj) - await session.commit() - - await redis_client.publish(POLICY_UPDATE_CHANNEL, "policy_updated") - return True - - -async def get_policy(policy: PolicyCreate) -> Optional[PolicyInDB]: - """ - Retrieves a specific policy from the database. - - Args: - policy (PolicyCreate): Object containing the fields of the policy to be retrieved. - - Returns: - Optional[PolicyInDB]: Policy object if it exists, None otherwise. - """ - subject = policy.subject.strip() - object_ = policy.object.strip() - action = policy.action.strip().lower() - condition = policy.condition.strip() - effect = policy.effect.strip().lower() - - async with get_sql_session() as session: - query = select(AuthRules).where( - and_( - AuthRules.v0 == subject, - AuthRules.v1 == object_, - AuthRules.v2 == action, - AuthRules.v3 == condition, - AuthRules.v4 == effect, - ) - ) - result = await session.execute(query) - policy_obj = result.scalar_one_or_none() - - if policy_obj: - return PolicyInDB( - id=policy_obj.id, - ptype=policy_obj.ptype, - subject=policy_obj.v0, - object=policy_obj.v1, - action=policy_obj.v2, - condition=policy_obj.v3, - effect=policy_obj.v4, - ) - - return None - - -async def list_policies( - subject: Optional[str] = None, - object_: Optional[str] = None, - action: Optional[str] = None, - condition: Optional[str] = None, - effect: Optional[str] = None, - skip: int = 0, - limit: int = 100, -) -> List[PolicyInDB]: - """ - Lists policies from the database with optional filters and pagination. - - Args: - subject (Optional[str]): Filter by 'subject' field if provided. - object_ (Optional[str]): Filter by 'object' field if provided. - action (Optional[str]): Filter by 'action' field if provided. - condition (Optional[str]): Filter by 'condition' field if provided. - effect (Optional[str]): Filter by 'effect' field if provided. - skip (int): Number of results to skip (for pagination). - limit (int): Maximum number of results to return. - - Returns: - List[PolicyInDB]: List of policies matching the filters. - """ - filters = [AuthRules.ptype == "p"] - - if subject: - filters.append(AuthRules.v0 == subject.strip()) - if object_: - filters.append(AuthRules.v1 == object_.strip()) - if action: - filters.append(AuthRules.v2 == action.strip().lower()) - if condition: - filters.append(AuthRules.v3 == condition.strip()) - if effect: - filters.append(AuthRules.v4 == effect.strip().lower()) - - async with get_sql_session() as session: - query = ( - select(AuthRules) - .where(and_(*filters)) - .order_by(AuthRules.id) - .offset(skip) - .limit(limit) - ) - result = await session.execute(query) - policies = result.scalars().all() - - return [ - PolicyInDB( - id=policy.id, - ptype=policy.ptype, - subject=policy.v0, - object=policy.v1, - action=policy.v2, - condition=policy.v3, - effect=policy.v4, - ) - for policy in policies - ] diff --git a/papi/base/user_auth_system/security/dependencies.py b/papi/base/user_auth_system/security/dependencies.py deleted file mode 100644 index 5bf9f4a..0000000 --- a/papi/base/user_auth_system/security/dependencies.py +++ /dev/null @@ -1,385 +0,0 @@ -from typing import Annotated, List, Optional - -import jwt -from casbin import AsyncEnforcer -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import OAuth2PasswordBearer -from loguru import logger -from pydantic import TypeAdapter - -from user_auth_system.config import config -from user_auth_system.crud.users import get_user_by_username -from user_auth_system.schemas import UserInDB -from user_auth_system.security.tokens import is_access_token_revoked - -from .audit import log_security_event_async -from .cache_utils import ( - cache_user, - get_cached_user, -) -from .enforcer import ( - CasbinRequest, - build_temp_enforcer, - debug_enforcement, - get_enforcer, -) -from .enums import AuditLogKeys, PolicyAction -from .jwt_utils import validate_token -from .rate_limit import check_auth_rate_limit - -PREFIX = "/auth" -oauth2_scheme = OAuth2PasswordBearer( - tokenUrl=f"{PREFIX}/login", - scheme_name="JWT", - auto_error=False, # Let the dependency handle the error -) - - -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> UserInDB: - """ - Retrieves and authenticates the current user from a JWT token. - Implements multiple security layers including token validation, - revocation checking, and rate limiting. - - Security Features: - - Token presence validation - - Rate limiting for authentication attempts - - JWT signature verification - - Expiration validation - - Token revocation (blacklist) check - - Historical key rotation support - - Cache-first user lookup - - Secure error handling - - Args: - token (str): JWT token provided in the Authorization header. - - Returns: - UserInDB: Authenticated user model. - - Raises: - HTTPException: For any authentication failure - """ - # Validate token presence - if not token: - logger.warning("Authentication attempt without token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - # Brute force protection - rate limit token verification attempts - await check_auth_rate_limit(token) - - # Decode and verify token with current key - payload = validate_token(token, "access") - - # Validate critical claims - jti = payload.get("jti") - if not jti: - logger.error(f"Token missing jti claim: {token[:20]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - - if "exp" not in payload: - logger.error(f"Token missing expiration claim: {token[:20]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - - # Check token revocation status - if await is_access_token_revoked(jti): - logger.info(f"Token revocado detectado: {jti}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token revoked", - ) - - except jwt.ExpiredSignatureError: - # Special handling for expired tokens (don't try historical keys) - logger.info(f"Expired token attempt: {token[:20]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token expired", - ) - - except jwt.PyJWTError as e: - # Generic JWT error handling - logger.warning(f"JWT validation error: {str(e)} - Token: {token[:20]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - - except HTTPException: - # Re-raise our own exceptions - raise - - except Exception as e: - # Catch-all for unexpected errors - logger.error(f"Unexpected authentication error: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal authentication error", - ) - - # Validate subject claim - username = payload.get("sub") - if not username: - logger.error(f"Token missing subject claim: {token[:20]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token claims", - ) - - # User retrieval - cache-first strategy - user = None - try: - # Try to get user from cache - cached_data = await get_cached_user(username) - if cached_data: - user = TypeAdapter(UserInDB).validate_json(cached_data) - logger.debug(f"User {username} retrieved from cache") - except Exception as e: - logger.warning(f"Cache read error for {username}: {str(e)}") - - # Fallback to database if not in cache - if not user: - try: - user = await get_user_by_username(username) - if not user: - logger.warning(f"User not found in DB: {username}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid credentials", - ) - - # Cache user for future requests - try: - await cache_user(username, user.model_dump_json()) - logger.debug(f"Cached user data for {username}") - except Exception as e: - logger.warning(f"Cache write error for {username}: {str(e)}") - - except HTTPException: - raise - except Exception as e: - logger.error(f"Database error for {username}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Authentication service unavailable", - ) - - # # Additional security check: validate token issue time against user's last password change - # if security.validate_token_against_password_change: - # token_iat = datetime.fromtimestamp(payload["iat"], tz=timezone.utc) - # if user.password_changed_at and token_iat < user.password_changed_at: - # logger.info(f"Token issued before password change for {username}") - # raise HTTPException( - # status_code=status.HTTP_401_UNAUTHORIZED, - # detail="Token invalidated by password change", - # ) - - return user - - -async def get_current_active_user( - current_user: Annotated[UserInDB, Depends(get_current_user)], -) -> UserInDB: - """ - Verifies that the authenticated user is active. - - Args: - current_user (UserInDB): Authenticated user instance. - - Returns: - UserInDB: Active user model. - - Raises: - HTTPException: If the user's account is inactive. - """ - if not current_user.is_active: - logger.warning(f"Access attempt by inactive user: {current_user.username}") - await log_security_event_async( - AuditLogKeys.ACCESS_ATTEMPT_INACTIVE, {"username": current_user.username} - ) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account deactivated", - ) - return current_user - - -def normalize_path(path: str) -> str: - """Normalizes the URL path for consistent policy evaluation. - - This function ensures that all paths are in a consistent format: - - Removes trailing slashes except for root path - - Ensures path starts with a slash - - Collapses multiple slashes into single ones - - Preserves query parameters and fragments - - Args: - path (str): The URL path to normalize - - Returns: - str: Normalized path string - - Examples: - >>> normalize_path("/users/") # returns '/users' - >>> normalize_path("users") # returns '/users' - >>> normalize_path("/") # returns '/' - >>> normalize_path("//users///test/") # returns '/users/test' - """ - if not path: - return "/" - - # Ensure path starts with slash and normalize multiple slashes - normalized = "/" + "/".join(filter(None, path.split("/"))) - - # Handle root path specially - if normalized == "/": - return normalized - - # Remove trailing slash for non-root paths - return normalized.rstrip("/") - - -def permission_required( - action: PolicyAction, - resource: Optional[str] = None, - required_roles: Optional[List[str]] = None, - required_groups: Optional[List[str]] = None, -): - """ - Factory function to create permission validation dependencies using RBAC/ABAC. - - This function creates a FastAPI dependency that combines role-based and - attribute-based access control. It performs the following checks in order: - 1. Verifies user authentication and active status - 2. Validates required roles if specified - 3. Validates required groups if specified - 4. Evaluates Casbin policies for fine-grained permissions - - Args: - action (PolicyAction): The action being performed (e.g., READ, WRITE). - Must be a valid PolicyAction enum value. - resource (str, optional): Resource path to validate against. - If not provided, uses the current request path. - Example: "/users" or "/groups/{id}" - required_roles (List[str], optional): List of role names that can access. - The user must have at least one of these roles. - Example: ["admin", "moderator"] - required_groups (List[str], optional): List of group names that can access. - The user must belong to at least one of these groups. - Example: ["staff", "support"] - - Returns: - Depends: FastAPI dependency that performs the permission checks. - When injected, raises HTTPException if access is denied. - - Raises: - HTTPException: - - 401: If user is not authenticated - - 403: If user lacks required permissions - - 404: If resource doesn't exist - - Example: - @router.get( - "/users", - dependencies=[permission_required( - action=PolicyAction.READ, - required_roles=["admin"], - required_groups=["staff"] - )] - ) - async def list_users(): - ... - """ - - async def dependency( - request: Request, - user: UserInDB = Depends(get_current_active_user), - enforcer: AsyncEnforcer = Depends(get_enforcer), - ): - resource_path = normalize_path(resource or str(request.url.path)) - - if required_roles and not any(r.name in required_roles for r in user.roles): - logger.warning( - f"User '{user.username}' missing required roles: {required_roles}" - ) - raise HTTPException(status_code=403, detail="Insufficient role permissions") - - if required_groups and not any(g.name in required_groups for g in user.groups): - logger.warning( - f"User '{user.username}' missing required groups: {required_groups}" - ) - raise HTTPException( - status_code=403, detail="Insufficient group permissions" - ) - - user_attrs = { - "id": str(user.id), - "username": user.username, - "roles": [r.name for r in user.roles], - "groups": [g.name for g in user.groups], - "is_active": user.is_active, - } - - auth_request = CasbinRequest(user_attrs, resource_path, action.value) - temp_enforcer = await build_temp_enforcer(enforcer, auth_request, user) - - if config.logger.level == "DEBUG": - allowed = await debug_enforcement(temp_enforcer, auth_request) - else: - allowed = temp_enforcer.enforce( - auth_request.sub, auth_request.obj, auth_request.act - ) - - await log_permission_result( - allowed=allowed, - username=user.username, - resource=resource_path, - action=action, - ) - - if not allowed: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Insufficient permissions for this action", - ) - - return Depends(dependency) - - -async def log_permission_result( - *, allowed: bool, username: str, resource: str, action: PolicyAction -) -> None: - """ - Logs the result of a permission check for audit purposes. - - Args: - allowed (bool): Whether the permission was granted - username (str): The username requesting access - resource (str): The resource being accessed - action (PolicyAction): The attempted action - """ - event = { - "username": username, - "resource": resource, - "action": action, - "policy": "allowed" if allowed else "denied", - } - - # Only log denied events to reduce I/O - if not allowed: - await log_security_event_async(AuditLogKeys.PERMISSION_DENIED, event) - # Log at warning level only for denied permissions - logger.warning( - f"Permission denied: user={username} resource={resource} action={action}" - ) diff --git a/papi/base/user_auth_system/security/enforcer.py b/papi/base/user_auth_system/security/enforcer.py deleted file mode 100644 index 7c9ca4f..0000000 --- a/papi/base/user_auth_system/security/enforcer.py +++ /dev/null @@ -1,274 +0,0 @@ -import ast -import asyncio -import datetime -from typing import Any, Dict - -from casbin import AsyncEnforcer, Model -from casbin.util import key_match2, regex_match -from loguru import logger - -from user_auth_system.security import casbin_policies -from user_auth_system.security.casbin_adapter import AsyncCasbinAdapter as Adapter - - -class CasbinRequest: - """ - Encapsulates an authorization request for Casbin evaluation. - - This class represents a request to be evaluated against Casbin policies. - It includes the subject (user or role), object (resource), and action - to be performed. - - Attributes: - sub (dict): Subject attributes including: - - username: User identifier - - roles: List of role names - - is_superuser: Superuser status - obj (str): Object or resource being accessed - act (str): Action to perform on the resource - - Example: - request = CasbinRequest( - sub={"username": "john", "roles": ["admin"]}, - obj="users", - act="read" - ) - """ - - def __init__(self, sub: Dict[str, Any], obj: str, act: str): - if not isinstance(sub, dict): - raise ValueError("Subject must be a dictionary") - if not isinstance(obj, str): - raise ValueError("Object must be a string") - if not isinstance(act, str): - raise ValueError("Action must be a string") - - self.sub = sub - self.obj = obj - self.act = act - - def __str__(self) -> str: - """Returns a human-readable representation of the request.""" - return f"CasbinRequest(sub={self.sub}, obj='{self.obj}', act='{self.act}')" - - def __repr__(self) -> str: - """Returns a detailed string representation for debugging.""" - return f"" - - def __eq__(self, other: object) -> bool: - """Enables comparison between CasbinRequest objects.""" - if not isinstance(other, CasbinRequest): - return NotImplemented - return self.sub == other.sub and self.obj == other.obj and self.act == other.act - - -# --- Casbin RBAC Model with Conditions --- -CASBIN_MODEL = """ -[request_definition] -r = sub, obj, act - -[policy_definition] -p = sub, obj, act, condition, eft - -[role_definition] -g = _, _ - -[policy_effect] -e = some(where (p.eft == allow)) - -[matchers] -m = (g(r.sub['username'], p.sub) || check_roles(r.sub['roles'], p.sub) || check_groups(r.sub['groups'], p.sub)) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act) && safe_abac_eval(p.condition) -""" - - -def check_roles(user_roles: list, policy_sub: str) -> bool: - """ - Checks whether the user has a role required by the policy subject. - - Args: - user_roles (list): Roles assigned to the user. - policy_sub (str): Policy subject string (e.g., 'role:admin'). - - Returns: - bool: True if role matches; otherwise False. - """ - if not isinstance(user_roles, list): - return False - if not policy_sub.startswith("role:"): - return False - try: - role = policy_sub.split(":", 1)[1] - return role in user_roles - except Exception: - return False - - -def check_groups(user_groups: list, policy_sub: str) -> bool: - """ - Checks whether the user belongs to a group required by the policy subject. - - Args: - user_groups (list): Groups assigned to the user. - policy_sub (str): Policy subject string (e.g., 'group:editors'). - - Returns: - bool: True if group matches; otherwise False. - """ - if not isinstance(user_groups, list): - return False - if not policy_sub.startswith("group:"): - return False - try: - group = policy_sub.split(":", 1)[1] - return group in user_groups - except Exception: - return False - - -def safe_abac_eval(condition: str, r: CasbinRequest) -> bool: - """ - Safely evaluates an ABAC (Attribute-Based Access Control) condition. - - Args: - condition (str): Condition string from the policy. - r (CasbinRequest): The request object to use in evaluation. - - Returns: - bool: True if condition passes; otherwise False. - """ - if not condition or condition.strip().lower() == "true": - return True - - try: - allowed_names = {"r", "sub", "obj", "act"} - tree = ast.parse(condition, mode="eval") - - for node in ast.walk(tree): - if isinstance(node, ast.Name) and node.id not in allowed_names: - logger.warning(f"Unauthorized name in condition: {node.id}") - return False - - context = {"r": r, "sub": r.sub, "obj": r.obj, "act": r.act} - return eval(condition, {"__builtins__": None}, context) - - except Exception as e: - logger.error(f"ABAC eval error: {e}") - return False - - -async def debug_enforcement( - enforcer: AsyncEnforcer, request_obj: CasbinRequest -) -> bool: - """ - Prints matching policies and evaluation result for debugging authorization. - - Args: - enforcer (AsyncEnforcer): The Casbin enforcer instance. - request_obj (CasbinRequest): The request to evaluate. - - Returns: - bool: True if access is allowed, False otherwise. - """ - logger.debug("=== DEBUGGING ENFORCEMENT ===") - logger.debug( - f"Request: sub={request_obj.sub}, obj={request_obj.obj}, act={request_obj.act}" - ) - - matches = [] - try: - for policy in enforcer.get_policy(): - if len(policy) < 5: - logger.warning(f"Skipping incomplete policy: {policy}") - continue - - pol_sub, pol_obj, pol_act, pol_cond, pol_eft = policy - - user_roles = await enforcer.get_roles_for_user( - request_obj.sub.get("username", "") - ) - sub_match = pol_sub == request_obj.sub.get("username") or any( - role.strip() in user_roles for role in pol_sub.split(",") - ) - obj_match = key_match2(request_obj.obj, pol_obj) - act_match = regex_match(request_obj.act, pol_act) - cond_match = safe_abac_eval(pol_cond, request_obj) - eft_match = pol_eft.strip().lower() == "allow" - - if sub_match and obj_match and act_match and cond_match and eft_match: - logger.info(f"- MATCHED: {policy}") - matches.append(policy) - - result = enforcer.enforce(request_obj.sub, request_obj.obj, request_obj.act) - logger.debug(f"Final enforce result: {result}") - return result - - except Exception: - logger.exception("Error during enforcement") - return False - - -async def build_temp_enforcer( - base: AsyncEnforcer, request_obj: CasbinRequest, user: Any -) -> AsyncEnforcer: - """ - Creates a temporary Casbin enforcer with custom context and functions. - - Args: - base (AsyncEnforcer): The base enforcer. - request_obj (CasbinRequest): The authorization request. - user (Any): User object, must have `casbin_roles`. - - Returns: - AsyncEnforcer: A new enforcer with dynamic roles and functions. - """ - temp = AsyncEnforcer(base.get_model(), base.get_adapter()) - await temp.load_policy() - temp.add_function("check_roles", check_roles) - temp.add_function("check_groups", check_groups) - temp.add_function("safe_abac_eval", lambda cond: safe_abac_eval(cond, request_obj)) - - if getattr(user, "casbin_roles", None): - try: - await temp.add_named_grouping_policies( - "g", [policy[1:] for policy in user.casbin_roles] - ) - except Exception as e: - logger.error(f"Failed to assign user casbin_roles: {e}") - - return temp - - -# --- Global Enforcer Singleton --- -_enforcer_cache: Dict[str, AsyncEnforcer] = {} - - -async def get_enforcer() -> AsyncEnforcer: - """ - Returns a singleton Casbin AsyncEnforcer instance with caching and auto-reload. - - Returns: - AsyncEnforcer: The main Casbin enforcer with Redis policy updates. - """ - enforcer = _enforcer_cache.get("AsyncEnforcer") - now = datetime.datetime.now() - - if enforcer: - if now - getattr(enforcer, "last_loaded", now) > datetime.timedelta(minutes=5): - logger.info("Reloading policies due to cache expiration") - await enforcer.load_policy() - enforcer.last_loaded = now - return enforcer - - logger.info("Initializing new Casbin enforcer") - adapter = Adapter() - model = Model() - model.load_model_from_text(CASBIN_MODEL) - - enforcer = AsyncEnforcer(model, adapter) - await enforcer.load_policy() - enforcer.last_loaded = now - _enforcer_cache["AsyncEnforcer"] = enforcer - - asyncio.create_task(casbin_policies.start_redis_policy_listener(enforcer)) - - return enforcer diff --git a/papi/base/user_auth_system/security/enums.py b/papi/base/user_auth_system/security/enums.py deleted file mode 100644 index 2aef9ed..0000000 --- a/papi/base/user_auth_system/security/enums.py +++ /dev/null @@ -1,66 +0,0 @@ -from enum import StrEnum - - -class AuditLogKeys(StrEnum): - ACCESS_ATTEMPT_INACTIVE = "access_attempt_inactive" - ACCOUNT_LOCKED = "account_locked" - KEY_ROTATION = "key_rotation" - LOGIN_FAILED = "login_failed" - LOGIN_INACTIVE = "login_inactive" - LOGIN_LOCKOUT = "login_lockout" - LOGIN_SUCCESS = "login_success" - PERMISSION_DENIED = "permission_denied" - PERMISSION_GRANTED = "permission_granted" - SYSTEM_LOCKOUT = "system_lockout" - - -class PolicyEffect(StrEnum): - """ - Defines the effect of a policy rule. - Typically used in Casbin to indicate whether a request should be allowed or denied. - """ - - ALLOW = "allow" - DENY = "deny" - - -class PolicyAction(StrEnum): - """ - Defines a comprehensive list of standard policy actions for access control. - These values are typically used in Casbin policy definitions. - - Use 'ALL' (".*") to represent any action when defining wildcard permissions. - """ - - # Wildcard - ALL = ".*" # Matches any action - - # CRUD operations - CREATE = "create" # Create new resources (alias of WRITE in some contexts) - READ = "read" # View or retrieve resources - WRITE = "write" # Write or submit new data (more generic than CREATE) - UPDATE = "update" # Modify existing resources - DELETE = "delete" # Remove resources - PATCH = "patch" # Partial update of a resource - - # Execution and control - EXECUTE = "execute" # Execute a command, job, or script - APPROVE = "approve" # Approve actions or resources (e.g., moderation) - REJECT = "reject" # Reject or decline actions/resources - - # Resource listing and searching - LIST = "list" # List resources (could be paginated) - SEARCH = "search" # Perform queries or search operations - EXPORT = "export" # Export data to file - IMPORT = "import" # Import data from file - - # Authentication and session - LOGIN = "login" # User login action - LOGOUT = "logout" # User logout action - REGISTER = "register" # Account creation - - # Permissions and roles - ASSIGN = "assign" # Assign roles or permissions - REVOKE = "revoke" # Revoke roles or permissions - GRANT = "grant" # Grant specific access - DENY = "deny" # Deny specific access diff --git a/papi/base/user_auth_system/security/jwt_utils.py b/papi/base/user_auth_system/security/jwt_utils.py deleted file mode 100644 index b8b10a3..0000000 --- a/papi/base/user_auth_system/security/jwt_utils.py +++ /dev/null @@ -1,145 +0,0 @@ -import jwt -from fastapi import HTTPException, status -from jwt import get_unverified_header -from loguru import logger - -from user_auth_system.config import security -from user_auth_system.security.key_manager import key_manager - - -def get_signing_key_by_kid(kid: str) -> str: - """ - Retrieves the signing key associated with a Key ID (kid). - - Args: - kid: Key identifier from JWT header - - Returns: - Corresponding signing key - - Raises: - HTTPException: 401 for invalid/missing kid - """ - if not kid: - logger.warning("Missing KID in token header") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token signature" - ) - - try: - return key_manager.get_key_by_kid(kid) - except HTTPException: - logger.warning(f"Invalid KID in token: {kid}") - raise - except Exception as e: - logger.critical(f"Key retrieval failed: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Token validation error", - ) - - -def validate_token(token: str, expected_type: str) -> dict: - """ - Validates a JWT token with comprehensive security checks. - - Verification includes: - 1. Token structure and header - 2. Key ID retrieval - 3. Cryptographic signature - 4. Standard claims (exp, iat, nbf, iss, aud) - 5. Required custom claims (jti, sub, device_id for refresh) - 6. Token type verification - - Args: - token: JWT token string - expected_type: Expected token type ('access' or 'refresh') - - Returns: - Decoded token payload - - Raises: - HTTPException: For any validation failure with appropriate status code - """ - try: - # Step 1: Basic token structure validation - if not token or len(token) < 50: - raise ValueError("Invalid token structure") - - # Step 2: Decode header to get Key ID - header = get_unverified_header(token) - kid = header.get("kid") - if not kid: - raise jwt.InvalidAlgorithmError("Missing KID in token header") - - # Step 3: Retrieve signing key - key = get_signing_key_by_kid(kid) - - # Step 4: Decode and verify token - payload = jwt.decode( - token, - key, - algorithms=[security.hash_algorithm], - audience=security.token_audience, - issuer=security.token_issuer, - options={ - "require": ["exp", "iat", "nbf", "iss", "aud", "jti", "sub"], - "verify_signature": True, - "verify_aud": True, - "verify_iss": True, - "verify_exp": True, - "verify_nbf": True, - }, - leeway=30, # 30 seconds leeway for clock skew - ) - - # Step 5: Verify token type - token_type = payload.get("typ") - if token_type != expected_type: - logger.warning( - f"Token type mismatch: expected {expected_type}, got {token_type}" - ) - raise ValueError("Invalid token type") - - # Step 6: Verify required claims for specific token types - if expected_type == "refresh" and "device_id" not in payload: - logger.error("Refresh token missing device_id claim") - raise ValueError("Missing required claim: device_id") - - return payload - - except jwt.ExpiredSignatureError: - logger.info(f"Expired {expected_type} token presented") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"{expected_type.capitalize()} token expired", - ) - except jwt.InvalidAudienceError: - logger.warning(f"Invalid audience in {expected_type} token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token audience" - ) - except jwt.InvalidIssuerError: - logger.warning(f"Invalid issuer in {expected_type} token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token issuer" - ) - except jwt.ImmatureSignatureError: - logger.info(f"Premature {expected_type} token usage") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Token not yet valid" - ) - except jwt.InvalidTokenError as e: - logger.warning(f"Invalid {expected_type} token: {str(e)}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" - ) - except ValueError as e: - logger.warning(f"Token validation failed: {str(e)}") - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) - except Exception as e: - logger.error(f"Unexpected token validation error: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Token validation failed", - ) diff --git a/papi/base/user_auth_system/security/key_manager.py b/papi/base/user_auth_system/security/key_manager.py deleted file mode 100644 index 33ae46f..0000000 --- a/papi/base/user_auth_system/security/key_manager.py +++ /dev/null @@ -1,284 +0,0 @@ -import base64 -import secrets -from datetime import datetime, timedelta, timezone -from typing import List, Tuple - -from fastapi import HTTPException, status -from loguru import logger -from pydantic import SecretStr -from sqlalchemy.future import select - -from papi.core.db import get_sql_session -from user_auth_system.config import security -from user_auth_system.models.jwt_key import JWTKey -from user_auth_system.schemas.auth import KeyRotation - -# Configuration constants -KEY_ROTATION_CONFIG: KeyRotation = security.key_rotation -DEFAULT_BASE_KEY = security.secret_key -DEFAULT_KEY_ID = "default" - - -class KeyManager: - """ - Manages JWT signing keys with support for key rotation and historical key storage. - - This class handles: - - Initial key loading from database or default configuration - - Periodic key rotation based on configured intervals - - Key pruning to maintain historical key limits - - Key retrieval by Key ID (kid) - - When key rotation is disabled, a single static key is used for all operations. - - Attributes: - _keys: List of tuples (key_string, creation_datetime) - _current_key_index: Index of the active key in _keys - _last_rotation: Timestamp of last key rotation - _session: Database session for key persistence - """ - - def __init__(self) -> None: - """Initializes the KeyManager with empty state.""" - self._keys: List[Tuple[str, datetime]] = [] - self._current_key_index: int = -1 - self._last_rotation: datetime = datetime.now(timezone.utc) - - async def initialize(self) -> None: - """ - Initializes key manager by loading keys from database or creating initial key. - - If key rotation is disabled: - - Uses the default base key from configuration - - Sets key ID to "default" - - If key rotation is enabled: - - Loads existing keys from database - - Creates initial key if no keys exist - - Sets current key to the most recent - - Args: - session: Database session for key operations - - Raises: - RuntimeError: If database operations fail during initialization - """ - - if not security.key_rotation.enabled: - self._setup_static_key() - logger.info("Key rotation disabled - using static key") - return - - await self._load_keys_from_database() - - def _setup_static_key(self) -> None: - """Configures manager to use static key from configuration.""" - now = datetime.now(timezone.utc) - - if isinstance(DEFAULT_BASE_KEY, SecretStr): - key_value = DEFAULT_BASE_KEY.get_secret_value() - else: - key_value = DEFAULT_BASE_KEY - - self._keys = [(key_value, now)] - self._current_key_index = 0 - self._last_rotation = now - - async def _load_keys_from_database(self) -> None: - """Loads keys from database or creates initial key if none exist.""" - try: - async with get_sql_session() as session: - result = await session.execute(select(JWTKey).order_by(JWTKey.id.asc())) - db_keys = result.scalars().all() - - if db_keys: - self._keys = [(row.key, row.created_at) for row in db_keys] - self._current_key_index = len(self._keys) - 1 - self._last_rotation = self._keys[-1][1] - logger.info(f"Loaded {len(self._keys)} existing keys from database") - else: - await self._create_initial_key() - except Exception as e: - logger.critical(f"Key initialization failed: {str(e)}") - raise RuntimeError("Key manager initialization failed") from e - - async def _create_initial_key(self) -> None: - """Creates and persists initial key in database.""" - now = datetime.now(timezone.utc) - - if isinstance(DEFAULT_BASE_KEY, SecretStr): - key_value = DEFAULT_BASE_KEY.get_secret_value() - else: - key_value = DEFAULT_BASE_KEY - - self._keys = [(key_value, now)] - self._current_key_index = 0 - self._last_rotation = now - - base_key_record = JWTKey(key=key_value, created_at=now) - async with get_sql_session() as session: - session.add(base_key_record) - await session.commit() - logger.info("Created initial key from configuration") - - @property - def current_key(self) -> str: - """ - Retrieves the current active signing key. - - Returns: - Current active key string - - Raises: - RuntimeError: If manager is not initialized - """ - if not self._keys: - raise RuntimeError("KeyManager is not initialized") - - key_value = self._keys[self._current_key_index][0] - - return key_value - - @property - def current_kid(self) -> str: - """ - Retrieves Key ID (kid) of the current active key. - - Returns: - "default" when rotation disabled, otherwise stringified index - """ - if not security.key_rotation.enabled: - return DEFAULT_KEY_ID - - return str(self._current_key_index) - - async def rotate_key(self) -> None: - """ - Rotates the signing key and persists to database. - - Steps: - 1. Generate new cryptographically secure key - 2. Store in database - 3. Update internal state - 4. Prune excess historical keys - - Raises: - RuntimeError: If rotation fails or database operations error - """ - if not security.key_rotation.enabled: - logger.warning("Key rotation attempted while disabled") - return - - try: - new_key = self._generate_secure_key() - now = datetime.now(timezone.utc) - - # Persist new key - new_jwt_key = JWTKey(key=new_key, created_at=now) - async with get_sql_session() as session: - session.add(new_jwt_key) - await session.commit() - - # Update state - self._keys.append((new_key, now)) - self._current_key_index = len(self._keys) - 1 - self._last_rotation = now - - # Maintain historical key limit - await self._prune_old_keys() - - logger.info(f"Rotated key (new kid: {self.current_kid})") - except Exception as e: - logger.critical(f"Key rotation failed: {str(e)}") - raise RuntimeError("Key rotation aborted") from e - - def _generate_secure_key(self) -> str: - """Generates a new base64-encoded cryptographically secure key.""" - key_bytes = secrets.token_bytes(64) - return base64.urlsafe_b64encode(key_bytes).decode() - - async def _prune_old_keys(self) -> None: - """Removes oldest keys exceeding historical key limit.""" - if not security.key_rotation.enabled: - return - - max_keys = KEY_ROTATION_CONFIG.max_historical_keys - if len(self._keys) <= max_keys: - return - - # Calculate keys to remove - excess_count = len(self._keys) - max_keys - oldest_allowed_time = self._keys[excess_count][1] - - # Remove from database - async with get_sql_session() as session: - await session.execute( - JWTKey.__table__.delete().where(JWTKey.created_at < oldest_allowed_time) - ) - await session.commit() - - # Update in-memory state - self._keys = self._keys[excess_count:] - self._current_key_index = len(self._keys) - 1 - - logger.info(f"Pruned {excess_count} historical keys") - - def should_rotate(self) -> bool: - """ - Determines if key rotation is needed based on rotation interval. - - Returns: - True if rotation interval has elapsed since last rotation - """ - if not security.key_rotation.enabled: - return False - - rotation_interval = timedelta(days=KEY_ROTATION_CONFIG.rotation_interval_days) - time_since_rotation = datetime.now(timezone.utc) - self._last_rotation - return time_since_rotation >= rotation_interval - - def get_key_by_kid(self, kid: str) -> str: - """ - Retrieves signing key by Key ID (kid). - - Args: - kid: Key ID string from JWT header - - Returns: - Corresponding signing key - - Raises: - HTTPException: For invalid/missing keys (401 Unauthorized) - """ - # Handle static key scenario - if not security.key_rotation.enabled: - if kid == DEFAULT_KEY_ID: - return self._keys[0][0] if self._keys else "" - - logger.warning(f"Invalid KID '{kid}' for static key configuration") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token signature", - ) - - # Handle key rotation scenario - try: - index = int(kid) - if 0 <= index < len(self._keys): - return self._keys[index][0] - - logger.warning(f"Out-of-range KID requested: {kid}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token signature", - ) - except (ValueError, TypeError): - logger.warning(f"Malformed KID format: {kid}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Malformed token header", - ) - - -# Global instance for application-wide key management -key_manager = KeyManager() diff --git a/papi/base/user_auth_system/security/lockout.py b/papi/base/user_auth_system/security/lockout.py deleted file mode 100644 index 0c96205..0000000 --- a/papi/base/user_auth_system/security/lockout.py +++ /dev/null @@ -1,91 +0,0 @@ -from loguru import logger - -from papi.core.db import get_redis_client -from user_auth_system.config import security - -from .audit import log_security_event_async -from .enums import AuditLogKeys - - -async def reset_failed_attempts(ip_address: str, username: str): - """Resets failed attempt counters for an IP-username combination. - - Args: - ip_address: Client IP address - username: User identifier - - Clears both the attempt counter and any active account lock. - """ - redis_client = await get_redis_client() - await redis_client.delete(f"login_attempts:{ip_address}:{username}") - await redis_client.delete(f"account_lock:{username}") - - -async def has_excessive_failed_attempts(username: str) -> bool: - """Checks if an account is locked due to excessive failed attempts. - - Args: - username: User identifier to check - - Returns: - bool: True if account is locked, False otherwise - """ - redis_client = await get_redis_client() - lock_key = f"account_lock:{username}" - return await redis_client.exists(lock_key) == 1 - - -async def record_failed_attempt(ip_address: str, username: str): - """Records a failed login attempt and triggers account lockout if threshold reached. - - Args: - ip_address: Client IP address - username: User identifier - - Uses Redis to track failed attempts with expiration. When attempt threshold - is reached, locks the account for a configured duration. - """ - key = f"login_attempts:{ip_address}:{username}" - redis_client = await get_redis_client() - attempts = await redis_client.incr(key) - await redis_client.expire(key, security.lockout_duration_minutes * 60) - - logger.debug( - f"Failed login attempt #{attempts} for user '{username}' from IP {ip_address}" - ) - - if attempts >= security.max_login_attempts: - lock_key = f"account_lock:{username}" - await redis_client.setex( - lock_key, security.lockout_duration_minutes * 60, "locked" - ) - - logger.info( - f"Account for user '{username}' locked after {attempts} failed attempts from IP {ip_address}" - ) - - await log_security_event_async( - AuditLogKeys.ACCOUNT_LOCKED, - details={"username": username, "ip": ip_address, "attempts": attempts}, - ) - - -async def is_system_locked_out() -> bool: - """Checks if the system is in global lockout mode. - - Returns: - bool: True if system lockout is active, False otherwise - """ - redis_client = await get_redis_client() - return await redis_client.exists("global_lockout") == 1 - - -async def activate_system_lockout(): - """Activates system-wide authentication lockout for a fixed duration. - - Used as a security measure during suspected brute-force attacks. - Logs the event to the security audit system. - """ - redis_client = await get_redis_client() - await redis_client.setex("global_lockout", 900, "locked") - await log_security_event_async(AuditLogKeys.SYSTEM_LOCKOUT) diff --git a/papi/base/user_auth_system/security/password.py b/papi/base/user_auth_system/security/password.py deleted file mode 100644 index 35df292..0000000 --- a/papi/base/user_auth_system/security/password.py +++ /dev/null @@ -1,91 +0,0 @@ -import re - -import bcrypt -from fastapi import HTTPException, status - -from user_auth_system.config import auth_settings, security - -MIN_PASSWORD_LENGTH = auth_settings.password_min_length -PASSWORD_VALIDATION_RULES = [ - (r"[A-Z]", "at least one uppercase letter"), - (r"[a-z]", "at least one lowercase letter"), - (r"\d", "at least one digit"), - (r"[!@#$%^&*(),.?\":{}|<>]", "at least one special character"), -] - - -def hash_password(password: str) -> str: - """Generates a bcrypt hash with configurable work factor. - - Args: - password: Plaintext password to hash - - Returns: - str: Bcrypt-hashed password string - - Raises: - ValueError: If password is invalid or too short - - The bcrypt work factor (rounds) is controlled by security configuration. - """ - if not isinstance(password, str): - if not security.allow_weak_passwords: - validate_password_strength(password) - - salt = bcrypt.gensalt(rounds=security.bcrypt_rounds) - hashed_bytes = bcrypt.hashpw(password.encode("utf-8"), salt) - return hashed_bytes.decode("utf-8") - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Securely verifies a password against a stored hash. - - Args: - plain_password: Password to verify - hashed_password: Stored bcrypt hash to compare against - - Returns: - bool: True if passwords match, False otherwise - - Uses constant-time comparison to prevent timing attacks. Returns False - immediately if either input is empty. - """ - if not plain_password or not hashed_password: - return False - - return bcrypt.checkpw( - plain_password.encode("utf-8"), hashed_password.encode("utf-8") - ) - - -def validate_password_strength(password: str) -> None: - """Validates password against configured complexity rules. - - Args: - password: Password to validate - - Raises: - HTTPException: If password doesn't meet strength requirements - - Bypasses validation if weak passwords are allowed in configuration. - Checks length, character diversity, and special character requirements. - """ - if security.allow_weak_passwords: - return - - errors = [] - - if len(password) < MIN_PASSWORD_LENGTH: - errors.append( - f"Password must be at least {MIN_PASSWORD_LENGTH} characters long" - ) - - for pattern, message in PASSWORD_VALIDATION_RULES: - if not re.search(pattern, password): - errors.append(f"Password must contain {message}") - - if errors: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="; ".join(errors), - ) diff --git a/papi/base/user_auth_system/security/rate_limit.py b/papi/base/user_auth_system/security/rate_limit.py deleted file mode 100644 index 0a9fbb0..0000000 --- a/papi/base/user_auth_system/security/rate_limit.py +++ /dev/null @@ -1,70 +0,0 @@ -import time -from typing import Dict, Tuple - -from fastapi import HTTPException, status -from loguru import logger - -# In-memory store for rate limiting -# token_hash -> (attempt_count, first_attempt_timestamp) -_rate_limit_store: Dict[str, Tuple[int, float]] = {} - -# Rate limit configuration -MAX_ATTEMPTS = 5 -WINDOW_SECONDS = 300 # 5 minutes -BLOCK_DURATION = 900 # 15 minutes - - -def _hash_token(token: str) -> str: - """ - Creates a secure hash of the token for rate limiting. - We don't store the actual token for security reasons. - """ - from hashlib import sha256 - - return sha256(token.encode()).hexdigest() - - -async def check_auth_rate_limit(token: str) -> None: - """ - Implements a sliding window rate limiting for authentication attempts. - - Args: - token: The authentication token to check - - Raises: - HTTPException: If rate limit is exceeded - """ - token_hash = _hash_token(token) - current_time = time.time() - - # Clean up old entries - cleanup_threshold = current_time - WINDOW_SECONDS - _rate_limit_store.clear() - - if token_hash in _rate_limit_store: - attempts, first_attempt = _rate_limit_store[token_hash] - - # Check if in blocking period - if attempts >= MAX_ATTEMPTS: - block_end = first_attempt + BLOCK_DURATION - if current_time < block_end: - logger.warning( - f"Rate limit exceeded for token hash: {token_hash[:8]}..." - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Too many authentication attempts. Try again in {int(block_end - current_time)} seconds", - ) - else: - # Reset after block period - del _rate_limit_store[token_hash] - - # Update attempts within window - if current_time - first_attempt <= WINDOW_SECONDS: - _rate_limit_store[token_hash] = (attempts + 1, first_attempt) - else: - # Reset window - _rate_limit_store[token_hash] = (1, current_time) - else: - # First attempt - _rate_limit_store[token_hash] = (1, current_time) diff --git a/papi/base/user_auth_system/security/tokens.py b/papi/base/user_auth_system/security/tokens.py deleted file mode 100644 index 773eafd..0000000 --- a/papi/base/user_auth_system/security/tokens.py +++ /dev/null @@ -1,424 +0,0 @@ -import secrets -from datetime import datetime, timedelta, timezone -from typing import Optional, Tuple - -import jwt -from fastapi import Request -from jwt import get_unverified_header -from loguru import logger -from redis.exceptions import LockError -from sqlalchemy import delete, or_, select, update -from sqlalchemy.exc import SQLAlchemyError - -from papi.core.db import get_redis_client, get_sql_session -from user_auth_system.config import security -from user_auth_system.models.token import AccessToken, RefreshToken -from user_auth_system.security.jwt_utils import get_signing_key_by_kid, validate_token - -from .audit import log_security_event_async -from .enums import AuditLogKeys -from .key_manager import key_manager - - -async def create_access_token( - data: dict, - expires_delta: Optional[timedelta] = None, -) -> str: - """ - Generates a JWT access token and revokes previous tokens for the same subject/device. - - Performs key rotation if needed and ensures token uniqueness per device by: - 1. Checking for key rotation requirements - 2. Generating new token metadata - 3. Revoking previous valid tokens for the subject/device - 4. Persisting the new token in the database - - Args: - data: Dictionary containing token claims (must include 'sub' and 'device_id') - expires_delta: Optional custom token expiration period - - Returns: - Encoded JWT access token string - - Raises: - SQLAlchemyError: If database operations fail - """ - redis_client = await get_redis_client() - lock = redis_client.lock("rotate_key_lock", timeout=10, blocking_timeout=1) - - # Handle key rotation if required - if key_manager.should_rotate(): - try: - if await lock.acquire(): - if key_manager.should_rotate(): - logger.info("Rotating JWT signing keys") - await key_manager.rotate_key() - await log_security_event_async(AuditLogKeys.KEY_ROTATION) - except LockError: - logger.warning("Failed to acquire lock for key rotation") - finally: - try: - await lock.release() - except Exception: - logger.warning("Failed to release lock after rotation attempt") - - # Prepare token expiration and metadata - now = datetime.now(timezone.utc) - expires_delta = expires_delta or timedelta( - minutes=security.access_token_expire_minutes - ) - expire = now + expires_delta - jti = secrets.token_urlsafe(32) # Unique token identifier - kid = str(key_manager.current_kid) - headers = {"kid": kid, "alg": security.hash_algorithm, "typ": "JWT"} - subject = data["sub"] - device_id = data["device_id"] - ttl = 30 # Not-before time buffer - - # Build token payload - payload = { - "exp": expire, - "iat": now, - "nbf": now - timedelta(seconds=ttl), - "iss": security.token_issuer, - "aud": security.token_audience, - "sub": subject, - "jti": jti, - "typ": "access", - "device_id": device_id, - } - - async with get_sql_session() as session: - # Revoke previous tokens for this subject/device - result = await session.execute( - select(AccessToken).where( - AccessToken.subject == subject, - AccessToken.device_id == device_id, - AccessToken.revoked.is_(False), - ) - ) - for token in result.scalars(): - token.revoked = True - token.revoked_at = now - - # Register new token - session.add( - AccessToken( - jti=jti, - subject=subject, - device_id=device_id, - expires_at=expire, - revoked=False, - ) - ) - await session.commit() - - logger.debug( - f"Generated access token for subject '{subject}' on device '{device_id}'" - ) - return jwt.encode( - payload, - key_manager.current_key, - algorithm=security.hash_algorithm, - headers=headers, - ) - - -async def create_or_replace_refresh_token( - subject: str, - device_id: str, - user_agent: Optional[str] = None, - expires_in_days: int = 30, -) -> Tuple[str, str, datetime]: - """ - Generates a new refresh token and revokes previous tokens for the subject/device. - - Ensures only one valid refresh token exists per device by: - 1. Generating new token with unique JTI - 2. Revoking previous valid tokens for the subject/device - 3. Storing the new token hash in the database - - Args: - subject: User identifier (subject claim) - device_id: Device identifier - user_agent: Optional client user agent string - expires_in_days: Token validity period in days - - Returns: - Tuple containing: - - Encoded refresh token string - - Unique token identifier (JTI) - - Expiration datetime - - Raises: - SQLAlchemyError: If database operations fail - """ - now = datetime.now(timezone.utc) - expire = now + timedelta(days=expires_in_days) - jti = secrets.token_urlsafe(32) - kid = key_manager.current_kid - headers = {"kid": kid, "alg": security.hash_algorithm, "typ": "JWT"} - - # Build token payload - payload = { - "exp": expire, - "iat": now, - "nbf": now - timedelta(seconds=30), - "iss": security.token_issuer, - "aud": security.token_audience, - "typ": "refresh", - "sub": subject, - "jti": jti, - "device_id": device_id, - } - - token_str = jwt.encode( - payload=payload, - key=key_manager.current_key, - algorithm=security.hash_algorithm, - headers=headers, - ) - - async with get_sql_session() as session: - # Revoke previous refresh tokens - result = await session.execute( - select(RefreshToken).where( - RefreshToken.subject == subject, - RefreshToken.device_id == device_id, - RefreshToken.revoked.is_(False), - RefreshToken.expires_at > now, - ) - ) - for token in result.scalars(): - token.revoked = True - token.revoked_at = now - - # Register new token - session.add( - RefreshToken( - jti=jti, - subject=subject, - token_hash=RefreshToken.compute_token_hash(token_str), - device_id=device_id, - user_agent=user_agent, - expires_at=expire, - revoked=False, - ) - ) - await session.commit() - - logger.info( - f"Generated refresh token for subject '{subject}' on device '{device_id}'" - ) - return token_str, jti, expire - - -async def revoke_refresh_token(refresh_jti: str) -> None: - """ - Revokes a specific refresh token by its unique identifier (JTI). - - Args: - refresh_jti: Unique token identifier (JTI) - - Returns: - None - - Note: - - Assumes jti is globally unique (enforced at database level) - - Logs warning if token is not found or already revoked - - Includes basic security audit logging - """ - async with get_sql_session() as session: - # Execute the update - result = await session.execute( - update(RefreshToken) - .where(RefreshToken.jti == refresh_jti) - .values(revoked=True, revoked_at=datetime.now(timezone.utc)) - ) - await session.commit() - - # Log appropriate message based on result - if result.rowcount == 0: - logger.info(f"Refresh token not found or already revoked: {refresh_jti}") - else: - logger.info(f"Refresh token revoked: {refresh_jti}") - - -async def revoke_access_token_from_request(request: Request) -> None: - """ - Extracts and revokes an access token from the Authorization header. - - Args: - request: Incoming FastAPI request object - - Returns: - None - - Note: - Silently handles missing/invalid tokens and logs errors - """ - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - logger.debug("No access token in Authorization header") - return - - token = auth_header.split(" ")[1] - - try: - header = get_unverified_header(token) - kid = header.get("kid", False) - key = get_signing_key_by_kid(kid) - - payload = validate_token(token, "access") - if payload.get("typ") != "access": - logger.warning("Provided token is not an access token") - return - - jti = payload.get("jti") - exp = payload.get("exp") - if not jti or not exp: - logger.warning("Access token missing jti or exp claim") - return - - # Skip revocation if token already expired - ttl = exp - datetime.now(timezone.utc).timestamp() - if ttl <= 0: - logger.debug("Access token already expired") - return - - await revoke_access_token(jti) - - except jwt.PyJWTError as e: - logger.warning(f"Access token decode error: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error during token revocation: {str(e)}") - - -async def revoke_access_token(jti: str) -> None: - """ - Revokes an access token and adds it to the revocation cache. - - Args: - jti: Unique token identifier - - Returns: - None - - Raises: - SQLAlchemyError: If database operations fail - """ - async with get_sql_session() as session: - try: - token = await session.scalar( - select(AccessToken).where(AccessToken.jti == jti) - ) - - if not token: - logger.info(f"Token {jti} not found in database") - return - - if token.revoked: - logger.info(f"Token {jti} already revoked") - return - - # Update database record - token.revoked = True - token.revoked_at = datetime.now(timezone.utc) - await session.commit() - - # Add to Redis revocation cache - ttl = int((token.expires_at - datetime.now(timezone.utc)).total_seconds()) - if ttl > 0: - redis_client = await get_redis_client() - await redis_client.setex( - f"papi:access_token_revoked:{jti}", - ttl, - "1", # Using string value for consistency - ) - - logger.info(f"Revoked access token: {jti}") - - except SQLAlchemyError as db_err: - await session.rollback() - logger.error(f"Database error revoking token: {db_err}") - raise - - -async def revoke_access_token_by_device_id(device_id: str) -> None: - async with get_sql_session() as session: - token = await session.scalar( - select(AccessToken) - .where(AccessToken.device_id == device_id) - .where(AccessToken.revoked.is_(False)) - ) - - if not token: - logger.info(f"No active access tokens for {device_id} found") - return - else: - await revoke_access_token(token.jti) - - -async def is_access_token_revoked(jti: str) -> bool: - """ - Checks if an access token is revoked or expired. - - Verification steps: - 1. Check Redis revocation cache - 2. Check database for revocation status - 3. Verify token expiration - - Args: - jti: Unique token identifier - - Returns: - True if token is invalid (revoked/expired/not found), False otherwise - """ - # Check Redis cache first - redis_client = await get_redis_client() - if await redis_client.exists(f"papi:access_token_revoked:{jti}"): - return True - - # Check database record - async with get_sql_session() as session: - token = await session.scalar(select(AccessToken).where(AccessToken.jti == jti)) - - if not token: - return True # Treat non-existent tokens as revoked - - # Check if token is expired or revoked - current_time = datetime.now(timezone.utc) - return token.revoked or token.expires_at < current_time - - -async def cleanup_expired_tokens() -> None: - """Deletes expired access and refresh tokens from the database.""" - now = datetime.now(timezone.utc) - - async with get_sql_session() as session: - # Delete expired access tokens - access_result = await session.execute( - delete(AccessToken).where( - or_( - AccessToken.expires_at < now, - AccessToken.revoked.is_(True), - ) - ) - ) - - # Delete expired refresh tokens - refresh_result = await session.execute( - delete(RefreshToken).where( - or_( - RefreshToken.expires_at < now, - RefreshToken.revoked.is_(True), - ) - ) - ) - - await session.commit() - - logger.info( - f"Token cleanup: Removed {access_result.rowcount} access tokens " - f"and {refresh_result.rowcount} refresh tokens" - ) diff --git a/papi/base/user_auth_system/security/user.py b/papi/base/user_auth_system/security/user.py deleted file mode 100644 index 08254a0..0000000 --- a/papi/base/user_auth_system/security/user.py +++ /dev/null @@ -1,49 +0,0 @@ -import re -from typing import Final, Set - -FORBIDDEN_TERMS: Final[Set[str]] = { - "root", - "admin", - "superuser", - "supervisor", - "support", - "system", - "role", - "group", -} - - -def validate_username(username: str) -> str: - """ - Validates a username string to ensure it meets security and formatting rules. - - The username must: - - Be composed of only letters (a-z, A-Z), digits (0-9), underscores (_) or hyphens (-). - - Start and end with a letter or digit. - - May contain a single '_' or '-' between segments (not consecutively). - - Not include reserved terms such as 'root', 'admin', 'role', etc. - - Args: - username (str): The username to validate. - - Returns: - str: The validated username if it meets all conditions. - - Raises: - ValueError: If the username does not meet the pattern requirements or includes a forbidden term. - """ - username = username.strip() - - # Validate allowed structure - pattern = re.compile(r"^[a-zA-Z0-9]+([_-]?[a-zA-Z0-9]+)*$") - if not pattern.fullmatch(username): - raise ValueError( - "Username must be alphanumeric and may include a single '-' or '_' between segments." - ) - - # Check for forbidden terms (case-insensitive) - lowered = username.lower() - if any(term in lowered for term in FORBIDDEN_TERMS): - raise ValueError("Username contains a reserved word.") - - return username diff --git a/papi/base/user_auth_system/setup.py b/papi/base/user_auth_system/setup.py deleted file mode 100644 index 01a58d6..0000000 --- a/papi/base/user_auth_system/setup.py +++ /dev/null @@ -1,281 +0,0 @@ -import os -import re -from datetime import datetime, timezone - -from loguru import logger -from sqlalchemy import func, select -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession - -from papi.core.addons import AddonSetupHook -from papi.core.db import get_sql_session -from papi.core.settings import get_config -from user_auth_system.models import Role, User -from user_auth_system.schemas.policy import PolicyCreate -from user_auth_system.schemas.root import RootUserEnv -from user_auth_system.security.casbin_policies import ( - add_policy, - start_redis_policy_listener, -) -from user_auth_system.security.enforcer import get_enforcer -from user_auth_system.security.enums import PolicyAction, PolicyEffect -from user_auth_system.security.key_manager import key_manager -from user_auth_system.security.password import hash_password -from user_auth_system.security.tokens import cleanup_expired_tokens - -config = get_config() - -# RFC 5322 compliant email validation regex -EMAIL_REGEX = re.compile( - r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" -) - - -def is_valid_email(email: str) -> bool: - """ - Validates email format against RFC 5322 specification. - - Args: - email: Email address to validate - - Returns: - True if valid email format, False otherwise - """ - return bool(EMAIL_REGEX.fullmatch(email)) - - -async def init_root_policies(root_env: RootUserEnv) -> None: - """ - Initializes default access policies for root user and root role. - - Policies grant: - - Full access to root username - - Full access to root role members - - Args: - root_env: Root environment configuration - - Raises: - RuntimeError: If policy initialization fails - """ - try: - # Policy for direct root user access - root_user_policy = PolicyCreate( - subject=root_env.username, - object="/*", - action=PolicyAction.ALL, - condition="True", - effect=PolicyEffect.ALLOW, - description="Root user full access policy", - ) - - # Policy for root role access - root_role_policy = PolicyCreate( - subject=f"role:{root_env.role_name}", - object="/*", - action=PolicyAction.ALL, - condition="True", - effect=PolicyEffect.ALLOW, - description="Root role full access policy", - ) - - await add_policy(root_user_policy) - await add_policy(root_role_policy) - logger.info("Root access policies initialized successfully") - - except Exception as e: - logger.critical(f"Policy initialization failed: {str(e)}") - raise RuntimeError("Root policy initialization failed") from e - - -class AuthSystemInitializer(AddonSetupHook): - """ - Authentication system initialization hook. Handles: - - Root role creation - - Root user creation - - Access policy initialization - - Security warnings for sensitive configurations - - Execution is idempotent and only runs on first system startup. - """ - - async def _create_root_role( - self, root_env: RootUserEnv, session: AsyncSession - ) -> Role: - """ - Ensures the root role exists in the database (idempotent). - - Args: - root_env: Root environment configuration - session: Database session - - Returns: - Existing or created root Role instance - - Raises: - RuntimeError: If database operation fails - """ - try: - # Check for existing role - existing_role = await session.scalar( - select(Role).where(Role.name == root_env.role_name) - ) - - if existing_role: - logger.debug(f"Root role already exists: {root_env.role_name}") - return existing_role - - # Create new role - new_role = Role( - name=root_env.role_name, - description="System super-administrator role with full privileges", - is_protected=True, # Prevent accidental deletion - ) - session.add(new_role) - await session.commit() - logger.info(f"Created root role: {root_env.role_name}") - return new_role - - except SQLAlchemyError as e: - logger.error("Root role creation failed") - await session.rollback() - raise RuntimeError("Database error during role creation") from e - - async def _create_root_user( - self, session: AsyncSession, root_env: RootUserEnv, role: Role - ) -> None: - """ - Creates root user account with secure credentials. - - Args: - session: Database session - root_env: Root environment configuration - role: Root role instance - - Raises: - RuntimeError: If user creation fails - ValueError: For invalid email format - """ - # Validate email format - if not is_valid_email(root_env.email): - raise ValueError(f"Invalid root email format: {root_env.email}") - - try: - # Check for existing user - existing_user = await session.scalar( - select(User).where(User.username == root_env.username) - ) - if existing_user: - logger.debug(f"Root user already exists: {root_env.username}") - return - - # Create new root user - new_user = User( - username=root_env.username, - full_name="System Administrator", - email=root_env.email, - hashed_password=hash_password(root_env.password), - created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc), - roles=[role], - is_active=True, - is_superuser=True, - is_protected=True, # Prevent accidental deletion - ) - session.add(new_user) - await session.commit() - logger.info(f"Created root user: {root_env.username}") - - except SQLAlchemyError as e: - logger.error("Root user creation failed") - await session.rollback() - raise RuntimeError("Database error during user creation") from e - - async def _warn_env_exposure(self, root_env: RootUserEnv) -> None: - """ - Generates security warnings about .env file exposure if credentials exist. - - Args: - root_env: Root environment configuration - """ - env_path = ".env" - if not os.path.exists(env_path): - return - - warning_msg = ( - "SECURITY WARNING: Environment file '.env' contains active root credentials. " - "Remove root credentials from '.env' in production environments." - ) - logger.warning(warning_msg) - - async def run(self) -> None: - """ - Main initialization workflow: - 1. Check for existing users - 2. Validate environment configuration - 3. Create root role and user - 4. Initialize access policies - 5. Start policy synchronization - 6. Generate security warnings - - Raises: - RuntimeError: For critical initialization failures - """ - try: - async with get_sql_session() as session: - # Check if system is already initialized - user_count = await session.scalar(select(func.count(User.id))) - root_env = RootUserEnv() - - if user_count and user_count > 0: - logger.info("Existing users found - skipping root initialization") - return - - # New system initialization - logger.info("Initializing authentication system") - - # Create root role and user - root_role = await self._create_root_role(root_env, session) - await self._create_root_user(session, root_env, root_role) - - # Initialize access policies - await init_root_policies(root_env) - - # Start policy synchronization listener - casbin_enforcer = await get_enforcer() - await start_redis_policy_listener(casbin_enforcer) - - logger.success("Authentication system initialized") - await self._warn_env_exposure(root_env) - - except SQLAlchemyError as e: - logger.critical(f"Database error: {str(e)}") - raise RuntimeError("Database operation failed") from e - except Exception as e: - logger.critical(f"Initialization failed: {str(e)}") - raise RuntimeError("System initialization aborted") from e - - -class TokenCleanUpHook(AddonSetupHook): - """Periodic hook to clean up expired tokens from the database.""" - - async def run(self): - """Executes token cleanup process.""" - try: - await cleanup_expired_tokens() - logger.debug("Expired tokens cleaned up successfully") - except Exception as e: - logger.error(f"Token cleanup failed: {str(e)}") - - -class KeyManagerInitHook(AddonSetupHook): - """Initialization hook for JWT key manager.""" - - async def run(self): - """Initializes the key manager with database session.""" - try: - await key_manager.initialize() - logger.info("Key manager initialized successfully") - except Exception as e: - logger.critical(f"Key manager initialization failed: {str(e)}") - raise RuntimeError("Key manager setup failed") from e diff --git a/papi/base/website/__init__.py b/papi/base/website/__init__.py deleted file mode 100644 index 3ac0358..0000000 --- a/papi/base/website/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import routes diff --git a/papi/base/website/manifest.yaml b/papi/base/website/manifest.yaml deleted file mode 100644 index a10648c..0000000 --- a/papi/base/website/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -title: "Website Module" -version: "0.1.0" - -authors: - - name: "pAPI Team" - -maintainers: - - name: "Eduardo Fírvida" - email: "efirvida@gmail.com" - -description: "Base module for public website interface and assets." - -keywords: - - website - - public - - interface - - assets \ No newline at end of file diff --git a/papi/base/website/routes.py b/papi/base/website/routes.py deleted file mode 100644 index aa80e93..0000000 --- a/papi/base/website/routes.py +++ /dev/null @@ -1,93 +0,0 @@ -from fastapi.responses import HTMLResponse - -from papi.core.router import RESTRouter - -website_router = RESTRouter() - - -@website_router.http("/") -async def website_index(): - html_content = """ - - - - - - pAPI - Pluggable API - - - -
-

pAPI

-

pluggable API platform

-

Server is online and ready

- 📘 Open API Docs - -
- - - """ - return HTMLResponse(content=html_content, status_code=200) diff --git a/papi/cli.py b/papi/cli.py index cee291c..699322f 100644 --- a/papi/cli.py +++ b/papi/cli.py @@ -11,7 +11,6 @@ """ import asyncio -import functools import importlib import importlib.metadata import logging @@ -20,12 +19,13 @@ from contextlib import asynccontextmanager, contextmanager from pathlib import Path from textwrap import dedent -from typing import Any, Generator, Set +from typing import Any, AsyncGenerator, Dict, Generator, Set import anyio import click import nest_asyncio import uvicorn +from click import Context from click_default_group import DefaultGroup from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -33,23 +33,61 @@ from IPython.terminal.embed import InteractiveShellEmbed from papi.core.addons import ( - get_addons_from_dirs, + AddonsGraph, get_router_from_addon, has_static_files, - load_and_import_all_addons, ) -from papi.core.cli import CLIRegistry -from papi.core.db import get_redis_client, query_helper +from papi.core.cli.registry import registry as main_cli_registry +from papi.core.db import get_redis_client from papi.core.exceptions import APIException from papi.core.init import init_base_system, init_mcp_server from papi.core.logger import logger, setup_logging +from papi.core.models.config import GeneralInfoConfig from papi.core.response import create_response from papi.core.settings import get_config __version__ = importlib.metadata.version("papi") +_registry_initialized = False +_addons_graph = AddonsGraph() -# Initialize registry at module level -registry = CLIRegistry() + +def init_addons_commands(): + """ + Initialize addon commands lazily and only once. + + This function: + 1. Discovers and registers addon CLI commands + 2. Ensures single initialization through a global flag + 3. Handles conflicts and errors gracefully + """ + global _registry_initialized + if _registry_initialized: + return + + logger.debug("Initializing addon commands") + + for addon_command in main_cli_registry.get_all_registered_commands(): + command_name = addon_command.name + + # Skip invalid commands + if not command_name: + logger.debug("Skipping unnamed addon command") + continue + if command_name in cli.commands: + logger.warning( + f"Command conflict: '{command_name}' already exists - skipping" + ) + continue + if not callable(getattr(addon_command, "callback", None)): + logger.warning(f"Addon command '{command_name}' has no valid callback") + continue + + # Register valid commands + try: + cli.add_command(addon_command, name=command_name) + logger.info(f"Registered addon command: {command_name}") + except Exception as e: + logger.error(f"Failed to register command '{command_name}': {e}") def get_banner() -> str: @@ -104,7 +142,7 @@ def get_mcp_server(as_sse: bool = False) -> Any: """ async def _init() -> Any: - modules_extra, _ = await init_base_system() + modules_extra = await init_base_system() return init_mcp_server(modules_extra, as_sse) try: @@ -119,123 +157,183 @@ async def _init() -> Any: @asynccontextmanager -async def run_api_server(app: FastAPI) -> Generator[None, None, None]: +async def run_api_server(app: FastAPI) -> AsyncGenerator: """ - FastAPI lifespan context manager for initializing the web server. + FastAPI lifespan context manager for application initialization and cleanup. - This sets up addon routes, static file mounts, and MCP tools. It also prepares - global storage directories defined in the configuration. + Performs the following operations: + 1. Initializes the base system components + 2. Sets up Redis client connection + 3. Registers addon routes and static assets + 4. Mounts the MCP server endpoint + 5. Configures global storage directories + 6. Ensures proper resource cleanup on shutdown Args: - app (FastAPI): The FastAPI application instance. + app: FastAPI application instance to configure Yields: - None + None: Control passes to the application runtime Raises: - Exception: If initialization fails, it logs the error and re-raises. + RuntimeError: For critical initialization failures """ - + redis_client = None try: + # Phase 1: System initialization + logger.info("Initializing base system components...") base_system = await init_base_system() - modules = base_system["modules"] - redis = await get_redis_client() + # Phase 2: Establish Redis connection + logger.debug("Establishing Redis connection...") + redis_client = await get_redis_client() + + # Phase 3: Addon registration loaded_routers: Set[Any] = set() + modules = base_system.get("modules", {}) if base_system else {} + for addon_id, module in modules.items(): - if addon_routers := get_router_from_addon(module): - for router in addon_routers: + # Register addon routes + if routers := get_router_from_addon(module): + for router in routers: if router not in loaded_routers: app.include_router(router) loaded_routers.add(router) - logger.debug( - f"Addon {addon_id}: Registered {len(addon_routers)} routes" - ) + logger.info(f"Addon '{addon_id}': Registered {len(routers)} routes") + # Mount static assets if has_static_files(module): static_path = Path(module.__path__[0]) / "static" - if static_path.exists() and static_path.is_dir(): + if static_path.is_dir(): app.mount( - f"/{addon_id}", + f"/static/{addon_id}", StaticFiles(directory=static_path), name=f"{addon_id}_static", ) + logger.debug( + f"Addon '{addon_id}': Mounted static assets at {static_path}" + ) else: logger.warning( - f"Addon {addon_id}: Missing static directory at {static_path}" + f"Addon '{addon_id}': Missing static directory {static_path}" ) - mcp_server = init_mcp_server(modules, as_sse=True) - app.mount("/", mcp_server, name="MCP Tools") + # Phase 4: MCP server setup + if modules: + mcp_server = init_mcp_server(modules, as_sse=True) + app.mount("/mcp", mcp_server, name="MCP Tools") + logger.info("Mounted MCP server at /mcp") + # Phase 5: Storage configuration config = get_config() if config.storage: for name, path in config.storage.model_dump().items(): os.makedirs(path, exist_ok=True) app.mount( - f"/{name}", + f"/storage/{name}", StaticFiles(directory=path), - name=f"Global {name} Storage", + name=f"{name}_storage", ) - logger.info(f"Storage '{name}' configured at: {path}") + logger.info(f"Storage '{name}' mounted at: /storage/{name}") + # Application ready + logger.info("Application initialization completed successfully") yield - logger.info("Closing redis connection...") - redis = await get_redis_client() - if redis: - await redis.aclose() except Exception as e: - logger.critical(f"Initialization error: {e}") - raise + logger.critical(f"Application initialization failed: {str(e)}") + raise RuntimeError("Critical startup failure") from e + + finally: + # Phase 6: Resource cleanup + logger.info("Starting application shutdown...") + if redis_client: + logger.debug("Closing Redis connection...") + await redis_client.aclose() + logger.info("Redis connection closed") + logger.info("Shutdown completed") + + +def create_fastapi_app() -> FastAPI: + """ + Creates and configures the main FastAPI application instance. + This function: + 1. Retrieves application configuration + 2. Sets core API metadata (title, version, description) + 3. Attaches the lifespan management context + 4. Returns the fully configured application instance + + Returns: + FastAPI: The configured application instance -def discover_addon_commands() -> None: + Raises: + RuntimeError: If critical configuration is missing + """ try: + logger.info("Creating FastAPI application instance") config = get_config() - base_addons_path = os.path.abspath(os.path.join(__file__, "..", "base")) - addons_paths = [ - p for p in [config.addons.extra_addons_path, base_addons_path] if p - ] - - addons_graph = get_addons_from_dirs( - addons_paths=addons_paths, - enabled_addons_ids=config.addons.enabled, + + # Validate essential configuration + if not config.info: + logger.warning("Missing info configuration - using defaults") + config.info = GeneralInfoConfig() # Assume default config class exists + + # Create application with metadata + app = FastAPI( + title=config.info.title or "pAPI", + version=config.info.version or __version__, + description=config.info.description or "", + lifespan=run_api_server, ) - modules = load_and_import_all_addons(addons_graph) - for addon_id, module in modules.items(): - try: - cli_module = importlib.import_module(f"{module.__package__}.cli") - if hasattr(cli_module, "register_commands"): - cli_module.register_commands(registry, addon_id) - except ImportError: - continue - except Exception as e: - logger.warning( - f"Failed to register CLI for addon '{addon_id}': {e}", exc_info=True - ) + logger.debug("FastAPI instance created successfully") + return app except Exception as e: - logger.critical(f"Addon discovery failed: {e}", exc_info=True) + logger.critical("Failed to create FastAPI application", exc_info=True) + raise RuntimeError("Application initialization failed") from e -def create_fastapi_app() -> FastAPI: - config = get_config() - return FastAPI( - title=config.info.title or "pAPI", - version=config.info.version or __version__, - description=config.info.description or "", - lifespan=run_api_server, - ) +def setup_api_exception_handler(app: FastAPI) -> None: + """ + Registers a global exception handler for custom API exceptions. + This handler: + 1. Catches APIException instances + 2. Structures consistent error responses + 3. Preserves error details and headers + 4. Returns standardized JSON error format + + Args: + app: FastAPI application instance to register the handler + + Note: + Must be called after app creation but before starting the server + """ -def setup_api_exception_handler(app: FastAPI) -> None: @app.exception_handler(APIException) async def api_exception_handler( request: Request, exc: APIException ) -> JSONResponse: - response = create_response( + """ + Handles APIException errors and returns structured responses. + + Args: + request: Incoming request object + exc: Raised APIException instance + + Returns: + JSONResponse: Formatted error response + """ + # Log the error with appropriate severity + if exc.status_code >= 500: + logger.error(f"Server error ({exc.code}): {exc.message}") + elif exc.status_code >= 400: + logger.warning(f"Client error ({exc.code}): {exc.message}") + + # Create standardized error response + error_response = create_response( success=False, message=exc.message, error={ @@ -245,12 +343,16 @@ async def api_exception_handler( "status_code": exc.status_code, }, ) + + # Return JSON response with appropriate status return JSONResponse( status_code=exc.status_code, - content=response.model_dump(), - headers=getattr(exc, "headers", None), + content=error_response.model_dump(), + headers=exc.headers or {}, ) + logger.debug("Registered global API exception handler") + @click.group( cls=DefaultGroup, @@ -259,155 +361,157 @@ async def api_exception_handler( context_settings={"help_option_names": ["-h", "--help"]}, help="Main entry point for pAPI service management CLI.", ) -@click.option("--config", default="config.yaml", help="Path to configuration file") +@click.option( + "--config", + default="config.yaml", + show_default=True, + help="Path to configuration file.", +) @click.pass_context -def cli(ctx: click.Context, config: str) -> None: +def cli(ctx: Context, config: str) -> None: """ Main entry point for pAPI service management CLI. Provides subcommands to launch the system in different modes, including interactive shell, web server, and MCP server. - """ ctx.ensure_object(dict) ctx.obj["CONFIG_PATH"] = config def init_logging_and_config(ctx: click.Context) -> None: - if not hasattr(ctx, "obj_initialized"): + """ + Initialize configuration and logging if not already initialized. + """ + if not getattr(ctx, "obj_initialized", False): try: - ctx.obj["CONFIG"] = get_config(ctx.obj["CONFIG_PATH"]) + config_path = ctx.obj.get("CONFIG_PATH") + if not config_path: + raise ValueError("Configuration path not provided.") + + ctx.obj["CONFIG"] = get_config(config_path) setup_logging() ctx.obj_initialized = True except Exception as e: - logger.critical(f"Error initializing system: {e}") + logger.critical("Failed to initialize configuration or logging", exc_info=e) sys.exit(1) -@cli.command() +@cli.command(name="shell") def shell() -> None: """ Launch an interactive IPython shell with the initialized system context. - This includes all Beanie documents and addon modules, fully initialized and - ready for inspection or manual testing. Async/await is fully supported. + Provides: + - Full async/await support + - Pre-loaded document models + - Helper functions for querying + - All system components initialized + - Addon modules available + + Usage: + $ papi shell """ ctx = click.get_current_context() init_logging_and_config(ctx) - nest_asyncio.apply() + nest_asyncio.apply() # Enable nested event loops async def start_shell() -> None: + """Initialize system and launch IPython shell.""" try: - with disable_logging(): - base_system = await init_base_system() - documents = base_system.get("documents", {}) - - def show_models() -> None: - print("Available document models:") - for name in documents: - print(f" - {name}") - - def env(model_name: str) -> Any: - return documents.get(model_name) + with disable_logging(): # Suppress initialization logs + base_system = await init_base_system() or {} - helpers = { - "env": env, - "show_models": show_models, - "sql_query": query_helper, - } - - user_namespace = { - **{k: v for k, v in base_system.items() if v}, - **helpers, - } + # Prepare shell environment + namespace: Dict[str, Any] = ( + {k: v for k, v in base_system.items() if v} if base_system else {} + ) + # Configure IPython shell shell = InteractiveShellEmbed( - banner1=get_banner(), user_ns=user_namespace + banner1=get_banner(), + user_ns=namespace, + exit_msg="Exiting pAPI shell. Goodbye!", ) shell.run_line_magic("autoawait", "asyncio") shell() except Exception as e: - logger.critical(f"Failed to start interactive shell: {e}") + logger.critical(f"Failed to start interactive shell: {e}", exc_info=True) sys.exit(1) anyio.run(start_shell) -@cli.command() +@cli.command(name="webserver") def webserver() -> None: """ Start the production FastAPI web server. - Loads configuration from file and initializes the full system via - the FastAPI lifespan context. Runs the server with Uvicorn. + Features: + - Full API endpoint routing + - Static asset serving + - MCP integration + - Custom exception handling + + Usage: + $ papi webserver """ ctx = click.get_current_context() init_logging_and_config(ctx) - app = create_fastapi_app() - setup_api_exception_handler(app) + try: + logger.info("Creating FastAPI application") + app = create_fastapi_app() + setup_api_exception_handler(app) + + config = get_config() + logger.info(f"Starting webserver at {config.server.host}:{config.server.port}") + + uvicorn_config = uvicorn.Config( + app, + host=config.server.host or "0.0.0.0", + port=config.server.port or 8000, + log_config=None, + access_log=False, + timeout_keep_alive=60, + ) - config = get_config() - uvicorn_config = uvicorn.Config( - app, - host=config.server.host or "0.0.0.0", - port=config.server.port or 8000, - log_config=None, - access_log=False, - ) + server = uvicorn.Server(uvicorn_config) + server.run() - server = uvicorn.Server(uvicorn_config) - server.run() + except Exception as e: + logger.critical(f"Webserver startup failed: {e}", exc_info=True) + sys.exit(1) -@cli.command() +@cli.command(name="mcpserver") def mcpserver() -> None: """ - Start the standalone MCP server. + Start the standalone Management Control Protocol server. - This runs the backend communication protocol server without launching the full API. + Runs the backend communication protocol without the full API stack. + + Usage: + $ papi mcpserver """ ctx = click.get_current_context() init_logging_and_config(ctx) try: + logger.info("Initializing MCP server") mcp = get_mcp_server(as_sse=False) + logger.info("Starting MCP server in standalone mode") mcp.run() except Exception as e: - logger.critical(f"MCP Server error: {e}") + logger.critical(f"MCP Server error: {e}", exc_info=True) sys.exit(1) -def create_wrapper(original: Any) -> Any: - @functools.wraps(original) - def wrapped_command(*args: Any, **kwargs: Any) -> Any: - click.echo(get_banner()) - ctx = click.get_current_context() - init_logging_and_config(ctx) - return original(*args, **kwargs) - - return wrapped_command - - -discover_addon_commands() -dynamic_cli_group = registry.create_cli() - -for command_name, command in dynamic_cli_group.commands.items(): - if command_name in cli.commands: - continue - if isinstance(command, click.Group): - cli.add_command(command, command_name) - continue - original_callback = command.callback - if original_callback is None: - logger.warning(f"Command '{command_name}' has no callback and was skipped.") - continue - if not callable(original_callback): - raise TypeError(f"Command '{command_name}' callback is not callable.") - command.callback = create_wrapper(original_callback) - cli.add_command(command, command_name) - if __name__ == "__main__": - print(get_banner()) - cli() + try: + init_addons_commands() + cli(prog_name="papi") + except Exception as e: + logger.critical(f"CLI runtime error: {e}", exc_info=True) + sys.exit(1) diff --git a/papi/core/addons.py b/papi/core/addons.py index b817129..7b61b9a 100644 --- a/papi/core/addons.py +++ b/papi/core/addons.py @@ -10,7 +10,7 @@ from inspect import isclass, ismodule from pathlib import Path from types import ModuleType -from typing import Any, Dict, List, Set, Type +from typing import Any, Dict, List, Optional, Set, Type from beanie import Document from fastapi import APIRouter as FASTApiRouter @@ -121,67 +121,115 @@ def __str__(self) -> str: return "\n".join(f"{source} -> {deps}" for source, deps in self.graph.items()) -def get_addons_from_dirs( - addons_paths: List[str], enabled_addons_ids: List[str] -) -> AddonsGraph: +def get_addons_from_dir( + addons_path: str, enabled_addons_ids: List[str], max_cycles: int = 1000 +) -> Optional["AddonsGraph"]: """ - Scan directories for available addons, parse their manifests, - and build a dependency graph including only the enabled addons - and their direct/indirect dependencies. + Builds a dependency graph of enabled addons and their dependencies. + + Scans the specified directory for addons, parses their manifests, and constructs + a directed acyclic graph (DAG) including only: + - Explicitly enabled addons + - Their direct and indirect dependencies + - Valid addons meeting all dependency requirements + addons_graph = get_addons_from_dir( + addons_path=addons_path, + enabled_addons_ids=config.addons.enabled, + ) + if not addons_graph: + return None + + Args: + addons_path: Filesystem path to the directory containing addon subdirectories + enabled_addons_ids: List of addon IDs to enable (must be present in addons_path) + max_cycles: Maximum iterations before aborting cycle detection (safeguard) + + Returns: + AddonsGraph: Dependency graph of resolved addons + None: If addons directory doesn't exist + + Raises: + RuntimeError: If dependency resolution fails due to cycles """ - graph = AddonsGraph() - all_manifests: Dict[str, AddonManifest] = {} - seen_addons: Set[str] = set() - - # Optimización 1: Escaneo de manifiestos sin modificar sys.path - for addons_path in addons_paths: - base_path = Path(addons_path).resolve() - if not base_path.is_dir(): - logger.warning(f"Addons directory not found: {base_path}") + # Validate input directory + base_path = Path(addons_path).resolve() + if not base_path.is_dir(): + logger.warning(f"Addons directory not found: {base_path}") + return None + + # Phase 1: Scan directory and parse manifests + all_manifests: Dict[str, "AddonManifest"] = {} + processed_addons: Set[str] = set() + + for entry in os.scandir(base_path): + if not entry.is_dir(): + continue # Skip files + + addon_id = entry.name + manifest_path = base_path / addon_id / "manifest.yaml" + + # Skip addons without manifest or duplicates + if not manifest_path.exists(): + logger.warning(f"Missing manifest in addon: {addon_id}") continue - - for entry in os.scandir(base_path): - if not entry.is_dir(): - continue - - manifest_path = base_path / entry.name / "manifest.yaml" - if not manifest_path.exists(): - logger.warning(f"Missing 'manifest.yaml' in addon: {entry.name}") - continue - - # Optimización 2: Evitar procesar duplicados - if entry.name in seen_addons: - continue - seen_addons.add(entry.name) - - try: - manifest = AddonManifest.from_yaml(manifest_path) - all_manifests[entry.name] = manifest - except Exception as e: - logger.error(f"Error loading manifest for {entry.name}: {str(e)}") - - # Optimización 3: Resolución iterativa de dependencias - resolved: Set[str] = set() - stack: List[str] = list(enabled_addons_ids) - - while stack: - addon_id = stack.pop() - if addon_id in resolved: + if addon_id in processed_addons: continue - manifest = all_manifests.get(addon_id) + # Parse and store valid manifests + try: + manifest = AddonManifest.from_yaml(manifest_path) + all_manifests[addon_id] = manifest + processed_addons.add(addon_id) + except Exception as e: + logger.error(f"Invalid manifest in {addon_id}: {str(e)}") + + # Phase 2: Dependency resolution with cycle detection + graph = AddonsGraph() + resolved_addons: Set[str] = set() + dependency_stack: List[str] = list(set(enabled_addons_ids)) # Deduplicate + resolution_path: Set[str] = set() # Track current resolution path + iteration_count = 0 + + while dependency_stack: + iteration_count += 1 + if iteration_count > max_cycles: + raise RuntimeError( + f"Dependency resolution exceeded {max_cycles} iterations. " + "Probable dependency cycle." + ) + + current_addon = dependency_stack.pop() + if current_addon in resolved_addons: + continue # Already processed + + # Handle circular dependencies + if current_addon in resolution_path: + raise RuntimeError( + f"Circular dependency detected involving: {current_addon}. " + f"Current resolution path: {', '.join(resolution_path)}" + ) + + # Retrieve manifest or skip unresolved + manifest = all_manifests.get(current_addon) if not manifest: - logger.warning(f"Addon '{addon_id}' not found in any addon path.") - resolved.add(addon_id) # Evitar reintentos + logger.warning(f"Addon '{current_addon}' not found - skipping") + resolved_addons.add(current_addon) # Prevent retries continue - # Agregar dependencias no resueltas al stack - unresolved_deps = [dep for dep in manifest.dependencies if dep not in resolved] - if unresolved_deps: - stack.append(addon_id) # Reingresar para procesar después - stack.extend(unresolved_deps) + # Check for unresolved dependencies + unresolved = [ + dep for dep in manifest.dependencies if dep not in resolved_addons + ] + if unresolved: + # Re-add current addon and process dependencies first + resolution_path.add(current_addon) + dependency_stack.append(current_addon) + dependency_stack.extend(unresolved) else: - resolved.add(addon_id) + # Finalize addon resolution + resolved_addons.add(current_addon) + if current_addon in resolution_path: + resolution_path.remove(current_addon) graph.add_module(manifest) return graph diff --git a/papi/core/cli/registry.py b/papi/core/cli/registry.py index 07a17d1..8cabfc6 100644 --- a/papi/core/cli/registry.py +++ b/papi/core/cli/registry.py @@ -9,9 +9,7 @@ from collections import defaultdict from typing import Dict, List, Optional, Union -import click - -from .base import PAPICommand, PAPICommandGroup +from papi.core.cli.base import PAPICommand, PAPICommandGroup class CLIRegistry: @@ -60,56 +58,22 @@ def register_command( else: self._commands[addon_id][command.name] = command - def create_cli(self) -> click.Group: - """ - Create the complete CLI by combining all registered commands. - - Returns: - The root command group containing all commands - """ - if not self._root_group: - self._root_group = PAPICommandGroup( - name="papi", help="pAPI CLI tool for managing the system", commands={} - ) - - # Add built-in commands at initialization - # These are imported at runtime to avoid circular imports - @self._root_group.command() - def webserver(): - """Start the production FastAPI web server.""" - from papi.cli import webserver as ws - - return ws() - - @self._root_group.command() - def shell(): - """Launch an interactive Python shell.""" - from papi.cli import shell as sh - - return sh() - - @self._root_group.command() - def mcpserver(): - """Start the standalone MCP server.""" - from papi.cli import mcpserver as mcp - - return mcp() - - # Add all registered addon commands to root group - for addon_id, commands in self._commands.items(): - for cmd in commands.values(): - if cmd.name is None or cmd.name in self._root_group.commands: - continue - self._root_group.add_command(cmd) - - return self._root_group - def get_commands_for_addon( self, addon_id: str ) -> List[Union[PAPICommand, PAPICommandGroup]]: """Get all commands registered for a specific addon.""" return list(self._commands[addon_id].values()) + def get_all_registered_commands(self) -> List[Union[PAPICommand, PAPICommandGroup]]: + """Returns a flat list of all registered commands from all addons.""" + all_commands = [] + for commands in self._commands.values(): + all_commands.extend(commands.values()) + return all_commands + def clear(self) -> None: """Clear all registered commands. Mainly useful for testing.""" self._initialize() + + +registry = CLIRegistry() diff --git a/papi/core/init.py b/papi/core/init.py index 5fc855e..9107345 100644 --- a/papi/core/init.py +++ b/papi/core/init.py @@ -1,4 +1,4 @@ -import os +import importlib from types import ModuleType from typing import Callable, Optional, Type @@ -17,12 +17,14 @@ from papi.core.addons import ( AddonSetupHook, get_addon_setup_hooks, - get_addons_from_dirs, + get_addons_from_dir, get_beanie_documents_from_addon, get_router_from_addon, get_sqlalchemy_models_from_addon, load_and_import_all_addons, ) +from papi.core.cli import CLIRegistry +from papi.core.cli import registry as main_cli_registry from papi.core.db import ( create_database_if_not_exists, extract_bases_from_models, @@ -35,6 +37,51 @@ from papi.core.utils import install_python_dependencies +def discover_addon_commands( + registry: CLIRegistry, modules: dict[str, ModuleType] +) -> None: + """ + Discovers and registers CLI commands from enabled addons. + + This function: + 1. Retrieves addon configuration + 2. Builds addon dependency graph + 3. Loads and imports addon modules + 4. Discovers and registers CLI commands from each addon + + Args: + registry: Command registry instance to register commands with + + Raises: + RuntimeError: If critical failure occurs during discovery + """ + + # Process each addon for CLI commands + logger.debug("Discovering CLI commands in addons") + for addon_id, module in modules.items(): + try: + logger.debug(f"Processing addon: {addon_id}") + + # Attempt to import CLI module + cli_module = importlib.import_module(f"{module.__package__}.cli") + + # Register commands if available + if hasattr(cli_module, "register_commands"): + logger.info(f"Registering commands for addon: {addon_id}") + cli_module.register_commands(registry, addon_id) + else: + logger.debug(f"No CLI commands found in addon: {addon_id}") + + except ImportError: + logger.debug(f"Addon '{addon_id}' has no CLI module") + except Exception as e: + logger.warning( + f"Failed to register CLI for addon '{addon_id}': {e}", exc_info=True + ) + + logger.info(f"Completed command discovery for {len(modules)} addons") + + async def init_addons(modules: dict[str, ModuleType]) -> None: """ Initialize and execute setup hooks for all registered addon modules. @@ -181,7 +228,7 @@ async def init_sqlalchemy( raise RuntimeError(f"SQLAlchemy initialization error: {exc!r}") -async def init_base_system(init_db_system: bool = True) -> dict: +async def init_base_system(init_db_system: bool = True) -> dict | None: """ Initialize the base system by loading addons and initializing the database. @@ -197,20 +244,23 @@ async def init_base_system(init_db_system: bool = True) -> dict: # Define addon paths logger.info(f"Loading addons from: {config.addons.extra_addons_path}") - base_addons_path = os.path.abspath(os.path.join(__file__, "..", "..", "base")) - addons_paths = [config.addons.extra_addons_path, base_addons_path] + addons_path = config.addons.extra_addons_path try: # Discover and import addons - addons_graph = get_addons_from_dirs( - addons_paths=addons_paths, + addons_graph = get_addons_from_dir( + addons_path=addons_path, enabled_addons_ids=config.addons.enabled, ) + if not addons_graph: + return + python_deps = addons_graph.get_all_python_dependencies() if python_deps: install_python_dependencies(python_deps) modules = load_and_import_all_addons(addons_graph) + discover_addon_commands(registry=main_cli_registry, modules=modules) except (ValueError, ImportError) as e: logger.exception(f"Failed to load addons: {e}") diff --git a/papi/core/models/config.py b/papi/core/models/config.py index 4556c9a..c122d59 100644 --- a/papi/core/models/config.py +++ b/papi/core/models/config.py @@ -119,6 +119,8 @@ class GeneralInfoConfig(BaseModel): """ title: Optional[str] = "pAPI Platform" + version: Optional[str] = "" + description: Optional[str] = "pAPI core API Platform" class Config: extra = "allow" From 4bd210462718a0c410c80898c5c052f4b4ab7160 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Tue, 17 Jun 2025 12:56:08 -0400 Subject: [PATCH 04/10] Add github helper files --- .github/CONTRIBUTING.md | 42 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 39 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 24 +++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 14 ++++++++ 4 files changed, 119 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..fd874d4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to pAPI + +Thank you for considering contributing to **pAPI**! + +## 🚀 How to Contribute + +1. **Fork** the repository. +2. **Clone** your fork locally: + ```bash + git clone https://github.com/efirvida/papi + cd papi +```` + +3. **Create a new branch**: + + ```bash + git checkout -b feature/my-awesome-feature + ``` +4. **Make your changes**, including tests and documentation where needed. +5. **Commit your changes** with a meaningful message: + + ```bash + git commit -m "Add my-awesome-feature" + ``` +6. **Push to your fork**: + + ```bash + git push origin feature/my-awesome-feature + ``` +7. **Open a Pull Request** to the `main` branch of the original repository. + +## ✅ Requirements + +* Use clear and descriptive commit messages. +* Follow existing code style and naming conventions. +* Update documentation or tutorials if your change affects them. +* Test your code if applicable. + +## 📬 Need Help? + +If you have questions, feel free to open an issue or start a discussion. + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..37091d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior in pAPI +title: "[Bug] " +labels: bug +assignees: '' + +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior + +What you expected to happen. + +## Screenshots or Logs + +If applicable, add screenshots or log output. + +## Environment (please complete the following information): + +- OS: [e.g. Ubuntu 22.04, Windows 11] +- Python version: [e.g. 3.10.9] +- Branch: [e.g. main] + +## Additional Context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8c7002f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement for pAPI +title: "[Feature] " +labels: enhancement +assignees: '' + +--- + +## Describe the Feature + +A clear and concise description of the feature you'd like to see. + +## Motivation + +Why is this feature important? What problem does it solve? + +## Suggested Implementation + +If you have an idea of how it could be implemented, describe it here. + +## Additional Context + +Include mockups, code samples, or other helpful references. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..747c48b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## 📌 Summary + +Briefly describe what this PR does. + +## 🔍 Changes + +- [ ] Feature 1 added +- [ ] Bug fix +- [ ] Documentation updated +- [ ] Tests added + +## 🧪 How to Test + +Explain how reviewers can test this PR: From a789eaee184f92ceef5e0e40103e0d43676c3561 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Wed, 18 Jun 2025 23:09:44 -0400 Subject: [PATCH 05/10] Fix load config file from cli --- papi/cli.py | 41 ++++++-------------------- papi/core/logger.py | 18 ++++++++---- papi/core/settings.py | 67 +++++++++++++++++++++++++++++++------------ 3 files changed, 70 insertions(+), 56 deletions(-) diff --git a/papi/cli.py b/papi/cli.py index 699322f..1e6e7a6 100644 --- a/papi/cli.py +++ b/papi/cli.py @@ -25,7 +25,6 @@ import click import nest_asyncio import uvicorn -from click import Context from click_default_group import DefaultGroup from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -365,33 +364,21 @@ async def api_exception_handler( "--config", default="config.yaml", show_default=True, - help="Path to configuration file.", + help="Path to configuration file. Default is '${PWD}/config.yaml'.", ) @click.pass_context -def cli(ctx: Context, config: str) -> None: - """ - Main entry point for pAPI service management CLI. - - Provides subcommands to launch the system in different modes, - including interactive shell, web server, and MCP server. - """ - ctx.ensure_object(dict) - ctx.obj["CONFIG_PATH"] = config - +def cli(ctx, config): + if ctx.obj is None: + ctx.obj = {} -def init_logging_and_config(ctx: click.Context) -> None: - """ - Initialize configuration and logging if not already initialized. - """ - if not getattr(ctx, "obj_initialized", False): + if not ctx.obj.get("_initialized", False): try: - config_path = ctx.obj.get("CONFIG_PATH") - if not config_path: - raise ValueError("Configuration path not provided.") + cfg = get_config(config) + ctx.obj["config"] = cfg - ctx.obj["CONFIG"] = get_config(config_path) setup_logging() - ctx.obj_initialized = True + ctx.obj["_initialized"] = True + logger.debug("CLI initialized successfully.") except Exception as e: logger.critical("Failed to initialize configuration or logging", exc_info=e) sys.exit(1) @@ -412,8 +399,6 @@ def shell() -> None: Usage: $ papi shell """ - ctx = click.get_current_context() - init_logging_and_config(ctx) nest_asyncio.apply() # Enable nested event loops async def start_shell() -> None: @@ -457,16 +442,12 @@ def webserver() -> None: Usage: $ papi webserver """ - ctx = click.get_current_context() - init_logging_and_config(ctx) - try: logger.info("Creating FastAPI application") app = create_fastapi_app() setup_api_exception_handler(app) config = get_config() - logger.info(f"Starting webserver at {config.server.host}:{config.server.port}") uvicorn_config = uvicorn.Config( app, @@ -495,9 +476,6 @@ def mcpserver() -> None: Usage: $ papi mcpserver """ - ctx = click.get_current_context() - init_logging_and_config(ctx) - try: logger.info("Initializing MCP server") mcp = get_mcp_server(as_sse=False) @@ -510,7 +488,6 @@ def mcpserver() -> None: if __name__ == "__main__": try: - init_addons_commands() cli(prog_name="papi") except Exception as e: logger.critical(f"CLI runtime error: {e}", exc_info=True) diff --git a/papi/core/logger.py b/papi/core/logger.py index ebc830e..5015620 100644 --- a/papi/core/logger.py +++ b/papi/core/logger.py @@ -2,10 +2,12 @@ import sys from pathlib import Path -from core.settings import get_config from loguru import logger from loguru._defaults import LOGURU_FORMAT +from papi.core.models.config import LoggerLevel +from papi.core.settings import get_config + class InterceptHandler(logging.Handler): """ @@ -32,10 +34,16 @@ def setup_logging() -> None: """ Configures logging for both stdlib and Loguru. """ - - config = get_config() - - LOG_LEVEL = logging.getLevelName(config.logger.level.upper()) + try: + config = get_config() + except Exception as e: + logger.warning(f"configure logger 2 {e}") + if not config.logger.level: + level = LoggerLevel.INFO + else: + level = config.logger.level.upper() + + LOG_LEVEL = logging.getLevelName(level) JSON_LOGS = config.logger.json_log LOG_FILE = config.logger.log_file diff --git a/papi/core/settings.py b/papi/core/settings.py index 87fdf9d..cfef2db 100644 --- a/papi/core/settings.py +++ b/papi/core/settings.py @@ -1,39 +1,68 @@ +from pathlib import Path +from typing import Optional + import yaml +from loguru import logger from papi.core.models.config import AppConfig -_config_cache: AppConfig | None = None +_config_cache: Optional[AppConfig] = None +_config_file_path: Optional[str] = None -def get_config(config_file_path: str | None = None) -> AppConfig: +def get_config(config_file_path: Optional[str] = None) -> AppConfig: """ - Load and return the application configuration as a cached singleton. - - This function reads a YAML configuration file and returns an instance of `AppConfig`. - It uses an internal cache to avoid reloading and reparsing the file on subsequent calls. - - If no file path is provided, it defaults to `"config.yaml"` in the current working directory. + Load and cache the application configuration from a YAML file. Args: - config_file_path (str | None): Path to the YAML configuration file. - If None, defaults to `"config.yaml"`. + config_file_path (Optional[str]): Path to the configuration file. + If not provided, uses cached path or defaults to 'config.yaml'. Returns: - AppConfig: The parsed application configuration. + AppConfig: The loaded configuration object. Raises: FileNotFoundError: If the specified config file does not exist. - yaml.YAMLError: If the YAML content cannot be parsed. - TypeError: If the content does not match the `AppConfig` schema. + yaml.YAMLError: If the file cannot be parsed as valid YAML. + Exception: If the data cannot be converted into an AppConfig. """ - global _config_cache + global _config_cache, _config_file_path + + logger.debug("=== get_config() called ===") + logger.debug(f"Received config_file_path: {config_file_path}") + logger.debug(f"Current cached _config_file_path: {_config_file_path}") + logger.debug(f"Config cache status: {'filled' if _config_cache else 'empty'}") + + if _config_cache is not None and config_file_path is None: + logger.debug("Returning cached configuration object.") + return _config_cache + + # Determine the effective configuration file path + if config_file_path: + requested_path = Path(config_file_path).resolve() + _config_file_path = str(requested_path) + elif _config_file_path: + requested_path = Path(_config_file_path).resolve() + else: + requested_path = Path("config.yaml").resolve() + _config_file_path = str(requested_path) + + logger.info(f"Loading configuration file from: {requested_path}") - if _config_cache is None: - if config_file_path is None: - config_file_path = "config.yaml" + if not requested_path.is_file(): + logger.error(f"Configuration file not found: {requested_path}") + raise FileNotFoundError(f"Configuration file not found: {requested_path}") - with open(config_file_path, "r") as f: + try: + with requested_path.open("r", encoding="utf-8") as f: data = yaml.safe_load(f) - _config_cache = AppConfig(**data) + _config_cache = AppConfig(**data) + logger.debug("Configuration file successfully parsed and cached.") + except yaml.YAMLError: + logger.exception("Failed to parse YAML configuration file.") + raise + except Exception: + logger.exception("Failed to load configuration into AppConfig.") + raise return _config_cache From 792f5b7f09c0e411e433e4fcdac93b281977fd61 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Wed, 18 Jun 2025 23:10:25 -0400 Subject: [PATCH 06/10] Use python graphlib to build dependency tree --- papi/core/addons.py | 307 ++++++++++++++++++++++---------------------- 1 file changed, 153 insertions(+), 154 deletions(-) diff --git a/papi/core/addons.py b/papi/core/addons.py index 7b61b9a..64b089e 100644 --- a/papi/core/addons.py +++ b/papi/core/addons.py @@ -7,6 +7,7 @@ import os import sys from collections import defaultdict +from graphlib import CycleError, TopologicalSorter from inspect import isclass, ismodule from pathlib import Path from types import ModuleType @@ -23,215 +24,213 @@ class AddonSetupHook: """ - Optional interface for addons that need to run setup logic before system start. - Can be used for tasks such as migrations, initial configuration, or checks. + Optional base class for addons that need to hook into the system lifecycle. + Subclasses may override any of the following methods. """ async def run(self) -> None: - """Executed when the addon is registered or initialized.""" + """Executed once during addon registration or initialization.""" + pass + + async def startup(self) -> None: + """Executed during startup event.""" + pass + + async def shutdown(self) -> None: + """Executed during shutdown event.""" pass class AddonsGraph: """ Represents a directed acyclic graph (DAG) of addon dependencies. - Provides cycle detection and topological sorting for load order resolution. + Provides functionality to add addons with their dependencies, + detect circular dependencies, and obtain a topological order. """ def __init__(self) -> None: - self.graph: Dict[str, List[str]] = defaultdict(list) + """ + Initializes the AddonsGraph. + - self.addons: maps addon_id to AddonManifest + - self.dependencies: maps addon_id to set of dependent addon_ids + - self.required_python_dependencies: set of all Python dependencies from addons + """ self.addons: Dict[str, AddonManifest] = {} + self.dependencies: Dict[str, Set[str]] = defaultdict(set) self.required_python_dependencies: Set[str] = set() + logger.debug("AddonsGraph initialized") - def add_module(self, addon_definition: AddonManifest) -> None: + def add_module(self, addon: AddonManifest) -> None: """ - Register a new addon and its dependencies in the graph. + Adds a single addon to the graph without its dependencies. + If the addon is already present, it is skipped. + + Args: + addon (AddonManifest): The addon manifest to add. """ - addon_id = addon_definition.addon_id + addon_id = addon.addon_id if addon_id in self.addons: - raise ValueError(f"Addon '{addon_definition.name}' is already registered") - - self.addons[addon_id] = addon_definition - self.graph[addon_id].extend(addon_definition.dependencies) + logger.debug(f"Addon '{addon_id}' already added, skipping") + return - for dependency in addon_definition.dependencies: - self.graph[dependency] + self.addons[addon_id] = addon + self.dependencies[addon_id].update(addon.dependencies) + for dep in addon.dependencies: + # Ensure all nodes exist in dependencies dictionary + if dep not in self.dependencies: + self.dependencies[dep] = set() - self.required_python_dependencies.update(addon_definition.python_dependencies) + self.required_python_dependencies.update(addon.python_dependencies) + logger.debug( + f"Added addon '{addon_id}' with dependencies: {addon.dependencies}" + ) - def detect_cycles(self) -> List[List[str]]: + def add_with_dependencies( + self, + addon: AddonManifest, + all_manifests: Dict[str, AddonManifest], + visited: Optional[Set[str]] = None, + ) -> None: """ - Detect cycles in the dependency graph. - Returns a list of cycles found (each as a list of addon IDs). - """ - visited: Set[str] = set() - stack: Set[str] = set() - current_path: List[str] = [] - cycles: List[List[str]] = [] + Recursively adds the given addon and all its dependencies to the graph. + Detects circular dependencies and raises RuntimeError if any are found. + + Args: + addon (AddonManifest): The addon manifest to add. + all_manifests (Dict[str, AddonManifest]): All available addon manifests. + visited (Optional[Set[str]]): Set of currently visited addon IDs during recursion. - def dfs(node: str) -> None: - visited.add(node) - stack.add(node) - current_path.append(node) + Raises: + RuntimeError: If a circular dependency or missing dependency is detected. + """ + if visited is None: + visited = set() + failed = False - for neighbor in self.graph[node]: - if neighbor not in visited: - dfs(neighbor) - elif neighbor in stack: - cycle_start = current_path.index(neighbor) - cycles.append(current_path[cycle_start:] + [neighbor]) + addon_id = addon.addon_id + if addon_id in visited: + path = " -> ".join(list(visited) + [addon_id]) + logger.error(f"Circular dependency detected: {path}") + raise RuntimeError(f"Circular dependency detected in path: {path}") - stack.remove(node) - current_path.pop() + if addon_id in self.addons: + logger.debug(f"Addon '{addon_id}' already processed, skipping recursion") + return - for node in self.graph: - if node not in visited: - dfs(node) + logger.debug( + f"Processing addon '{addon_id}' with dependencies {addon.dependencies}" + ) + visited.add(addon_id) + + for dep_id in addon.dependencies: + dep_manifest = all_manifests.get(dep_id) + if not dep_manifest: + logger.error( + f"Dependency '{dep_id}' of addon '{addon_id}' not found, skipping {addon_id}" + ) + failed = True + continue + self.add_with_dependencies(dep_manifest, all_manifests, visited) - return cycles + visited.remove(addon_id) + if not failed: + self.add_module(addon) + logger.debug(f"Finished processing addon '{addon_id}'") + logger.debug(f"Finished processing addon '{addon_id} with errors'") def topological_order(self) -> List[str]: """ - Returns a list of addon IDs in topological order of dependencies. - Raises an exception if cycles are detected. - """ - if cycles := self.detect_cycles(): - raise ValueError(f"Dependency cycle detected: {cycles}") - - visited: Set[str] = set() - order: List[str] = [] - - def dfs(node: str) -> None: - visited.add(node) - for neighbor in self.graph[node]: - if neighbor not in visited: - dfs(neighbor) - order.append(node) + Computes and returns a topological order of the addons + based on their dependencies. - for node in self.graph: - if node not in visited: - dfs(node) + Returns: + List[str]: Addon IDs in topological order. - return order + Raises: + RuntimeError: If a circular dependency is detected. + """ + ts = TopologicalSorter(self.dependencies) + try: + order = list(ts.static_order()) + logger.debug(f"Topological order computed: {order}") + return order + except CycleError as e: + logger.error( + f"Circular dependency detected during topological sort: {e.args}" + ) + raise RuntimeError(f"Circular dependency detected: {e.args}") def get_all_python_dependencies(self) -> List[str]: - return sorted(self.required_python_dependencies) + """ + Returns a sorted list of all unique Python package dependencies + required by the addons. + + Returns: + List[str]: Sorted list of Python dependencies. + """ + deps = sorted(self.required_python_dependencies) + logger.debug(f"Collected Python dependencies: {deps}") + return deps def __str__(self) -> str: - return "\n".join(f"{source} -> {deps}" for source, deps in self.graph.items()) + """ + Returns a human-readable string representing the dependency graph. + + Returns: + str: Multi-line string with addon dependencies. + """ + return "\n".join( + f"{addon} -> {sorted(deps)}" for addon, deps in self.dependencies.items() + ) -def get_addons_from_dir( - addons_path: str, enabled_addons_ids: List[str], max_cycles: int = 1000 -) -> Optional["AddonsGraph"]: +def get_addons_from_dir(addons_path: str, enabled_addons_ids: List[str]) -> AddonsGraph: """ - Builds a dependency graph of enabled addons and their dependencies. - - Scans the specified directory for addons, parses their manifests, and constructs - a directed acyclic graph (DAG) including only: - - Explicitly enabled addons - - Their direct and indirect dependencies - - Valid addons meeting all dependency requirements - addons_graph = get_addons_from_dir( - addons_path=addons_path, - enabled_addons_ids=config.addons.enabled, - ) - if not addons_graph: - return None + Loads all addon manifests from the given directory and builds an AddonsGraph + containing the enabled addons and all their recursive dependencies. Args: - addons_path: Filesystem path to the directory containing addon subdirectories - enabled_addons_ids: List of addon IDs to enable (must be present in addons_path) - max_cycles: Maximum iterations before aborting cycle detection (safeguard) + addons_path (str): Path to the directory containing addon subdirectories. + enabled_addons_ids (List[str]): List of addon IDs to enable. Returns: - AddonsGraph: Dependency graph of resolved addons - None: If addons directory doesn't exist + AddonsGraph: The constructed graph of enabled addons and dependencies. Raises: - RuntimeError: If dependency resolution fails due to cycles + RuntimeError: If the addons directory is not found, if an enabled addon + manifest is missing, or if circular/missing dependencies occur. """ - # Validate input directory base_path = Path(addons_path).resolve() + logger.debug(f"Resolving addons directory: {base_path}") if not base_path.is_dir(): - logger.warning(f"Addons directory not found: {base_path}") - return None - - # Phase 1: Scan directory and parse manifests - all_manifests: Dict[str, "AddonManifest"] = {} - processed_addons: Set[str] = set() + logger.error(f"Addons directory not found: {base_path}") + raise RuntimeError(f"Addons directory not found: {base_path}") + # Load all manifests from directory + all_manifests: Dict[str, AddonManifest] = {} for entry in os.scandir(base_path): - if not entry.is_dir(): - continue # Skip files - - addon_id = entry.name - manifest_path = base_path / addon_id / "manifest.yaml" - - # Skip addons without manifest or duplicates - if not manifest_path.exists(): - logger.warning(f"Missing manifest in addon: {addon_id}") - continue - if addon_id in processed_addons: - continue - - # Parse and store valid manifests - try: - manifest = AddonManifest.from_yaml(manifest_path) - all_manifests[addon_id] = manifest - processed_addons.add(addon_id) - except Exception as e: - logger.error(f"Invalid manifest in {addon_id}: {str(e)}") + if entry.is_dir(): + manifest_path = base_path / entry.name / "manifest.yaml" + if manifest_path.exists(): + try: + manifest = AddonManifest.from_yaml(manifest_path) + all_manifests[manifest.addon_id] = manifest + logger.debug(f"Loaded manifest for addon '{manifest.addon_id}'") + except Exception as e: + logger.error(f"Failed to load manifest {manifest_path}: {e}") - # Phase 2: Dependency resolution with cycle detection graph = AddonsGraph() - resolved_addons: Set[str] = set() - dependency_stack: List[str] = list(set(enabled_addons_ids)) # Deduplicate - resolution_path: Set[str] = set() # Track current resolution path - iteration_count = 0 - - while dependency_stack: - iteration_count += 1 - if iteration_count > max_cycles: - raise RuntimeError( - f"Dependency resolution exceeded {max_cycles} iterations. " - "Probable dependency cycle." - ) - - current_addon = dependency_stack.pop() - if current_addon in resolved_addons: - continue # Already processed - - # Handle circular dependencies - if current_addon in resolution_path: - raise RuntimeError( - f"Circular dependency detected involving: {current_addon}. " - f"Current resolution path: {', '.join(resolution_path)}" - ) - # Retrieve manifest or skip unresolved - manifest = all_manifests.get(current_addon) + # Add enabled addons and their recursive dependencies + for addon_id in enabled_addons_ids: + manifest = all_manifests.get(addon_id) if not manifest: - logger.warning(f"Addon '{current_addon}' not found - skipping") - resolved_addons.add(current_addon) # Prevent retries - continue - - # Check for unresolved dependencies - unresolved = [ - dep for dep in manifest.dependencies if dep not in resolved_addons - ] - if unresolved: - # Re-add current addon and process dependencies first - resolution_path.add(current_addon) - dependency_stack.append(current_addon) - dependency_stack.extend(unresolved) - else: - # Finalize addon resolution - resolved_addons.add(current_addon) - if current_addon in resolution_path: - resolution_path.remove(current_addon) - graph.add_module(manifest) + logger.error(f"Enabled addon '{addon_id}' not found in manifests") + raise RuntimeError(f"Enabled addon '{addon_id}' not found") + logger.debug(f"Adding enabled addon '{addon_id}' and its dependencies") + graph.add_with_dependencies(manifest, all_manifests) + logger.info(f"Finished building AddonsGraph with {len(graph.addons)} addons") return graph From 1770a7ac7d7f7c6cb8108cdfd8df7fc9cdacfb8a Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Thu, 19 Jun 2025 13:16:40 -0400 Subject: [PATCH 07/10] Improve addon lifecycle hook replace the run method of AddonSetupHook was replaced by startupt and a new shutdown method is inlcuded. So addons can register startup and shutdown funcionalities. --- papi/__init__.py | 3 ++- papi/cli.py | 8 +++++--- papi/core/addons.py | 4 ---- papi/core/init.py | 48 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/papi/__init__.py b/papi/__init__.py index c3eb836..33da244 100644 --- a/papi/__init__.py +++ b/papi/__init__.py @@ -1 +1,2 @@ -from .core.router import MPCRouter, RESTRouter +from papi.core.addons import AddonSetupHook +from papi.core.router import MPCRouter, RESTRouter diff --git a/papi/cli.py b/papi/cli.py index 1e6e7a6..d5ac575 100644 --- a/papi/cli.py +++ b/papi/cli.py @@ -32,14 +32,13 @@ from IPython.terminal.embed import InteractiveShellEmbed from papi.core.addons import ( - AddonsGraph, get_router_from_addon, has_static_files, ) from papi.core.cli.registry import registry as main_cli_registry from papi.core.db import get_redis_client from papi.core.exceptions import APIException -from papi.core.init import init_base_system, init_mcp_server +from papi.core.init import init_base_system, init_mcp_server, shutdown_addons from papi.core.logger import logger, setup_logging from papi.core.models.config import GeneralInfoConfig from papi.core.response import create_response @@ -47,7 +46,6 @@ __version__ = importlib.metadata.version("papi") _registry_initialized = False -_addons_graph = AddonsGraph() def init_addons_commands(): @@ -246,6 +244,10 @@ async def run_api_server(app: FastAPI) -> AsyncGenerator: finally: # Phase 6: Resource cleanup logger.info("Starting application shutdown...") + + if modules: + await shutdown_addons(modules) + if redis_client: logger.debug("Closing Redis connection...") await redis_client.aclose() diff --git a/papi/core/addons.py b/papi/core/addons.py index 64b089e..e4007af 100644 --- a/papi/core/addons.py +++ b/papi/core/addons.py @@ -28,10 +28,6 @@ class AddonSetupHook: Subclasses may override any of the following methods. """ - async def run(self) -> None: - """Executed once during addon registration or initialization.""" - pass - async def startup(self) -> None: """Executed during startup event.""" pass diff --git a/papi/core/init.py b/papi/core/init.py index 9107345..be3b075 100644 --- a/papi/core/init.py +++ b/papi/core/init.py @@ -82,18 +82,54 @@ def discover_addon_commands( logger.info(f"Completed command discovery for {len(modules)} addons") -async def init_addons(modules: dict[str, ModuleType]) -> None: +async def startup_addons(modules: dict[str, ModuleType]) -> None: """ - Initialize and execute setup hooks for all registered addon modules. + Initializes and executes startup hooks for all registered addon modules. + + Args: + modules (dict[str, ModuleType]): A dictionary mapping addon IDs to their loaded modules. + + This function retrieves all startup hook factories from each module using `get_addon_setup_hooks`, + instantiates each hook, and calls its `startup()` coroutine. + """ + for addon_id, module in modules.items(): + logger.debug(f"Initializing startup hooks for addon '{addon_id}'") + hook_factories: list[Callable[[], AddonSetupHook]] = get_addon_setup_hooks( + module + ) + + for factory in hook_factories: + try: + hook = factory() + await hook.startup() + logger.debug(f"Startup completed for addon '{addon_id}' hook: {hook}") + except Exception as e: + logger.exception(f"Error during startup of addon '{addon_id}': {e}") + + +async def shutdown_addons(modules: dict[str, ModuleType]) -> None: + """ + Initializes and executes shutdown hooks for all registered addon modules. + + Args: + modules (dict[str, ModuleType]): A dictionary mapping addon IDs to their loaded modules. + + This function retrieves all shutdown hook factories from each module using `get_addon_setup_hooks`, + instantiates each hook, and calls its `shutdown()` coroutine. """ for addon_id, module in modules.items(): + logger.debug(f"Initializing shutdown hooks for addon '{addon_id}'") hook_factories: list[Callable[[], AddonSetupHook]] = get_addon_setup_hooks( module ) - for hook_factory in hook_factories: - setup_hook = hook_factory() - await setup_hook.run() + for factory in hook_factories: + try: + hook = factory() + await hook.shutdown() + logger.debug(f"Shutdown completed for addon '{addon_id}' hook: {hook}") + except Exception as e: + logger.exception(f"Error during shutdown of addon '{addon_id}': {e}") async def init_mongodb(config, modules: dict[str, ModuleType]) -> dict[str, type]: @@ -295,7 +331,7 @@ async def init_base_system(init_db_system: bool = True) -> dict | None: beanie_document_models = [] sql_models = [] - await init_addons(modules) + await startup_addons(modules) return { "modules": modules, From 5e97d96629c34821c0e6394f182a422d1828dd96 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Thu, 19 Jun 2025 15:29:04 -0400 Subject: [PATCH 08/10] Move disable_logging context manager to logger module --- papi/cli.py | 65 +++------------------------------------------ papi/core/logger.py | 18 +++++++++++++ 2 files changed, 22 insertions(+), 61 deletions(-) diff --git a/papi/cli.py b/papi/cli.py index d5ac575..85e211e 100644 --- a/papi/cli.py +++ b/papi/cli.py @@ -13,13 +13,12 @@ import asyncio import importlib import importlib.metadata -import logging import os import sys -from contextlib import asynccontextmanager, contextmanager +from contextlib import asynccontextmanager from pathlib import Path from textwrap import dedent -from typing import Any, AsyncGenerator, Dict, Generator, Set +from typing import Any, AsyncGenerator, Dict, Set import anyio import click @@ -35,56 +34,15 @@ get_router_from_addon, has_static_files, ) -from papi.core.cli.registry import registry as main_cli_registry from papi.core.db import get_redis_client from papi.core.exceptions import APIException from papi.core.init import init_base_system, init_mcp_server, shutdown_addons -from papi.core.logger import logger, setup_logging +from papi.core.logger import disable_logging, logger, setup_logging from papi.core.models.config import GeneralInfoConfig from papi.core.response import create_response from papi.core.settings import get_config __version__ = importlib.metadata.version("papi") -_registry_initialized = False - - -def init_addons_commands(): - """ - Initialize addon commands lazily and only once. - - This function: - 1. Discovers and registers addon CLI commands - 2. Ensures single initialization through a global flag - 3. Handles conflicts and errors gracefully - """ - global _registry_initialized - if _registry_initialized: - return - - logger.debug("Initializing addon commands") - - for addon_command in main_cli_registry.get_all_registered_commands(): - command_name = addon_command.name - - # Skip invalid commands - if not command_name: - logger.debug("Skipping unnamed addon command") - continue - if command_name in cli.commands: - logger.warning( - f"Command conflict: '{command_name}' already exists - skipping" - ) - continue - if not callable(getattr(addon_command, "callback", None)): - logger.warning(f"Addon command '{command_name}' has no valid callback") - continue - - # Register valid commands - try: - cli.add_command(addon_command, name=command_name) - logger.info(f"Registered addon command: {command_name}") - except Exception as e: - logger.error(f"Failed to register command '{command_name}': {e}") def get_banner() -> str: @@ -105,22 +63,6 @@ def get_banner() -> str: """) -@contextmanager -def disable_logging() -> Generator[None, None, None]: - """ - Temporarily disable all logging within a context. - - Useful for suppressing noisy output during initialization - or interactive shell startup. - """ - previous_level = logging.root.manager.disable - logging.disable(logging.CRITICAL) - try: - yield - finally: - logging.disable(previous_level) - - def get_mcp_server(as_sse: bool = False) -> Any: """ Initialize and return the MCP server instance. @@ -381,6 +323,7 @@ def cli(ctx, config): setup_logging() ctx.obj["_initialized"] = True logger.debug("CLI initialized successfully.") + except Exception as e: logger.critical("Failed to initialize configuration or logging", exc_info=e) sys.exit(1) diff --git a/papi/core/logger.py b/papi/core/logger.py index 5015620..060aadd 100644 --- a/papi/core/logger.py +++ b/papi/core/logger.py @@ -1,6 +1,8 @@ import logging import sys +from contextlib import contextmanager from pathlib import Path +from typing import Generator from loguru import logger from loguru._defaults import LOGURU_FORMAT @@ -96,3 +98,19 @@ def setup_logging() -> None: extra={"logger_name": "pAPI"}, ) logger.info("Logging initialized.") + + +@contextmanager +def disable_logging() -> Generator[None, None, None]: + """ + Temporarily disable all logging within a context. + + Useful for suppressing noisy output during initialization + or interactive shell startup. + """ + previous_level = logging.root.manager.disable + logging.disable(logging.CRITICAL) + try: + yield + finally: + logging.disable(previous_level) From b569bedd8e54cd4c97efbd27f9fd4804b8a4bd21 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Thu, 19 Jun 2025 15:30:23 -0400 Subject: [PATCH 09/10] Remove Dynamic CLI discovery --- papi/core/cli/__init__.py | 13 ------- papi/core/cli/base.py | 78 -------------------------------------- papi/core/cli/registry.py | 79 --------------------------------------- papi/core/init.py | 49 ------------------------ 4 files changed, 219 deletions(-) delete mode 100644 papi/core/cli/__init__.py delete mode 100644 papi/core/cli/base.py delete mode 100644 papi/core/cli/registry.py diff --git a/papi/core/cli/__init__.py b/papi/core/cli/__init__.py deleted file mode 100644 index c914b56..0000000 --- a/papi/core/cli/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -pAPI CLI System. - -This package provides a modular CLI system that allows addons to dynamically -register their own commands. The main components are: -- PAPICommand/PAPICommandGroup: Base classes for CLI commands -- CLIRegistry: Central registry for managing commands -""" - -from .base import PAPICommand, PAPICommandGroup -from .registry import CLIRegistry - -__all__ = ['PAPICommand', 'PAPICommandGroup', 'CLIRegistry'] \ No newline at end of file diff --git a/papi/core/cli/base.py b/papi/core/cli/base.py deleted file mode 100644 index e902a62..0000000 --- a/papi/core/cli/base.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Base classes for the pAPI CLI system. - -This module provides the base classes that addons will use to define their CLI commands. -Commands and groups inherit from Click's Command and Group classes to maintain -compatibility while adding pAPI-specific functionality. -""" - -from typing import Any, Callable, Optional - -import click -from click_default_group import DefaultGroup - - -class PAPICommand(click.Command): - """Base class for pAPI CLI commands.""" - - def __init__(self, name: Optional[str] = None, **attrs: Any) -> None: - if name is None and "callback" in attrs: - name = attrs["callback"].__name__ - super().__init__(name=name, **attrs) - self.addon_id: Optional[str] = None - - def set_addon_id(self, addon_id: str) -> None: - """Set the ID of the addon that owns this command.""" - self.addon_id = addon_id - - -class PAPICommandGroup(DefaultGroup): - """Base class for pAPI CLI command groups.""" - - def __init__( - self, - name: Optional[str] = None, - commands: Optional[dict[str, PAPICommand]] = None, - **attrs: Any, - ) -> None: - super().__init__(name=name, **attrs) - # Initialize commands dict safely - if commands: - for cmd_name, cmd in commands.items(): - self.add_command(cmd, cmd_name) - self.addon_id: Optional[str] = None - - def set_addon_id(self, addon_id: str) -> None: - """Set the ID of the addon that owns this command group.""" - self.addon_id = addon_id - for cmd in self.commands.values(): - if isinstance(cmd, (PAPICommand, PAPICommandGroup)): - cmd.set_addon_id(addon_id) - - def command(self, *args: Any, **kwargs: Any) -> Callable[..., click.Command]: - """Decorator to create a new PAPICommand.""" - # Set default command class if not specified - kwargs.setdefault("cls", PAPICommand) - # Get the parent class's command decorator - parent_decorator = super(PAPICommandGroup, self).command(*args, **kwargs) - - def decorator(f: Callable[..., Any]) -> click.Command: - cmd = parent_decorator(f) - if isinstance(cmd, PAPICommand) and self.addon_id: - cmd.set_addon_id(self.addon_id) - return cmd - - return decorator - - def group(self, *args: Any, **kwargs: Any) -> Callable[..., click.Group]: - """Decorator to create a new PAPICommandGroup.""" - kwargs.setdefault("cls", PAPICommandGroup) - parent_decorator = super(PAPICommandGroup, self).group(*args, **kwargs) - - def decorator(f: Callable[..., Any]) -> click.Group: - grp = parent_decorator(f) - if isinstance(grp, PAPICommandGroup) and self.addon_id: - grp.set_addon_id(self.addon_id) - return grp - - return decorator diff --git a/papi/core/cli/registry.py b/papi/core/cli/registry.py deleted file mode 100644 index 8cabfc6..0000000 --- a/papi/core/cli/registry.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -CLI Registry for pAPI. - -This module provides the central registry for CLI commands, allowing addons to -register their commands dynamically. The registry manages command namespacing -and ensures commands from different addons don't conflict. -""" - -from collections import defaultdict -from typing import Dict, List, Optional, Union - -from papi.core.cli.base import PAPICommand, PAPICommandGroup - - -class CLIRegistry: - """Central registry for pAPI CLI commands.""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(CLIRegistry, cls).__new__(cls) - cls._instance._initialize() - return cls._instance - - def _initialize(self) -> None: - """Initialize the registry.""" - self._commands: Dict[str, Dict[str, Union[PAPICommand, PAPICommandGroup]]] = ( - defaultdict(dict) - ) - self._root_group: Optional[PAPICommandGroup] = None - - def register_command( - self, - addon_id: str, - command: Union[PAPICommand, PAPICommandGroup], - parent_group: Optional[str] = None, - ) -> None: - """ - Register a command or command group for an addon. - - Args: - addon_id: The ID of the addon registering the command - command: The command or command group to register - parent_group: Optional name of parent command group - """ - if command.name is None: - raise ValueError("Command must have a name") - - command.set_addon_id(addon_id) - if parent_group: - parent = self._commands[addon_id].get(parent_group) - if not isinstance(parent, PAPICommandGroup): - raise ValueError( - f"Parent group '{parent_group}' not found for addon {addon_id}" - ) - parent.add_command(command) - else: - self._commands[addon_id][command.name] = command - - def get_commands_for_addon( - self, addon_id: str - ) -> List[Union[PAPICommand, PAPICommandGroup]]: - """Get all commands registered for a specific addon.""" - return list(self._commands[addon_id].values()) - - def get_all_registered_commands(self) -> List[Union[PAPICommand, PAPICommandGroup]]: - """Returns a flat list of all registered commands from all addons.""" - all_commands = [] - for commands in self._commands.values(): - all_commands.extend(commands.values()) - return all_commands - - def clear(self) -> None: - """Clear all registered commands. Mainly useful for testing.""" - self._initialize() - - -registry = CLIRegistry() diff --git a/papi/core/init.py b/papi/core/init.py index be3b075..f07af22 100644 --- a/papi/core/init.py +++ b/papi/core/init.py @@ -1,4 +1,3 @@ -import importlib from types import ModuleType from typing import Callable, Optional, Type @@ -23,8 +22,6 @@ get_sqlalchemy_models_from_addon, load_and_import_all_addons, ) -from papi.core.cli import CLIRegistry -from papi.core.cli import registry as main_cli_registry from papi.core.db import ( create_database_if_not_exists, extract_bases_from_models, @@ -37,51 +34,6 @@ from papi.core.utils import install_python_dependencies -def discover_addon_commands( - registry: CLIRegistry, modules: dict[str, ModuleType] -) -> None: - """ - Discovers and registers CLI commands from enabled addons. - - This function: - 1. Retrieves addon configuration - 2. Builds addon dependency graph - 3. Loads and imports addon modules - 4. Discovers and registers CLI commands from each addon - - Args: - registry: Command registry instance to register commands with - - Raises: - RuntimeError: If critical failure occurs during discovery - """ - - # Process each addon for CLI commands - logger.debug("Discovering CLI commands in addons") - for addon_id, module in modules.items(): - try: - logger.debug(f"Processing addon: {addon_id}") - - # Attempt to import CLI module - cli_module = importlib.import_module(f"{module.__package__}.cli") - - # Register commands if available - if hasattr(cli_module, "register_commands"): - logger.info(f"Registering commands for addon: {addon_id}") - cli_module.register_commands(registry, addon_id) - else: - logger.debug(f"No CLI commands found in addon: {addon_id}") - - except ImportError: - logger.debug(f"Addon '{addon_id}' has no CLI module") - except Exception as e: - logger.warning( - f"Failed to register CLI for addon '{addon_id}': {e}", exc_info=True - ) - - logger.info(f"Completed command discovery for {len(modules)} addons") - - async def startup_addons(modules: dict[str, ModuleType]) -> None: """ Initializes and executes startup hooks for all registered addon modules. @@ -296,7 +248,6 @@ async def init_base_system(init_db_system: bool = True) -> dict | None: install_python_dependencies(python_deps) modules = load_and_import_all_addons(addons_graph) - discover_addon_commands(registry=main_cli_registry, modules=modules) except (ValueError, ImportError) as e: logger.exception(f"Failed to load addons: {e}") From c8403a8e8c216187101396437c79ef9873bd62b4 Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Fri, 20 Jun 2025 21:16:39 -0400 Subject: [PATCH 10/10] Remove extendable cli references from docs --- README.md | 5 ++--- docs/index.md | 15 +++------------ docs/reference/cli.md | 8 -------- mkdocs.yml | 1 - 4 files changed, 5 insertions(+), 24 deletions(-) delete mode 100644 docs/reference/cli.md diff --git a/README.md b/README.md index 9f01e23..880370f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Fully async, lazily loaded, and built on FastAPI’s high-performance core. - 🛠️ **Developer Tooling** - Extensible CLI system and async-enabled IPython shell for rapid development. + Async-enabled IPython shell for rapid development. --- @@ -42,8 +42,7 @@ These addons can: * Register API routes (`RESTRouter`) * Define database models (Beanie or SQLAlchemy) -* Hook into startup processes (`AddonSetupHook`) -* Extend the CLI with custom commands +* Hook into startup, shutdown processes (`AddonSetupHook`) Together, they form a cohesive and scalable API system, enabling you to build robust, modular services by simply connecting or extending the building blocks your application needs. diff --git a/docs/index.md b/docs/index.md index cec8438..8a007be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ Aquí tienes una versión más clara, profesional y precisa de la sección, con ### 🧩 Modular Architecture -* Addons are self-contained: they register their own routers, models, CLI commands, and lifecycle hooks. +* Addons are self-contained: they register their own routers, models, and lifecycle hooks. * Route behavior can be extended or overridden by other addons. * Each addon can expose tools through standard HTTP endpoints or as **Model Context Protocol (MCP)**-compatible tools for agent-based workflows. @@ -202,7 +202,6 @@ $ papi_cli shell # Open an interactive, async-ready developer shell #### 🧠 Key Features * ✅ **Async-aware interactive shell** (with full `await` support), powered by IPython if available. -* 🧩 **Addon-aware**: all loaded addons can inject their own CLI commands. * ⚙️ **Config-aware**: automatically loads `config.yaml` and injects environment context. --- @@ -214,13 +213,6 @@ $ rye run python papi/cli.py --help ``` ```text - _ ____ ___ - ___ / \ | _ \ |_ _| -| _ \ / _ \ | |_) | | | -| |_) | / ___ \ | __/ | | -| __/ /_/ \_\|_| |___| -|_| Version: v0.0.1 - Usage: cli.py [OPTIONS] COMMAND [ARGS]... Main entry point for pAPI service management CLI. @@ -270,7 +262,7 @@ user = result.scalar_one_or_none() ## 📦 Addon System -pAPI provides a robust, modular plugin system via **addons**—isolated Python modules that encapsulate logic, routes, models, CLI commands, configuration, and static assets. This architecture promotes separation of concerns, extensibility, and reusability. +pAPI provides a robust, modular plugin system via **addons**—isolated Python modules that encapsulate logic, routes, models, configuration, and static assets. This architecture promotes separation of concerns, extensibility, and reusability. --- @@ -284,8 +276,7 @@ addons/ ├── __init__.py ├── manifest.yaml ├── routers.py - ├── models.py - └── cli.py + └── models.py ``` Each addon behaves as a self-contained package and is dynamically discovered and registered at runtime based on the configuration. diff --git a/docs/reference/cli.md b/docs/reference/cli.md deleted file mode 100644 index 2e8a6b7..0000000 --- a/docs/reference/cli.md +++ /dev/null @@ -1,8 +0,0 @@ -# `papi.core.cli` - -:::papi.core.cli - options: - members: - - PAPICommand - - PAPICommandGroup - - CLIRegistry diff --git a/mkdocs.yml b/mkdocs.yml index dd43c36..f30be29 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,7 +14,6 @@ nav: - Router: reference/router.md - Settings: reference/settings.md - DB: reference/db.md - - CLI: reference/cli.md - Models: reference/models.md - build in addons: - Image Storage: build-in-addons/image_storage.md