Add: bug bounty platform project
This commit is contained in:
parent
0794c43e9b
commit
e4501c6697
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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! 🔒**
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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,
|
||||
)
|
||||
"""
|
||||
|
||||
⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀
|
||||
|
||||
"""
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
admin module
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
Auth Domain
|
||||
"""
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
},
|
||||
}
|
||||
|
|
@ -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 = "/")
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
||||
from .correlation import CorrelationIdMiddleware
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CorrelationIdMiddleware",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
User Domain
|
||||
"""
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in New Issue