Merge pull request #27 from CarterPerez-dev/project/bug-bounty-platform

Add: bug bounty platform project
This commit is contained in:
Carter Perez 2026-01-06 13:14:49 -05:00 committed by GitHub
commit e7040a059f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
197 changed files with 35392 additions and 0 deletions

View File

@ -0,0 +1,109 @@
# =============================================================================
# AngelaMos | 2026
# .env.example
# =============================================================================
# Copy this file to .env and update values for your environment
# =============================================================================
# =============================================================================
# HOST PORTS (change these to avoid conflicts between projects)
# =============================================================================
NGINX_HOST_PORT=8420
BACKEND_HOST_PORT=5420
FRONTEND_HOST_PORT=3420
POSTGRES_HOST_PORT=4420
REDIS_HOST_PORT=6420
# =============================================================================
# Application
# =============================================================================
APP_NAME=FullStack-Template
ENVIRONMENT=development
DEBUG=true
LOG_LEVEL=INFO
LOG_JSON_FORMAT=false
# =============================================================================
# Server (internal container settings)
# =============================================================================
HOST=0.0.0.0
PORT=8000
RELOAD=true
# =============================================================================
# PostgreSQL
# =============================================================================
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app_db
POSTGRES_HOST=db
POSTGRES_CONTAINER_PORT=5432
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_CONTAINER_PORT}/${POSTGRES_DB}
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
# =============================================================================
# Redis
# =============================================================================
REDIS_HOST=redis
REDIS_CONTAINER_PORT=6379
REDIS_PASSWORD=
REDIS_URL=redis://${REDIS_HOST}:${REDIS_CONTAINER_PORT}
# =============================================================================
# Security / JWT
# =============================================================================
SECRET_KEY=dev-only-change-this-in-production-minimum-32-characters-long
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# =============================================================================
# Admin Bootstrap (optional)
# =============================================================================
# If set, registering with this email auto-promotes to admin role.
# Leave empty or remove to disable auto-admin (all users register as USER).
ADMIN_EMAIL=
# =============================================================================
# CORS (must match HOST PORTS above!)
# =============================================================================
# Format: http://localhost:<NGINX_HOST_PORT>,http://localhost:<FRONTEND_HOST_PORT>
# Update these if you change the host ports above
CORS_ORIGINS=http://localhost,http://localhost:8420,http://localhost:3420
# =============================================================================
# Rate Limiting
# =============================================================================
RATE_LIMIT_DEFAULT=100/minute
RATE_LIMIT_AUTH=20/minute
# =============================================================================
# Frontend (Vite)
# =============================================================================
VITE_API_URL=/api
VITE_API_TARGET=http://localhost:8000
VITE_APP_TITLE=My App
# =============================================================================
# Cloudflare Tunnel (Production only - or use whatever deployment method you want)
# =============================================================================
# Option 1: Cloudflare Tunnel (zero config, no port forwarding needed)
# - Get token from Cloudflare Zero Trust dashboard
# - Access > Tunnels > Create a tunnel > Name it > Copy token below
# - Configure public hostname: yourdomain.com -> http://nginx:80
#
# Option 2: Traditional hosting (VM + domain + DNS)
# - Host on a VM, buy a domain, create A/CNAME record in Cloudflare DNS
# - Point to your VM's IP, expose nginx port, configure reverse proxy/SSL
#
# Option 3: Whatever works for you
# - Just make sure your nginx container is accessible and SSL is configured
CLOUDFLARE_TUNNEL_TOKEN=

39
PROJECTS/bug-bounty-platform/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# AngelaMos | 2025
# dev.compose.yml
venv
*.venv
*.env
*.cache
*.egg
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.dmypy.json
dmypy.json
.DS_Store

View File

@ -0,0 +1,36 @@
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
hooks:
- id: ruff
args: ["backend/"]
always_run: true
- repo: local
hooks:
- id: mypy-check
name: MyPy Type Checking
entry: bash -c 'cd backend && mypy .'
language: system
types: [python]
pass_filenames: false
always_run: true
- id: pylint-check
name: PyLint Code Quality
entry: bash -c 'cd backend && pylint .'
language: system
types: [python]
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-toml
- id: debug-statements
- id: end-of-file-fixer
- id: check-yaml
args: [--allow-multiple-documents]

View File

