From de3c12ce7fda0b40fdef4b2711dd54e5b1d92b72 Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 14:57:21 -0400 Subject: [PATCH 1/3] Harden auth/upload security and add OSS project docs - Refuse to start in production with the default/placeholder SECRET_KEY - Rate-limit login attempts per IP+email to blunt brute-forcing - Enforce a max upload size and content-type allowlist on document uploads, plus an early Content-Length guard middleware - Prompt for the admin bootstrap password via getpass instead of a CLI arg - Flag docker-compose.yml credentials as dev-only - Add SECURITY.md, CONTRIBUTING.md, and CODE_OF_CONDUCT.md Co-Authored-By: Claude Sonnet 5 --- .env.example | 6 +++++ CODE_OF_CONDUCT.md | 53 ++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 51 ++++++++++++++++++++++++++++++++++++ README.md | 8 ++++++ SECURITY.md | 54 +++++++++++++++++++++++++++++++++++++++ app/core/config.py | 28 +++++++++++++++++++- app/core/rate_limit.py | 27 ++++++++++++++++++++ app/main.py | 25 ++++++++++++++++++ app/routers/auth.py | 21 ++++++++++++--- app/services/documents.py | 20 +++++++++++++-- docker-compose.yml | 2 ++ scripts/create_admin.py | 21 ++++++++++++--- 12 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 app/core/rate_limit.py diff --git a/.env.example b/.env.example index 8aa8ede..52d710c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # Copy to .env and adjust as needed for local development. DATABASE_URL=postgresql+psycopg://altsmanager:altsmanager@localhost:5432/altsmanager +# Generate a real value for anything beyond local dev, e.g.: python -c "import secrets; print(secrets.token_urlsafe(48))" +# The app refuses to start with this placeholder when ENVIRONMENT=production. SECRET_KEY=change-me-to-a-random-value-at-least-32-bytes-long JWT_ALGORITHM=HS256 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -15,3 +17,7 @@ S3_SECRET_ACCESS_KEY= ENVIRONMENT=development # Comma-separated list of origins allowed to make credentialed requests (the web frontend) CORS_ORIGINS=http://localhost:3000 +# Document upload limits +MAX_UPLOAD_SIZE_BYTES=26214400 +LOGIN_RATE_LIMIT_ATTEMPTS=5 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b3052d8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,53 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing for mistakes, and learning from them + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our +standards and will take appropriate and fair corrective action in response to +any behavior they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces (issues, pull +requests, discussions) and when an individual is officially representing the +project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at **TODO: add a maintainer contact email here**. +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cac6986 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to AltsManager (API) + +Thanks for your interest in contributing. This project is early and the data +model/API surface is still changing, so it's worth opening an issue to +discuss larger changes before investing time in a PR. + +## Getting set up + +Follow the [Setup section of the README](README.md#setup). In short: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +cp .env.example .env +docker compose up -d postgres +alembic upgrade head +``` + +## Before opening a PR + +```bash +ruff check . # lint +pytest # tests (needs postgres up) +``` + +If you changed any SQLAlchemy models, generate a migration: + +```bash +alembic revision --autogenerate -m "describe the change" +``` + +## Pull requests + +- Keep PRs focused — one logical change per PR is easier to review. +- Add or update tests for behavior you change. +- Use the PR template; link the issue it addresses if there is one. +- Security-sensitive changes (auth, access control, document storage) get + extra scrutiny — see [SECURITY.md](SECURITY.md) for the project's security + model. + +## Reporting bugs / proposing features + +Use the issue templates under `.github/ISSUE_TEMPLATE/`. For security +vulnerabilities, see [SECURITY.md](SECURITY.md) instead of opening a public +issue. + +## Code of conduct + +This project follows the [Code of Conduct](CODE_OF_CONDUCT.md). By +participating, you're expected to uphold it. diff --git a/README.md b/README.md index 50192d7..c432198 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ alembic revision --autogenerate -m "..." # generate a migration after model cha alembic upgrade head # apply migrations ``` +## Security + +This app handles sensitive financial and tax data. See +[SECURITY.md](SECURITY.md) for the vulnerability-reporting process and a +summary of the current security model (including the single-tenant +authorization model and other known tradeoffs). See +[CONTRIBUTING.md](CONTRIBUTING.md) for how to propose changes. + ## Project structure ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5fb58f3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security Policy + +AltsManager is designed to be self-hosted and handles sensitive financial and +tax data (ownership records, transactions, K-1s and other tax documents). We +take security issues seriously and appreciate responsible disclosure. + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. +Instead, report them privately using +[GitHub Security Advisories](https://github.com/AltsManager/api/security/advisories/new) +for this repository. + +Include as much detail as you can: + +- A description of the vulnerability and its potential impact +- Steps to reproduce (a minimal repro is ideal) +- Any relevant logs, screenshots, or proof-of-concept code + +We'll acknowledge your report, investigate, and keep you updated as we work +toward a fix. Once a fix is released, we'll credit you (unless you'd prefer +otherwise) in the release notes. + +## Supported versions + +This project is pre-1.0 and under active development. Only the `main` branch +receives security fixes; there are no maintained release branches yet. + +## Security model and known tradeoffs + +This is a good-faith summary of the current design, intended to help +self-hosters make informed decisions — not a guarantee of fitness for any +particular threat model. + +- **Single-tenant authorization.** Any authenticated user can read all + entities, investments, transactions, and documents in the instance; write + access is gated by role (`ADMIN`/`STAFF`), not by per-user data ownership. + This matches the intended self-hosted, single-household/single-organization + use case. If you add multiple unrelated users to one instance expecting + per-user data isolation, that isolation does **not** currently exist. +- **No signup endpoint by design.** The first admin is bootstrapped via + `scripts/create_admin.py`; additional users are created by an existing + admin. +- **Sessions are stateless JWTs** in an httpOnly, `SameSite=Lax` cookie with a + short default expiry (60 minutes). There is no server-side revocation list, + so a stolen token remains valid until it expires or the `SECRET_KEY` is + rotated. +- **`SECRET_KEY` must be a unique, random value in production** — the app + refuses to start with the placeholder default when `ENVIRONMENT=production`. + Rotating it invalidates all existing sessions. +- **Login attempts are rate-limited per-process**, not across a multi-worker + or multi-instance deployment. If you run several API workers behind a load + balancer, consider pairing this with rate limiting at the edge (reverse + proxy/WAF). diff --git a/app/core/config.py b/app/core/config.py index 3b97e59..f639125 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -2,12 +2,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +DEFAULT_SECRET_KEY = "change-me-to-a-random-value-at-least-32-bytes-long" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") database_url: str = "postgresql+psycopg://altsmanager:altsmanager@localhost:5432/altsmanager" - secret_key: str = "change-me-to-a-random-value-at-least-32-bytes-long" + secret_key: str = DEFAULT_SECRET_KEY jwt_algorithm: str = "HS256" jwt_access_token_expire_minutes: int = 60 storage_backend: str = "local" @@ -19,6 +21,18 @@ class Settings(BaseSettings): s3_secret_access_key: str = "" environment: str = "development" cors_origins: str = "http://localhost:3000" + max_upload_size_bytes: int = 25 * 1024 * 1024 + allowed_document_content_types: str = ( + "application/pdf," + "image/png,image/jpeg,image/heic,image/webp," + "application/vnd.ms-excel," + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet," + "application/msword," + "application/vnd.openxmlformats-officedocument.wordprocessingml.document," + "text/csv,text/plain" + ) + login_rate_limit_attempts: int = 5 + login_rate_limit_window_seconds: int = 300 @property def is_production(self) -> bool: @@ -28,6 +42,18 @@ def is_production(self) -> bool: def cors_origin_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + @property + def allowed_document_content_type_list(self) -> list[str]: + return [ct.strip() for ct in self.allowed_document_content_types.split(",") if ct.strip()] + + def model_post_init(self, __context: object) -> None: + key_is_weak = self.secret_key == DEFAULT_SECRET_KEY or len(self.secret_key) < 32 + if self.is_production and key_is_weak: + raise RuntimeError( + "SECRET_KEY must be set to a unique random value (at least 32 bytes) " + "when ENVIRONMENT=production. Refusing to start with the default/placeholder key." + ) + @lru_cache def get_settings() -> Settings: diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py new file mode 100644 index 0000000..9548667 --- /dev/null +++ b/app/core/rate_limit.py @@ -0,0 +1,27 @@ +"""Minimal in-process rate limiter. + +Single-process, in-memory sliding window keyed by caller. Good enough to blunt +online brute-force/credential-stuffing against a single self-hosted instance. +It does *not* share state across multiple worker processes or machines -- a +deployment running several API workers behind a load balancer should replace +this with a shared store (e.g. Redis) instead. +""" + +import time +from collections import defaultdict +from threading import Lock + +_attempts: dict[str, list[float]] = defaultdict(list) +_lock = Lock() + + +def is_rate_limited(key: str, *, max_attempts: int, window_seconds: int) -> bool: + """Record an attempt for `key` and return True if it should be rejected.""" + now = time.monotonic() + with _lock: + attempts = _attempts[key] + attempts[:] = [t for t in attempts if now - t < window_seconds] + if len(attempts) >= max_attempts: + return True + attempts.append(now) + return False diff --git a/app/main.py b/app/main.py index 4e32c69..dd65589 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware from app.admin import mount_admin from app.core.config import get_settings @@ -9,6 +10,30 @@ app = FastAPI(title="AltsManager API") + +class MaxBodySizeMiddleware(BaseHTTPMiddleware): + """Rejects requests whose declared Content-Length exceeds the configured limit. + + This is a cheap, early rejection based on the client-supplied header -- it + doesn't protect against a chunked-encoding client lying about size, which + the upload-size check in app.services.documents backstops once the body has + been read. + """ + + async def dispatch(self, request: Request, call_next): + max_size = get_settings().max_upload_size_bytes + content_length = request.headers.get("content-length") + if content_length is not None: + try: + too_large = int(content_length) > max_size + except ValueError: + too_large = False + if too_large: + return JSONResponse(status_code=413, content={"detail": "Request body too large"}) + return await call_next(request) + + +app.add_middleware(MaxBodySizeMiddleware) app.add_middleware( CORSMiddleware, allow_origins=get_settings().cors_origin_list, diff --git a/app/routers/auth.py b/app/routers/auth.py index 3b2768e..3999365 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, HTTPException, Response, status +from fastapi import APIRouter, HTTPException, Request, Response, status from app.core.config import get_settings from app.core.deps import CurrentUser, DbSession +from app.core.rate_limit import is_rate_limited from app.core.security import ( ACCESS_TOKEN_COOKIE_NAME, create_access_token, @@ -15,7 +16,22 @@ @router.post("/login", response_model=UserRead) -def login(credentials: LoginRequest, response: Response, db: DbSession) -> UserRead: +def login( + credentials: LoginRequest, request: Request, response: Response, db: DbSession +) -> UserRead: + settings = get_settings() + client_host = request.client.host if request.client else "unknown" + rate_limit_key = f"{client_host}:{credentials.email.lower()}" + if is_rate_limited( + rate_limit_key, + max_attempts=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, + ): + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many login attempts. Try again later.", + ) + user = get_user_by_email(db, credentials.email) invalid_credentials = HTTPException( status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" @@ -25,7 +41,6 @@ def login(credentials: LoginRequest, response: Response, db: DbSession) -> UserR if not verify_password(credentials.password, user.hashed_password): raise invalid_credentials - settings = get_settings() token = create_access_token(user.id) response.set_cookie( key=ACCESS_TOKEN_COOKIE_NAME, diff --git a/app/services/documents.py b/app/services/documents.py index 9161720..3d69098 100644 --- a/app/services/documents.py +++ b/app/services/documents.py @@ -1,9 +1,11 @@ +import os import uuid from pathlib import Path from fastapi import UploadFile from sqlalchemy.orm import Session +from app.core.config import get_settings from app.core.exceptions import NotFoundError, ValidationError from app.crud import document as document_crud from app.crud import entity as entity_crud @@ -46,9 +48,23 @@ def save_uploaded_document( ) -> Document: validate_document_links(db, entity_id, investment_id, transaction_id) + settings = get_settings() + content_type = file.content_type or "application/octet-stream" + if content_type not in settings.allowed_document_content_type_list: + raise ValidationError(f"Unsupported file type: {content_type}") + + size_bytes = file.size + if size_bytes is None: + file.file.seek(0, os.SEEK_END) + size_bytes = file.file.tell() + file.file.seek(0) + if size_bytes > settings.max_upload_size_bytes: + raise ValidationError( + f"File exceeds maximum upload size of {settings.max_upload_size_bytes} bytes" + ) + filename = Path(file.filename or "upload").name storage_key = f"{uuid.uuid4().hex}/{filename}" - content_type = file.content_type or "application/octet-stream" get_storage_backend().save(file.file, storage_key, content_type) return document_crud.create_document( @@ -56,7 +72,7 @@ def save_uploaded_document( filename=filename, storage_key=storage_key, content_type=content_type, - size_bytes=file.size or 0, + size_bytes=size_bytes, doc_type=doc_type, tax_year=tax_year, entity_id=entity_id, diff --git a/docker-compose.yml b/docker-compose.yml index 2857545..6ba4fc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +# Local development only. Hardcoded credentials and a host-exposed port are +# fine here but must never be reused for a production deployment. services: postgres: image: postgres:16 diff --git a/scripts/create_admin.py b/scripts/create_admin.py index 50f27e1..6a754fe 100644 --- a/scripts/create_admin.py +++ b/scripts/create_admin.py @@ -1,10 +1,16 @@ """Bootstrap the first admin user. There is no public signup endpoint by design. Usage: - python -m scripts.create_admin --email admin@example.com --name "Admin" --password secret + python -m scripts.create_admin --email admin@example.com --name "Admin" + +You'll be prompted for a password interactively so it never appears in shell +history or process listings. For scripted/non-interactive use (e.g. a Docker +entrypoint), set ALTSMANAGER_ADMIN_PASSWORD in the environment instead. """ import argparse +import getpass +import os import sys from app.core.security import hash_password @@ -18,9 +24,18 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--email", required=True) parser.add_argument("--name", required=True) - parser.add_argument("--password", required=True) args = parser.parse_args() + password = os.environ.get("ALTSMANAGER_ADMIN_PASSWORD") + if not password: + password = getpass.getpass("Password: ") + if password != getpass.getpass("Confirm password: "): + print("Passwords did not match.", file=sys.stderr) + raise SystemExit(1) + if len(password) < 8: + print("Password must be at least 8 characters.", file=sys.stderr) + raise SystemExit(1) + with SessionLocal() as db: if get_user_by_email(db, args.email) is not None: print(f"User {args.email} already exists.", file=sys.stderr) @@ -29,7 +44,7 @@ def main() -> None: user = User( email=args.email, full_name=args.name, - hashed_password=hash_password(args.password), + hashed_password=hash_password(password), role=UserRole.ADMIN, ) db.add(user) From 5f7d93b9965ee9e59adf87d419dd663e3c1f2b84 Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 14:59:10 -0400 Subject: [PATCH 2/3] Add maintainer contact email to security/conduct docs Co-Authored-By: Claude Sonnet 5 --- CODE_OF_CONDUCT.md | 2 +- SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index b3052d8..12c096a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -42,7 +42,7 @@ project in public spaces. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the maintainers at **TODO: add a maintainer contact email here**. +reported to the maintainers at **info@altsmanager.com**. All complaints will be reviewed and investigated promptly and fairly. ## Attribution diff --git a/SECURITY.md b/SECURITY.md index 5fb58f3..e1db1cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,7 @@ take security issues seriously and appreciate responsible disclosure. Please **do not** open a public GitHub issue for security vulnerabilities. Instead, report them privately using [GitHub Security Advisories](https://github.com/AltsManager/api/security/advisories/new) -for this repository. +for this repository, or by emailing **info@altsmanager.com**. Include as much detail as you can: From 6d7ead9b17cc9de7c8a57dbd54bda96358d7521e Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 15:08:53 -0400 Subject: [PATCH 3/3] Pin Python dependencies via pip-compile lockfiles - Add requirements.in / requirements-dev.in as the loose, hand-edited source of truth; requirements.txt / requirements-dev.txt are now fully pinned lockfiles (every direct and transitive dependency) generated via `uv pip compile`, so installs are reproducible across dev/CI/prod - Explicitly depend on sqlalchemy[asyncio] to pull in greenlet, which sqladmin requires unconditionally (it imports sqlalchemy.ext.asyncio internally) -- previously undeclared, so a clean install could fail to import the app - Fix test suite fallout from the new login rate limiter: reset its in-memory state between tests, and add coverage for rate limiting and the document upload size/content-type limits - Document the lockfile regeneration workflow in CONTRIBUTING.md Verified: clean-room install + ruff + full pytest suite (41 passed) against the pinned versions on Python 3.12. Co-Authored-By: Claude Sonnet 5 --- CONTRIBUTING.md | 17 +++ requirements-dev.in | 3 + requirements-dev.txt | 49 +++++++- requirements.in | 14 +++ requirements.txt | 264 ++++++++++++++++++++++++++++++++++++++-- tests/conftest.py | 11 ++ tests/test_auth.py | 17 +++ tests/test_documents.py | 20 +++ 8 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 requirements-dev.in create mode 100644 requirements.in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cac6986..2474c44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,23 @@ If you changed any SQLAlchemy models, generate a migration: alembic revision --autogenerate -m "describe the change" ``` +## Dependencies + +`requirements.in` / `requirements-dev.in` are the source of truth (loose, +hand-edited); `requirements.txt` / `requirements-dev.txt` are fully pinned +lockfiles generated from them, for reproducible installs. **Don't hand-edit +the `.txt` files** — edit the `.in` file and recompile: + +```bash +pip install uv # one-time, any Python version +uv pip compile requirements.in -o requirements.txt --python-version 3.12 +uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.12 --constraint requirements.txt +``` + +Then reinstall (`pip install -r requirements.txt -r requirements-dev.txt`) and +run the test suite before committing the regenerated lockfiles alongside your +`.in` change. + ## Pull requests - Keep PRs focused — one logical change per PR is easier to review. diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..00171d6 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,3 @@ +ruff +pytest +httpx diff --git a/requirements-dev.txt b/requirements-dev.txt index 00171d6..7fc91b2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,46 @@ -ruff -pytest -httpx +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.12 --constraint requirements.txt +anyio==4.15.1 + # via + # -c requirements.txt + # httpx +certifi==2026.10.19 + # via + # -c requirements.txt + # httpcore + # httpx +h11==0.16.0 + # via + # -c requirements.txt + # httpcore +httpcore==1.0.9 + # via + # -c requirements.txt + # httpx +httpx==0.28.1 + # via + # -c requirements.txt + # -r requirements-dev.in +idna==3.20 + # via + # -c requirements.txt + # anyio + # httpx +iniconfig==2.3.1 + # via pytest +packaging==26.3 + # via pytest +pluggy==1.7.0 + # via pytest +pygments==2.21.0 + # via + # -c requirements.txt + # pytest +pytest==9.1.1 + # via -r requirements-dev.in +ruff==0.17.1 + # via -r requirements-dev.in +typing-extensions==4.16.0 + # via + # -c requirements.txt + # anyio diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..07fd6bb --- /dev/null +++ b/requirements.in @@ -0,0 +1,14 @@ +fastapi[standard] +uvicorn[standard] +# [asyncio] extra pulls in greenlet, which sqladmin requires unconditionally +# (it imports sqlalchemy.ext.asyncio internally) even though the app itself +# only uses sync sessions. +sqlalchemy[asyncio]>=2.0 +alembic +psycopg[binary] +pydantic-settings +bcrypt +pyjwt +sqladmin +itsdangerous +boto3 diff --git a/requirements.txt b/requirements.txt index a7354fa..f803e88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,253 @@ -fastapi[standard] -uvicorn[standard] -sqlalchemy>=2.0 -alembic -psycopg[binary] -pydantic-settings -bcrypt -pyjwt -sqladmin -itsdangerous -boto3 +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt --python-version 3.12 +agent-detector==2.0.0 + # via fastapi-cloud-cli +alembic==1.20.0 + # via -r requirements.in +annotated-doc==0.0.5 + # via + # fastapi + # typer +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # httpx + # httpx2 + # starlette + # watchfiles +bcrypt==5.0.0 + # via -r requirements.in +boto3==1.43.116 + # via -r requirements.in +botocore==1.43.116 + # via + # boto3 + # s3transfer +certifi==2026.10.19 + # via + # httpcore + # httpx + # requests + # sentry-sdk +charset-normalizer==3.5.2 + # via requests +click==8.5.0 + # via + # rich-toolkit + # uvicorn +detect-installer==0.2.1 + # via fastapi-cloud-cli +dnspython==2.9.0 + # via email-validator +email-validator==2.3.0 + # via + # fastapi + # pydantic +fastapi==0.158.1 + # via -r requirements.in +fastapi-cli==0.0.32 + # via fastapi +fastapi-cloud-cli==0.26.0 + # via fastapi-cli +fastar==0.12.0 + # via + # fastapi + # fastapi-cloud-cli +googleapis-common-protos==1.75.5 + # via opentelemetry-exporter-otlp-proto-http +greenlet==3.5.6 + # via sqlalchemy +h11==0.16.0 + # via + # httpcore + # httpcore2 + # uvicorn +httpcore==1.0.9 + # via httpx +httpcore2==2.16.0 + # via httpx2 +httptools==0.9.0 + # via uvicorn +httpx==0.28.1 + # via + # fastapi + # fastapi-cloud-cli +httpx2==2.16.0 + # via fastapi +idna==3.20 + # via + # anyio + # email-validator + # httpx + # httpx2 + # requests +itsdangerous==2.2.0 + # via -r requirements.in +jinja2==3.1.6 + # via + # fastapi + # sqladmin +jmespath==1.1.0 + # via + # boto3 + # botocore +mako==1.4.3 + # via alembic +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.4 + # via + # jinja2 + # mako + # wtforms +mdurl==0.1.2 + # via markdown-it-py +opentelemetry-api==1.45.1 + # via + # fastapi + # opentelemetry-exporter-http-transport + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-http-transport==0.66b1 + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-common==0.66b1 + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-common==1.45.1 + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-http==1.45.1 + # via fastapi +opentelemetry-proto==1.45.1 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.45.1 + # via + # fastapi + # opentelemetry-exporter-otlp-common + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.66b1 + # via opentelemetry-sdk +protobuf==7.36.2 + # via + # googleapis-common-protos + # opentelemetry-proto +psycopg==3.3.6 + # via -r requirements.in +psycopg-binary==3.3.6 + # via psycopg +pydantic==2.14.1 + # via + # fastapi + # fastapi-cloud-cli + # pydantic-extra-types + # pydantic-settings +pydantic-core==2.50.1 + # via pydantic +pydantic-extra-types==2.11.1 + # via fastapi +pydantic-settings==2.16.1 + # via + # -r requirements.in + # fastapi +pygments==2.21.0 + # via rich +pyjwt==2.15.1 + # via -r requirements.in +python-dateutil==2.9.0.post0 + # via botocore +python-dotenv==1.2.4 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.32 + # via + # fastapi + # sqladmin +pyyaml==6.0.3 + # via uvicorn +requests==2.34.2 + # via + # opentelemetry-exporter-http-transport + # opentelemetry-exporter-otlp-proto-http +rich==15.0.0 + # via + # rich-toolkit + # typer +rich-toolkit==0.20.6 + # via + # fastapi-cli + # fastapi-cloud-cli +rignore==0.8.1 + # via fastapi-cloud-cli +s3transfer==0.19.3 + # via boto3 +sentry-sdk==2.72.0 + # via fastapi-cloud-cli +shellingham==1.5.4 + # via typer +six==1.17.0 + # via python-dateutil +sqladmin==0.32.0 + # via -r requirements.in +sqlalchemy==2.1.6 + # via + # -r requirements.in + # alembic + # sqladmin +starlette==1.9.0 + # via + # fastapi + # sqladmin +truststore==0.10.5 + # via + # httpcore2 + # httpx2 +typer==0.27.3 + # via + # fastapi-cli + # fastapi-cloud-cli +typing-extensions==4.16.0 + # via + # alembic + # anyio + # fastapi + # httpx2 + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # psycopg + # pydantic + # pydantic-core + # pydantic-extra-types + # pydantic-settings + # rich-toolkit + # sqlalchemy + # starlette + # typing-inspection +typing-inspection==0.4.4 + # via + # fastapi + # pydantic + # pydantic-settings +urllib3==2.8.0 + # via + # botocore + # requests + # sentry-sdk +uvicorn==0.54.0 + # via + # -r requirements.in + # fastapi + # fastapi-cli + # fastapi-cloud-cli +uvloop==0.23.0 + # via uvicorn +watchfiles==1.3.0 + # via uvicorn +websockets==17.3 + # via uvicorn +wtforms==3.2.2 + # via sqladmin diff --git a/tests/conftest.py b/tests/conftest.py index 3c6aed7..6d32acf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.core.config import get_settings +from app.core.rate_limit import _attempts as _login_rate_limit_attempts from app.core.security import hash_password from app.db.session import engine, get_db from app.main import app @@ -23,6 +24,16 @@ def _cleanup_uploaded_documents(): shutil.rmtree(storage_path, ignore_errors=True) +@pytest.fixture(autouse=True) +def _reset_login_rate_limit(): + # The limiter's in-memory state is process-global; tests reuse the same + # client/email combinations many times in one run, so it must be cleared + # between tests to avoid spuriously tripping on test traffic. + _login_rate_limit_attempts.clear() + yield + _login_rate_limit_attempts.clear() + + @pytest.fixture() def db_session(): connection = engine.connect() diff --git a/tests/test_auth.py b/tests/test_auth.py index 58369b4..e5fcb97 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,3 +1,4 @@ +from app.core.config import get_settings from app.models.enums import UserRole from tests.conftest import TEST_PASSWORD, make_user @@ -42,3 +43,19 @@ def test_logout_clears_cookie(admin_client): response = admin_client.post("/api/v1/auth/logout") assert response.status_code == 204 assert admin_client.get("/api/v1/auth/me").status_code == 401 + + +def test_login_is_rate_limited_after_repeated_attempts(client, db_session): + make_user(db_session, role=UserRole.ADMIN, email="admin@example.com") + max_attempts = get_settings().login_rate_limit_attempts + + for _ in range(max_attempts): + response = client.post( + "/api/v1/auth/login", json={"email": "admin@example.com", "password": "wrong"} + ) + assert response.status_code == 401 + + limited = client.post( + "/api/v1/auth/login", json={"email": "admin@example.com", "password": TEST_PASSWORD} + ) + assert limited.status_code == 429 diff --git a/tests/test_documents.py b/tests/test_documents.py index aa5b9e1..94b35d4 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -66,6 +66,26 @@ def test_upload_document_with_unknown_entity_404s(admin_client): assert response.status_code == 404 +def test_upload_document_rejects_disallowed_content_type(admin_client): + entity_id = _create_entity(admin_client) + files = {"file": ("malware.exe", io.BytesIO(b"MZ"), "application/x-msdownload")} + response = admin_client.post( + "/api/v1/documents", + data={"doc_type": "other", "entity_id": entity_id}, + files=files, + ) + assert response.status_code == 400 + + +def test_upload_document_rejects_oversized_file(admin_client, monkeypatch): + from app.core.config import get_settings + + entity_id = _create_entity(admin_client) + monkeypatch.setattr(get_settings(), "max_upload_size_bytes", 10) + response = _upload(admin_client, "/api/v1/documents", doc_type="other", entity_id=entity_id) + assert response.status_code in (400, 413) + + def test_download_document_returns_uploaded_bytes(admin_client): entity_id = _create_entity(admin_client) content = b"%PDF-1.4 the actual k1 content"