@ -0,0 +1,21 @@
MIT License
©AngelaMos | 2026 | CarterPerez-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,532 @@
# Bug Bounty Platform
A production-ready, enterprise-grade bug bounty platform built with modern web technologies. This platform enables companies to run coordinated vulnerability disclosure programs, allowing security researchers to submit findings and receive rewards.
**Live Demo:** [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com)
**API Documentation:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs)
---
## Overview
This project demonstrates enterprise-level software architecture with:
- Async-first FastAPI backend with strict type safety
- Modern React frontend with TypeScript
- Production-ready security (JWT with refresh token rotation, Argon2id hashing)
- Advanced design patterns (Dependency Injection, Repository Pattern, Layered Architecture)
- Docker containerization with multi-stage builds
- Database migrations with Alembic
- Comprehensive testing and linting infrastructure
**Part of:** [Cybersecurity-Projects Repository](https://github.com/CarterPerez-dev/Cybersecurity-Projects) (60+ security-focused projects)
---
## Features
### Security Researcher Features
- User registration and authentication
- Browse public bug bounty programs
- Submit vulnerability reports with markdown support
- Track report status and receive updates
- Earn reputation and rewards
### Company Features
- Create and manage bug bounty programs
- Define program scope (assets, reward tiers, SLA)
- Triage incoming vulnerability reports
- Assess severity using CVSS scoring
- Award bounties to researchers
- Communicate via comments and attachments
### Platform Features
- Role-based access control (Researcher, Company, Admin)
- JWT authentication with refresh token rotation
- Token versioning for instant session invalidation
- Multi-device session management
- Rate limiting on all endpoints
- Comprehensive audit logging
- OpenAPI/Swagger documentation
---
## Tech Stack
### Backend
- **FastAPI** 0.123.0+ - Modern async Python web framework
- **Python** 3.12+ - Strict typing with mypy
- **PostgreSQL** 18 - Primary database with asyncpg driver
- **Redis** 7 - Caching and session storage
- **SQLAlchemy** 2.0+ - Async ORM
- **Alembic** - Database migrations
- **Pydantic** v2 - Data validation and settings
- **JWT** - Token-based authentication with rotation
- **Argon2id** - Password hashing via pwdlib
### Frontend
- **React** 19.2+ - UI library
- **TypeScript** 5.9 - Static typing
- **Vite** 7 - Build tool with Rolldown
- **React Router** 7.1 - File-based routing
- **TanStack Query** v5 - Server state management
- **Zustand** - Client state management
- **Axios** - HTTP client
- **SASS** - CSS preprocessing
### Infrastructure
- **Docker** + **Docker Compose** - Containerization
- **Nginx** - Reverse proxy and static file serving
- **Cloudflare Tunnel** - Zero-config deployment (optional)
- **Gunicorn** + **Uvicorn** - Production ASGI server
### Development Tools
- **Ruff** - Python linting and formatting
- **Biome** - JavaScript/TypeScript linting
- **MyPy** - Static type checking
- **Pytest** - Testing framework
- **Just** - Task runner (30+ commands)
- **Pre-commit hooks** - Automated quality checks
---
## Getting Started
You have two options:
### Option 1: Use the Live API (Easiest)
The platform is already deployed and running! You can:
- Use the web interface: [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com)
- Access the API directly: [bugbounty.carterperez-dev.com/api/](https://bugbounty.carterperez-dev.com/api/)
- View API documentation: [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs)
You can build your own client application using the deployed API endpoints. See the OpenAPI documentation for available endpoints and schemas.
### Option 2: Run It Yourself
If you want to run the entire platform locally or deploy your own instance:
#### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- [Just](https://github.com/casey/just) (task runner) - `cargo install just` or see [installation guide](https://github.com/casey/just#installation)
- Git
#### Quick Start
1. **Clone the repository:**
```bash
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd Cybersecurity-Projects/PROJECTS/bug-bounty-platform
```
2. **Configure environment variables:**
```bash
cp .env.example .env
```
Edit `.env` and update these critical values:
- `SECRET_KEY` - Generate a secure random string (minimum 32 characters)
- `POSTGRES_PASSWORD` - Set a strong database password
- `ADMIN_EMAIL` - (Optional) First user with this email becomes admin
- `CORS_ORIGINS` - Update if using different ports
3. **Start the platform (development mode with hot reload):**
```bash
just dev-up
```
Or in production mode:
```bash
just up
```
4. **Access the platform:**
- **Frontend:** http://localhost:8420
- **API:** http://localhost:8420/api
- **API Docs:** http://localhost:8420/api/docs
- **Backend (direct):** http://localhost:5420
- **Frontend Dev Server:** http://localhost:3420 (dev mode only)
5. **Apply database migrations:**
```bash
just migrate head
```
6. **Create your first account:**
- Navigate to http://localhost:8420
- Click "Register"
- If you set `ADMIN_EMAIL` in `.env`, registering with that email grants admin privileges
#### Common Commands
The `justfile` provides 30+ commands for development:
```bash
just # List all available commands
# Development
just dev-up # Start in development mode (hot reload)
just dev-down # Stop development containers
just dev-logs backend # View backend logs
just dev-shell backend # Open shell in backend container
# Production
just up # Start in production mode
just down # Stop production containers
just build # Build all containers
just rebuild # Rebuild without cache
# Database
just migrate head # Apply all migrations
just migration "message" # Create new migration
just rollback # Rollback last migration
just db-current # Show current migration
# Linting & Type Checking
just lint # Run ruff + pylint
just ruff-fix # Auto-fix linting issues
just mypy # Type check with mypy
just biome-fix # Fix frontend linting issues
just typecheck # Run all type checks
# Testing
just test # Run all tests
just test-cov # Run tests with coverage report
# CI
just ci # Run full CI pipeline (lint + typecheck + test)
```
#### Project Structure
```
bug-bounty-platform/
├── backend/ # FastAPI backend (~7,000 lines)
│ ├── src/
│ │ ├── app/
│ │ │ ├── core/ # Base classes, database, security, constants and enums, etc.
│ │ │ ├── user/ # User domain
│ │ │ ├── auth/ # Authentication
│ │ │ ├── program/ # Bug bounty programs
│ │ │ ├── report/ # Vulnerability reports
│ │ │ └── admin/ # Admin functionality
│ │ └── config.py # configuration values
│ │ └── factory.py # essentially the 'main.py' file
│ │ └── __main__.py # Where the run command lives
│ ├── alembic/ # Database migrations
│ ├── tests/ # Unit and integration tests
│ └── pyproject.toml # Python dependencies
├── frontend/ # React + TypeScript frontend
│ ├── src/
│ │ ├── routes/ # Pages
│ │ ├── api/ # API client and hooks
│ │ ├── components/ # Reusable components
│ │ ├── styles/ # SCSS global values
│ │ └── core/ # App configuration, zustand stores (ui state management), api configuration
│ └── package.json
├── infra/ # Docker and Nginx configs
│ ├── nginx/
│ └── docker/
├── learn/ # Educational documentation (see below)
├── compose.yml # Production Docker Compose
├── dev.compose.yml # Development Docker Compose
├── justfile # Task runner commands
└── .env.example # Environment variables template
```
---
## Configuration
### Environment Variables
All configuration is done via `.env` file. Key variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `NGINX_HOST_PORT` | External port for Nginx | 8420 |
| `BACKEND_HOST_PORT` | External port for backend API | 5420 |
| `FRONTEND_HOST_PORT` | External port for frontend dev server | 3420 |
| `POSTGRES_HOST_PORT` | External port for PostgreSQL | 4420 |
| `REDIS_HOST_PORT` | External port for Redis | 6420 |
| `SECRET_KEY` | JWT signing key (min 32 chars) | **MUST CHANGE** |
| `POSTGRES_PASSWORD` | Database password | **MUST CHANGE** |
| `ADMIN_EMAIL` | Auto-promote this email to admin | (empty) |
| `ENVIRONMENT` | dev/staging/production | development |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT access token lifetime | 15 |
| `REFRESH_TOKEN_EXPIRE_DAYS` | Refresh token lifetime | 7 |
See `.env.example` for all available options.
### Port Configuration
If the default ports conflict with other services, update these in `.env`:
```bash
NGINX_HOST_PORT=8420 # Change to any available port
BACKEND_HOST_PORT=5420 # Change to any available port
FRONTEND_HOST_PORT=3420 # Change to any available port
POSTGRES_HOST_PORT=4420 # Change to any available port
REDIS_HOST_PORT=6420 # Change to any available port
# IMPORTANT: Update CORS_ORIGINS to match NGINX_HOST_PORT
CORS_ORIGINS=http://localhost,http://localhost:8420,http://localhost:3420
```
---
## Deployment
### Option 1: Cloudflare Tunnel (Recommended for beginners)
No port forwarding or reverse proxy configuration needed!
1. Create a Cloudflare account and add your domain
2. Go to Zero Trust Dashboard > Access > Tunnels
3. Create a new tunnel, name it, and copy the token
4. Add the token to `.env`:
```bash
CLOUDFLARE_TUNNEL_TOKEN=your-token-here
```
5. Configure public hostname in Cloudflare:
- Public hostname: `yourdomain.com`
- Service: `http://nginx:80`
6. Start the platform:
```bash
just up
```
Your platform is now live at `https://yourdomain.com`!
### Option 2: Traditional Hosting (VPS)
1. Rent a VPS (DigitalOcean, AWS, Linode, etc.)
2. Install Docker and Docker Compose
3. Clone the repository and configure `.env`
4. Point your domain's A record to your VPS IP
5. Configure SSL (Let's Encrypt with Certbot)
6. Start the platform:
```bash
just up
```
### Option 3: Use the Existing Deployment
Just use the API at `bugbounty.carterperez-dev.com/api/` - no deployment needed!
---
## Learning Resources
This project includes comprehensive educational documentation in the `learn/` directory:
- **[ARCHITECTURE.md](learn/ARCHITECTURE.md)** - Deep dive into system architecture and design decisions
- **[PATTERNS.md](learn/PATTERNS.md)** - Explanation of design patterns used (DI, Repository, etc.)
- **[GETTING-STARTED.md](learn/GETTING-STARTED.md)** - Step-by-step tutorial for building similar applications
- **[DATABASE.md](learn/DATABASE.md)** - Database schema design and migration strategies
- **[SECURITY.md](learn/SECURITY.md)** - Security features and best practices explained
These documents are designed to help you understand not just *what* the code does, but *why* it's architected this way and *how* you can apply these patterns to your own projects.
---
## API Documentation
### Interactive Documentation
When running locally, access interactive API docs at:
- **Swagger UI:** http://localhost:8420/api/docs
- **ReDoc:** http://localhost:8420/api/redoc
For the live deployment:
- **Swagger UI:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs)
### Key Endpoints
**Authentication:**
- `POST /api/v1/auth/register` - Create new account
- `POST /api/v1/auth/login` - Login (returns access + refresh tokens)
- `POST /api/v1/auth/refresh` - Refresh access token
- `POST /api/v1/auth/logout` - Logout (invalidates refresh token)
- `POST /api/v1/auth/logout-all` - Logout from all devices
**Users:**
- `GET /api/v1/users/me` - Get current user profile
- `PATCH /api/v1/users/me` - Update profile
- `GET /api/v1/users/{id}` - Get public user profile
**Programs:**
- `GET /api/v1/programs` - List all programs (paginated)
- `GET /api/v1/programs/{slug}` - Get program details
- `POST /api/v1/programs` - Create program (company only)
- `PATCH /api/v1/programs/{slug}` - Update program (owner only)
- `DELETE /api/v1/programs/{slug}` - Delete program (owner only)
**Reports:**
- `GET /api/v1/reports` - List your reports
- `GET /api/v1/reports/{id}` - Get report details
- `POST /api/v1/reports` - Submit vulnerability report
- `PATCH /api/v1/reports/{id}` - Update report (various endpoints for status changes)
**Admin:**
- `GET /api/v1/admin/stats` - Platform statistics
- `GET /api/v1/admin/users` - Manage users
- `GET /api/v1/admin/programs` - Manage programs
- `GET /api/v1/admin/reports` - Manage reports
All endpoints return JSON and use standard HTTP status codes.
---
## Development
### Running Tests
```bash
just test # Run all tests
just test-cov # Run with coverage report
```
### Type Checking
```bash
just mypy # Check backend types
just tsc # Check frontend types
just typecheck # Check all types
```
### Linting
```bash
just lint # Backend: ruff + pylint
just ruff-fix # Auto-fix backend linting issues
just biome-fix # Auto-fix frontend linting issues
just stylelint-fix # Auto-fix SCSS linting issues
```
### Database Migrations
```bash
just migration "Add user reputation field" # Create new migration
just migrate head # Apply all migrations
just rollback # Rollback last migration
just db-history # View migration history
```
### Docker Management
```bash
just dev-shell backend # Open shell in backend container
just dev-shell db # Open psql in database container
just dev-logs nginx # View nginx logs
just ps # List running containers
```
---
## Architecture Highlights
### Dependency Injection
FastAPI's dependency injection system is used extensively:
```python
from fastapi import Depends
from typing import Annotated
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/me")
async def get_me(user: CurrentUser) -> UserSchema:
return user
```
### Repository Pattern
All database operations go through repositories:
```python
class UserRepository(BaseRepository[User]):
async def find_by_email(self, email: str) -> User | None:
stmt = select(User).where(User.email == email)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
# Usage in service layer
async def authenticate_user(email: str, password: str) -> User:
user = await user_repo.find_by_email(email)
if not user or not verify_password(password, user.password_hash):
raise InvalidCredentialsError()
return user
```
### Type Safety
Strict type checking with mypy and TypeScript:
```python
from typing import Generic, TypeVar
ModelT = TypeVar("ModelT", bound=Base)
class BaseRepository(Generic[ModelT]):
def __init__(self, session: AsyncSession, model: type[ModelT]) -> None:
self.session = session
self.model = model
```
### Security
Multiple layers of security:
- JWT tokens with HS256 algorithm
- Token versioning (instant invalidation on password change)
- Refresh token rotation (prevents replay attacks)
- Argon2id password hashing
- Rate limiting (100 req/min default, 20 req/min for auth)
- CORS protection
- Input validation with Pydantic
See [learn/SECURITY.md](learn/SECURITY.md) for detailed explanations.
---
## Contributing
This is an educational project demonstrating production-level architecture. Feel free to:
- Fork the repository and build upon it
- Use it as a reference for your own projects
- Submit issues if you find bugs
- Share feedback and suggestions
---
## License
This project is part of the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository.
© AngelaMos | 2026
---
## Links
- **Live Platform:** [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com)
- **API Docs:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs)
- **Parent Repository:** [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects)
---
## Support
For questions, issues, or discussions:
1. Check the [learn/](learn/) directory for detailed documentation
2. Review the API documentation at `/api/docs`
3. Open an issue in the parent repository
4. Email: [contact information if applicable]
---
**Happy Hacking! 🔒**

View File

@ -0,0 +1,85 @@
# ===============================
# © AngelaMos | 2025
# .dockerignore
# ===============================
# Virtual environments
.venv/
venv/
ENV/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.pyo
# Type checker / linter caches
.mypy_cache/
.ruff_cache/
.pytest_cache/
.ty_cache/
# Coverage
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
# Build artifacts
dist/
build/
*.egg-info/
*.egg
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# Git
.git/
.gitignore
.gitattributes
# Docker (don't need these in the image)
Dockerfile*
docker-compose*
.dockerignore
# Tests (not needed in prod image)
tests/
conftest.py
pytest.ini
.coveragerc
# Documentation
docs/
*.md
*.rst
LICENSE
# Logs
*.log
logs/
# Local databases
*.db
*.sqlite
*.sqlite3
# Alembic versions not needed in image (migrations run separately)
# alembic/versions/
# Misc
.DS_Store
Thumbs.db
*.bak

View File

@ -0,0 +1,46 @@
[style]
based_on_style = pep8
column_limit = 75
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,43 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,124 @@
"""
AngelaMos | 2025
env.py
"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from config import settings
from core.Base import Base
from core.enums import SafeEnum
from user.User import User
from auth.RefreshToken import RefreshToken
from program.Program import Program
from program.Asset import Asset
from program.RewardTier import RewardTier
from report.Report import Report
from report.Comment import Comment
from report.Attachment import Attachment
config = context.config
def render_item(type_, obj, autogen_context):
"""
Custom renderer for alembic autogenerate.
Converts SafeEnum to standard sa.Enum and ensures DateTime uses timezone.
"""
import sqlalchemy as sa
if isinstance(obj, SafeEnum):
enum_class = obj.enum_class
values = [e.value for e in enum_class]
return f"sa.Enum({', '.join(repr(v) for v in values)}, name='{obj.name}')"
if isinstance(obj, sa.DateTime):
return "sa.DateTime(timezone=True)"
return False
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
"""
Get database URL from settings
"""
return str(settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode
"""
url = get_url()
context.configure(
url = url,
target_metadata = target_metadata,
literal_binds = True,
dialect_opts = {"paramstyle": "named"},
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""
Run migrations with connection
"""
context.configure(
connection = connection,
target_metadata = target_metadata,
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in async mode
"""
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix = "sqlalchemy.",
poolclass = pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,67 @@
"""initial
Revision ID: 801b86be184b
Revises:
Create Date: 2025-12-24 03:31:04.712921
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '801b86be184b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.Column('role', sa.Enum('unknown', 'user', 'admin', name='userrole'), nullable=False),
sa.Column('token_version', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('refresh_tokens',
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('family_id', sa.Uuid(), nullable=False),
sa.Column('device_id', sa.String(length=255), nullable=True),
sa.Column('device_name', sa.String(length=100), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens'))
)
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@ -0,0 +1,152 @@
"""add bug bounty models
Revision ID: de05481ca143
Revises: 801b86be184b
Create Date: 2026-01-06 01:01:22.847036
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'de05481ca143'
down_revision: Union[str, None] = '801b86be184b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('programs',
sa.Column('company_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('rules', sa.Text(), nullable=True),
sa.Column('response_sla_hours', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('draft', 'active', 'paused', 'closed', name='programstatus'), nullable=False),
sa.Column('visibility', sa.Enum('public', 'private', 'invite_only', name='programvisibility'), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['company_id'], ['users.id'], name=op.f('fk_programs_company_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_programs'))
)
op.create_index(op.f('ix_programs_company_id'), 'programs', ['company_id'], unique=False)
op.create_index(op.f('ix_programs_slug'), 'programs', ['slug'], unique=True)
op.create_index(op.f('ix_programs_status'), 'programs', ['status'], unique=False)
op.create_table('assets',
sa.Column('program_id', sa.Uuid(), nullable=False),
sa.Column('asset_type', sa.Enum('domain', 'api', 'mobile_app', 'source_code', 'hardware', 'other', name='assettype'), nullable=False),
sa.Column('identifier', sa.String(length=500), nullable=False),
sa.Column('in_scope', sa.Boolean(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_assets_program_id_programs'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_assets'))
)
op.create_index(op.f('ix_assets_program_id'), 'assets', ['program_id'], unique=False)
op.create_table('reports',
sa.Column('program_id', sa.Uuid(), nullable=False),
sa.Column('researcher_id', sa.Uuid(), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('steps_to_reproduce', sa.Text(), nullable=True),
sa.Column('impact', sa.Text(), nullable=True),
sa.Column('severity_submitted', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=False),
sa.Column('severity_final', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=True),
sa.Column('status', sa.Enum('new', 'triaging', 'needs_more_info', 'accepted', 'duplicate', 'informative', 'not_applicable', 'resolved', 'disclosed', name='reportstatus'), nullable=False),
sa.Column('cvss_score', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('cwe_id', sa.String(length=20), nullable=True),
sa.Column('bounty_amount', sa.Integer(), nullable=True),
sa.Column('duplicate_of_id', sa.Uuid(), nullable=True),
sa.Column('triaged_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('disclosed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['duplicate_of_id'], ['reports.id'], name=op.f('fk_reports_duplicate_of_id_reports'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_reports_program_id_programs'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['researcher_id'], ['users.id'], name=op.f('fk_reports_researcher_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reports'))
)
op.create_index(op.f('ix_reports_program_id'), 'reports', ['program_id'], unique=False)
op.create_index(op.f('ix_reports_researcher_id'), 'reports', ['researcher_id'], unique=False)
op.create_index(op.f('ix_reports_status'), 'reports', ['status'], unique=False)
op.create_table('reward_tiers',
sa.Column('program_id', sa.Uuid(), nullable=False),
sa.Column('severity', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=False),
sa.Column('min_bounty', sa.Integer(), nullable=False),
sa.Column('max_bounty', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_reward_tiers_program_id_programs'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reward_tiers'))
)
op.create_index(op.f('ix_reward_tiers_program_id'), 'reward_tiers', ['program_id'], unique=False)
op.create_table('comments',
sa.Column('report_id', sa.Uuid(), nullable=False),
sa.Column('author_id', sa.Uuid(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('is_internal', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name=op.f('fk_comments_author_id_users'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], name=op.f('fk_comments_report_id_reports'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_comments'))
)
op.create_index(op.f('ix_comments_author_id'), 'comments', ['author_id'], unique=False)
op.create_index(op.f('ix_comments_report_id'), 'comments', ['report_id'], unique=False)
op.create_table('attachments',
sa.Column('report_id', sa.Uuid(), nullable=False),
sa.Column('comment_id', sa.Uuid(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('storage_path', sa.String(length=500), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['comment_id'], ['comments.id'], name=op.f('fk_attachments_comment_id_comments'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], name=op.f('fk_attachments_report_id_reports'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachments'))
)
op.create_index(op.f('ix_attachments_report_id'), 'attachments', ['report_id'], unique=False)
op.add_column('users', sa.Column('company_name', sa.String(length=255), nullable=True))
op.add_column('users', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('users', sa.Column('website', sa.String(length=500), nullable=True))
op.add_column('users', sa.Column('reputation_score', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'reputation_score')
op.drop_column('users', 'website')
op.drop_column('users', 'bio')
op.drop_column('users', 'company_name')
op.drop_index(op.f('ix_attachments_report_id'), table_name='attachments')
op.drop_table('attachments')
op.drop_index(op.f('ix_comments_report_id'), table_name='comments')
op.drop_index(op.f('ix_comments_author_id'), table_name='comments')
op.drop_table('comments')
op.drop_index(op.f('ix_reward_tiers_program_id'), table_name='reward_tiers')
op.drop_table('reward_tiers')
op.drop_index(op.f('ix_reports_status'), table_name='reports')
op.drop_index(op.f('ix_reports_researcher_id'), table_name='reports')
op.drop_index(op.f('ix_reports_program_id'), table_name='reports')
op.drop_table('reports')
op.drop_index(op.f('ix_assets_program_id'), table_name='assets')
op.drop_table('assets')
op.drop_index(op.f('ix_programs_status'), table_name='programs')
op.drop_index(op.f('ix_programs_slug'), table_name='programs')
op.drop_index(op.f('ix_programs_company_id'), table_name='programs')
op.drop_table('programs')
# ### end Alembic commands ###

View File

@ -0,0 +1,42 @@
"""
AngelaMos | 2026
__main__.py
"""
import uvicorn
from config import settings
from factory import create_app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"app.__main__:app",
host = settings.HOST,
port = settings.PORT,
reload = settings.RELOAD,
)
"""
"""

View File

@ -0,0 +1,9 @@
"""
AngelaMos | 2026
admin module
"""
from .routes import router
__all__ = ["router"]

View File

@ -0,0 +1,25 @@
"""
AngelaMos | 2026
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_db_session
from .service import AdminService
async def get_admin_service(
session: Annotated[AsyncSession,
Depends(get_db_session)],
) -> AdminService:
"""
Dependency for AdminService
"""
return AdminService(session)
AdminServiceDep = Annotated[AdminService, Depends(get_admin_service)]

View File

@ -0,0 +1,314 @@
"""
AngelaMos | 2026
repository.py
"""
from typing import Any
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import ProgramStatus, ReportStatus, UserRole
from program.Program import Program
from report.Report import Report
from user.User import User
class AdminRepository:
"""
Repository for admin-specific database operations
"""
@classmethod
async def get_all_programs(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 20,
status_filter: ProgramStatus | None = None,
) -> Sequence[tuple[Program,
User,
int]]:
"""
Get all programs with company info and report count
"""
report_count_subq = (
select(
Report.program_id,
func.count(Report.id).label("report_count")
).group_by(Report.program_id).subquery()
)
query = (
select(
Program,
User,
func.coalesce(report_count_subq.c.report_count,
0).label("report_count")
).join(User,
Program.company_id == User.id).outerjoin(
report_count_subq,
Program.id == report_count_subq.c.program_id
)
)
if status_filter:
query = query.where(Program.status == status_filter)
result = await session.execute(
query.order_by(Program.created_at.desc()
).offset(skip).limit(limit)
)
return [tuple(row) for row in result.all()]
@classmethod
async def count_all_programs(
cls,
session: AsyncSession,
status_filter: ProgramStatus | None = None,
) -> int:
"""
Count all programs
"""
query = select(func.count()).select_from(Program)
if status_filter:
query = query.where(Program.status == status_filter)
result = await session.execute(query)
return result.scalar_one()
@classmethod
async def get_program_by_id(
cls,
session: AsyncSession,
program_id: UUID,
) -> Program | None:
"""
Get program by ID for admin operations
"""
return await session.get(Program, program_id)
@classmethod
async def get_all_reports(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 20,
status_filter: ReportStatus | None = None,
severity_filter: str | None = None,
) -> Sequence[tuple[Report,
Program,
User]]:
"""
Get all reports with program and researcher info
"""
query = (
select(Report,
Program,
User).join(Program,
Report.program_id == Program.id).join(
User,
Report.researcher_id == User.id
)
)
if status_filter:
query = query.where(Report.status == status_filter)
if severity_filter:
query = query.where(
Report.severity_submitted == severity_filter
)
result = await session.execute(
query.order_by(Report.created_at.desc()
).offset(skip).limit(limit)
)
return [tuple(row) for row in result.all()]
@classmethod
async def count_all_reports(
cls,
session: AsyncSession,
status_filter: ReportStatus | None = None,
severity_filter: str | None = None,
) -> int:
"""
Count all reports
"""
query = select(func.count()).select_from(Report)
if status_filter:
query = query.where(Report.status == status_filter)
if severity_filter:
query = query.where(
Report.severity_submitted == severity_filter
)
result = await session.execute(query)
return result.scalar_one()
@classmethod
async def get_report_by_id(
cls,
session: AsyncSession,
report_id: UUID,
) -> Report | None:
"""
Get report by ID for admin operations
"""
return await session.get(Report, report_id)
@classmethod
async def get_all_users_with_stats(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 20,
role_filter: UserRole | None = None,
) -> Sequence[tuple[User,
int,
int]]:
"""
Get all users with program and report counts
"""
program_count_subq = (
select(
Program.company_id,
func.count(Program.id).label("program_count")
).group_by(Program.company_id).subquery()
)
report_count_subq = (
select(
Report.researcher_id,
func.count(Report.id).label("report_count")
).group_by(Report.researcher_id).subquery()
)
query = (
select(
User,
func.coalesce(program_count_subq.c.program_count,
0).label("program_count"),
func.coalesce(report_count_subq.c.report_count,
0).label("report_count")
).outerjoin(
program_count_subq,
User.id == program_count_subq.c.company_id
).outerjoin(
report_count_subq,
User.id == report_count_subq.c.researcher_id
)
)
if role_filter:
query = query.where(User.role == role_filter)
result = await session.execute(
query.order_by(User.created_at.desc()
).offset(skip).limit(limit)
)
return [tuple(row) for row in result.all()]
@classmethod
async def count_all_users(
cls,
session: AsyncSession,
role_filter: UserRole | None = None,
) -> int:
"""
Count all users
"""
query = select(func.count()).select_from(User)
if role_filter:
query = query.where(User.role == role_filter)
result = await session.execute(query)
return result.scalar_one()
@classmethod
async def get_platform_stats(
cls,
session: AsyncSession,
) -> dict[str,
Any]:
"""
Get platform-wide statistics
"""
now = datetime.now(UTC)
month_ago = now - timedelta(days = 30)
total_users = await session.execute(
select(func.count()).select_from(User)
)
users_by_role = await session.execute(
select(User.role,
func.count()).group_by(User.role)
)
role_counts = {
str(role): count
for role, count in users_by_role.all()
}
total_programs = await session.execute(
select(func.count()).select_from(Program)
)
active_programs = await session.execute(
select(func.count()).select_from(Program).where(
Program.status == ProgramStatus.ACTIVE
)
)
total_reports = await session.execute(
select(func.count()).select_from(Report)
)
reports_by_status = await session.execute(
select(Report.status,
func.count()).group_by(Report.status)
)
status_counts = {
str(status): count
for status, count in reports_by_status.all()
}
total_bounties = await session.execute(
select(func.coalesce(func.sum(Report.bounty_amount),
0)).where(
Report.bounty_amount.isnot(None)
)
)
reports_this_month = await session.execute(
select(func.count()).select_from(Report).where(
Report.created_at >= month_ago
)
)
new_users_this_month = await session.execute(
select(func.count()
).select_from(User).where(User.created_at >= month_ago)
)
return {
"total_users": total_users.scalar_one(),
"total_researchers": role_counts.get("user",
0),
"total_companies": role_counts.get("company",
0),
"total_programs": total_programs.scalar_one(),
"active_programs": active_programs.scalar_one(),
"total_reports": total_reports.scalar_one(),
"reports_by_status": status_counts,
"total_bounties_paid": total_bounties.scalar_one(),
"reports_this_month": reports_this_month.scalar_one(),
"new_users_this_month": new_users_this_month.scalar_one(),
}

View File

@ -0,0 +1,309 @@
"""
AngelaMos | 2026
routes.py
"""
from uuid import UUID
from typing import Annotated
from fastapi import (
APIRouter,
Depends,
Query,
status,
)
from config import (
settings,
ProgramStatus,
ReportStatus,
UserRole,
)
from core.dependencies import RequireRole
from core.responses import (
AUTH_401,
CONFLICT_409,
FORBIDDEN_403,
NOT_FOUND_404,
)
from user.schemas import (
AdminUserCreate,
UserResponse,
UserUpdateAdmin,
)
from user.User import User
from user.dependencies import UserServiceDep
from .dependencies import AdminServiceDep
from .schemas import (
AdminProgramListResponse,
AdminProgramResponse,
AdminProgramUpdate,
AdminReportListResponse,
AdminReportResponse,
AdminReportUpdate,
AdminUserListResponse,
PlatformStatsResponse,
)
router = APIRouter(prefix = "/admin", tags = ["admin"])
AdminOnly = Annotated[User, Depends(RequireRole(UserRole.ADMIN))]
@router.get(
"/stats",
response_model = PlatformStatsResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403
},
)
async def get_platform_stats(
admin_service: AdminServiceDep,
_: AdminOnly,
) -> PlatformStatsResponse:
"""
Get platform-wide statistics (admin only)
"""
return await admin_service.get_platform_stats()
@router.get(
"/programs",
response_model = AdminProgramListResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403
},
)
async def list_programs(
admin_service: AdminServiceDep,
_: AdminOnly,
page: int = Query(default = 1,
ge = 1),
size: int = Query(
default = settings.PAGINATION_DEFAULT_SIZE,
ge = 1,
le = settings.PAGINATION_MAX_SIZE
),
status_filter: ProgramStatus
| None = Query(default = None,
alias = "status"),
) -> AdminProgramListResponse:
"""
List all programs (admin only)
"""
return await admin_service.list_programs(page, size, status_filter)
@router.patch(
"/programs/{program_id}",
response_model = AdminProgramResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def update_program(
admin_service: AdminServiceDep,
_: AdminOnly,
program_id: UUID,
data: AdminProgramUpdate,
) -> AdminProgramResponse:
"""
Update a program (admin only)
"""
return await admin_service.update_program(program_id, data)
@router.delete(
"/programs/{program_id}",
status_code = status.HTTP_204_NO_CONTENT,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def delete_program(
admin_service: AdminServiceDep,
_: AdminOnly,
program_id: UUID,
) -> None:
"""
Delete a program (admin only, hard delete)
"""
await admin_service.delete_program(program_id)
@router.get(
"/reports",
response_model = AdminReportListResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403
},
)
async def list_reports(
admin_service: AdminServiceDep,
_: AdminOnly,
page: int = Query(default = 1,
ge = 1),
size: int = Query(
default = settings.PAGINATION_DEFAULT_SIZE,
ge = 1,
le = settings.PAGINATION_MAX_SIZE
),
status_filter: ReportStatus
| None = Query(default = None,
alias = "status"),
severity_filter: str
| None = Query(default = None,
alias = "severity"),
) -> AdminReportListResponse:
"""
List all reports (admin only)
"""
return await admin_service.list_reports(
page,
size,
status_filter,
severity_filter
)
@router.patch(
"/reports/{report_id}",
response_model = AdminReportResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def update_report(
admin_service: AdminServiceDep,
_: AdminOnly,
report_id: UUID,
data: AdminReportUpdate,
) -> AdminReportResponse:
"""
Update a report (admin only, override)
"""
return await admin_service.update_report(report_id, data)
@router.get(
"/users",
response_model = AdminUserListResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403
},
)
async def list_users(
admin_service: AdminServiceDep,
_: AdminOnly,
page: int = Query(default = 1,
ge = 1),
size: int = Query(
default = settings.PAGINATION_DEFAULT_SIZE,
ge = 1,
le = settings.PAGINATION_MAX_SIZE
),
role_filter: UserRole | None = Query(default = None,
alias = "role"),
) -> AdminUserListResponse:
"""
List all users with stats (admin only)
"""
return await admin_service.list_users_with_stats(
page,
size,
role_filter
)
@router.post(
"/users",
response_model = UserResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**CONFLICT_409
},
)
async def create_user(
user_service: UserServiceDep,
_: AdminOnly,
user_data: AdminUserCreate,
) -> UserResponse:
"""
Create a new user (admin only, bypasses registration)
"""
return await user_service.admin_create_user(user_data)
@router.get(
"/users/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def get_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
) -> UserResponse:
"""
Get user by ID (admin only)
"""
return await user_service.get_user_by_id(user_id)
@router.patch(
"/users/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404,
**CONFLICT_409
},
)
async def update_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
user_data: UserUpdateAdmin,
) -> UserResponse:
"""
Update user (admin only)
"""
return await user_service.admin_update_user(user_id, user_data)
@router.delete(
"/users/{user_id}",
status_code = status.HTTP_204_NO_CONTENT,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def delete_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
) -> None:
"""
Delete user (admin only, hard delete)
"""
await user_service.admin_delete_user(user_id)

View File

@ -0,0 +1,136 @@
"""
AngelaMos | 2026
schemas.py
"""
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import Field
from config import (
ProgramStatus,
ProgramVisibility,
ReportStatus,
Severity,
UserRole,
)
from core.base_schema import BaseSchema, BaseResponseSchema
class AdminProgramUpdate(BaseSchema):
"""
Schema for admin updating a program
"""
name: str | None = None
status: ProgramStatus | None = None
visibility: ProgramVisibility | None = None
is_featured: bool | None = None
class AdminProgramResponse(BaseResponseSchema):
"""
Schema for admin program view with company info
"""
company_id: UUID
company_email: str
company_name: str | None
name: str
slug: str
description: str | None
status: ProgramStatus
visibility: ProgramVisibility
response_sla_hours: int
report_count: int
class AdminProgramListResponse(BaseSchema):
"""
Schema for paginated admin program list
"""
items: list[AdminProgramResponse]
total: int
page: int
size: int
class AdminReportUpdate(BaseSchema):
"""
Schema for admin overriding a report
"""
status: ReportStatus | None = None
severity_final: Severity | None = None
cvss_score: Decimal | None = Field(default = None, ge = 0, le = 10)
bounty_amount: int | None = Field(default = None, ge = 0)
admin_notes: str | None = None
class AdminReportResponse(BaseResponseSchema):
"""
Schema for admin report view with program/researcher info
"""
program_id: UUID
program_name: str
program_slug: str
researcher_id: UUID
researcher_email: str
researcher_name: str | None
title: str
severity_submitted: Severity
severity_final: Severity | None
status: ReportStatus
bounty_amount: int | None
triaged_at: datetime | None
resolved_at: datetime | None
class AdminReportListResponse(BaseSchema):
"""
Schema for paginated admin report list
"""
items: list[AdminReportResponse]
total: int
page: int
size: int
class PlatformStatsResponse(BaseSchema):
"""
Schema for platform-wide statistics
"""
total_users: int
total_researchers: int
total_companies: int
total_programs: int
active_programs: int
total_reports: int
reports_by_status: dict[str, int]
total_bounties_paid: int
reports_this_month: int
new_users_this_month: int
class AdminUserResponse(BaseResponseSchema):
"""
Schema for admin user view with stats
"""
email: str
full_name: str | None
company_name: str | None
is_active: bool
is_verified: bool
role: UserRole
reputation_score: int
program_count: int
report_count: int
class AdminUserListResponse(BaseSchema):
"""
Schema for paginated admin user list with stats
"""
items: list[AdminUserResponse]
total: int
page: int
size: int

View File

@ -0,0 +1,303 @@
"""
AngelaMos | 2026
service.py
"""
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from config import ProgramStatus, ReportStatus, UserRole
from core.exceptions import ResourceNotFound
from .repository import AdminRepository
from .schemas import (
AdminProgramListResponse,
AdminProgramResponse,
AdminProgramUpdate,
AdminReportListResponse,
AdminReportResponse,
AdminReportUpdate,
AdminUserListResponse,
AdminUserResponse,
PlatformStatsResponse,
)
class AdminService:
"""
Service for admin operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def list_programs(
self,
page: int,
size: int,
status_filter: ProgramStatus | None = None,
) -> AdminProgramListResponse:
"""
List all programs with company info
"""
skip = (page - 1) * size
results = await AdminRepository.get_all_programs(
self.session,
skip = skip,
limit = size,
status_filter = status_filter,
)
total = await AdminRepository.count_all_programs(
self.session,
status_filter = status_filter,
)
items = [
AdminProgramResponse(
id = program.id,
created_at = program.created_at,
updated_at = program.updated_at,
company_id = program.company_id,
company_email = company.email,
company_name = company.company_name or company.full_name,
name = program.name,
slug = program.slug,
description = program.description,
status = program.status,
visibility = program.visibility,
response_sla_hours = program.response_sla_hours,
report_count = report_count,
) for program, company, report_count in results
]
return AdminProgramListResponse(
items = items,
total = total,
page = page,
size = size,
)
async def update_program(
self,
program_id: UUID,
data: AdminProgramUpdate,
) -> AdminProgramResponse:
"""
Update a program (admin override)
"""
program = await AdminRepository.get_program_by_id(
self.session,
program_id
)
if not program:
raise ResourceNotFound("Program", str(program_id))
update_data = data.model_dump(exclude_unset = True)
for key, value in update_data.items():
setattr(program, key, value)
await self.session.flush()
await self.session.refresh(program)
results = await AdminRepository.get_all_programs(
self.session,
skip = 0,
limit = 1,
status_filter = None
)
for p, company, report_count in results:
if p.id == program_id:
return AdminProgramResponse(
id = program.id,
created_at = program.created_at,
updated_at = program.updated_at,
company_id = program.company_id,
company_email = company.email,
company_name = company.company_name
or company.full_name,
name = program.name,
slug = program.slug,
description = program.description,
status = program.status,
visibility = program.visibility,
response_sla_hours = program.response_sla_hours,
report_count = report_count,
)
raise ResourceNotFound("Program", str(program_id))
async def delete_program(self, program_id: UUID) -> None:
"""
Delete a program (hard delete)
"""
program = await AdminRepository.get_program_by_id(
self.session,
program_id
)
if not program:
raise ResourceNotFound("Program", str(program_id))
await self.session.delete(program)
await self.session.flush()
async def list_reports(
self,
page: int,
size: int,
status_filter: ReportStatus | None = None,
severity_filter: str | None = None,
) -> AdminReportListResponse:
"""
List all reports with program/researcher info
"""
skip = (page - 1) * size
results = await AdminRepository.get_all_reports(
self.session,
skip = skip,
limit = size,
status_filter = status_filter,
severity_filter = severity_filter,
)
total = await AdminRepository.count_all_reports(
self.session,
status_filter = status_filter,
severity_filter = severity_filter,
)
items = [
AdminReportResponse(
id = report.id,
created_at = report.created_at,
updated_at = report.updated_at,
program_id = report.program_id,
program_name = program.name,
program_slug = program.slug,
researcher_id = report.researcher_id,
researcher_email = researcher.email,
researcher_name = researcher.full_name,
title = report.title,
severity_submitted = report.severity_submitted,
severity_final = report.severity_final,
status = report.status,
bounty_amount = report.bounty_amount,
triaged_at = report.triaged_at,
resolved_at = report.resolved_at,
) for report, program, researcher in results
]
return AdminReportListResponse(
items = items,
total = total,
page = page,
size = size,
)
async def update_report(
self,
report_id: UUID,
data: AdminReportUpdate,
) -> AdminReportResponse:
"""
Update a report (admin override)
"""
report = await AdminRepository.get_report_by_id(
self.session,
report_id
)
if not report:
raise ResourceNotFound("Report", str(report_id))
update_data = data.model_dump(
exclude_unset = True,
exclude = {"admin_notes"}
)
for key, value in update_data.items():
setattr(report, key, value)
await self.session.flush()
await self.session.refresh(report)
results = await AdminRepository.get_all_reports(
self.session,
skip = 0,
limit = 1000
)
for r, program, researcher in results:
if r.id == report_id:
return AdminReportResponse(
id = report.id,
created_at = report.created_at,
updated_at = report.updated_at,
program_id = report.program_id,
program_name = program.name,
program_slug = program.slug,
researcher_id = report.researcher_id,
researcher_email = researcher.email,
researcher_name = researcher.full_name,
title = report.title,
severity_submitted = report.severity_submitted,
severity_final = report.severity_final,
status = report.status,
bounty_amount = report.bounty_amount,
triaged_at = report.triaged_at,
resolved_at = report.resolved_at,
)
raise ResourceNotFound("Report", str(report_id))
async def list_users_with_stats(
self,
page: int,
size: int,
role_filter: UserRole | None = None,
) -> AdminUserListResponse:
"""
List all users with program/report counts
"""
skip = (page - 1) * size
results = await AdminRepository.get_all_users_with_stats(
self.session,
skip = skip,
limit = size,
role_filter = role_filter,
)
total = await AdminRepository.count_all_users(
self.session,
role_filter = role_filter,
)
items = [
AdminUserResponse(
id = user.id,
created_at = user.created_at,
updated_at = user.updated_at,
email = user.email,
full_name = user.full_name,
company_name = user.company_name,
is_active = user.is_active,
is_verified = user.is_verified,
role = user.role,
reputation_score = user.reputation_score,
program_count = program_count,
report_count = report_count,
) for user, program_count, report_count in results
]
return AdminUserListResponse(
items = items,
total = total,
page = page,
size = size,
)
async def get_platform_stats(self) -> PlatformStatsResponse:
"""
Get platform-wide statistics
"""
stats = await AdminRepository.get_platform_stats(self.session)
return PlatformStatsResponse(**stats)

View File

@ -0,0 +1,117 @@
"""
AngelaMos | 2025
RefreshToken.py
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
import uuid6
from sqlalchemy import (
DateTime,
ForeignKey,
String,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
DEVICE_ID_MAX_LENGTH,
DEVICE_NAME_MAX_LENGTH,
IP_ADDRESS_MAX_LENGTH,
TOKEN_HASH_LENGTH,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from user.User import User
class RefreshToken(Base, UUIDMixin, TimestampMixin):
"""
Refresh token for JWT authentication
Tokens are stored as SHA 256 hashes, never raw
Family ID enables detection of token reuse attacks
"""
__tablename__ = "refresh_tokens"
token_hash: Mapped[str] = mapped_column(
String(TOKEN_HASH_LENGTH),
unique = True,
index = True,
)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id",
ondelete = "CASCADE"),
index = True,
)
family_id: Mapped[UUID] = mapped_column(
default = uuid6.uuid7,
index = True,
)
device_id: Mapped[str | None] = mapped_column(
String(DEVICE_ID_MAX_LENGTH),
default = None,
)
device_name: Mapped[str | None] = mapped_column(
String(DEVICE_NAME_MAX_LENGTH),
default = None,
)
ip_address: Mapped[str | None] = mapped_column(
String(IP_ADDRESS_MAX_LENGTH),
default = None,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone = True),
index = True,
)
is_revoked: Mapped[bool] = mapped_column(default = False)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
user: Mapped[User] = relationship(back_populates = "refresh_tokens")
def revoke(self) -> None:
"""
Revoke this token
"""
self.is_revoked = True
self.revoked_at = datetime.now(UTC)
@property
def is_expired(self) -> bool:
"""
Check if token has expired
Handles both timezone aware and naive datetimes for SQLite compatibility
"""
now = datetime.now(UTC)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo = UTC)
return now > expires
@property
def is_valid(self) -> bool:
"""
Check if token is usable
"""
return not self.is_revoked and not self.is_expired

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
Auth Domain
"""

View File

@ -0,0 +1,21 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import AuthService
def get_auth_service(db: DBSession) -> AuthService:
"""
Dependency to inject AuthService instance
"""
return AuthService(db)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]

View File

@ -0,0 +1,178 @@
"""
AngelaMos | 2025
repository.py
"""
from uuid import UUID
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .RefreshToken import RefreshToken
from core.base_repository import BaseRepository
class RefreshTokenRepository(BaseRepository[RefreshToken]):
"""
Repository for RefreshToken model database operations
"""
model = RefreshToken
@classmethod
async def get_by_hash(
cls,
session: AsyncSession,
token_hash: str,
) -> RefreshToken | None:
"""
Get refresh token by its hash
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash
)
)
return result.scalars().first()
@classmethod
async def get_valid_by_hash(
cls,
session: AsyncSession,
token_hash: str,
) -> RefreshToken | None:
"""
Get valid (not revoked, not expired) refresh token by hash
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(UTC),
)
)
return result.scalars().first()
@classmethod
async def create_token(
cls,
session: AsyncSession,
user_id: UUID,
token_hash: str,
family_id: UUID,
expires_at: datetime,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> RefreshToken:
"""
Create a new refresh token
"""
token = RefreshToken(
user_id = user_id,
token_hash = token_hash,
family_id = family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
session.add(token)
await session.flush()
await session.refresh(token)
return token
@classmethod
async def revoke_token(
cls,
session: AsyncSession,
token: RefreshToken,
) -> RefreshToken:
"""
Revoke a single token
"""
token.revoke()
await session.flush()
await session.refresh(token)
return token
@classmethod
async def revoke_family(
cls,
session: AsyncSession,
family_id: UUID,
) -> int:
"""
Revoke all tokens in a family (for replay attack response)
Returns count of revoked tokens
"""
result = await session.execute(
update(RefreshToken).where(
RefreshToken.family_id == family_id,
RefreshToken.is_revoked == False,
).values(is_revoked = True,
revoked_at = datetime.now(UTC))
)
await session.flush()
return result.rowcount or 0
@classmethod
async def revoke_all_user_tokens(
cls,
session: AsyncSession,
user_id: UUID,
) -> int:
"""
Revoke all tokens for a user (logout all devices)
Returns count of revoked tokens
"""
result = await session.execute(
update(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
).values(is_revoked = True,
revoked_at = datetime.now(UTC))
)
await session.flush()
return result.rowcount or 0
@classmethod
async def get_user_active_sessions(
cls,
session: AsyncSession,
user_id: UUID,
) -> list[RefreshToken]:
"""
Get all active sessions for a user
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(UTC),
)
)
return list(result.scalars().all())
@classmethod
async def cleanup_expired(
cls,
session: AsyncSession,
) -> int:
"""
Delete expired tokens (for maintenance job)
Returns count of deleted tokens
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.expires_at < datetime.now(UTC)
)
)
tokens = result.scalars().all()
for token in tokens:
await session.delete(token)
await session.flush()
return len(tokens)

View File

@ -0,0 +1,154 @@
"""
AngelaMos | 2025
routes.py
"""
from typing import Annotated
from fastapi import (
APIRouter,
Cookie,
Depends,
Request,
Response,
status,
)
from fastapi.security import (
OAuth2PasswordRequestForm,
)
from config import settings
from core.dependencies import (
ClientIP,
CurrentUser,
)
from core.security import (
clear_refresh_cookie,
set_refresh_cookie,
)
from core.rate_limit import limiter
from core.exceptions import TokenError
from .schemas import (
PasswordChange,
TokenResponse,
TokenWithUserResponse,
)
from user.schemas import UserResponse
from .dependencies import AuthServiceDep
from user.dependencies import UserServiceDep
from core.responses import AUTH_401
router = APIRouter(prefix = "/auth", tags = ["auth"])
@router.post(
"/login",
response_model = TokenWithUserResponse,
responses = {**AUTH_401}
)
@limiter.limit(settings.RATE_LIMIT_AUTH)
async def login(
request: Request,
response: Response,
auth_service: AuthServiceDep,
ip: ClientIP,
form_data: Annotated[OAuth2PasswordRequestForm,
Depends()],
) -> TokenWithUserResponse:
"""
Login with email and password
"""
result, refresh_token = await auth_service.login(
email=form_data.username,
password=form_data.password,
ip_address=ip,
)
set_refresh_cookie(response, refresh_token)
return result
@router.post(
"/refresh",
response_model = TokenResponse,
responses = {**AUTH_401}
)
async def refresh_token(
response: Response,
auth_service: AuthServiceDep,
ip: ClientIP,
refresh_token: str | None = Cookie(None),
) -> TokenResponse:
"""
Refresh access token
"""
if not refresh_token:
raise TokenError("Refresh token required")
result, new_refresh_token = await auth_service.refresh_tokens(
refresh_token,
ip_address = ip
)
set_refresh_cookie(response, new_refresh_token)
return result
@router.post(
"/logout",
status_code = status.HTTP_204_NO_CONTENT,
responses = {**AUTH_401}
)
async def logout(
response: Response,
auth_service: AuthServiceDep,
refresh_token: str | None = Cookie(None),
) -> None:
"""
Logout current session
"""
if not refresh_token:
raise TokenError("Refresh token required")
await auth_service.logout(refresh_token)
clear_refresh_cookie(response)
@router.post("/logout-all", responses = {**AUTH_401})
async def logout_all(
response: Response,
auth_service: AuthServiceDep,
current_user: CurrentUser,
) -> dict[str,
int]:
"""
Logout from all devices
"""
count = await auth_service.logout_all(current_user)
clear_refresh_cookie(response)
return {"revoked_sessions": count}
@router.get("/me", response_model = UserResponse, responses = {**AUTH_401})
async def get_current_user(current_user: CurrentUser) -> UserResponse:
"""
Get current authenticated user
"""
return UserResponse.model_validate(current_user)
@router.post(
"/change-password",
status_code = status.HTTP_204_NO_CONTENT,
responses = {**AUTH_401}
)
async def change_password(
user_service: UserServiceDep,
current_user: CurrentUser,
data: PasswordChange,
) -> None:
"""
Change current user password
"""
await user_service.change_password(
current_user,
data.current_password,
data.new_password,
)

View File

@ -0,0 +1,78 @@
"""
AngelaMos | 2025
schemas.py
"""
from pydantic import (
Field,
EmailStr,
)
from config import (
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
)
from core.base_schema import BaseSchema
from user.schemas import UserResponse
class LoginRequest(BaseSchema):
"""
Schema for login request
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
class TokenResponse(BaseSchema):
"""
Schema for token response
"""
access_token: str
token_type: str = "bearer"
class TokenWithUserResponse(TokenResponse):
"""
Schema for login response with user data
"""
user: UserResponse
class RefreshTokenRequest(BaseSchema):
"""
Schema for refresh token request via body
"""
refresh_token: str
class PasswordResetRequest(BaseSchema):
"""
Schema for password reset request
"""
email: EmailStr
class PasswordResetConfirm(BaseSchema):
"""
Schema for password reset confirmation
"""
token: str
new_password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
class PasswordChange(BaseSchema):
"""
Schema for changing password while authenticated
"""
current_password: str
new_password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)

View File

@ -0,0 +1,213 @@
"""
AngelaMos | 2025
service.py
"""
import uuid6
from sqlalchemy.ext.asyncio import (
AsyncSession,
)
from core.exceptions import (
InvalidCredentials,
TokenError,
TokenRevokedError,
)
from core.security import (
hash_token,
create_access_token,
create_refresh_token,
verify_password_with_timing_safety,
)
from user.User import User
from user.repository import UserRepository
from .repository import RefreshTokenRepository
from .schemas import (
TokenResponse,
TokenWithUserResponse,
)
from user.schemas import UserResponse
class AuthService:
"""
Business logic for authentication operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def authenticate(
self,
email: str,
password: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> tuple[str,
str,
User]:
"""
Authenticate user and create tokens
"""
user = await UserRepository.get_by_email(self.session, email)
hashed_password = user.hashed_password if user else None
is_valid, new_hash = await verify_password_with_timing_safety(
password, hashed_password
)
if not is_valid or user is None:
raise InvalidCredentials()
if not user.is_active:
raise InvalidCredentials()
if new_hash:
await UserRepository.update_password(
self.session,
user,
new_hash
)
access_token = create_access_token(user.id, user.token_version)
family_id = uuid6.uuid7()
raw_refresh, token_hash, expires_at = create_refresh_token(user.id, family_id)
await RefreshTokenRepository.create_token(
self.session,
user_id = user.id,
token_hash = token_hash,
family_id = family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
return access_token, raw_refresh, user
async def login(
self,
email: str,
password: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> tuple[TokenWithUserResponse,
str]:
"""
Login and return tokens with user data
"""
access_token, refresh_token, user = await self.authenticate(
email,
password,
device_id,
device_name,
ip_address,
)
response = TokenWithUserResponse(
access_token = access_token,
user = UserResponse.model_validate(user),
)
return response, refresh_token
async def refresh_tokens(
self,
refresh_token: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> tuple[TokenResponse,
str]:
"""
Refresh access token using refresh token
Implements token rotation with replay attack detection
"""
token_hash = hash_token(refresh_token)
stored_token = await RefreshTokenRepository.get_by_hash(
self.session,
token_hash
)
if stored_token is None:
raise TokenError(message = "Invalid refresh token")
if stored_token.is_revoked:
await RefreshTokenRepository.revoke_family(
self.session,
stored_token.family_id
)
raise TokenRevokedError()
if stored_token.is_expired:
raise TokenError(message = "Refresh token expired")
user = await UserRepository.get_by_id(
self.session,
stored_token.user_id
)
if user is None or not user.is_active:
raise TokenError(message = "User not found or inactive")
await RefreshTokenRepository.revoke_token(
self.session,
stored_token
)
access_token = create_access_token(user.id, user.token_version)
new_raw_token, new_hash, expires_at = create_refresh_token(
user.id, stored_token.family_id
)
await RefreshTokenRepository.create_token(
self.session,
user_id = user.id,
token_hash = new_hash,
family_id = stored_token.family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
return TokenResponse(access_token = access_token), new_raw_token
async def logout(
self,
refresh_token: str,
) -> None:
"""
Logout by revoking refresh token
Silently succeeds if token is already revoked or doesn't exist
"""
token_hash = hash_token(refresh_token)
stored_token = await RefreshTokenRepository.get_by_hash(
self.session,
token_hash
)
if stored_token and not stored_token.is_revoked:
await RefreshTokenRepository.revoke_token(
self.session,
stored_token
)
async def logout_all(
self,
user: User,
) -> int:
"""
Logout from all devices
Returns count of revoked sessions
"""
await UserRepository.increment_token_version(self.session, user)
return await RefreshTokenRepository.revoke_all_user_tokens(
self.session,
user.id
)

View File

@ -0,0 +1,213 @@
"""
AngelaMos | 2026
config.py
"""
from pathlib import Path
from typing import Literal
from functools import lru_cache
from pydantic import (
EmailStr,
Field,
RedisDsn,
SecretStr,
PostgresDsn,
model_validator,
)
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
from core.constants import (
API_PREFIX,
API_VERSION,
ASSET_DESCRIPTION_MAX_LENGTH,
ASSET_IDENTIFIER_MAX_LENGTH,
BIO_MAX_LENGTH,
COMMENT_MAX_LENGTH,
COMPANY_NAME_MAX_LENGTH,
CURRENCY_MAX_LENGTH,
CWE_ID_MAX_LENGTH,
DEVICE_ID_MAX_LENGTH,
DEVICE_NAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
FILENAME_MAX_LENGTH,
FULL_NAME_MAX_LENGTH,
IP_ADDRESS_MAX_LENGTH,
MIME_TYPE_MAX_LENGTH,
PASSWORD_HASH_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PROGRAM_DESCRIPTION_MAX_LENGTH,
PROGRAM_NAME_MAX_LENGTH,
PROGRAM_RULES_MAX_LENGTH,
PROGRAM_SLUG_MAX_LENGTH,
REPORT_DESCRIPTION_MAX_LENGTH,
REPORT_IMPACT_MAX_LENGTH,
REPORT_STEPS_MAX_LENGTH,
REPORT_TITLE_MAX_LENGTH,
STORAGE_PATH_MAX_LENGTH,
TOKEN_HASH_LENGTH,
WEBSITE_MAX_LENGTH,
)
from core.enums import (
AssetType,
Environment,
HealthStatus,
ProgramStatus,
ProgramVisibility,
ReportStatus,
SafeEnum,
Severity,
TokenType,
UserRole,
)
__all__ = [
"API_PREFIX",
"API_VERSION",
"ASSET_DESCRIPTION_MAX_LENGTH",
"ASSET_IDENTIFIER_MAX_LENGTH",
"BIO_MAX_LENGTH",
"COMMENT_MAX_LENGTH",
"COMPANY_NAME_MAX_LENGTH",
"CURRENCY_MAX_LENGTH",
"CWE_ID_MAX_LENGTH",
"DEVICE_ID_MAX_LENGTH",
"DEVICE_NAME_MAX_LENGTH",
"EMAIL_MAX_LENGTH",
"FILENAME_MAX_LENGTH",
"FULL_NAME_MAX_LENGTH",
"IP_ADDRESS_MAX_LENGTH",
"MIME_TYPE_MAX_LENGTH",
"PASSWORD_HASH_MAX_LENGTH",
"PASSWORD_MAX_LENGTH",
"PASSWORD_MIN_LENGTH",
"PROGRAM_DESCRIPTION_MAX_LENGTH",
"PROGRAM_NAME_MAX_LENGTH",
"PROGRAM_RULES_MAX_LENGTH",
"PROGRAM_SLUG_MAX_LENGTH",
"REPORT_DESCRIPTION_MAX_LENGTH",
"REPORT_IMPACT_MAX_LENGTH",
"REPORT_STEPS_MAX_LENGTH",
"REPORT_TITLE_MAX_LENGTH",
"STORAGE_PATH_MAX_LENGTH",
"TOKEN_HASH_LENGTH",
"WEBSITE_MAX_LENGTH",
"AssetType",
"Environment",
"HealthStatus",
"ProgramStatus",
"ProgramVisibility",
"ReportStatus",
"SafeEnum",
"Settings",
"Severity",
"TokenType",
"UserRole",
"get_settings",
"settings",
]
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ENV_FILE = _PROJECT_ROOT / ".env"
class Settings(BaseSettings):
"""
Application settings loaded from environment variables
"""
model_config = SettingsConfigDict(
env_file = _ENV_FILE,
env_file_encoding = "utf-8",
case_sensitive = False,
extra = "ignore",
)
APP_NAME: str = "bug-bounty-platform"
APP_VERSION: str = "1.0.0"
APP_SUMMARY: str = "Developed CarterPerez-dev"
APP_DESCRIPTION: str = "FastAPI async first boilerplate - JWT, Asyncdb, PostgreSQL"
APP_CONTACT_NAME: str = "AngelaMos LLC"
APP_CONTACT_EMAIL: str = "support@certgames.com"
APP_LICENSE_NAME: str = "MIT"
APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/blob/main/LICENSE"
ENVIRONMENT: Environment = Environment.DEVELOPMENT
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8000
RELOAD: bool = True
DATABASE_URL: PostgresDsn
DB_POOL_SIZE: int = Field(default = 20, ge = 5, le = 100)
DB_MAX_OVERFLOW: int = Field(default = 10, ge = 0, le = 50)
DB_POOL_TIMEOUT: int = Field(default = 30, ge = 10)
DB_POOL_RECYCLE: int = Field(default = 1800, ge = 300)
SECRET_KEY: SecretStr = Field(..., min_length = 32)
JWT_ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30)
ADMIN_EMAIL: EmailStr | None = None
REDIS_URL: RedisDsn | None = None
CORS_ORIGINS: list[str] = [
"http://localhost",
"http://localhost:3420",
"http://localhost:8420",
]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: list[str] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS"
]
CORS_ALLOW_HEADERS: list[str] = ["*"]
RATE_LIMIT_DEFAULT: str = "100/minute"
RATE_LIMIT_AUTH: str = "20/minute"
PAGINATION_DEFAULT_SIZE: int = Field(default = 20, ge = 1, le = 100)
PAGINATION_MAX_SIZE: int = Field(default = 100, ge = 1, le = 500)
LOG_LEVEL: Literal["DEBUG",
"INFO",
"WARNING",
"ERROR",
"CRITICAL"] = "INFO"
LOG_JSON_FORMAT: bool = True
@model_validator(mode = "after")
def validate_production_settings(self) -> "Settings":
"""
Enforce security constraints in production environment.
"""
if self.ENVIRONMENT == Environment.PRODUCTION:
if self.DEBUG:
raise ValueError("DEBUG must be False in production")
if self.CORS_ORIGINS == ["*"]:
raise ValueError(
"CORS_ORIGINS cannot be ['*'] in production"
)
return self
@lru_cache
def get_settings() -> Settings:
"""
Cached settings instance to avoid repeated env parsing
"""
return Settings()
settings = get_settings()

View File

@ -0,0 +1,84 @@
"""
AngelaMos | 2025
Base.py
"""
from uuid import UUID
from datetime import UTC, datetime
import uuid6
from sqlalchemy.orm import (
Mapped,
mapped_column,
DeclarativeBase,
)
from sqlalchemy import (
DateTime,
MetaData,
func,
)
from sqlalchemy.ext.asyncio import AsyncAttrs
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(AsyncAttrs, DeclarativeBase):
"""
Base class for all SQLAlchemy models
"""
metadata = MetaData(naming_convention = NAMING_CONVENTION)
class UUIDMixin:
"""
Mixin for UUID v7 primary key
UUID v7 is time sortable and distributed safe, optimal for B tree indexes
"""
id: Mapped[UUID] = mapped_column(
primary_key = True,
default = uuid6.uuid7,
)
class TimestampMixin:
"""
Mixin for created_at and updated_at timestamps
"""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone = True),
default = lambda: datetime.now(UTC),
server_default = func.now(),
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
onupdate = lambda: datetime.now(UTC),
server_onupdate = func.now(),
)
class SoftDeleteMixin:
"""
Mixin for soft delete functionality
"""
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
is_deleted: Mapped[bool] = mapped_column(default = False)
def soft_delete(self) -> None:
self.is_deleted = True
self.deleted_at = datetime.now(UTC)
def restore(self) -> None:
self.is_deleted = False
self.deleted_at = None

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -0,0 +1,106 @@
"""
AngelaMos | 2025
base_repository.py
"""
from collections.abc import Sequence
from typing import (
Any,
Generic,
TypeVar,
)
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from .Base import Base
ModelT = TypeVar("ModelT", bound = Base)
class BaseRepository(Generic[ModelT]):
"""
Generic repository with common CRUD operations
"""
model: type[ModelT]
@classmethod
async def get_by_id(
cls,
session: AsyncSession,
id: UUID,
) -> ModelT | None:
"""
Get a single record by ID
"""
return await session.get(cls.model, id)
@classmethod
async def get_multi(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 100,
) -> Sequence[ModelT]:
"""
Get multiple records with pagination
"""
result = await session.execute(
select(cls.model).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count(cls, session: AsyncSession) -> int:
"""
Count total records
"""
result = await session.execute(
select(func.count()).select_from(cls.model)
)
return result.scalar_one()
@classmethod
async def create(
cls,
session: AsyncSession,
**kwargs: Any,
) -> ModelT:
"""
Create a new record
"""
instance = cls.model(**kwargs)
session.add(instance)
await session.flush()
await session.refresh(instance)
return instance
@classmethod
async def update(
cls,
session: AsyncSession,
instance: ModelT,
**kwargs: Any,
) -> ModelT:
"""
Update an existing record
"""
for key, value in kwargs.items():
setattr(instance, key, value)
await session.flush()
await session.refresh(instance)
return instance
@classmethod
async def delete(
cls,
session: AsyncSession,
instance: ModelT,
) -> None:
"""
Delete a record
"""
await session.delete(instance)
await session.flush()

View File

@ -0,0 +1,50 @@
"""
AngelaMos | 2025
base.py
"""
from typing import Any
from collections.abc import Callable
from decimal import Decimal
from uuid import UUID
from datetime import datetime
from pydantic import (
BaseModel,
ConfigDict,
field_serializer,
)
class BaseSchema(BaseModel):
"""
Base schema with common configuration
"""
model_config = ConfigDict(
from_attributes = True,
str_strip_whitespace = True,
)
@field_serializer('*', mode = 'wrap', when_used = 'json')
def serialize_decimals(
self,
value: Any,
nxt: Callable[[Any],
Any],
_info: Any,
) -> Any:
"""
Serialize Decimal fields as float for JSON compatibility
"""
if isinstance(value, Decimal):
return float(value)
return nxt(value)
class BaseResponseSchema(BaseSchema):
"""
Base schema for API responses with common fields
"""
id: UUID
created_at: datetime
updated_at: datetime | None = None

View File

@ -0,0 +1,34 @@
"""
AngelaMos | 2025
common_schemas.py
"""
from config import HealthStatus
from .base_schema import BaseSchema
class HealthResponse(BaseSchema):
"""
Health check response
"""
status: HealthStatus
environment: str
version: str
class HealthDetailedResponse(HealthResponse):
"""
Detailed health check with component status
"""
database: HealthStatus
redis: HealthStatus | None = None
class AppInfoResponse(BaseSchema):
"""
Root endpoint response with API information
"""
name: str
version: str
environment: str
docs_url: str | None

View File

@ -0,0 +1,44 @@
"""
AngelaMos | 2025
constants.py
"""
EMAIL_MAX_LENGTH = 320
PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128
PASSWORD_HASH_MAX_LENGTH = 1024
FULL_NAME_MAX_LENGTH = 255
TOKEN_HASH_LENGTH = 64
DEVICE_ID_MAX_LENGTH = 255
DEVICE_NAME_MAX_LENGTH = 100
IP_ADDRESS_MAX_LENGTH = 45
API_VERSION = "v1"
API_PREFIX = f"/{API_VERSION}"
COMPANY_NAME_MAX_LENGTH = 255
BIO_MAX_LENGTH = 2000
WEBSITE_MAX_LENGTH = 500
PROGRAM_NAME_MAX_LENGTH = 255
PROGRAM_SLUG_MAX_LENGTH = 100
PROGRAM_DESCRIPTION_MAX_LENGTH = 10000
PROGRAM_RULES_MAX_LENGTH = 50000
ASSET_IDENTIFIER_MAX_LENGTH = 500
ASSET_DESCRIPTION_MAX_LENGTH = 2000
CURRENCY_MAX_LENGTH = 3
REPORT_TITLE_MAX_LENGTH = 500
REPORT_DESCRIPTION_MAX_LENGTH = 50000
REPORT_STEPS_MAX_LENGTH = 50000
REPORT_IMPACT_MAX_LENGTH = 10000
CWE_ID_MAX_LENGTH = 20
COMMENT_MAX_LENGTH = 20000
FILENAME_MAX_LENGTH = 255
STORAGE_PATH_MAX_LENGTH = 500
MIME_TYPE_MAX_LENGTH = 100

View File

@ -0,0 +1,160 @@
"""
AngelaMos | 2025
database.py
"""
import contextlib
from collections.abc import (
AsyncIterator,
Iterator,
)
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
AsyncConnection,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import Session, sessionmaker
from config import settings
class DatabaseSessionManager:
"""
Manages database connections and sessions for both sync and async contexts
"""
def __init__(self) -> None:
self._async_engine: AsyncEngine | None = None
self._sync_engine: Engine | None = None
self._async_sessionmaker: async_sessionmaker[AsyncSession
] | None = None
self._sync_sessionmaker: sessionmaker[Session] | None = None
def init(self, database_url: str) -> None:
"""
Initialize database engines and session factories
"""
base_url = make_url(database_url)
async_url = base_url.set(drivername = "postgresql+asyncpg")
self._async_engine = create_async_engine(
async_url,
pool_size = settings.DB_POOL_SIZE,
max_overflow = settings.DB_MAX_OVERFLOW,
pool_timeout = settings.DB_POOL_TIMEOUT,
pool_recycle = settings.DB_POOL_RECYCLE,
pool_pre_ping = True,
echo = settings.DEBUG,
)
self._async_sessionmaker = async_sessionmaker(
bind = self._async_engine,
class_ = AsyncSession,
autocommit = False,
autoflush = False,
expire_on_commit = False,
)
sync_url = base_url.set(drivername = "postgresql+psycopg2")
self._sync_engine = create_engine(
sync_url,
pool_size = settings.DB_POOL_SIZE,
max_overflow = settings.DB_MAX_OVERFLOW,
pool_timeout = settings.DB_POOL_TIMEOUT,
pool_recycle = settings.DB_POOL_RECYCLE,
pool_pre_ping = True,
echo = settings.DEBUG,
)
self._sync_sessionmaker = sessionmaker(
bind = self._sync_engine,
autocommit = False,
autoflush = False,
expire_on_commit = False,
)
async def close(self) -> None:
"""
Dispose of all database connections
"""
if self._async_engine:
await self._async_engine.dispose()
self._async_engine = None
self._async_sessionmaker = None
if self._sync_engine:
self._sync_engine.dispose()
self._sync_engine = None
self._sync_sessionmaker = None
@contextlib.asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
"""
Async context manager for database sessions
Handles commit on success, rollback on exception
"""
if self._async_sessionmaker is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
session = self._async_sessionmaker()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@contextlib.asynccontextmanager
async def connect(self) -> AsyncIterator[AsyncConnection]:
"""
Async context manager for raw database connections
"""
if self._async_engine is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
async with self._async_engine.begin() as connection:
yield connection
@property
def sync_engine(self) -> Engine:
"""
Sync engine for Alembic migrations
"""
if self._sync_engine is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
return self._sync_engine
@contextlib.contextmanager
def sync_session(self) -> Iterator[Session]:
"""
Sync context manager for migrations and CLI tools
"""
if self._sync_sessionmaker is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
session = self._sync_sessionmaker()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
sessionmanager = DatabaseSessionManager()
async def get_db_session() -> AsyncIterator[AsyncSession]:
"""
FastAPI dependency for database sessions
"""
async with sessionmanager.session() as session:
yield session

View File

@ -0,0 +1,146 @@
"""
AngelaMos | 2025
dependencies.py
"""
from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
API_PREFIX,
TokenType,
UserRole,
)
from .database import get_db_session
from .exceptions import (
InactiveUser,
PermissionDenied,
TokenError,
TokenRevokedError,
UserNotFound,
)
from user.User import User
from .security import decode_access_token
from user.repository import UserRepository
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl = f"{API_PREFIX}/auth/login",
auto_error = True,
)
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl = f"{API_PREFIX}/auth/login",
auto_error = False,
)
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
async def get_current_user(
token: Annotated[str,
Depends(oauth2_scheme)],
db: DBSession,
) -> User:
"""
Validate access token and return current user
"""
try:
payload = decode_access_token(token)
except jwt.InvalidTokenError as e:
raise TokenError(message = str(e)) from e
if payload.get("type") != TokenType.ACCESS.value:
raise TokenError(message = "Invalid token type")
user_id = UUID(payload["sub"])
user = await UserRepository.get_by_id(db, user_id)
if user is None:
raise UserNotFound(identifier = str(user_id))
if payload.get("token_version") != user.token_version:
raise TokenRevokedError()
return user
async def get_current_active_user(
user: Annotated[User,
Depends(get_current_user)],
) -> User:
"""
Ensure user is active
"""
if not user.is_active:
raise InactiveUser()
return user
async def get_optional_user(
token: Annotated[str | None,
Depends(oauth2_scheme_optional)],
db: DBSession,
) -> User | None:
"""
Return current user if authenticated, None otherwise
"""
if token is None:
return None
try:
payload = decode_access_token(token)
if payload.get("type") != TokenType.ACCESS.value:
return None
user_id = UUID(payload["sub"])
user = await UserRepository.get_by_id(db, user_id)
if user and user.token_version == payload.get("token_version"):
return user
except (jwt.InvalidTokenError, ValueError):
pass
return None
class RequireRole:
"""
Dependency class to check user role
"""
def __init__(self, *allowed_roles: UserRole) -> None:
self.allowed_roles = allowed_roles
async def __call__(
self,
user: Annotated[User,
Depends(get_current_active_user)],
) -> User:
if user.role not in self.allowed_roles:
raise PermissionDenied(
message =
f"Requires one of roles: {', '.join(r.value for r in self.allowed_roles)}",
)
return user
CurrentUser = Annotated["User", Depends(get_current_active_user)]
OptionalUser = Annotated["User | None", Depends(get_optional_user)]
def get_client_ip(request: Request) -> str:
"""
Extract client IP considering proxy headers
"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
ClientIP = Annotated[str, Depends(get_client_ip)]

View File

@ -0,0 +1,139 @@
"""
AngelaMos | 2025
enums.py
"""
from enum import Enum
from typing import Any
import sqlalchemy as sa
def enum_values_callable(enum_class: type[Enum]) -> list[str]:
"""
Returns enum VALUES (not names) for SQLAlchemy storage
Prevents the common trap where SQLAlchemy stores enum NAMES by default,
causing database breakage if you rename an enum member
"""
return [str(item.value) for item in enum_class]
class SafeEnum(sa.Enum):
"""
SQLAlchemy Enum type that stores VALUES and handles unknown values gracefully
https://blog.wrouesnel.com/posts/sqlalchemy-enums-careful-what-goes-into-the-database/
"""
def __init__(self, *enums: type[Enum], **kw: Any) -> None:
if "values_callable" not in kw:
kw["values_callable"] = enum_values_callable
super().__init__(*enums, **kw)
self._unknown_value = (
kw["_adapted_from"]._unknown_value
if "_adapted_from" in kw else kw.get("unknown_value")
)
def _object_value_for_elem(self, elem: str) -> Enum:
"""
Override to return unknown_value instead of raising LookupError
"""
try:
return self._object_lookup[elem]
except LookupError:
if self._unknown_value is not None:
return self._unknown_value
raise
class Environment(str, Enum):
"""
Application environment.
"""
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
class UserRole(str, Enum):
"""
User roles for authorization.
"""
UNKNOWN = "unknown"
USER = "user"
COMPANY = "company"
ADMIN = "admin"
class TokenType(str, Enum):
"""
JWT token types.
"""
ACCESS = "access"
REFRESH = "refresh"
class HealthStatus(str, Enum):
"""
Health check status values.
"""
HEALTHY = "healthy"
UNHEALTHY = "unhealthy"
DEGRADED = "degraded"
class ProgramStatus(str, Enum):
"""
Bug bounty program lifecycle status.
"""
DRAFT = "draft"
ACTIVE = "active"
PAUSED = "paused"
CLOSED = "closed"
class ProgramVisibility(str, Enum):
"""
Bug bounty program visibility level.
"""
PUBLIC = "public"
PRIVATE = "private"
INVITE_ONLY = "invite_only"
class AssetType(str, Enum):
"""
Type of asset in a bug bounty program scope.
"""
DOMAIN = "domain"
API = "api"
MOBILE_APP = "mobile_app"
SOURCE_CODE = "source_code"
HARDWARE = "hardware"
OTHER = "other"
class Severity(str, Enum):
"""
Vulnerability severity levels aligned with CVSS.
"""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFORMATIONAL = "informational"
class ReportStatus(str, Enum):
"""
Vulnerability report lifecycle status.
"""
NEW = "new"
TRIAGING = "triaging"
NEEDS_MORE_INFO = "needs_more_info"
ACCEPTED = "accepted"
DUPLICATE = "duplicate"
INFORMATIVE = "informative"
NOT_APPLICABLE = "not_applicable"
RESOLVED = "resolved"
DISCLOSED = "disclosed"

View File

@ -0,0 +1,27 @@
"""
AngelaMos | 2025
errors.py
"""
from typing import ClassVar
from pydantic import Field, ConfigDict
from core.base_schema import BaseSchema
class ErrorDetail(BaseSchema):
"""
Standard error response format
"""
detail: str = Field(..., description = "Human readable error message")
type: str = Field(..., description = "Exception class name")
model_config: ClassVar[ConfigDict] = ConfigDict(
json_schema_extra = {
"examples": [
{
"detail": "User with id '123' not found",
"type": "UserNotFound"
}
]
}
)

View File

@ -0,0 +1,340 @@
"""
AngelaMos | 2025
exceptions.py
"""
from typing import Any
class BaseAppException(Exception):
"""
Base exception for all application specific errors
"""
def __init__(
self,
message: str,
status_code: int = 500,
extra: dict[str,
Any] | None = None,
) -> None:
self.message = message
self.status_code = status_code
self.extra = extra or {}
super().__init__(self.message)
class ResourceNotFound(BaseAppException):
"""
Raised when a requested resource does not exist
"""
def __init__(
self,
resource: str,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = f"{resource} with id '{identifier}' not found",
status_code = 404,
extra = extra,
)
self.resource = resource
self.identifier = identifier
class ConflictError(BaseAppException):
"""
Raised when an operation conflicts with existing state
"""
def __init__(
self,
message: str,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 409,
extra = extra
)
class ValidationError(BaseAppException):
"""
Raised when input validation fails outside of Pydantic
"""
def __init__(
self,
message: str,
field: str | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 422,
extra = extra
)
self.field = field
class AuthenticationError(BaseAppException):
"""
Raised when authentication fails
"""
def __init__(
self,
message: str = "Authentication failed",
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 401,
extra = extra
)
class TokenError(AuthenticationError):
"""
Raised for JWT token specific errors
"""
def __init__(
self,
message: str = "Invalid or expired token",
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(message = message, extra = extra)
class TokenRevokedError(TokenError):
"""
Raised when a revoked token is used
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(message = "Token has been revoked", extra = extra)
class PermissionDenied(BaseAppException):
"""
Raised when user lacks required permissions
"""
def __init__(
self,
message: str = "Permission denied",
required_permission: str | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 403,
extra = extra
)
self.required_permission = required_permission
class RateLimitExceeded(BaseAppException):
"""
Raised when rate limit is exceeded
"""
def __init__(
self,
message: str = "Calm down a little bit...",
retry_after: int | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 420,
extra = extra
)
self.retry_after = retry_after
class UserNotFound(ResourceNotFound):
"""
Raised when a user is not found
"""
def __init__(
self,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
resource = "User",
identifier = identifier,
extra = extra
)
class EmailAlreadyExists(ConflictError):
"""
Raised when attempting to register with an existing email
"""
def __init__(
self,
email: str,
extra: dict[str,
Any] | None = None
) -> None:
super().__init__(
message = f"Email '{email}' is already registered",
extra = extra,
)
self.email = email
class InvalidCredentials(AuthenticationError):
"""
Raised when login credentials are invalid
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "Invalid email or password",
extra = extra
)
class InactiveUser(AuthenticationError):
"""
Raised when an inactive user attempts to authenticate.
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "User account is inactive",
extra = extra
)
class ProgramNotFound(ResourceNotFound):
"""
Raised when a program is not found
"""
def __init__(
self,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
resource = "Program",
identifier = identifier,
extra = extra
)
class SlugAlreadyExists(ConflictError):
"""
Raised when attempting to create a program with existing slug
"""
def __init__(
self,
slug: str,
extra: dict[str,
Any] | None = None
) -> None:
super().__init__(
message = f"Program with slug '{slug}' already exists",
extra = extra,
)
self.slug = slug
class AssetNotFound(ResourceNotFound):
"""
Raised when an asset is not found
"""
def __init__(
self,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
resource = "Asset",
identifier = identifier,
extra = extra
)
class NotProgramOwner(PermissionDenied):
"""
Raised when user tries to modify a program they don't own
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "You are not the owner of this program",
extra = extra
)
class ReportNotFound(ResourceNotFound):
"""
Raised when a report is not found
"""
def __init__(
self,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
resource = "Report",
identifier = identifier,
extra = extra
)
class NotReportOwner(PermissionDenied):
"""
Raised when user tries to modify a report they don't own
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "You are not the owner of this report",
extra = extra
)
class ProgramNotActive(ValidationError):
"""
Raised when trying to submit to an inactive program
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "This program is not accepting submissions",
extra = extra
)
class CannotSubmitToOwnProgram(ValidationError):
"""
Raised when user tries to submit a report to their own program
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "You cannot submit reports to your own program",
extra = extra
)
class InvalidStatusTransition(ValidationError):
"""
Raised when an invalid status transition is attempted
"""
def __init__(
self,
current: str,
target: str,
extra: dict[str,
Any] | None = None
) -> None:
super().__init__(
message = f"Cannot transition from '{current}' to '{target}'",
extra = extra
)

View File

@ -0,0 +1,79 @@
"""
AngelaMos | 2025
health_routes.py
"""
from fastapi import (
APIRouter,
status,
)
import redis.asyncio as redis
from sqlalchemy import text
from config import (
settings,
HealthStatus,
)
from .common_schemas import (
HealthResponse,
HealthDetailedResponse,
)
from .database import sessionmanager
router = APIRouter(tags = ["health"])
@router.get(
"/health",
response_model = HealthResponse,
status_code = status.HTTP_200_OK,
)
async def health_check() -> HealthResponse:
"""
Basic health check
"""
return HealthResponse(
status = HealthStatus.HEALTHY,
environment = settings.ENVIRONMENT.value,
version = settings.APP_VERSION,
)
@router.get(
"/health/detailed",
response_model = HealthDetailedResponse,
status_code = status.HTTP_200_OK,
)
async def health_check_detailed() -> HealthDetailedResponse:
"""
Detailed health check including database connectivity
"""
db_status = HealthStatus.UNHEALTHY
redis_status = None
try:
async with sessionmanager.connect() as conn:
await conn.execute(text("SELECT 1"))
db_status = HealthStatus.HEALTHY
except Exception:
db_status = HealthStatus.UNHEALTHY
if settings.REDIS_URL:
try:
r = redis.from_url(str(settings.REDIS_URL))
await r.ping()
redis_status = HealthStatus.HEALTHY
await r.close()
except Exception:
redis_status = HealthStatus.UNHEALTHY
overall = HealthStatus.HEALTHY if db_status == HealthStatus.HEALTHY else HealthStatus.DEGRADED
return HealthDetailedResponse(
status = overall,
environment = settings.ENVIRONMENT.value,
version = settings.APP_VERSION,
database = db_status,
redis = redis_status,
)

View File

@ -0,0 +1,79 @@
"""
AngelaMos | 2025
logging.py
"""
import logging
import sys
import structlog
from structlog.types import Processor
from config import (
settings,
Environment,
)
def configure_logging() -> None:
"""
Structlog with appropriate processors for the environment
"""
shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt = "iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if settings.ENVIRONMENT == Environment.PRODUCTION:
shared_processors.append(structlog.processors.format_exc_info)
renderer: Processor = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors = True)
structlog.configure(
processors = shared_processors + [
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory = structlog.stdlib.LoggerFactory(),
wrapper_class = structlog.stdlib.BoundLogger,
cache_logger_on_first_use = True,
)
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain = shared_processors,
processors = [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(settings.LOG_LEVEL)
for logger_name in ["uvicorn",
"uvicorn.access",
"uvicorn.error",
"sqlalchemy.engine"]:
logger = logging.getLogger(logger_name)
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
if settings.ENVIRONMENT != Environment.DEVELOPMENT:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""
Get a structured logger instance
"""
return structlog.get_logger(name)

View File

@ -0,0 +1,44 @@
"""
AngelaMos | 2025
rate_limit.py
"""
import jwt
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request
from config import settings
def get_identifier(request: Request) -> str:
"""
Get rate limit identifier
Uses user ID if authenticated, otherwise falls back to IP address
(Will add more fingerprinting if needed depending on project)
"""
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
token = auth_header.split(" ")[1]
payload = jwt.decode(
token,
options = {"verify_signature": False},
)
user_id = payload.get("sub")
if user_id:
return f"user:{user_id}"
except Exception:
pass
return get_remote_address(request)
limiter = Limiter(
key_func = get_identifier,
storage_uri = str(settings.REDIS_URL) if settings.REDIS_URL else None,
default_limits = [settings.RATE_LIMIT_DEFAULT],
headers_enabled = True,
in_memory_fallback_enabled = True,
)

View File

@ -0,0 +1,45 @@
"""
AngelaMos | 2025
responses.py
"""
from typing import Any
from .error_schemas import ErrorDetail
AUTH_401: dict[int | str,
dict[str,
Any]] = {
401: {
"model": ErrorDetail,
"description": "Authentication failed"
},
}
FORBIDDEN_403: dict[int | str,
dict[str,
Any]] = {
403: {
"model": ErrorDetail,
"description": "Permission denied"
},
}
NOT_FOUND_404: dict[int | str,
dict[str,
Any]] = {
404: {
"model": ErrorDetail,
"description": "Resource not found"
},
}
CONFLICT_409: dict[int | str,
dict[str,
Any]] = {
409: {
"model": ErrorDetail,
"description": "Resource conflict"
},
}

View File

@ -0,0 +1,190 @@
"""
AngelaMos | 2025
security.py
"""
import asyncio
import hashlib
import secrets
from datetime import (
UTC,
datetime,
timedelta,
)
from typing import Any
from uuid import UUID
import jwt
from fastapi import Response
from pwdlib import PasswordHash
from config import (
settings,
TokenType,
)
password_hasher = PasswordHash.recommended()
async def hash_password(password: str) -> str:
"""
Hash password using Argon2id
Runs in thread pool to avoid blocking the async event loop
since Argon2 is CPU intensive by design
"""
return await asyncio.to_thread(password_hasher.hash, password)
async def verify_password(plain_password: str,
hashed_password: str) -> tuple[bool,
str | None]:
"""
Verify password and check if rehash is needed
Returns:
Tuple of (is_valid, new_hash_if_needs_rehash)
If password is valid but hash params are outdated, returns new hash
"""
try:
return await asyncio.to_thread(
password_hasher.verify_and_update,
plain_password,
hashed_password
)
except Exception:
return False, None
DUMMY_HASH = password_hasher.hash(
"dummy_password_for_timing_attack_prevention"
)
async def verify_password_with_timing_safety(
plain_password: str,
hashed_password: str | None,
) -> tuple[bool,
str | None]:
"""
Verify password with constant time behavior to prevent user enumeration
If no hash is provided (user doesn't exist), still performs a dummy
hash operation to prevent timing attacks
"""
if hashed_password is None:
await asyncio.to_thread(
password_hasher.verify,
plain_password,
DUMMY_HASH
)
return False, None
return await verify_password(plain_password, hashed_password)
def create_access_token(
user_id: UUID,
token_version: int,
extra_claims: dict[str,
Any] | None = None,
) -> str:
"""
Create a short lived access token
"""
now = datetime.now(UTC)
payload = {
"sub": str(user_id),
"type": TokenType.ACCESS.value,
"token_version": token_version,
"iat": now,
"exp":
now + timedelta(minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES),
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(
payload,
settings.SECRET_KEY.get_secret_value(),
algorithm = settings.JWT_ALGORITHM,
)
def create_refresh_token(
user_id: UUID,
family_id: UUID,
) -> tuple[str,
str,
datetime]:
"""
Create a long lived refresh token
Returns:
Tuple of (raw_token, token_hash, expires_at)
Raw token is sent to client, hash is stored in database
"""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(UTC) + timedelta(
days = settings.REFRESH_TOKEN_EXPIRE_DAYS
)
return raw_token, token_hash, expires_at
def decode_access_token(token: str) -> dict[str, Any]:
"""
Decode and validate an access token
Raises:
jwt.InvalidTokenError: If token is invalid or expired
"""
return jwt.decode(
token,
settings.SECRET_KEY.get_secret_value(),
algorithms = [settings.JWT_ALGORITHM],
options = {
"require": ["exp",
"sub",
"iat",
"type",
"token_version"]
},
)
def hash_token(token: str) -> str:
"""
Hash a token for secure storage
"""
return hashlib.sha256(token.encode()).hexdigest()
def generate_secure_token(nbytes: int = 32) -> str:
"""
Generate a cryptographically secure random token
"""
return secrets.token_urlsafe(nbytes)
def set_refresh_cookie(response: Response, token: str) -> None:
"""
Set refresh token as HttpOnly cookie
"""
response.set_cookie(
key = "refresh_token",
value = token,
httponly = True,
secure = settings.ENVIRONMENT.value != "development",
samesite = "strict",
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path = "/",
)
def clear_refresh_cookie(response: Response) -> None:
"""
Clear refresh token cookie
"""
response.delete_cookie(key = "refresh_token", path = "/")

View File

@ -0,0 +1,145 @@
"""
AngelaMos | 2025
factory.py
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from config import settings, Environment, API_PREFIX
from core.database import sessionmanager
from core.exceptions import BaseAppException
from core.logging import configure_logging
from core.rate_limit import limiter
from middleware.correlation import CorrelationIdMiddleware
from core.common_schemas import AppInfoResponse
from core.health_routes import router as health_router
from user.routes import router as user_router
from auth.routes import router as auth_router
from admin.routes import router as admin_router
from program.routes import router as program_router
from report.routes import router as report_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""
Application lifespan handler for startup and shutdown
"""
configure_logging()
sessionmanager.init(str(settings.DATABASE_URL))
yield
await sessionmanager.close()
OPENAPI_TAGS = [
{
"name": "root",
"description": "API information"
},
{
"name": "health",
"description": "Health check endpoints"
},
{
"name": "auth",
"description": "Authentication and authorization"
},
{
"name": "users",
"description": "User registration and profile management"
},
{
"name": "programs",
"description": "Bug bounty program management"
},
{
"name": "reports",
"description": "Vulnerability report submission and triage"
},
{
"name": "admin",
"description": "Admin only operations"
},
]
def create_app() -> FastAPI:
"""
Application factory
"""
is_production = settings.ENVIRONMENT == Environment.PRODUCTION
app = FastAPI(
title = settings.APP_NAME,
summary = settings.APP_SUMMARY,
description = settings.APP_DESCRIPTION,
version = settings.APP_VERSION,
contact = {
"name": settings.APP_CONTACT_NAME,
"email": settings.APP_CONTACT_EMAIL,
},
license_info = {
"name": settings.APP_LICENSE_NAME,
"url": settings.APP_LICENSE_URL,
},
openapi_tags = OPENAPI_TAGS,
openapi_version = "3.1.0",
lifespan = lifespan,
root_path = "/api",
openapi_url = "/openapi.json",
docs_url = "/docs",
redoc_url = "/redoc",
)
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins = settings.CORS_ORIGINS,
allow_credentials = settings.CORS_ALLOW_CREDENTIALS,
allow_methods = settings.CORS_ALLOW_METHODS,
allow_headers = settings.CORS_ALLOW_HEADERS,
)
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler
)
@app.exception_handler(BaseAppException)
async def app_exception_handler(
request: Request,
exc: BaseAppException,
) -> JSONResponse:
return JSONResponse(
status_code = exc.status_code,
content = {
"detail": exc.message,
"type": exc.__class__.__name__,
},
)
@app.get("/", response_model = AppInfoResponse, tags = ["root"])
async def root() -> AppInfoResponse:
return AppInfoResponse(
name = settings.APP_NAME,
version = settings.APP_VERSION,
environment = settings.ENVIRONMENT.value,
docs_url = "/docs",
)
app.include_router(health_router)
app.include_router(admin_router, prefix = API_PREFIX)
app.include_router(auth_router, prefix = API_PREFIX)
app.include_router(user_router, prefix = API_PREFIX)
app.include_router(program_router, prefix = API_PREFIX)
app.include_router(report_router, prefix = API_PREFIX)
return app

View File

@ -0,0 +1,11 @@
"""
AngelaMos | 2025
__init__.py
"""
from .correlation import CorrelationIdMiddleware
__all__ = [
"CorrelationIdMiddleware",
]

View File

@ -0,0 +1,43 @@
"""
AngelaMos | 2025
correlation.py
"""
import uuid
from collections.abc import (
Awaitable,
Callable,
)
import structlog
from starlette.requests import Request
from starlette.responses import Response
from starlette.middleware.base import BaseHTTPMiddleware
RequestResponseEndpoint = Callable[[Request], Awaitable[Response]]
class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""
Correlation ID to requests for distributed tracing
"""
async def dispatch(
self,
request: Request,
call_next: RequestResponseEndpoint,
) -> Response:
correlation_id = request.headers.get(
"X-Correlation-ID",
str(uuid.uuid4())
)
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
correlation_id = correlation_id,
method = request.method,
path = request.url.path,
)
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response

View File

@ -0,0 +1,64 @@
"""
AngelaMos | 2025
Asset.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
ASSET_IDENTIFIER_MAX_LENGTH,
AssetType,
SafeEnum,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from program.Program import Program
class Asset(Base, UUIDMixin, TimestampMixin):
"""
Target asset within a bug bounty program scope
"""
__tablename__ = "assets"
program_id: Mapped[UUID] = mapped_column(
ForeignKey("programs.id",
ondelete = "CASCADE"),
index = True,
)
asset_type: Mapped[AssetType] = mapped_column(
SafeEnum(AssetType),
default = AssetType.DOMAIN,
)
identifier: Mapped[str] = mapped_column(
String(ASSET_IDENTIFIER_MAX_LENGTH),
)
in_scope: Mapped[bool] = mapped_column(default = True)
description: Mapped[str | None] = mapped_column(
Text,
default = None,
)
program: Mapped[Program] = relationship(
back_populates = "assets",
lazy = "raise",
)

View File

@ -0,0 +1,114 @@
"""
AngelaMos | 2025
Program.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
PROGRAM_NAME_MAX_LENGTH,
PROGRAM_SLUG_MAX_LENGTH,
ProgramStatus,
ProgramVisibility,
SafeEnum,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from program.Asset import Asset
from program.RewardTier import RewardTier
from report.Report import Report
from user.User import User
class Program(Base, UUIDMixin, TimestampMixin):
"""
Bug bounty program hosted by a company or individual
"""
__tablename__ = "programs"
company_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id",
ondelete = "CASCADE"),
index = True,
)
name: Mapped[str] = mapped_column(
String(PROGRAM_NAME_MAX_LENGTH),
)
slug: Mapped[str] = mapped_column(
String(PROGRAM_SLUG_MAX_LENGTH),
unique = True,
index = True,
)
description: Mapped[str | None] = mapped_column(
Text,
default = None,
)
rules: Mapped[str | None] = mapped_column(
Text,
default = None,
)
response_sla_hours: Mapped[int] = mapped_column(default = 72)
status: Mapped[ProgramStatus] = mapped_column(
SafeEnum(ProgramStatus),
default = ProgramStatus.DRAFT,
index = True,
)
visibility: Mapped[ProgramVisibility] = mapped_column(
SafeEnum(ProgramVisibility),
default = ProgramVisibility.PUBLIC,
)
company: Mapped[User] = relationship(
back_populates = "programs",
lazy = "raise",
)
assets: Mapped[list[Asset]] = relationship(
back_populates = "program",
cascade = "all, delete-orphan",
lazy = "raise",
)
reward_tiers: Mapped[list[RewardTier]] = relationship(
back_populates = "program",
cascade = "all, delete-orphan",
lazy = "raise",
)
reports: Mapped[list[Report]] = relationship(
back_populates = "program",
cascade = "all, delete-orphan",
lazy = "raise",
)
@property
def is_active(self) -> bool:
"""
Check if program is accepting submissions
"""
return self.status == ProgramStatus.ACTIVE
@property
def is_public(self) -> bool:
"""
Check if program is publicly visible
"""
return self.visibility == ProgramVisibility.PUBLIC

View File

@ -0,0 +1,60 @@
"""
AngelaMos | 2025
RewardTier.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
CURRENCY_MAX_LENGTH,
SafeEnum,
Severity,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from program.Program import Program
class RewardTier(Base, UUIDMixin, TimestampMixin):
"""
Bounty reward tier by severity for a program
"""
__tablename__ = "reward_tiers"
program_id: Mapped[UUID] = mapped_column(
ForeignKey("programs.id",
ondelete = "CASCADE"),
index = True,
)
severity: Mapped[Severity] = mapped_column(
SafeEnum(Severity),
)
min_bounty: Mapped[int] = mapped_column(default = 0)
max_bounty: Mapped[int] = mapped_column(default = 0)
currency: Mapped[str] = mapped_column(
String(CURRENCY_MAX_LENGTH),
default = "USD",
)
program: Mapped[Program] = relationship(
back_populates = "reward_tiers",
lazy = "raise",
)

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -0,0 +1,21 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import ProgramService
def get_program_service(db: DBSession) -> ProgramService:
"""
Dependency to inject ProgramService instance
"""
return ProgramService(db)
ProgramServiceDep = Annotated[ProgramService, Depends(get_program_service)]

View File

@ -0,0 +1,232 @@
"""
AngelaMos | 2025
repository.py
"""
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from config import ProgramStatus, ProgramVisibility
from core.base_repository import BaseRepository
from .Program import Program
from .Asset import Asset
from .RewardTier import RewardTier
class ProgramRepository(BaseRepository[Program]):
"""
Repository for Program model database operations
"""
model = Program
@classmethod
async def get_by_slug(
cls,
session: AsyncSession,
slug: str,
) -> Program | None:
"""
Get program by slug
"""
result = await session.execute(
select(Program).where(Program.slug == slug)
)
return result.scalars().first()
@classmethod
async def get_by_slug_with_details(
cls,
session: AsyncSession,
slug: str,
) -> Program | None:
"""
Get program by slug with assets and reward tiers
"""
result = await session.execute(
select(Program).where(Program.slug == slug).options(
selectinload(Program.assets),
selectinload(Program.reward_tiers),
)
)
return result.scalars().first()
@classmethod
async def get_by_id_with_details(
cls,
session: AsyncSession,
program_id: UUID,
) -> Program | None:
"""
Get program by ID with assets and reward tiers
"""
result = await session.execute(
select(Program).where(Program.id == program_id).options(
selectinload(Program.assets),
selectinload(Program.reward_tiers),
)
)
return result.scalars().first()
@classmethod
async def slug_exists(
cls,
session: AsyncSession,
slug: str,
) -> bool:
"""
Check if slug is already taken
"""
result = await session.execute(
select(Program.id).where(Program.slug == slug)
)
return result.scalars().first() is not None
@classmethod
async def get_public_programs(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 20,
) -> Sequence[Program]:
"""
Get active public programs
"""
result = await session.execute(
select(Program).where(
Program.status == ProgramStatus.ACTIVE,
Program.visibility == ProgramVisibility.PUBLIC,
).order_by(Program.created_at.desc()
).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count_public_programs(
cls,
session: AsyncSession,
) -> int:
"""
Count active public programs
"""
result = await session.execute(
select(func.count()).select_from(Program).where(
Program.status == ProgramStatus.ACTIVE,
Program.visibility == ProgramVisibility.PUBLIC,
)
)
return result.scalar_one()
@classmethod
async def get_by_company(
cls,
session: AsyncSession,
company_id: UUID,
skip: int = 0,
limit: int = 20,
) -> Sequence[Program]:
"""
Get programs by company/owner
"""
result = await session.execute(
select(Program).where(Program.company_id == company_id
).order_by(Program.created_at.desc()
).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count_by_company(
cls,
session: AsyncSession,
company_id: UUID,
) -> int:
"""
Count programs by company
"""
result = await session.execute(
select(func.count()).select_from(Program).where(
Program.company_id == company_id
)
)
return result.scalar_one()
class AssetRepository(BaseRepository[Asset]):
"""
Repository for Asset model database operations
"""
model = Asset
@classmethod
async def get_by_program(
cls,
session: AsyncSession,
program_id: UUID,
) -> Sequence[Asset]:
"""
Get all assets for a program
"""
result = await session.execute(
select(Asset).where(
Asset.program_id == program_id
).order_by(Asset.in_scope.desc(),
Asset.created_at)
)
return result.scalars().all()
class RewardTierRepository(BaseRepository[RewardTier]):
"""
Repository for RewardTier model database operations
"""
model = RewardTier
@classmethod
async def get_by_program(
cls,
session: AsyncSession,
program_id: UUID,
) -> Sequence[RewardTier]:
"""
Get all reward tiers for a program
"""
result = await session.execute(
select(RewardTier).where(RewardTier.program_id == program_id)
)
return result.scalars().all()
@classmethod
async def get_by_program_and_severity(
cls,
session: AsyncSession,
program_id: UUID,
severity: str,
) -> RewardTier | None:
"""
Get reward tier by program and severity
"""
result = await session.execute(
select(RewardTier).where(
RewardTier.program_id == program_id,
RewardTier.severity == severity,
)
)
return result.scalars().first()
@classmethod
async def delete_by_program(
cls,
session: AsyncSession,
program_id: UUID,
) -> None:
"""
Delete all reward tiers for a program
"""
tiers = await cls.get_by_program(session, program_id)
for tier in tiers:
await session.delete(tier)
await session.flush()

View File

@ -0,0 +1,279 @@
"""
AngelaMos | 2025
routes.py
"""
from uuid import UUID
from fastapi import APIRouter, Query, status
from core.dependencies import CurrentUser
from core.responses import (
AUTH_401,
CONFLICT_409,
FORBIDDEN_403,
NOT_FOUND_404,
)
from .schemas import (
AssetCreate,
AssetResponse,
AssetUpdate,
ProgramCreate,
ProgramDetailResponse,
ProgramListResponse,
ProgramResponse,
ProgramUpdate,
RewardTierCreate,
RewardTierResponse,
)
from .dependencies import ProgramServiceDep
router = APIRouter(prefix = "/programs", tags = ["programs"])
@router.post(
"",
response_model = ProgramResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**CONFLICT_409
},
)
async def create_program(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_data: ProgramCreate,
) -> ProgramResponse:
"""
Create a new bug bounty program
"""
return await program_service.create_program(current_user, program_data)
@router.get(
"",
response_model = ProgramListResponse,
)
async def list_programs(
program_service: ProgramServiceDep,
page: int = Query(default = 1,
ge = 1),
size: int = Query(default = 20,
ge = 1,
le = 100),
) -> ProgramListResponse:
"""
List active public programs
"""
return await program_service.list_public_programs(page, size)
@router.get(
"/mine",
response_model = ProgramListResponse,
responses = {**AUTH_401},
)
async def list_my_programs(
program_service: ProgramServiceDep,
current_user: CurrentUser,
page: int = Query(default = 1,
ge = 1),
size: int = Query(default = 20,
ge = 1,
le = 100),
) -> ProgramListResponse:
"""
List programs owned by current user
"""
return await program_service.list_my_programs(current_user, page, size)
@router.get(
"/{slug}",
response_model = ProgramDetailResponse,
responses = {**NOT_FOUND_404},
)
async def get_program(
program_service: ProgramServiceDep,
slug: str,
) -> ProgramDetailResponse:
"""
Get program by slug with full details
"""
return await program_service.get_program_by_slug(slug)
@router.patch(
"/{program_id}",
response_model = ProgramResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def update_program(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
program_data: ProgramUpdate,
) -> ProgramResponse:
"""
Update program details
"""
return await program_service.update_program(
current_user,
program_id,
program_data
)
@router.delete(
"/{program_id}",
status_code = status.HTTP_204_NO_CONTENT,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def delete_program(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
) -> None:
"""
Delete a program
"""
await program_service.delete_program(current_user, program_id)
@router.get(
"/{program_id}/assets",
response_model = list[AssetResponse],
responses = {**NOT_FOUND_404},
)
async def list_assets(
program_service: ProgramServiceDep,
program_id: UUID,
) -> list[AssetResponse]:
"""
List program assets (scope)
"""
return await program_service.list_assets(program_id)
@router.post(
"/{program_id}/assets",
response_model = AssetResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def add_asset(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
asset_data: AssetCreate,
) -> AssetResponse:
"""
Add asset to program scope
"""
return await program_service.add_asset(
current_user,
program_id,
asset_data
)
@router.patch(
"/{program_id}/assets/{asset_id}",
response_model = AssetResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def update_asset(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
asset_id: UUID,
asset_data: AssetUpdate,
) -> AssetResponse:
"""
Update an asset
"""
return await program_service.update_asset(
current_user,
program_id,
asset_id,
asset_data
)
@router.delete(
"/{program_id}/assets/{asset_id}",
status_code = status.HTTP_204_NO_CONTENT,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def delete_asset(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
asset_id: UUID,
) -> None:
"""
Delete an asset
"""
await program_service.delete_asset(current_user, program_id, asset_id)
@router.get(
"/{program_id}/rewards",
response_model = list[RewardTierResponse],
responses = {**NOT_FOUND_404},
)
async def list_reward_tiers(
program_service: ProgramServiceDep,
program_id: UUID,
) -> list[RewardTierResponse]:
"""
List program reward tiers
"""
return await program_service.list_reward_tiers(program_id)
@router.put(
"/{program_id}/rewards",
response_model = list[RewardTierResponse],
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def set_reward_tiers(
program_service: ProgramServiceDep,
current_user: CurrentUser,
program_id: UUID,
tiers: list[RewardTierCreate],
) -> list[RewardTierResponse]:
"""
Set reward tiers for program (replaces existing)
"""
return await program_service.set_reward_tiers(
current_user,
program_id,
tiers
)

View File

@ -0,0 +1,179 @@
"""
AngelaMos | 2025
schemas.py
"""
from uuid import UUID
from decimal import Decimal
from pydantic import Field, field_validator, ValidationInfo
from config import (
ASSET_IDENTIFIER_MAX_LENGTH,
AssetType,
PROGRAM_DESCRIPTION_MAX_LENGTH,
PROGRAM_NAME_MAX_LENGTH,
PROGRAM_RULES_MAX_LENGTH,
PROGRAM_SLUG_MAX_LENGTH,
ProgramStatus,
ProgramVisibility,
Severity,
)
from core.base_schema import (
BaseSchema,
BaseResponseSchema,
)
class RewardTierCreate(BaseSchema):
"""
Schema for creating a reward tier
"""
severity: Severity
min_bounty: int = Field(ge = 0, default = 0)
max_bounty: int = Field(ge = 0, default = 0)
currency: str = Field(default = "USD", max_length = 3)
@field_validator("max_bounty")
@classmethod
def max_gte_min(cls, v: int, info: ValidationInfo) -> int:
"""
Ensure max bounty is greater than or equal to min bounty
"""
if "min_bounty" in info.data and v < info.data["min_bounty"]:
raise ValueError("max_bounty must be >= min_bounty")
return v
class RewardTierResponse(BaseResponseSchema):
"""
Schema for reward tier API responses
"""
program_id: UUID
severity: Severity
min_bounty: int
max_bounty: int
currency: str
class AssetCreate(BaseSchema):
"""
Schema for creating an asset
"""
asset_type: AssetType = AssetType.DOMAIN
identifier: str = Field(max_length = ASSET_IDENTIFIER_MAX_LENGTH)
in_scope: bool = True
description: str | None = None
class AssetUpdate(BaseSchema):
"""
Schema for updating an asset
"""
asset_type: AssetType | None = None
identifier: str | None = Field(
default = None,
max_length = ASSET_IDENTIFIER_MAX_LENGTH
)
in_scope: bool | None = None
description: str | None = None
class AssetResponse(BaseResponseSchema):
"""
Schema for asset API responses
"""
program_id: UUID
asset_type: AssetType
identifier: str
in_scope: bool
description: str | None
class ProgramCreate(BaseSchema):
"""
Schema for creating a program
"""
name: str = Field(max_length = PROGRAM_NAME_MAX_LENGTH)
slug: str = Field(
max_length = PROGRAM_SLUG_MAX_LENGTH,
pattern = r"^[a-z0-9-]+$"
)
description: str | None = Field(
default = None,
max_length = PROGRAM_DESCRIPTION_MAX_LENGTH
)
rules: str | None = Field(
default = None,
max_length = PROGRAM_RULES_MAX_LENGTH
)
response_sla_hours: int = Field(default = 72, ge = 1, le = 720)
visibility: ProgramVisibility = ProgramVisibility.PUBLIC
class ProgramUpdate(BaseSchema):
"""
Schema for updating a program
"""
name: str | None = Field(
default = None,
max_length = PROGRAM_NAME_MAX_LENGTH
)
description: str | None = Field(
default = None,
max_length = PROGRAM_DESCRIPTION_MAX_LENGTH
)
rules: str | None = Field(
default = None,
max_length = PROGRAM_RULES_MAX_LENGTH
)
response_sla_hours: int | None = Field(
default = None,
ge = 1,
le = 720
)
status: ProgramStatus | None = None
visibility: ProgramVisibility | None = None
class ProgramResponse(BaseResponseSchema):
"""
Schema for program API responses
"""
company_id: UUID
name: str
slug: str
description: str | None
rules: str | None
response_sla_hours: int
status: ProgramStatus
visibility: ProgramVisibility
class ProgramDetailResponse(ProgramResponse):
"""
Schema for program detail with assets and rewards
"""
assets: list[AssetResponse]
reward_tiers: list[RewardTierResponse]
class ProgramListResponse(BaseSchema):
"""
Schema for paginated program list
"""
items: list[ProgramResponse]
total: int
page: int
size: int
class ProgramStatsResponse(BaseSchema):
"""
Schema for program statistics
"""
total_reports: int
open_reports: int
resolved_reports: int
total_paid: int
average_response_hours: Decimal | None

View File

@ -0,0 +1,375 @@
"""
AngelaMos | 2025
service.py
"""
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from core.exceptions import (
AssetNotFound,
NotProgramOwner,
ProgramNotFound,
SlugAlreadyExists,
)
from user.User import User
from .schemas import (
AssetCreate,
AssetResponse,
AssetUpdate,
ProgramCreate,
ProgramDetailResponse,
ProgramListResponse,
ProgramResponse,
ProgramUpdate,
RewardTierCreate,
RewardTierResponse,
)
from .repository import (
AssetRepository,
ProgramRepository,
RewardTierRepository,
)
class ProgramService:
"""
Business logic for program operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create_program(
self,
user: User,
program_data: ProgramCreate,
) -> ProgramResponse:
"""
Create a new bug bounty program
"""
if await ProgramRepository.slug_exists(self.session,
program_data.slug):
raise SlugAlreadyExists(program_data.slug)
program = await ProgramRepository.create(
self.session,
company_id = user.id,
name = program_data.name,
slug = program_data.slug,
description = program_data.description,
rules = program_data.rules,
response_sla_hours = program_data.response_sla_hours,
visibility = program_data.visibility,
)
return ProgramResponse.model_validate(program)
async def get_program_by_slug(
self,
slug: str,
) -> ProgramDetailResponse:
"""
Get program by slug with full details
"""
program = await ProgramRepository.get_by_slug_with_details(
self.session,
slug
)
if not program:
raise ProgramNotFound(slug)
return ProgramDetailResponse(
id = program.id,
created_at = program.created_at,
updated_at = program.updated_at,
company_id = program.company_id,
name = program.name,
slug = program.slug,
description = program.description,
rules = program.rules,
response_sla_hours = program.response_sla_hours,
status = program.status,
visibility = program.visibility,
assets = [
AssetResponse.model_validate(a) for a in program.assets
],
reward_tiers = [
RewardTierResponse.model_validate(r)
for r in program.reward_tiers
],
)
async def get_program_by_id(
self,
program_id: UUID,
) -> ProgramResponse:
"""
Get program by ID
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
return ProgramResponse.model_validate(program)
async def list_public_programs(
self,
page: int,
size: int,
) -> ProgramListResponse:
"""
List active public programs
"""
skip = (page - 1) * size
programs = await ProgramRepository.get_public_programs(
self.session,
skip = skip,
limit = size,
)
total = await ProgramRepository.count_public_programs(self.session)
return ProgramListResponse(
items = [ProgramResponse.model_validate(p) for p in programs],
total = total,
page = page,
size = size,
)
async def list_my_programs(
self,
user: User,
page: int,
size: int,
) -> ProgramListResponse:
"""
List programs owned by user
"""
skip = (page - 1) * size
programs = await ProgramRepository.get_by_company(
self.session,
company_id = user.id,
skip = skip,
limit = size,
)
total = await ProgramRepository.count_by_company(
self.session,
user.id
)
return ProgramListResponse(
items = [ProgramResponse.model_validate(p) for p in programs],
total = total,
page = page,
size = size,
)
async def update_program(
self,
user: User,
program_id: UUID,
program_data: ProgramUpdate,
) -> ProgramResponse:
"""
Update program details
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
update_dict = program_data.model_dump(exclude_unset = True)
updated = await ProgramRepository.update(
self.session,
program,
**update_dict,
)
return ProgramResponse.model_validate(updated)
async def delete_program(
self,
user: User,
program_id: UUID,
) -> None:
"""
Delete a program
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
await ProgramRepository.delete(self.session, program)
async def add_asset(
self,
user: User,
program_id: UUID,
asset_data: AssetCreate,
) -> AssetResponse:
"""
Add asset to program scope
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
asset = await AssetRepository.create(
self.session,
program_id = program_id,
asset_type = asset_data.asset_type,
identifier = asset_data.identifier,
in_scope = asset_data.in_scope,
description = asset_data.description,
)
return AssetResponse.model_validate(asset)
async def update_asset(
self,
user: User,
program_id: UUID,
asset_id: UUID,
asset_data: AssetUpdate,
) -> AssetResponse:
"""
Update an asset
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
asset = await AssetRepository.get_by_id(self.session, asset_id)
if not asset or asset.program_id != program_id:
raise AssetNotFound(str(asset_id))
update_dict = asset_data.model_dump(exclude_unset = True)
updated = await AssetRepository.update(
self.session,
asset,
**update_dict,
)
return AssetResponse.model_validate(updated)
async def delete_asset(
self,
user: User,
program_id: UUID,
asset_id: UUID,
) -> None:
"""
Delete an asset
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
asset = await AssetRepository.get_by_id(self.session, asset_id)
if not asset or asset.program_id != program_id:
raise AssetNotFound(str(asset_id))
await AssetRepository.delete(self.session, asset)
async def list_assets(
self,
program_id: UUID,
) -> list[AssetResponse]:
"""
List all assets for a program
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
assets = await AssetRepository.get_by_program(
self.session,
program_id
)
return [AssetResponse.model_validate(a) for a in assets]
async def set_reward_tiers(
self,
user: User,
program_id: UUID,
tiers: list[RewardTierCreate],
) -> list[RewardTierResponse]:
"""
Set reward tiers for a program (replaces existing)
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
await RewardTierRepository.delete_by_program(
self.session,
program_id
)
created_tiers = []
for tier_data in tiers:
tier = await RewardTierRepository.create(
self.session,
program_id = program_id,
severity = tier_data.severity,
min_bounty = tier_data.min_bounty,
max_bounty = tier_data.max_bounty,
currency = tier_data.currency,
)
created_tiers.append(RewardTierResponse.model_validate(tier))
return created_tiers
async def list_reward_tiers(
self,
program_id: UUID,
) -> list[RewardTierResponse]:
"""
List reward tiers for a program
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
tiers = await RewardTierRepository.get_by_program(
self.session,
program_id
)
return [RewardTierResponse.model_validate(t) for t in tiers]

View File

@ -0,0 +1,64 @@
"""
AngelaMos | 2025
Attachment.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
FILENAME_MAX_LENGTH,
MIME_TYPE_MAX_LENGTH,
STORAGE_PATH_MAX_LENGTH,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from report.Report import Report
class Attachment(Base, UUIDMixin, TimestampMixin):
"""
File attachment on a vulnerability report
"""
__tablename__ = "attachments"
report_id: Mapped[UUID] = mapped_column(
ForeignKey("reports.id",
ondelete = "CASCADE"),
index = True,
)
comment_id: Mapped[UUID | None] = mapped_column(
ForeignKey("comments.id",
ondelete = "SET NULL"),
default = None,
)
filename: Mapped[str] = mapped_column(
String(FILENAME_MAX_LENGTH),
)
storage_path: Mapped[str] = mapped_column(
String(STORAGE_PATH_MAX_LENGTH),
)
mime_type: Mapped[str] = mapped_column(
String(MIME_TYPE_MAX_LENGTH),
)
size_bytes: Mapped[int] = mapped_column(BigInteger)
report: Mapped[Report] = relationship(
back_populates = "attachments",
lazy = "raise",
)

View File

@ -0,0 +1,54 @@
"""
AngelaMos | 2025
Comment.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import ForeignKey, Text
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from report.Report import Report
from user.User import User
class Comment(Base, UUIDMixin, TimestampMixin):
"""
Comment on a vulnerability report
"""
__tablename__ = "comments"
report_id: Mapped[UUID] = mapped_column(
ForeignKey("reports.id",
ondelete = "CASCADE"),
index = True,
)
author_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id",
ondelete = "CASCADE"),
index = True,
)
content: Mapped[str] = mapped_column(Text)
is_internal: Mapped[bool] = mapped_column(default = False)
report: Mapped[Report] = relationship(
back_populates = "comments",
lazy = "raise",
)
author: Mapped[User] = relationship(lazy = "raise")

View File

@ -0,0 +1,185 @@
"""
AngelaMos | 2025
Report.py
"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
CWE_ID_MAX_LENGTH,
REPORT_TITLE_MAX_LENGTH,
ReportStatus,
SafeEnum,
Severity,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from program.Program import Program
from report.Attachment import Attachment
from report.Comment import Comment
from user.User import User
class Report(Base, UUIDMixin, TimestampMixin):
"""
Vulnerability report submitted by a researcher
"""
__tablename__ = "reports"
program_id: Mapped[UUID] = mapped_column(
ForeignKey("programs.id",
ondelete = "CASCADE"),
index = True,
)
researcher_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id",
ondelete = "CASCADE"),
index = True,
)
title: Mapped[str] = mapped_column(
String(REPORT_TITLE_MAX_LENGTH),
)
description: Mapped[str] = mapped_column(Text)
steps_to_reproduce: Mapped[str | None] = mapped_column(
Text,
default = None,
)
impact: Mapped[str | None] = mapped_column(
Text,
default = None,
)
severity_submitted: Mapped[Severity] = mapped_column(
SafeEnum(Severity),
default = Severity.MEDIUM,
)
severity_final: Mapped[Severity | None] = mapped_column(
SafeEnum(Severity),
default = None,
)
status: Mapped[ReportStatus] = mapped_column(
SafeEnum(ReportStatus),
default = ReportStatus.NEW,
index = True,
)
cvss_score: Mapped[Decimal | None] = mapped_column(
Numeric(precision = 3,
scale = 1),
default = None,
)
cwe_id: Mapped[str | None] = mapped_column(
String(CWE_ID_MAX_LENGTH),
default = None,
)
bounty_amount: Mapped[int | None] = mapped_column(default = None)
duplicate_of_id: Mapped[UUID | None] = mapped_column(
ForeignKey("reports.id",
ondelete = "SET NULL"),
default = None,
)
triaged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
disclosed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
program: Mapped[Program] = relationship(
back_populates = "reports",
lazy = "raise",
)
researcher: Mapped[User] = relationship(
back_populates = "reports",
lazy = "raise",
)
comments: Mapped[list[Comment]] = relationship(
back_populates = "report",
cascade = "all, delete-orphan",
lazy = "raise",
)
attachments: Mapped[list[Attachment]] = relationship(
back_populates = "report",
cascade = "all, delete-orphan",
lazy = "raise",
)
duplicate_of: Mapped[Report | None] = relationship(
remote_side = "Report.id",
lazy = "raise",
)
def mark_triaging(self) -> None:
"""
Transition report to triaging status
"""
self.status = ReportStatus.TRIAGING
self.triaged_at = datetime.now(UTC)
def mark_resolved(self) -> None:
"""
Transition report to resolved status
"""
self.status = ReportStatus.RESOLVED
self.resolved_at = datetime.now(UTC)
def mark_disclosed(self) -> None:
"""
Transition report to disclosed status
"""
self.status = ReportStatus.DISCLOSED
self.disclosed_at = datetime.now(UTC)
@property
def is_open(self) -> bool:
"""
Check if report is still open for action
"""
return self.status in (
ReportStatus.NEW,
ReportStatus.TRIAGING,
ReportStatus.NEEDS_MORE_INFO,
)
@property
def is_closed(self) -> bool:
"""
Check if report has reached a terminal state
"""
return self.status in (
ReportStatus.ACCEPTED,
ReportStatus.DUPLICATE,
ReportStatus.INFORMATIVE,
ReportStatus.NOT_APPLICABLE,
ReportStatus.RESOLVED,
ReportStatus.DISCLOSED,
)

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -0,0 +1,21 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import ReportService
def get_report_service(db: DBSession) -> ReportService:
"""
Dependency to inject ReportService instance
"""
return ReportService(db)
ReportServiceDep = Annotated[ReportService, Depends(get_report_service)]

View File

@ -0,0 +1,256 @@
"""
AngelaMos | 2025
repository.py
"""
from typing import Any
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from config import ReportStatus
from core.base_repository import BaseRepository
from program.Program import Program
from .Report import Report
from .Comment import Comment
from .Attachment import Attachment
class ReportRepository(BaseRepository[Report]):
"""
Repository for Report model database operations
"""
model = Report
@classmethod
async def get_by_id_with_details(
cls,
session: AsyncSession,
report_id: UUID,
) -> Report | None:
"""
Get report by ID with comments and attachments
"""
result = await session.execute(
select(Report).where(Report.id == report_id).options(
selectinload(Report.comments),
selectinload(Report.attachments),
)
)
return result.scalars().first()
@classmethod
async def get_by_researcher(
cls,
session: AsyncSession,
researcher_id: UUID,
skip: int = 0,
limit: int = 20,
) -> Sequence[Report]:
"""
Get reports by researcher
"""
result = await session.execute(
select(Report).where(Report.researcher_id == researcher_id
).order_by(Report.created_at.desc()
).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count_by_researcher(
cls,
session: AsyncSession,
researcher_id: UUID,
) -> int:
"""
Count reports by researcher
"""
result = await session.execute(
select(func.count()).select_from(Report).where(
Report.researcher_id == researcher_id
)
)
return result.scalar_one()
@classmethod
async def get_by_program(
cls,
session: AsyncSession,
program_id: UUID,
skip: int = 0,
limit: int = 20,
status_filter: ReportStatus | None = None,
) -> Sequence[Report]:
"""
Get reports for a program (inbox view)
"""
query = select(Report).where(Report.program_id == program_id)
if status_filter:
query = query.where(Report.status == status_filter)
result = await session.execute(
query.order_by(Report.created_at.desc()
).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count_by_program(
cls,
session: AsyncSession,
program_id: UUID,
status_filter: ReportStatus | None = None,
) -> int:
"""
Count reports for a program
"""
query = (
select(func.count()).select_from(Report).where(
Report.program_id == program_id
)
)
if status_filter:
query = query.where(Report.status == status_filter)
result = await session.execute(query)
return result.scalar_one()
@classmethod
async def get_inbox_for_company(
cls,
session: AsyncSession,
company_id: UUID,
skip: int = 0,
limit: int = 20,
) -> Sequence[Report]:
"""
Get all reports across all programs owned by company
"""
result = await session.execute(
select(Report).join(Program,
Report.program_id == Program.id).where(
Program.company_id == company_id
).order_by(Report.created_at.desc()
).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count_inbox_for_company(
cls,
session: AsyncSession,
company_id: UUID,
) -> int:
"""
Count all reports for company's programs
"""
result = await session.execute(
select(func.count()).select_from(Report).join(
Program,
Report.program_id == Program.id
).where(Program.company_id == company_id)
)
return result.scalar_one()
@classmethod
async def get_researcher_stats(
cls,
session: AsyncSession,
researcher_id: UUID,
) -> dict[str,
Any]:
"""
Get statistics for a researcher
"""
total_result = await session.execute(
select(func.count()).select_from(Report).where(
Report.researcher_id == researcher_id
)
)
total = total_result.scalar_one()
accepted_result = await session.execute(
select(func.count()).select_from(Report).where(
Report.researcher_id == researcher_id,
Report.status.in_(
[
ReportStatus.ACCEPTED,
ReportStatus.RESOLVED,
ReportStatus.DISCLOSED,
]
)
)
)
accepted = accepted_result.scalar_one()
earned_result = await session.execute(
select(func.coalesce(func.sum(Report.bounty_amount),
0)).where(
Report.researcher_id == researcher_id,
Report.bounty_amount.isnot(None),
)
)
earned = earned_result.scalar_one()
return {
"total_reports": total,
"accepted_reports": accepted,
"total_earned": earned,
}
class CommentRepository(BaseRepository[Comment]):
"""
Repository for Comment model database operations
"""
model = Comment
@classmethod
async def get_by_report(
cls,
session: AsyncSession,
report_id: UUID,
include_internal: bool = False,
) -> Sequence[Comment]:
"""
Get comments for a report
"""
query = select(Comment).where(Comment.report_id == report_id)
if not include_internal:
query = query.where(Comment.is_internal == False)
result = await session.execute(
query.order_by(Comment.created_at.asc())
)
return result.scalars().all()
class AttachmentRepository(BaseRepository[Attachment]):
"""
Repository for Attachment model database operations
"""
model = Attachment
@classmethod
async def get_by_report(
cls,
session: AsyncSession,
report_id: UUID,
) -> Sequence[Attachment]:
"""
Get attachments for a report
"""
result = await session.execute(
select(Attachment).where(Attachment.report_id == report_id
).order_by(
Attachment.created_at.asc()
)
)
return result.scalars().all()

View File

@ -0,0 +1,251 @@
"""
AngelaMos | 2025
routes.py
"""
from uuid import UUID
from fastapi import APIRouter, Query, status
from config import ReportStatus
from core.dependencies import CurrentUser
from core.responses import (
AUTH_401,
FORBIDDEN_403,
NOT_FOUND_404,
)
from .schemas import (
CommentCreate,
CommentResponse,
ReportCreate,
ReportDetailResponse,
ReportListResponse,
ReportResponse,
ReportStatsResponse,
ReportTriageUpdate,
ReportUpdate,
)
from .dependencies import ReportServiceDep
router = APIRouter(prefix = "/reports", tags = ["reports"])
@router.post(
"",
response_model = ReportResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def submit_report(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_data: ReportCreate,
) -> ReportResponse:
"""
Submit a new vulnerability report
"""
return await report_service.submit_report(current_user, report_data)
@router.get(
"",
response_model = ReportListResponse,
responses = {**AUTH_401},
)
async def list_my_reports(
report_service: ReportServiceDep,
current_user: CurrentUser,
page: int = Query(default = 1,
ge = 1),
size: int = Query(default = 20,
ge = 1,
le = 100),
) -> ReportListResponse:
"""
List reports submitted by current user
"""
return await report_service.list_my_reports(current_user, page, size)
@router.get(
"/inbox",
response_model = ReportListResponse,
responses = {**AUTH_401},
)
async def list_inbox(
report_service: ReportServiceDep,
current_user: CurrentUser,
page: int = Query(default = 1,
ge = 1),
size: int = Query(default = 20,
ge = 1,
le = 100),
) -> ReportListResponse:
"""
List all reports across user's programs (company inbox)
"""
return await report_service.list_inbox(current_user, page, size)
@router.get(
"/stats",
response_model = ReportStatsResponse,
responses = {**AUTH_401},
)
async def get_my_stats(
report_service: ReportServiceDep,
current_user: CurrentUser,
) -> ReportStatsResponse:
"""
Get current user's report statistics
"""
return await report_service.get_my_stats(current_user)
@router.get(
"/program/{program_id}",
response_model = ReportListResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def list_program_reports(
report_service: ReportServiceDep,
current_user: CurrentUser,
program_id: UUID,
page: int = Query(default = 1,
ge = 1),
size: int = Query(default = 20,
ge = 1,
le = 100),
status_filter: ReportStatus | None = None,
) -> ReportListResponse:
"""
List reports for a specific program (program owner only)
"""
return await report_service.list_program_reports(
current_user,
program_id,
page,
size,
status_filter,
)
@router.get(
"/{report_id}",
response_model = ReportDetailResponse,
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def get_report(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_id: UUID,
) -> ReportDetailResponse:
"""
Get report by ID with full details
"""
return await report_service.get_report(current_user, report_id)
@router.patch(
"/{report_id}",
response_model = ReportResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def update_report(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_id: UUID,
report_data: ReportUpdate,
) -> ReportResponse:
"""
Update report (researcher only, only if still open)
"""
return await report_service.update_report(
current_user,
report_id,
report_data
)
@router.patch(
"/{report_id}/triage",
response_model = ReportResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def triage_report(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_id: UUID,
triage_data: ReportTriageUpdate,
) -> ReportResponse:
"""
Triage a report (program owner only)
"""
return await report_service.triage_report(
current_user,
report_id,
triage_data
)
@router.get(
"/{report_id}/comments",
response_model = list[CommentResponse],
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def list_comments(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_id: UUID,
) -> list[CommentResponse]:
"""
List comments for a report
"""
return await report_service.list_comments(current_user, report_id)
@router.post(
"/{report_id}/comments",
response_model = CommentResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def add_comment(
report_service: ReportServiceDep,
current_user: CurrentUser,
report_id: UUID,
comment_data: CommentCreate,
) -> CommentResponse:
"""
Add comment to a report
"""
return await report_service.add_comment(
current_user,
report_id,
comment_data
)

View File

@ -0,0 +1,141 @@
"""
AngelaMos | 2025
schemas.py
"""
from uuid import UUID
from datetime import datetime
from decimal import Decimal
from pydantic import Field
from config import (
CWE_ID_MAX_LENGTH,
REPORT_TITLE_MAX_LENGTH,
ReportStatus,
Severity,
)
from core.base_schema import (
BaseSchema,
BaseResponseSchema,
)
class ReportCreate(BaseSchema):
"""
Schema for submitting a vulnerability report
"""
program_id: UUID
title: str = Field(max_length = REPORT_TITLE_MAX_LENGTH)
description: str
steps_to_reproduce: str | None = None
impact: str | None = None
severity_submitted: Severity = Severity.MEDIUM
class ReportUpdate(BaseSchema):
"""
Schema for researcher updating their report
"""
title: str | None = Field(
default = None,
max_length = REPORT_TITLE_MAX_LENGTH
)
description: str | None = None
steps_to_reproduce: str | None = None
impact: str | None = None
severity_submitted: Severity | None = None
class ReportTriageUpdate(BaseSchema):
"""
Schema for company triaging a report
"""
status: ReportStatus | None = None
severity_final: Severity | None = None
cvss_score: Decimal | None = Field(default = None, ge = 0, le = 10)
cwe_id: str | None = Field(
default = None,
max_length = CWE_ID_MAX_LENGTH
)
bounty_amount: int | None = Field(default = None, ge = 0)
duplicate_of_id: UUID | None = None
class ReportResponse(BaseResponseSchema):
"""
Schema for report API responses
"""
program_id: UUID
researcher_id: UUID
title: str
description: str
steps_to_reproduce: str | None
impact: str | None
severity_submitted: Severity
severity_final: Severity | None
status: ReportStatus
cvss_score: Decimal | None
cwe_id: str | None
bounty_amount: int | None
duplicate_of_id: UUID | None
triaged_at: datetime | None
resolved_at: datetime | None
disclosed_at: datetime | None
class ReportListResponse(BaseSchema):
"""
Schema for paginated report list
"""
items: list[ReportResponse]
total: int
page: int
size: int
class CommentCreate(BaseSchema):
"""
Schema for adding a comment to a report
"""
content: str
is_internal: bool = False
class CommentResponse(BaseResponseSchema):
"""
Schema for comment API responses
"""
report_id: UUID
author_id: UUID
content: str
is_internal: bool
class AttachmentResponse(BaseResponseSchema):
"""
Schema for attachment API responses
"""
report_id: UUID
comment_id: UUID | None
filename: str
mime_type: str
size_bytes: int
class ReportDetailResponse(ReportResponse):
"""
Schema for report detail with comments
"""
comments: list[CommentResponse]
attachments: list[AttachmentResponse]
class ReportStatsResponse(BaseSchema):
"""
Schema for researcher stats
"""
total_reports: int
accepted_reports: int
total_earned: int
reputation_score: int

View File

@ -0,0 +1,381 @@
"""
AngelaMos | 2025
service.py
"""
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from config import ReportStatus
from core.exceptions import (
CannotSubmitToOwnProgram,
NotProgramOwner,
NotReportOwner,
ProgramNotActive,
ProgramNotFound,
ReportNotFound,
)
from program.repository import ProgramRepository
from user.User import User
from .schemas import (
AttachmentResponse,
CommentCreate,
CommentResponse,
ReportCreate,
ReportDetailResponse,
ReportListResponse,
ReportResponse,
ReportStatsResponse,
ReportTriageUpdate,
ReportUpdate,
)
from .repository import (
CommentRepository,
ReportRepository,
)
class ReportService:
"""
Business logic for report operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def submit_report(
self,
user: User,
report_data: ReportCreate,
) -> ReportResponse:
"""
Submit a new vulnerability report
"""
program = await ProgramRepository.get_by_id(
self.session,
report_data.program_id
)
if not program:
raise ProgramNotFound(str(report_data.program_id))
if not program.is_active:
raise ProgramNotActive()
if program.company_id == user.id:
raise CannotSubmitToOwnProgram()
report = await ReportRepository.create(
self.session,
program_id = report_data.program_id,
researcher_id = user.id,
title = report_data.title,
description = report_data.description,
steps_to_reproduce = report_data.steps_to_reproduce,
impact = report_data.impact,
severity_submitted = report_data.severity_submitted,
)
return ReportResponse.model_validate(report)
async def get_report(
self,
user: User,
report_id: UUID,
) -> ReportDetailResponse:
"""
Get report by ID with details
"""
report = await ReportRepository.get_by_id_with_details(
self.session,
report_id
)
if not report:
raise ReportNotFound(str(report_id))
program = await ProgramRepository.get_by_id(
self.session,
report.program_id
)
is_researcher = report.researcher_id == user.id
is_program_owner = program and program.company_id == user.id
if not is_researcher and not is_program_owner:
raise ReportNotFound(str(report_id))
comments = report.comments
if is_researcher and not is_program_owner:
comments = [c for c in comments if not c.is_internal]
return ReportDetailResponse(
id = report.id,
created_at = report.created_at,
updated_at = report.updated_at,
program_id = report.program_id,
researcher_id = report.researcher_id,
title = report.title,
description = report.description,
steps_to_reproduce = report.steps_to_reproduce,
impact = report.impact,
severity_submitted = report.severity_submitted,
severity_final = report.severity_final,
status = report.status,
cvss_score = report.cvss_score,
cwe_id = report.cwe_id,
bounty_amount = report.bounty_amount,
duplicate_of_id = report.duplicate_of_id,
triaged_at = report.triaged_at,
resolved_at = report.resolved_at,
disclosed_at = report.disclosed_at,
comments = [
CommentResponse.model_validate(c) for c in comments
],
attachments = [
AttachmentResponse.model_validate(a)
for a in report.attachments
],
)
async def list_my_reports(
self,
user: User,
page: int,
size: int,
) -> ReportListResponse:
"""
List reports submitted by current user
"""
skip = (page - 1) * size
reports = await ReportRepository.get_by_researcher(
self.session,
researcher_id = user.id,
skip = skip,
limit = size,
)
total = await ReportRepository.count_by_researcher(
self.session,
user.id
)
return ReportListResponse(
items = [ReportResponse.model_validate(r) for r in reports],
total = total,
page = page,
size = size,
)
async def list_program_reports(
self,
user: User,
program_id: UUID,
page: int,
size: int,
status_filter: ReportStatus | None = None,
) -> ReportListResponse:
"""
List reports for a program (program owner only)
"""
program = await ProgramRepository.get_by_id(
self.session,
program_id
)
if not program:
raise ProgramNotFound(str(program_id))
if program.company_id != user.id:
raise NotProgramOwner()
skip = (page - 1) * size
reports = await ReportRepository.get_by_program(
self.session,
program_id = program_id,
skip = skip,
limit = size,
status_filter = status_filter,
)
total = await ReportRepository.count_by_program(
self.session,
program_id,
status_filter
)
return ReportListResponse(
items = [ReportResponse.model_validate(r) for r in reports],
total = total,
page = page,
size = size,
)
async def list_inbox(
self,
user: User,
page: int,
size: int,
) -> ReportListResponse:
"""
List all reports across user's programs
"""
skip = (page - 1) * size
reports = await ReportRepository.get_inbox_for_company(
self.session,
company_id = user.id,
skip = skip,
limit = size,
)
total = await ReportRepository.count_inbox_for_company(
self.session,
user.id
)
return ReportListResponse(
items = [ReportResponse.model_validate(r) for r in reports],
total = total,
page = page,
size = size,
)
async def update_report(
self,
user: User,
report_id: UUID,
report_data: ReportUpdate,
) -> ReportResponse:
"""
Update report (researcher only, only if still open)
"""
report = await ReportRepository.get_by_id(self.session, report_id)
if not report:
raise ReportNotFound(str(report_id))
if report.researcher_id != user.id:
raise NotReportOwner()
if not report.is_open:
raise ReportNotFound(str(report_id))
update_dict = report_data.model_dump(exclude_unset = True)
updated = await ReportRepository.update(
self.session,
report,
**update_dict,
)
return ReportResponse.model_validate(updated)
async def triage_report(
self,
user: User,
report_id: UUID,
triage_data: ReportTriageUpdate,
) -> ReportResponse:
"""
Triage a report (program owner only)
"""
report = await ReportRepository.get_by_id(self.session, report_id)
if not report:
raise ReportNotFound(str(report_id))
program = await ProgramRepository.get_by_id(
self.session,
report.program_id
)
if not program or program.company_id != user.id:
raise NotProgramOwner()
update_dict = triage_data.model_dump(exclude_unset = True)
if "status" in update_dict:
new_status = update_dict["status"]
if new_status == ReportStatus.TRIAGING and report.triaged_at is None:
report.mark_triaging()
elif new_status == ReportStatus.RESOLVED:
report.mark_resolved()
elif new_status == ReportStatus.DISCLOSED:
report.mark_disclosed()
updated = await ReportRepository.update(
self.session,
report,
**update_dict,
)
return ReportResponse.model_validate(updated)
async def add_comment(
self,
user: User,
report_id: UUID,
comment_data: CommentCreate,
) -> CommentResponse:
"""
Add comment to a report
"""
report = await ReportRepository.get_by_id(self.session, report_id)
if not report:
raise ReportNotFound(str(report_id))
program = await ProgramRepository.get_by_id(
self.session,
report.program_id
)
is_researcher = report.researcher_id == user.id
is_program_owner = program and program.company_id == user.id
if not is_researcher and not is_program_owner:
raise ReportNotFound(str(report_id))
if comment_data.is_internal and not is_program_owner:
comment_data.is_internal = False
comment = await CommentRepository.create(
self.session,
report_id = report_id,
author_id = user.id,
content = comment_data.content,
is_internal = comment_data.is_internal,
)
return CommentResponse.model_validate(comment)
async def list_comments(
self,
user: User,
report_id: UUID,
) -> list[CommentResponse]:
"""
List comments for a report
"""
report = await ReportRepository.get_by_id(self.session, report_id)
if not report:
raise ReportNotFound(str(report_id))
program = await ProgramRepository.get_by_id(
self.session,
report.program_id
)
is_researcher = report.researcher_id == user.id
is_program_owner = bool(program and program.company_id == user.id)
if not is_researcher and not is_program_owner:
raise ReportNotFound(str(report_id))
include_internal = is_program_owner
comments = await CommentRepository.get_by_report(
self.session,
report_id,
include_internal = include_internal,
)
return [CommentResponse.model_validate(c) for c in comments]
async def get_my_stats(
self,
user: User,
) -> ReportStatsResponse:
"""
Get current user's report statistics
"""
stats = await ReportRepository.get_researcher_stats(
self.session,
user.id
)
return ReportStatsResponse(
total_reports = stats["total_reports"],
accepted_reports = stats["accepted_reports"],
total_earned = stats["total_earned"],
reputation_score = user.reputation_score,
)

View File

@ -0,0 +1,126 @@
"""
AngelaMos | 2025
User.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String, Text
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
COMPANY_NAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
FULL_NAME_MAX_LENGTH,
PASSWORD_HASH_MAX_LENGTH,
SafeEnum,
UserRole,
WEBSITE_MAX_LENGTH,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from auth.RefreshToken import RefreshToken
from program.Program import Program
from report.Report import Report
class User(Base, UUIDMixin, TimestampMixin):
"""
User account model supporting both researchers and companies
"""
__tablename__ = "users"
email: Mapped[str] = mapped_column(
String(EMAIL_MAX_LENGTH),
unique = True,
index = True,
)
hashed_password: Mapped[str] = mapped_column(
String(PASSWORD_HASH_MAX_LENGTH)
)
full_name: Mapped[str | None] = mapped_column(
String(FULL_NAME_MAX_LENGTH),
default = None,
)
is_active: Mapped[bool] = mapped_column(default = True)
is_verified: Mapped[bool] = mapped_column(default = False)
role: Mapped[UserRole] = mapped_column(
SafeEnum(UserRole,
unknown_value = UserRole.UNKNOWN),
default = UserRole.USER,
)
token_version: Mapped[int] = mapped_column(default = 0)
company_name: Mapped[str | None] = mapped_column(
String(COMPANY_NAME_MAX_LENGTH),
default = None,
)
bio: Mapped[str | None] = mapped_column(
Text,
default = None,
)
website: Mapped[str | None] = mapped_column(
String(WEBSITE_MAX_LENGTH),
default = None,
)
reputation_score: Mapped[int] = mapped_column(default = 0)
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates = "user",
cascade = "all, delete-orphan",
lazy = "raise",
)
programs: Mapped[list[Program]] = relationship(
back_populates = "company",
cascade = "all, delete-orphan",
lazy = "raise",
)
reports: Mapped[list[Report]] = relationship(
back_populates = "researcher",
cascade = "all, delete-orphan",
lazy = "raise",
)
def increment_token_version(self) -> None:
"""
Invalidate all existing tokens for this user
"""
self.token_version += 1
@property
def is_verified_company(self) -> bool:
"""
Check if user is a verified organization (optional upgrade)
"""
return self.role == UserRole.COMPANY
@property
def can_submit_reports(self) -> bool:
"""
Any authenticated user can submit reports
"""
return self.role != UserRole.UNKNOWN
@property
def can_create_program(self) -> bool:
"""
Any authenticated user can create a program
"""
return self.role != UserRole.UNKNOWN

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
User Domain
"""

View File

@ -0,0 +1,21 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import UserService
def get_user_service(db: DBSession) -> UserService:
"""
Dependency to inject UserService instance
"""
return UserService(db)
UserServiceDep = Annotated[UserService, Depends(get_user_service)]

View File

@ -0,0 +1,111 @@
"""
AngelaMos | 2025
repository.py
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import UserRole
from .User import User
from core.base_repository import BaseRepository
class UserRepository(BaseRepository[User]):
"""
Repository for User model database operations
"""
model = User
@classmethod
async def get_by_email(
cls,
session: AsyncSession,
email: str,
) -> User | None:
"""
Get user by email address
"""
result = await session.execute(
select(User).where(User.email == email)
)
return result.scalars().first()
@classmethod
async def get_by_id(
cls,
session: AsyncSession,
id: UUID,
) -> User | None:
"""
Get user by ID
"""
return await session.get(User, id)
@classmethod
async def email_exists(
cls,
session: AsyncSession,
email: str,
) -> bool:
"""
Check if email is already registered
"""
result = await session.execute(
select(User.id).where(User.email == email)
)
return result.scalars().first() is not None
@classmethod
async def create_user(
cls,
session: AsyncSession,
email: str,
hashed_password: str,
full_name: str | None = None,
role: UserRole = UserRole.USER,
) -> User:
"""
Create a new user
"""
user = User(
email = email,
hashed_password = hashed_password,
full_name = full_name,
role = role,
)
session.add(user)
await session.flush()
await session.refresh(user)
return user
@classmethod
async def update_password(
cls,
session: AsyncSession,
user: User,
hashed_password: str,
) -> User:
"""
Update user password and increment token version
"""
user.hashed_password = hashed_password
user.increment_token_version()
await session.flush()
await session.refresh(user)
return user
@classmethod
async def increment_token_version(
cls,
session: AsyncSession,
user: User,
) -> User:
"""
Invalidate all user tokens
"""
user.increment_token_version()
await session.flush()
await session.refresh(user)
return user

View File

@ -0,0 +1,78 @@
"""
AngelaMos | 2025
routes.py
"""
from uuid import UUID
from fastapi import (
APIRouter,
status,
)
from core.dependencies import CurrentUser
from core.responses import (
AUTH_401,
CONFLICT_409,
NOT_FOUND_404,
)
from .schemas import (
UserCreate,
UserResponse,
UserUpdate,
)
from .dependencies import UserServiceDep
router = APIRouter(prefix = "/users", tags = ["users"])
@router.post(
"",
response_model = UserResponse,
status_code = status.HTTP_201_CREATED,
responses = {**CONFLICT_409},
)
async def create_user(
user_service: UserServiceDep,
user_data: UserCreate,
) -> UserResponse:
"""
Register a new user
"""
return await user_service.create_user(user_data)
@router.get(
"/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def get_user(
user_service: UserServiceDep,
user_id: UUID,
_: CurrentUser,
) -> UserResponse:
"""
Get user by ID
"""
return await user_service.get_user_by_id(user_id)
@router.patch(
"/me",
response_model = UserResponse,
responses = {**AUTH_401},
)
async def update_current_user(
user_service: UserServiceDep,
current_user: CurrentUser,
user_data: UserUpdate,
) -> UserResponse:
"""
Update current user profile
"""
return await user_service.update_user(current_user, user_data)

View File

@ -0,0 +1,109 @@
"""
AngelaMos | 2025
schemas.py
"""
from pydantic import (
Field,
EmailStr,
field_validator,
)
from config import (
UserRole,
FULL_NAME_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
)
from core.base_schema import (
BaseSchema,
BaseResponseSchema,
)
class UserCreate(BaseSchema):
"""
Schema for user registration
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
"""
Ensure password has minimum complexity
"""
if not any(c.isupper() for c in v):
raise ValueError(
"Password must contain at least one uppercase letter"
)
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
class UserUpdate(BaseSchema):
"""
Schema for updating user profile
"""
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
class UserUpdateAdmin(UserUpdate):
"""
Schema for admin updating user
"""
email: EmailStr | None = None
is_active: bool | None = None
is_verified: bool | None = None
role: UserRole | None = None
class AdminUserCreate(BaseSchema):
"""
Schema for admin creating a user
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
role: UserRole = UserRole.USER
is_active: bool = True
is_verified: bool = False
class UserResponse(BaseResponseSchema):
"""
Schema for user API responses
"""
email: EmailStr
full_name: str | None
is_active: bool
is_verified: bool
role: UserRole
class UserListResponse(BaseSchema):
"""
Schema for paginated user list
"""
items: list[UserResponse]
total: int
page: int
size: int

View File

@ -0,0 +1,221 @@
"""
AngelaMos | 2025
service.py
"""
from uuid import UUID
from sqlalchemy.ext.asyncio import (
AsyncSession,
)
from config import settings, UserRole
from core.exceptions import (
EmailAlreadyExists,
InvalidCredentials,
UserNotFound,
)
from core.security import (
hash_password,
verify_password,
)
from .schemas import (
AdminUserCreate,
UserCreate,
UserListResponse,
UserResponse,
UserUpdate,
UserUpdateAdmin,
)
from .User import User
from .repository import UserRepository
class UserService:
"""
Business logic for user operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create_user(
self,
user_data: UserCreate,
) -> UserResponse:
"""
Register a new user
"""
if await UserRepository.email_exists(self.session,
user_data.email):
raise EmailAlreadyExists(user_data.email)
role = UserRole.USER
if settings.ADMIN_EMAIL and user_data.email.lower(
) == settings.ADMIN_EMAIL.lower():
role = UserRole.ADMIN
hashed = await hash_password(user_data.password)
user = await UserRepository.create_user(
self.session,
email = user_data.email,
hashed_password = hashed,
full_name = user_data.full_name,
role = role,
)
return UserResponse.model_validate(user)
async def get_user_by_id(
self,
user_id: UUID,
) -> UserResponse:
"""
Get user by ID
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
return UserResponse.model_validate(user)
async def get_user_model_by_id(
self,
user_id: UUID,
) -> User:
"""
Get user model by ID (for internal use)
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
return user
async def update_user(
self,
user: User,
user_data: UserUpdate,
) -> UserResponse:
"""
Update user profile
"""
update_dict = user_data.model_dump(exclude_unset = True)
updated_user = await UserRepository.update(
self.session,
user,
**update_dict
)
return UserResponse.model_validate(updated_user)
async def change_password(
self,
user: User,
current_password: str,
new_password: str,
) -> None:
"""
Change user password
"""
is_valid, _ = await verify_password(current_password, user.hashed_password)
if not is_valid:
raise InvalidCredentials()
hashed = await hash_password(new_password)
await UserRepository.update_password(self.session, user, hashed)
async def deactivate_user(
self,
user: User,
) -> UserResponse:
"""
Deactivate user account
"""
updated = await UserRepository.update(
self.session,
user,
is_active = False
)
return UserResponse.model_validate(updated)
async def list_users(
self,
page: int,
size: int,
) -> UserListResponse:
"""
List users with pagination
"""
skip = (page - 1) * size
users = await UserRepository.get_multi(
self.session,
skip = skip,
limit = size
)
total = await UserRepository.count(self.session)
return UserListResponse(
items = [UserResponse.model_validate(u) for u in users],
total = total,
page = page,
size = size,
)
async def admin_create_user(
self,
user_data: AdminUserCreate,
) -> UserResponse:
"""
Admin creates a new user
"""
if await UserRepository.email_exists(self.session,
user_data.email):
raise EmailAlreadyExists(user_data.email)
hashed = await hash_password(user_data.password)
user = await UserRepository.create(
self.session,
email = user_data.email,
hashed_password = hashed,
full_name = user_data.full_name,
role = user_data.role,
is_active = user_data.is_active,
is_verified = user_data.is_verified,
)
return UserResponse.model_validate(user)
async def admin_update_user(
self,
user_id: UUID,
user_data: UserUpdateAdmin,
) -> UserResponse:
"""
Admin updates a user
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
update_dict = user_data.model_dump(exclude_unset = True)
if "email" in update_dict:
existing = await UserRepository.get_by_email(
self.session,
update_dict["email"]
)
if existing and existing.id != user_id:
raise EmailAlreadyExists(update_dict["email"])
updated_user = await UserRepository.update(
self.session,
user,
**update_dict
)
return UserResponse.model_validate(updated_user)
async def admin_delete_user(
self,
user_id: UUID,
) -> None:
"""
Admin deletes a user (hard delete)
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
await UserRepository.delete(self.session, user)

View File

@ -0,0 +1,291 @@
"""
©AngelaMos | 2025
conftest.py
Test configuration, fixtures, and factories
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "app"))
import hashlib
import secrets
from datetime import (
UTC,
datetime,
timedelta,
)
from uuid import uuid4
from collections.abc import AsyncIterator
import pytest
from httpx import (
AsyncClient,
ASGITransport,
)
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
)
from sqlalchemy.pool import StaticPool
from core.security import (
hash_password,
create_access_token,
)
from config import UserRole
from core.database import get_db_session
from core.Base import Base
from user.User import User
from auth.RefreshToken import RefreshToken
@pytest_asyncio.fixture(scope = "session", loop_scope = "session")
async def test_engine():
"""
Session scoped async engine with in memory SQLite
StaticPool keeps single connection so DB persists
"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass = StaticPool,
connect_args = {"check_same_thread": False},
echo = False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def db_session(test_engine) -> AsyncIterator[AsyncSession]:
"""
Per test session with transaction rollback for isolation
App commits become savepoints that rollback with test
"""
async with test_engine.connect() as conn:
await conn.begin()
session = AsyncSession(
bind = conn,
expire_on_commit = False,
join_transaction_mode = "create_savepoint",
)
yield session
await session.close()
await conn.rollback()
@pytest.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
"""
Async HTTP client with DB session override
"""
from factory import create_app
app = create_app()
async def override_get_db():
yield db_session
app.dependency_overrides[get_db_session] = override_get_db
async with AsyncClient(
transport = ASGITransport(app = app),
base_url = "http://test",
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
def auth_headers(access_token: str) -> dict[str, str]:
"""
Authorization headers for authenticated requests
"""
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def admin_auth_headers(admin_access_token: str) -> dict[str, str]:
"""
Authorization headers for admin requests
"""
return {"Authorization": f"Bearer {admin_access_token}"}
class UserFactory:
"""
Factory for creating test users
"""
_counter = 0
@classmethod
async def create(
cls,
session: AsyncSession,
*,
email: str | None = None,
password: str = "TestPass123",
full_name: str | None = None,
role: UserRole = UserRole.USER,
is_active: bool = True,
is_verified: bool = True,
) -> User:
cls._counter += 1
user = User(
email = email or f"user{cls._counter}@test.com",
hashed_password = await hash_password(password),
full_name = full_name or f"Test User {cls._counter}",
role = role,
is_active = is_active,
is_verified = is_verified,
)
session.add(user)
await session.flush()
await session.refresh(user)
return user
@classmethod
def reset(cls) -> None:
cls._counter = 0
class RefreshTokenFactory:
"""
Factory for creating test refresh tokens
"""
@classmethod
async def create(
cls,
session: AsyncSession,
user: User,
*,
is_revoked: bool = False,
expires_delta: timedelta = timedelta(days = 7),
) -> tuple[RefreshToken,
str]:
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token = RefreshToken(
user_id = user.id,
token_hash = token_hash,
family_id = uuid4(),
expires_at = datetime.now(UTC) + expires_delta,
is_revoked = is_revoked,
)
session.add(token)
await session.flush()
await session.refresh(token)
return token, raw_token
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
"""
Standard test user
"""
return await UserFactory.create(db_session)
@pytest.fixture
async def admin_user(db_session: AsyncSession) -> User:
"""
Admin test user
"""
return await UserFactory.create(
db_session,
email = "admin@test.com",
role = UserRole.ADMIN,
)
@pytest.fixture
async def inactive_user(db_session: AsyncSession) -> User:
"""
Inactive test user
"""
return await UserFactory.create(
db_session,
email = "inactive@test.com",
is_active = False,
)
@pytest.fixture
def access_token(test_user: User) -> str:
"""
Valid access token for test_user
"""
return create_access_token(test_user.id, test_user.token_version)
@pytest.fixture
def admin_access_token(admin_user: User) -> str:
"""
Valid access token for admin_user
"""
return create_access_token(admin_user.id, admin_user.token_version)
@pytest.fixture
async def refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Refresh token DB record and raw token string
"""
return await RefreshTokenFactory.create(db_session, test_user)
@pytest.fixture
async def expired_refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Expired refresh token for testing
"""
return await RefreshTokenFactory.create(
db_session,
test_user,
expires_delta = timedelta(days = -1),
)
@pytest.fixture
async def revoked_refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Revoked refresh token for testing
"""
return await RefreshTokenFactory.create(
db_session,
test_user,
is_revoked = True,
)
@pytest.fixture(autouse = True)
def reset_factories():
"""
Reset factory counters between tests
"""
yield
UserFactory.reset()

View File

@ -0,0 +1,287 @@
[project]
name = "bug-bounty-platform"
version = "1.0.0"
description = "Full stack bug bounty platform"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.123.0,<1.0.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"psycopg2-binary>=2.9.11",
"sqlalchemy>=2.0.44,<3.0.0",
"alembic>=1.17.0,<2.0.0",
"asyncpg>=0.31.0,<1.0.0",
"python-multipart>=0.0.20",
"pyjwt>=2.10.0",
"pwdlib[argon2]>=0.3.0",
"uuid6>=2025.0.1",
"slowapi>=0.1.9",
"redis>=7.1.0",
"structlog>=24.4.0",
"gunicorn>=23.0.0",
"uvicorn[standard]>=0.38.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.1",
"aiosqlite>=0.21.0",
"asgi-lifespan>=2.1.0",
"mypy>=1.19.0",
"types-redis>=4.6.0",
"ruff>=0.14.8",
"pylint>=4.0.4",
"pylint-pydantic>=0.4.1",
"pylint-per-file-ignores>=3.2.0",
"ty>=0.0.1a32",
"pre-commit>=4.2.0",
]
[project.urls]
Homepage = "https://bugbountyplatform.sh"
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
target-version = "py312"
line-length = 88
src = ["app"]
exclude = ["alembic"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
"ASYNC", # flake8-async
"S", # flake8-bandit (security)
"N", # pep8-naming
]
ignore = [
"E501", # line too long (formatter handles this)
"B008", # function call in default argument (FastAPI Depends)
"S101", # assert usage (needed for tests)
"S104", # 0.0.0.0 binding (intentional for Docker)
"S105", # "bearer" token_type is not a password
"ARG001", # unused function argument (common in FastAPI deps)
"E712", # == False is REQUIRED for SQLAlchemy WHERE clauses
"N999", # PascalCase module names (intentional: Base.py, User.py)
"N818", # exception naming convention (style preference)
"UP046", # Generic[T] syntax (keep for compatibility)
"RUF005", # list concatenation style (preference)
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ARG001"]
"conftest.py" = ["S107"]
"app/core/rate_limit.py" = ["S110"]
"app/config.py" = ["F401"]
"app/**/schemas.py" = ["RUF012"]
"app/core/error_schemas.py" = ["RUF012"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
plugins = ["pydantic.mypy"]
exclude = ["alembic", ".venv", "venv"]
[[tool.mypy.overrides]]
module = ["tests.*", "conftest"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["core.logging"]
disable_error_code = ["no-any-return"]
[[tool.mypy.overrides]]
module = [
"uuid6",
"structlog",
"structlog.*",
"pwdlib",
"slowapi",
"slowapi.*",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["pydantic_settings.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["config"]
implicit_reexport = true
[[tool.mypy.overrides]]
module = ["core.enums", "core.security"]
disable_error_code = ["return-value", "no-any-return"]
[[tool.mypy.overrides]]
module = ["user.repository", "auth.repository", "core.base_repository"]
disable_error_code = ["return-value", "no-any-return", "attr-defined"]
[[tool.mypy.overrides]]
module = ["user.service", "auth.service"]
disable_error_code = ["no-any-return"]
[[tool.mypy.overrides]]
module = ["factory"]
disable_error_code = ["arg-type"]
[[tool.mypy.overrides]]
module = ["auth.routes"]
disable_error_code = ["misc"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
[tool.pylint.main]
py-version = "3.11"
jobs = 4
load-plugins = [
"pylint_pydantic",
"pylint_per_file_ignores",
]
persistent = true
ignore = [
"alembic",
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]
ignore-paths = [
"^alembic/.*",
"^venv/.*",
"^.venv/.*",
"^build/.*",
"^dist/.*",
]
[tool.pylint.messages_control]
disable = [
"C0103", # invalid-name
"C0116", # missing-function-docstring (we use minimal docs)
"C0121", # singleton-comparison (== False required for SQLAlchemy)
"C0301", # line-too-long
"C0302", # too-many-lines
"C0303", # trailing-whitespace
"C0304", # final-newline-missing
"C0305", # trailing-newlines
"C0411", # wrong-import-order
"C0412", # ungrouped-imports (style preference)
"E0401", # import-error (uuid6/structlog/pwdlib not found by pylint)
"E0611", # no-name-in-module (false positive for config re-exports)
"E1102", # not-callable (false positive for SQLAlchemy func.now/count)
"E1136", # unsubscriptable-object (false positive for generics)
"R0801", # similar-lines
"R0901", # too-many-ancestors (SQLAlchemy inheritance)
"R0903", # too-few-public-methods
"R0917", # too-many-positional-arguments (FastAPI patterns)
"W0611", # unused-import (handled by ruff, config.py re-exports)
"W0612", # unused-variable (handled by ruff)
"W0613", # unused-argument (handled by ruff)
"W0621", # redefined-outer-name (FastAPI route/param naming)
"W0622", # redefined-builtin
"W0718", # broad-exception-caught (intentional in health/rate-limit)
]
[tool.pylint-per-file-ignores]
"alembic/env.py" = "no-member"
"conftest.py" = "import-outside-toplevel"
"app/__main__.py" = "pointless-string-statement"
[tool.pylint.format]
max-line-length = 95
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-branches = 15
max-locals = 20
max-statements = 55
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
branch = true
source = ["src"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
[tool.ty.src]
include = ["app", "tests"]
exclude = ["alembic/versions/**", ".venv/**"]
respect-ignore-files = true
[tool.ty.environment]
python-version = "3.12"
root = ["./app"]
python = "./.venv"
[tool.ty.rules]
possibly-missing-attribute = "error"
possibly-missing-import = "error"
unused-ignore-comment = "warn"
redundant-cast = "warn"
undefined-reveal = "warn"
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
unresolved-reference = "warn"
invalid-argument-type = "warn"
[[tool.ty.overrides]]
include = ["app/repositories/**", "app/services/**"]
[tool.ty.overrides.rules]
unresolved-attribute = "warn"
[tool.ty.terminal]
error-on-warning = false
output-format = "full"

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -0,0 +1,256 @@
"""
©AngelaMos | 2025
test_admin.py
"""
import pytest
from httpx import AsyncClient
from user.User import User
URL_ADMIN_USERS = "/v1/admin/users"
def url_admin_user_by_id(user_id: str) -> str:
return f"{URL_ADMIN_USERS}/{user_id}"
@pytest.mark.asyncio
async def test_admin_create_user(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
):
"""
Admin can create a new user
"""
response = await client.post(
URL_ADMIN_USERS,
headers = admin_auth_headers,
json = {
"email": "adminmade@test.com",
"password": "ValidPass123",
"full_name": "Admin Created User",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "adminmade@test.com"
assert data["full_name"] == "Admin Created User"
assert "id" in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_admin_create_user_non_admin_forbidden(
client: AsyncClient,
test_user: User,
auth_headers: dict[str, str],
):
"""
Non admin cannot create user via admin endpoint
"""
response = await client.post(
URL_ADMIN_USERS,
headers = auth_headers,
json = {
"email": "shouldfail@test.com",
"password": "ValidPass123",
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_create_user_duplicate_email(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
test_user: User,
):
"""
Admin create with duplicate email returns 409
"""
response = await client.post(
URL_ADMIN_USERS,
headers = admin_auth_headers,
json = {
"email": test_user.email,
"password": "ValidPass123",
},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_admin_get_user_by_id(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
test_user: User,
):
"""
Admin can get any user by ID
"""
response = await client.get(
url_admin_user_by_id(str(test_user.id)),
headers = admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(test_user.id)
assert data["email"] == test_user.email
@pytest.mark.asyncio
async def test_admin_get_user_not_found(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
):
"""
Admin get non existent user returns 404
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.get(
url_admin_user_by_id(fake_id),
headers = admin_auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_update_user(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
test_user: User,
):
"""
Admin can update any user
"""
response = await client.patch(
url_admin_user_by_id(str(test_user.id)),
headers = admin_auth_headers,
json = {"full_name": "Admin Updated Name"},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] == "Admin Updated Name"
assert data["id"] == str(test_user.id)
@pytest.mark.asyncio
async def test_admin_update_user_not_found(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
):
"""
Admin update non existent user returns 404
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.patch(
url_admin_user_by_id(fake_id),
headers = admin_auth_headers,
json = {"full_name": "Should Fail"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_update_user_non_admin_forbidden(
client: AsyncClient,
test_user: User,
auth_headers: dict[str, str],
):
"""
Non admin cannot update via admin endpoint
"""
response = await client.patch(
url_admin_user_by_id(str(test_user.id)),
headers = auth_headers,
json = {"full_name": "Should Fail"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_delete_user(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
db_session,
):
"""
Admin can delete a user
"""
from user.User import User as UserModel
from core.security import hash_password
user_to_delete = UserModel(
email = "deleteme@test.com",
hashed_password = await hash_password("TestPass123"),
full_name = "Delete Me",
)
db_session.add(user_to_delete)
await db_session.flush()
await db_session.refresh(user_to_delete)
user_id = str(user_to_delete.id)
response = await client.delete(
url_admin_user_by_id(user_id),
headers = admin_auth_headers,
)
assert response.status_code == 204
get_response = await client.get(
url_admin_user_by_id(user_id),
headers = admin_auth_headers,
)
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_admin_delete_user_not_found(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str, str],
):
"""
Admin delete non existent user returns 404
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.delete(
url_admin_user_by_id(fake_id),
headers = admin_auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_delete_user_non_admin_forbidden(
client: AsyncClient,
test_user: User,
auth_headers: dict[str, str],
):
"""
Non admin cannot delete users
"""
response = await client.delete(
url_admin_user_by_id(str(test_user.id)),
headers = auth_headers,
)
assert response.status_code == 403

View File

@ -0,0 +1,288 @@
"""
©AngelaMos | 2025
test_auth.py
"""
import pytest
from httpx import AsyncClient
from user.User import User
from auth.RefreshToken import RefreshToken
URL_LOGIN = "/v1/auth/login"
URL_REFRESH = "/v1/auth/refresh"
URL_LOGOUT = "/v1/auth/logout"
URL_LOGOUT_ALL = "/v1/auth/logout-all"
URL_ME = "/v1/auth/me"
URL_CHANGE_PASSWORD = "/v1/auth/change-password"
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient, test_user: User):
"""
Valid credentials return access token and set refresh cookie
"""
response = await client.post(
URL_LOGIN,
data = {
"username": test_user.email,
"password": "TestPass123",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "user" in data
assert data["user"]["email"] == test_user.email
assert "refresh_token" in response.cookies
@pytest.mark.asyncio
async def test_login_invalid_password(
client: AsyncClient,
test_user: User
):
"""
Wrong password returns 401
"""
response = await client.post(
URL_LOGIN,
data = {
"username": test_user.email,
"password": "WrongPassword123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_invalid_email(client: AsyncClient):
"""
Non-existent email returns 401
"""
response = await client.post(
URL_LOGIN,
data = {
"username": "nonexistent@test.com",
"password": "TestPass123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_inactive_user(
client: AsyncClient,
inactive_user: User
):
"""
Inactive user cannot login
"""
response = await client.post(
URL_LOGIN,
data = {
"username": inactive_user.email,
"password": "TestPass123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_success(
client: AsyncClient,
refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Valid refresh token returns new access token
"""
_, raw_token = refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_refresh_token_missing_returns_401(client: AsyncClient):
"""
Missing refresh token cookie returns 401, not 422.
"""
response = await client.post(URL_REFRESH)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_expired(
client: AsyncClient,
expired_refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Expired refresh token returns 401
"""
_, raw_token = expired_refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_revoked(
client: AsyncClient,
revoked_refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Revoked refresh token returns 401.
"""
_, raw_token = revoked_refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_logout_success(
client: AsyncClient,
refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Logout revokes refresh token and clears cookie.
"""
_, raw_token = refresh_token_pair
response = await client.post(
URL_LOGOUT,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_logout_missing_token_returns_401(client: AsyncClient):
"""
Logout without refresh token returns 401, not 422.
"""
response = await client.post(URL_LOGOUT)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_logout_all(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Logout all revokes all user sessions.
"""
response = await client.post(
URL_LOGOUT_ALL,
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "revoked_sessions" in data
@pytest.mark.asyncio
async def test_get_current_user(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
/me returns current authenticated user.
"""
response = await client.get(
URL_ME,
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["id"] == str(test_user.id)
@pytest.mark.asyncio
async def test_get_current_user_unauthenticated(client: AsyncClient):
"""
/me without auth returns 401.
"""
response = await client.get(URL_ME)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_change_password(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Password change works with valid current password.
"""
response = await client.post(
URL_CHANGE_PASSWORD,
headers = auth_headers,
json = {
"current_password": "TestPass123",
"new_password": "NewTestPass456",
},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_change_password_wrong_current(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Password change fails with wrong current password.
"""
response = await client.post(
URL_CHANGE_PASSWORD,
headers = auth_headers,
json = {
"current_password": "WrongPassword123",
"new_password": "NewTestPass456",
},
)
assert response.status_code == 401

View File

@ -0,0 +1,40 @@
"""
©AngelaMos | 2025
test_health.py
"""
import pytest
from httpx import AsyncClient
URL_HEALTH = "/health"
URL_HEALTH_DETAILED = "/health/detailed"
@pytest.mark.asyncio
async def test_health_basic(client: AsyncClient):
"""
Basic health check returns 200 with healthy status
"""
response = await client.get(URL_HEALTH)
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "environment" in data
assert "version" in data
@pytest.mark.asyncio
async def test_health_detailed(client: AsyncClient):
"""
Detailed health check includes database status
"""
response = await client.get(URL_HEALTH_DETAILED)
assert response.status_code == 200
data = response.json()
assert data["status"] in ["healthy", "degraded"]
assert "database" in data
assert "environment" in data
assert "version" in data

View File

@ -0,0 +1,240 @@
"""
©AngelaMos | 2025
test_users.py
"""
import pytest
from httpx import AsyncClient
from user.User import User
URL_USERS = "/v1/users"
URL_ADMIN_USERS = "/v1/admin/users"
URL_USER_ME = "/v1/users/me"
def url_user_by_id(user_id: str) -> str:
return f"{URL_USERS}/{user_id}"
def url_admin_user_by_id(user_id: str) -> str:
return f"{URL_ADMIN_USERS}/{user_id}"
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
"""
User registration creates new user
"""
response = await client.post(
URL_USERS,
json = {
"email": "newuser@test.com",
"password": "ValidPass123",
"full_name": "New User",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@test.com"
assert data["full_name"] == "New User"
assert "id" in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_create_user_duplicate_email(
client: AsyncClient,
test_user: User
):
"""
Duplicate email returns 409 conflict
"""
response = await client.post(
URL_USERS,
json = {
"email": test_user.email,
"password": "ValidPass123",
},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_user_weak_password(client: AsyncClient):
"""
Weak password (no uppercase/digit) returns 422
"""
response = await client.post(
URL_USERS,
json = {
"email": "weakpass@test.com",
"password": "weakpassword",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_user_by_id(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Get user by ID returns user data
"""
response = await client.get(
url_user_by_id(str(test_user.id)),
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["id"] == str(test_user.id)
@pytest.mark.asyncio
async def test_get_user_unauthenticated(
client: AsyncClient,
test_user: User
):
"""
Get user without auth returns 401
"""
response = await client.get(url_user_by_id(str(test_user.id)))
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_user_not_found(
client: AsyncClient,
auth_headers: dict[str,
str],
):
"""
Get non existent user returns 404
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.get(
url_user_by_id(fake_id),
headers = auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_current_user(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Update current user profile
"""
response = await client.patch(
URL_USER_ME,
headers = auth_headers,
json = {"full_name": "Updated Name"},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] == "Updated Name"
@pytest.mark.asyncio
async def test_update_user_clear_field(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Setting field to null clears it
"""
response = await client.patch(
URL_USER_ME,
headers = auth_headers,
json = {"full_name": None},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] is None
@pytest.mark.asyncio
async def test_list_users_admin_only(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Non admin cannot list users (403).
"""
response = await client.get(
URL_ADMIN_USERS,
headers = auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_list_users_as_admin(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str,
str],
):
"""
Admin can list users with pagination
"""
response = await client.get(
URL_ADMIN_USERS,
headers = admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "size" in data
assert isinstance(data["items"], list)
@pytest.mark.asyncio
async def test_list_users_pagination(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str,
str],
):
"""
Pagination params work correctly
"""
response = await client.get(
URL_ADMIN_USERS,
headers = admin_auth_headers,
params = {
"page": 1,
"size": 5
},
)
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["size"] == 5

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
# =========================================
# AngelaMos | 2026
# Production | compose.yml
# =========================================
name: ${APP_NAME:-template}
services:
# Nginx + Frontend
nginx:
build:
context: .
dockerfile: infra/docker/frontend-builder.prod
args:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-My App}
container_name: ${APP_NAME:-template}-nginx
ports:
- "${NGINX_HOST_PORT:-8420}:80"
depends_on:
backend:
condition: service_healthy
networks:
- frontend
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
reservations:
cpus: '0.25'
memory: 64M
restart: unless-stopped
# FastAPI backend
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.prod
container_name: ${APP_NAME:-template}-backend
expose:
- "8000"
env_file:
- .env
environment:
- ENVIRONMENT=production
- DEBUG=false
- RELOAD=false
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped
# PostgreSQL DB
db:
image: postgres:18-alpine
container_name: ${APP_NAME:-template}-db
ports:
- "${POSTGRES_HOST_PORT:-3420}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-app_db}
networks:
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Redis
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-template}-redis
ports:
- "${REDIS_HOST_PORT:-6420}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
networks:
- backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Cloudflare Tunnel
cloudflared:
image: cloudflare/cloudflared:latest
container_name: ${APP_NAME:-template}-tunnel
command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- backend
depends_on:
nginx:
condition: service_started
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
reservations:
cpus: '0.1'
memory: 32M
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
postgres_data:
redis_data:

View File

@ -0,0 +1,128 @@
# =============================================================================
# AngelaMos | 2026
# dev.compose.yml
# =============================================================================
name: ${APP_NAME:-template}-dev
services:
# Nginx
nginx:
image: nginx:1.27-alpine
container_name: ${APP_NAME:-template}-nginx-dev
ports:
- "${NGINX_HOST_PORT:-8420}:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
networks:
- frontend
- backend
restart: unless-stopped
# FastAPI
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.dev
container_name: ${APP_NAME:-template}-backend-dev
ports:
- "${BACKEND_HOST_PORT:-5420}:8000"
volumes:
- ./backend:/app
- backend_cache:/app/.venv
env_file:
- .env
environment:
- ENVIRONMENT=development
- DEBUG=true
- RELOAD=true
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Vite dev server
frontend:
build:
context: ./frontend
dockerfile: ../infra/docker/vite.dev
container_name: ${APP_NAME:-template}-frontend-dev
ports:
- "${FRONTEND_HOST_PORT:-3420}:5173"
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
environment:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-My App}
networks:
- frontend
restart: unless-stopped
# PostgreSQL DB
db:
image: postgres:16-alpine
container_name: ${APP_NAME:-template}-db-dev
ports:
- "${POSTGRES_HOST_PORT:-3420}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-app_db}
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Redis
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-template}-redis-dev
ports:
- "${REDIS_HOST_PORT:-6420}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
postgres_data:
redis_data:
backend_cache:
frontend_modules:

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2026
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<!--
Bug Bounty Platform
Author(s): © AngelaMos, CarterPerez-dev
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' github.com api.github.com; form-action 'self' github.com; worker-src 'self' blob:;"
/>
<title>Bug Bounty Platform</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"name": "bug-bounty-platform",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.0",
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"sass": "^1.95.0",
"stylelint": "^16.26.1",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5",
"vite-tsconfig-paths": "^5.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Some files were not shown because too many files have changed in this diff Show More